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
1 change: 1 addition & 0 deletions HYDEPHP_V3_PLANNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Having this document in code lets us know the devlopment state at any given poin

### Feature Changes

- Added an optional `_static` source directory for files that should be copied verbatim to the root of the compiled site while preserving relative paths, such as `robots.txt`, `llms.txt`, favicons, and `.well-known` files. The existing `_media` to `_site/media` convention is unchanged.
- Fenced code blocks are now rendered through a publishable Blade view, `components/markdown/code-block.blade.php`, in the same way terminal blocks are. The view receives the rendered code block markup as `$contents`, along with `$language` and `$label`, and decides what goes around it, so changing what surrounds a code block is a view change instead of a framework change. Highlighting itself is unaffected: the fence stays in the syntax tree as the wrapper's child, and is rendered by whichever renderer the environment already had for it, be it Torchlight, a third-party extension, or CommonMark's own.
- Code block labels are now set with a `title="…"` modifier on the fence, using the same attribute syntax as terminal block titles, such as ` ```php title="app/Model.php" `. The language is optional, so ` ``` title=".env" ` labels a block that declares none, which is then treated as `plaintext`. The label is no longer tied to file paths, so a block can be titled with anything. Labels use a responsive header instead of the v2 top-right badge. The v2 `// filepath:` comment syntax is removed.
- Blade in Markdown is now enabled by default. The `markdown.enable_blade` option controls both `[Blade]:` directives and executable Blade Blocks. Hyde sites generally treat project content as trusted and reviewed; sites that compile untrusted or unreviewed Markdown can disable both forms with this option.
Expand Down
4 changes: 4 additions & 0 deletions docs/creating-content/managing-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Hyde ships with a complete frontend using Blade views, TailwindCSS styles, and A

To get you started quickly, all the styles are already compiled and minified into `_media/app.css`, which will be copied to the `_site/media/app.css` directory when you run `php hyde build`.

## Root-Level Static Files

Files that need to be published directly to the site root can be placed in an optional `_static` directory. Paths are preserved, so `_static/robots.txt` becomes `_site/robots.txt` and `_static/.well-known/security.txt` becomes `_site/.well-known/security.txt`. Use `_media` for normal site assets published under `/media`.

## Vite

Hyde uses [Vite](https://vite.dev/) to compile assets. Vite is a build tool that aims to provide a faster and more efficient development experience for modern web projects.
Expand Down
3 changes: 3 additions & 0 deletions packages/framework/src/Console/Commands/BuildSiteCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Hyde\Facades\Vite;
use Hyde\Facades\Config;
use Hyde\Support\BuildWarnings;
use Hyde\Framework\Actions\TransferStaticFiles;
use Hyde\Console\Concerns\Command;
use Hyde\Framework\Services\BuildService;
use Hyde\Framework\Services\BuildTaskService;
Expand Down Expand Up @@ -59,6 +60,8 @@ public function handle(): int
Vite::forceDisable(false);
}

TransferStaticFiles::handle();

$this->runPostBuildActions();

$this->printFinishMessage($timeStart);
Expand Down
78 changes: 78 additions & 0 deletions packages/framework/src/Framework/Actions/TransferStaticFiles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Hyde\Framework\Actions;

use Hyde\Hyde;
use Hyde\Facades\Config;
use Hyde\Facades\Filesystem;
use Hyde\Framework\Concerns\InteractsWithDirectories;
use Hyde\Framework\Exceptions\FileConflictException;
use Hyde\Pages\Concerns\HydePage;
use Hyde\Support\Filesystem\MediaFile;
use Illuminate\Support\Collection;
use SplFileInfo;

use function collect;
use function strlen;
use function substr;

class TransferStaticFiles
{
use InteractsWithDirectories;

public static function handle(): void
{
$files = static::findStaticFiles()->map(function (SplFileInfo $file): array {
$sourcePath = Hyde::pathToRelative($file->getPathname());

return [
'source' => $file->getPathname(),
'output' => Hyde::sitePath(substr($sourcePath, strlen('_static/'))),
];
});

$currentBuildOutputPaths = static::currentBuildOutputPaths();

$files->each(function (array $file) use ($currentBuildOutputPaths): void {
if ($currentBuildOutputPaths->contains($file['output']) || Filesystem::isDirectory($file['output'])) {
throw new FileConflictException($file['output']);
}
});

$files->each(function (array $file): void {
static::needsParentDirectory($file['output']);
Filesystem::copy($file['source'], $file['output']);
});
}

/** @return \Illuminate\Support\Collection<int, \SplFileInfo> */
protected static function findStaticFiles(): Collection
{
if (! Filesystem::isDirectory('_static')) {
return collect();
}

return collect(Filesystem::allFiles(Hyde::path('_static'), true));
}

/** @return \Illuminate\Support\Collection<int, string> */
protected static function currentBuildOutputPaths(): Collection
{
$paths = Hyde::pages()->map(fn (HydePage $page): string => Hyde::sitePath($page->getOutputPath()))->values();

if (Config::getBool('hyde.transfer_media_assets', true)) {
$paths = $paths->concat(MediaFile::all()->map(fn (MediaFile $file): string => $file->getOutputPath()));
}

if (Config::getBool('hyde.generate_build_manifest', true)) {
$paths->push(Hyde::path(Config::getString(
'hyde.build_manifest_path',
'app/storage/framework/cache/build-manifest.json'
)));
}

return $paths;
}
}
105 changes: 105 additions & 0 deletions packages/framework/tests/Feature/StaticFilePassthroughTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace Hyde\Framework\Testing\Feature;

