diff --git a/app/Foundation/PortableFilesystem.php b/app/Foundation/PortableFilesystem.php new file mode 100644 index 00000000..e7e89811 --- /dev/null +++ b/app/Foundation/PortableFilesystem.php @@ -0,0 +1,32 @@ +contains($stylesheet) + && ($matchExtensions === false || in_array('css', (array) $matchExtensions, true))) { + $files->push($stylesheet); + } + + return $files->sort()->values(); + } +} diff --git a/app/Foundation/PortableHydeKernel.php b/app/Foundation/PortableHydeKernel.php new file mode 100644 index 00000000..02fa0f12 --- /dev/null +++ b/app/Foundation/PortableHydeKernel.php @@ -0,0 +1,17 @@ +filesystem = new PortableFilesystem($this); + } +} diff --git a/app/Foundation/PortableIlluminateFilesystem.php b/app/Foundation/PortableIlluminateFilesystem.php new file mode 100644 index 00000000..bcb52f08 --- /dev/null +++ b/app/Foundation/PortableIlluminateFilesystem.php @@ -0,0 +1,84 @@ +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), '/'); + } +} diff --git a/app/Launcher/RuntimeManager.php b/app/Launcher/RuntimeManager.php index 98d1abac..62051539 100644 --- a/app/Launcher/RuntimeManager.php +++ b/app/Launcher/RuntimeManager.php @@ -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. * diff --git a/app/Support/BundledStylesheet.php b/app/Support/BundledStylesheet.php new file mode 100644 index 00000000..64709b08 --- /dev/null +++ b/app/Support/BundledStylesheet.php @@ -0,0 +1,32 @@ +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'); @@ -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 { diff --git a/bin/build-phar.php b/bin/build-phar.php index 33f45bed..d7da4581 100644 --- a/bin/build-phar.php +++ b/bin/build-phar.php @@ -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); @@ -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. * diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 157bb05b..8e139e58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,6 +18,7 @@ scattered set of file-existence checks. _pages/ _posts/ _media/ +_static/ hyde.yml # optional ``` @@ -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 ``` @@ -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 diff --git a/tests/Feature/NewProjectCommandTest.php b/tests/Feature/NewProjectCommandTest.php index e3b4f5eb..ed045faa 100644 --- a/tests/Feature/NewProjectCommandTest.php +++ b/tests/Feature/NewProjectCommandTest.php @@ -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(); }); diff --git a/tests/Feature/PortableBuildTest.php b/tests/Feature/PortableBuildTest.php index 42bac734..c89cd5c1 100644 --- a/tests/Feature/PortableBuildTest.php +++ b/tests/Feature/PortableBuildTest.php @@ -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(); diff --git a/tests/Integration/NativeExecutableTest.php b/tests/Integration/NativeExecutableTest.php index 91adbbcc..9f193fd5 100644 --- a/tests/Integration/NativeExecutableTest.php +++ b/tests/Integration/NativeExecutableTest.php @@ -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 () {