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
32 changes: 32 additions & 0 deletions app/Foundation/PortableFilesystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Foundation;

use Hyde\Hyde;
use Hyde\Foundation\Kernel\Filesystem;
use Illuminate\Support\Collection;

use function file_exists;
use function in_array;

/** Exposes the bundled stylesheet as a normal Portable media asset. */
final class PortableFilesystem extends Filesystem
{
public function findFiles(string $directory, string|array|false $matchExtensions = false, bool $recursive = false): Collection
{
$files = parent::findFiles($directory, $matchExtensions, $recursive);
$mediaDirectory = Hyde::getMediaDirectory();
$stylesheet = $mediaDirectory.'/app.css';

if ($directory === $mediaDirectory
&& ! file_exists(Hyde::path($stylesheet))
&& ! $files->contains($stylesheet)
&& ($matchExtensions === false || in_array('css', (array) $matchExtensions, true))) {
$files->push($stylesheet);
}

return $files->sort()->values();
}
}
17 changes: 17 additions & 0 deletions app/Foundation/PortableHydeKernel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Foundation;

use Hyde\Foundation\HydeKernel;

/** Hyde kernel used by the embedded application for Portable projects. */
final class PortableHydeKernel extends HydeKernel
{
public function __construct(?string $basePath = null)
{
parent::__construct($basePath);
$this->filesystem = new PortableFilesystem($this);
}
}
84 changes: 84 additions & 0 deletions app/Foundation/PortableIlluminateFilesystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Foundation;

use Illuminate\Filesystem\Filesystem;

use function file_get_contents;
use function file_exists;
use function filesize;
use function hash_file;
use function is_file;
use function is_string;
use function rtrim;
use function str_replace;

/**
* Adds the executable's app.css to the Portable project's read-only media view.
*
* The file is virtual: normal files always win, and no project directory is changed.
*/
final class PortableIlluminateFilesystem extends Filesystem
{
public function __construct(
private readonly string $bundledStylesheet,
private readonly string $virtualStylesheet,
)
{
// Illuminate's Filesystem has no constructor; keeping this explicit makes the
// dependency on the bundled resource visible.
}

public function exists($path): bool
{
return parent::exists($path) || $this->isBundledStylesheet($path);
}

public function missing($path): bool
{
return ! $this->exists($path);
}

public function get($path, $lock = false): string
{
if (! parent::exists($path) && $this->isBundledStylesheet($path)) {
return (string) file_get_contents($this->bundledStylesheet);
}

return parent::get($path, $lock);
}

public function hash($path, $algorithm = 'md5')
{
if (! parent::exists($path) && $this->isBundledStylesheet($path)) {
return hash_file($algorithm, $this->bundledStylesheet);
}

return parent::hash($path, $algorithm);
}

public function size($path)
{
if (! parent::exists($path) && $this->isBundledStylesheet($path)) {
return (int) filesize($this->bundledStylesheet);
}

return parent::size($path);
}

private function isBundledStylesheet($path): bool
{
if (! is_string($path) || ! is_file($this->bundledStylesheet)) {
return false;
}

return $this->normalize($path) === $this->normalize($this->virtualStylesheet);
}

private function normalize(string $path): string
{
return rtrim(str_replace('\\', '/', $path), '/');
}
}
3 changes: 3 additions & 0 deletions app/Launcher/RuntimeManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ final class RuntimeManager
*/
public const COMPOSER_FILE = 'composer.phar';

/** The production Hyde stylesheet used as the Portable asset fallback. */
public const STYLESHEET_FILE = 'app.css';

/**
* The suffix the embedded runtime binary carries inside the archive.
*
Expand Down
32 changes: 32 additions & 0 deletions app/Support/BundledStylesheet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Support;

use App\Launcher\RuntimeManager;
use RuntimeException;

use function dirname;
use function file_exists;

/** Locates the production stylesheet carried by the CLI application. */
final class BundledStylesheet
{
public static function path(): string
{
$candidates = [
dirname(__DIR__, 2).'/runtime/'.RuntimeManager::STYLESHEET_FILE,
dirname(__DIR__, 3).'/develop/packages/hyde/_media/app.css',
dirname(__DIR__, 3).'/develop/_media/app.css',
];

foreach ($candidates as $candidate) {
if (file_exists($candidate)) {
return $candidate;
}
}

throw new RuntimeException('The bundled Hyde app.css stylesheet is missing. Rebuild the CLI or sync the Hyde develop checkout.');
}
}
22 changes: 19 additions & 3 deletions app/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