use Hyde\Hyde;
use Hyde\Facades\Filesystem;
use Hyde\Framework\Actions\TransferStaticFiles;
use Hyde\Framework\Exceptions\FileConflictException;
use Hyde\Testing\TestCase;
use Illuminate\Support\Facades\File;

#[\PHPUnit\Framework\Attributes\CoversClass(TransferStaticFiles::class)]
class StaticFilePassthroughTest extends TestCase
{
protected function tearDown(): void
{
File::cleanDirectory(Hyde::sitePath());

parent::tearDown();
}

public function testStaticFilesAreCopiedVerbatimToTheSiteRoot(): void
{
$binary = "\x00\x01\x02\xff";

$this->file('_static/robots.txt', "User-agent: *\nAllow: /");
$this->file('_static/.well-known/security.txt', 'Contact: mailto:security@example.com');
$this->file('_static/favicon.ico', $binary);

$this->artisan('build')->assertExitCode(0);

$this->assertSame("User-agent: *\nAllow: /", Filesystem::getContents('_site/robots.txt'));
$this->assertSame('Contact: mailto:security@example.com', Filesystem::getContents('_site/.well-known/security.txt'));
$this->assertSame($binary, Filesystem::getContents('_site/favicon.ico'));
}

public function testAbsentStaticDirectoryIsIgnored(): void
{
$this->artisan('build')->assertExitCode(0);

$this->assertDirectoryDoesNotExist(Hyde::path('_static'));
}

public function testStaticFilesOverwriteTheirOutputFromThePreviousBuild(): void
{
$this->file('_static/robots.txt', 'first');
$this->artisan('build')->assertExitCode(0);

$this->file('_static/robots.txt', 'second');
$this->artisan('build')->assertExitCode(0);

$this->assertSame('second', Filesystem::getContents('_site/robots.txt'));
}

public function testStaticFilesCannotOverwriteGeneratedOutput(): void
{
$this->file('_static/a.txt', 'copied first without preflight');
$this->file('_static/index.html', 'static replacement');

try {
$this->artisan('build')->run();
$this->fail('The conflicting static file was not rejected.');
} catch (FileConflictException $exception) {
$this->assertSame('File [_site/index.html] already exists.', $exception->getMessage());
}

$this->assertFileDoesNotExist(Hyde::sitePath('a.txt'));
$this->assertStringNotContainsString('static replacement', Filesystem::getContents('_site/index.html'));
}

public function testStaticFilesCannotOverwriteTransferredMedia(): void
{
$this->file('_media/static-collision.jpg', 'media');
$this->file('_static/a.txt', 'copied first without preflight');
$this->file('_static/media/static-collision.jpg', 'static');

try {
$this->artisan('build')->run();
$this->fail('The conflicting static file was not rejected.');
} catch (FileConflictException $exception) {
$this->assertSame('File [_site/media/static-collision.jpg] already exists.', $exception->getMessage());
} finally {
Filesystem::unlink('_media/static-collision.jpg');
}

$this->assertFileDoesNotExist(Hyde::sitePath('a.txt'));
$this->assertSame('media', Filesystem::getContents('_site/media/static-collision.jpg'));
}

public function testDisabledMediaTransferDoesNotCreateAStaticFileCollision(): void
{
config(['hyde.transfer_media_assets' => false]);
$this->file('_media/static-collision.jpg', 'media');
$this->file('_static/media/static-collision.jpg', 'static');

try {
$this->artisan('build')->assertExitCode(0);
$this->assertSame('static', Filesystem::getContents('_site/media/static-collision.jpg'));
} finally {
Filesystem::unlink('_media/static-collision.jpg');
}
}
}
17 changes: 15 additions & 2 deletions packages/realtime-compiler/src/Actions/AssetFileLocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,21 @@ public static function find(string $path): ?string
{
$path = trim($path, '/');

$file = BASE_PATH.'/_media/'.str_replace('media/', '', $path);
$static = BASE_PATH.'/_static/'.$path;

return file_exists($file) ? $file : null;
if (is_file($static)) {
return $static;
}

// TODO: Custom media directories are unsupported because media is proxied before the application boots.
if (str_starts_with($path, 'media/')) {
$media = BASE_PATH.'/_media/'.substr($path, strlen('media/'));

if (is_file($media)) {
return $media;
}
}

return null;
}
}
3 changes: 2 additions & 1 deletion packages/realtime-compiler/src/Routing/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public function handle(): Response
// A path with a file extension that isn't a web page is a static asset request,
// unless a page route is registered for the path (like `docs/search.json`),
// as pages take precedence over the on-disk files the proxy serves.
if ($this->hasAssetLikeExtension() && ! PageRouter::hasRoute($this->request)) {
if (! PageRouter::hasRoute($this->request)
&& ($this->hasAssetLikeExtension() || AssetFileLocator::find($this->request->path) !== null)) {
return $this->proxyStatic();
}

Expand Down
53 changes: 46 additions & 7 deletions packages/realtime-compiler/tests/RealtimeCompilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ protected function setUp(): void
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
Filesystem::deleteDirectory('_static');

parent::tearDown();
ob_end_clean();
Expand Down Expand Up @@ -127,6 +128,24 @@ public function testNormalizesMediaPath()
Filesystem::unlink('_media/test.css');
}

public function testStaticDirectoryTakesPrecedenceForMediaPath(): void
{
$this->mockCompilerRoute('media/static.jpg');
Filesystem::ensureDirectoryExists('_static/media');
Filesystem::put('_media/static.jpg', 'media');
Filesystem::put('_static/media/static.jpg', 'static media');

try {
$kernel = new HttpKernel();
$response = $kernel->handle(new Request());

$this->assertSame(200, $response->statusCode);
$this->assertSame('static media', $response->body);
} finally {
Filesystem::unlink('_media/static.jpg');
}
}

public function testThrowsRouteNotFoundExceptionForMissingRoute()
{
$this->mockCompilerRoute('missing');
Expand Down Expand Up @@ -221,8 +240,9 @@ public function testServesRegisteredPageRouteEvenWhenMatchingAssetExists()
$this->mockCompilerRoute('9.x');

Filesystem::ensureDirectoryExists('_pages/9.x');
Filesystem::ensureDirectoryExists('_static');
Filesystem::put('_pages/9.x/index.md', '# Hello World!');
Filesystem::put('_media/9.x', 'static decoy');
Filesystem::put('_static/9.x', 'static decoy');

try {
$kernel = new HttpKernel();
Expand All @@ -233,7 +253,7 @@ public function testServesRegisteredPageRouteEvenWhenMatchingAssetExists()
$this->assertStringNotContainsString('static decoy', $response->body);
} finally {
Filesystem::deleteDirectory('_pages/9.x');
Filesystem::unlink('_media/9.x');
Filesystem::unlink('_static/9.x');
}
}

Expand All @@ -242,8 +262,8 @@ public function testDocsSearchJsonRouteWinsOverMatchingAssetFile()
$this->mockCompilerRoute('docs/search.json');

Filesystem::put('_docs/index.md', '# Hello World!');
Filesystem::ensureDirectoryExists('_media/docs');
Filesystem::put('_media/docs/search.json', '"static decoy"');
Filesystem::ensureDirectoryExists('_static/docs');
Filesystem::put('_static/docs/search.json', '"static decoy"');

try {
$kernel = new HttpKernel();
Expand All @@ -254,15 +274,16 @@ public function testDocsSearchJsonRouteWinsOverMatchingAssetFile()
$this->assertIsArray(json_decode($response->body, true));
} finally {
Filesystem::unlink('_docs/index.md');
Filesystem::deleteDirectory('_media/docs');
Filesystem::deleteDirectory('_static/docs');
}
}

public function testProxiesRootLevelAssetWhenNoRouteMatchesThePath()
{
$this->mockCompilerRoute('data.json');

Filesystem::put('_media/data.json', '{"static": true}');
Filesystem::ensureDirectoryExists('_static');
Filesystem::put('_static/data.json', '{"static": true}');

try {
$kernel = new HttpKernel();
Expand All @@ -271,7 +292,25 @@ public function testProxiesRootLevelAssetWhenNoRouteMatchesThePath()
$this->assertSame(200, $response->statusCode);
$this->assertSame('{"static": true}', $response->body);
} finally {
Filesystem::unlink('_media/data.json');
Filesystem::unlink('_static/data.json');
}
}

public function testProxiesExtensionlessRootStaticFileWhenNoRouteMatchesThePath()
{
$this->mockCompilerRoute('security');

Filesystem::ensureDirectoryExists('_static');
Filesystem::put('_static/security', 'contact@example.com');

try {
$kernel = new HttpKernel();
$response = $kernel->handle(new Request());

$this->assertSame(200, $response->statusCode);
$this->assertSame('contact@example.com', $response->body);
} finally {
Filesystem::unlink('_static/security');
}
}

Expand Down
Loading