use App\Launcher\Launcher;
use App\Launcher\RuntimeManager;
use App\Foundation\PortableHydeKernel;
use App\Foundation\PortableIlluminateFilesystem;
use App\Support\BundledStylesheet;

// Deprecation notices raised by dependencies are not actionable for someone running a
// pre-built executable, and printing them would corrupt command output. Warnings and
Expand Down Expand Up @@ -59,6 +62,19 @@

$app = new \App\Application($environment['working']);

$bundledStylesheet = $project->isPortable() ? BundledStylesheet::path() : null;

if ($bundledStylesheet !== null) {
// Hyde's service provider applies the configured media directory while providers boot.
// Bind the overlay after that phase so its exact virtual path matches the kernel.
$app->afterBootstrapping(\Illuminate\Foundation\Bootstrap\BootProviders::class, function () use ($app, $bundledStylesheet): void {
$mediaDirectory = \Hyde\Hyde::getMediaDirectory();
$virtualStylesheet = \Hyde\Hyde::path($mediaDirectory.'/app.css');

$app->singleton(\Illuminate\Filesystem\Filesystem::class, fn (): PortableIlluminateFilesystem => new PortableIlluminateFilesystem($bundledStylesheet, $virtualStylesheet));
});
}

// Everything the framework writes goes outside the project being built.
$app->useStoragePath($environment['temp'].'/app/storage');

Expand Down Expand Up @@ -123,9 +139,9 @@
|
*/

$hyde = new \Hyde\Foundation\HydeKernel(
$environment['working']
);
$hyde = $project->isPortable()
? new PortableHydeKernel($environment['working'])
: new \Hyde\Foundation\HydeKernel($environment['working']);

$app->singleton(
\Hyde\Foundation\HydeKernel::class, function (): Hyde\Foundation\HydeKernel {
Expand Down
26 changes: 26 additions & 0 deletions bin/build-phar.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function main(array $argv): int

$version = embedRuntime($options['runtime'], $platform);
$composer = embedComposer($options['composer'], $options['runtime']);
embedStylesheet();

writeRuntimeManifest($version, $platform, $options['runtime'], $options['micro'], $composer);

Expand All @@ -108,6 +109,31 @@ function main(array $argv): int
return verify($output, $options['micro']);
}

/** Copy the production Hyde stylesheet into the archive's runtime resources. */
function embedStylesheet(): void
{
$candidates = [
ROOT.'/../develop/packages/hyde/_media/app.css',
ROOT.'/../develop/_media/app.css',
];

foreach ($candidates as $source) {
if (is_file($source)) {
$target = ROOT.'/'.RuntimeManager::RUNTIME_DIRECTORY.'/'.RuntimeManager::STYLESHEET_FILE;

if (! copy($source, $target)) {
fail("Unable to copy the production stylesheet from $source");
}

info('Stylesheet', $source);

return;
}
}

fail('Unable to find the production Hyde app.css stylesheet in the develop checkout.');
}

/**
* Refuse to build a release artifact out of a development dependency tree.
*
Expand Down
15 changes: 15 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ scattered set of file-existence checks.
_pages/
_posts/
_media/
_static/
hyde.yml # optional
```

Expand All @@ -29,6 +30,11 @@ never invoked at any point in a build.
A Portable project cannot load Composer addons. There is no hybrid autoloading, and a
local `vendor/autoload.php` is never merged into the embedded dependency graph.

Portable projects remain content/configuration-only. The executable supplies the production
Hyde stylesheet as a read-only fallback during a build when `_media/app.css` is absent; it is
transferred to the normal compiled `media/app.css` location. `hyde new` does not create the
source stylesheet, and a user-provided file always takes precedence.

### Composer project

```
Expand Down Expand Up @@ -174,12 +180,21 @@ hyde = micro.sfx ++ hyde.phar
└── runtime/
├── php.gz (a full static PHP CLI, gzipped)
├── composer.phar.gz (Composer, patched and gzipped)
├── app.css (the production Hyde stylesheet for Portable projects)
└── runtime.json (versions, platform, checksums, patches, offset)
```

`bin/build-native.sh` (POSIX) and `bin/build-native.ps1` (Windows) drive static-php-cli;
`bin/build-phar.php` assembles the archive and concatenates it onto the micro binary.

The production Hyde stylesheet is copied from the v3 develop checkout into `runtime/app.css`
while the archive is assembled. In Portable mode the embedded application exposes that file
through a read-only media overlay only when the project has no `_media/app.css`. The framework
therefore continues to use its normal local `Asset::exists('app.css')` path: user stylesheets
win, the source `_media/` directory is never modified, and the generated site receives the
stylesheet at `media/app.css`. Composer projects are not given this overlay and retain their
own asset behavior.

### Why a second PHP is embedded

The micro SAPI can run the embedded application, but it is not a general CLI SAPI: it has
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/NewProjectCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
->and($workspace.'/my-site/hyde.yml')->toBeFile()
->and($workspace.'/my-site/_posts')->toBeDirectory()
->and($workspace.'/my-site/_media')->toBeDirectory()
->and($workspace.'/my-site/_media/app.css')->not->toBeFile()
->and($workspace.'/my-site/composer.json')->not->toBeFile()
->and($workspace.'/my-site/vendor')->not->toBeDirectory();
});
Expand Down
53 changes: 53 additions & 0 deletions tests/Feature/PortableBuildTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,59 @@
->toContain('Test Page');
});

it('builds the bundled stylesheet locally without changing the source media directory', function () {
$path = TemporaryProject::portable();

$this->boot($path);

expect($this->runCommand('build'))->toBe(0)
->and($path.'/_media/app.css')->not->toBeFile()
->and($path.'/_site/media/app.css')->toBeFile()
->and(file_get_contents($path.'/_site/index.html'))
->toContain('media/app.css')
->and(file_get_contents($path.'/_site/media/app.css'))
->toContain('tailwindcss');
});

it('prefers and preserves a user stylesheet', function () {
$userStylesheet = '/* user stylesheet */\nbody { color: rebeccapurple; }\n';
$path = TemporaryProject::portable(['_media/app.css' => $userStylesheet]);

$this->boot($path);

expect($this->runCommand('build'))->toBe(0)
->and(file_get_contents($path.'/_site/media/app.css'))->toBe($userStylesheet)
->and(file_get_contents($path.'/_media/app.css'))->toBe($userStylesheet);
});

it('supports the bundled stylesheet with a custom media directory', function () {
$path = TemporaryProject::portable([
'_pages/index.md' => "# Home\n",
'assets/.gitkeep' => '',
'hyde.yml' => "name: Custom Media Site\nmedia_directory: assets\n",
]);

$this->boot($path);

expect($this->runCommand('build'))->toBe(0)
->and($path.'/assets/app.css')->not->toBeFile()
->and($path.'/_site/assets/app.css')->toBeFile()
->and(file_get_contents($path.'/_site/index.html'))
->toContain('assets/app.css');
});

it('produces the same bundled stylesheet on repeated builds', function () {
$path = TemporaryProject::portable();

$this->boot($path);

expect($this->runCommand('build'))->toBe(0);
$first = file_get_contents($path.'/_site/media/app.css');

expect($this->runCommand('build'))->toBe(0)
->and(file_get_contents($path.'/_site/media/app.css'))->toBe($first);
});

it('never creates a vendor directory or a composer manifest', function () {
$path = TemporaryProject::portable();

Expand Down
4 changes: 3 additions & 1 deletion tests/Integration/NativeExecutableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@
->and($path.'/_site/index.html')->toBeFile()
->and(file_get_contents($path.'/_site/index.html'))
->toContain('Hello Portable World')
->toContain('Test Page');
->toContain('Test Page')
->toContain('media/app.css')
->and($path.'/_site/media/app.css')->toBeFile();
});

it('never creates a vendor directory or a composer manifest while building', function () {
Expand Down
Loading