diff --git a/phpunit.xml b/phpunit.xml
index 1b7cef5f..0558506c 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -27,5 +27,8 @@
+
+
+
diff --git a/site-dynamic-php/index.php b/site-dynamic-php/index.php
index 5bf35bf0..fbe2b1b9 100644
--- a/site-dynamic-php/index.php
+++ b/site-dynamic-php/index.php
@@ -14,7 +14,10 @@
JoshBruce\Site\SiteDynamic\Emitter::emit(
response:JoshBruce\Site\HttpResponse::from(
- request: JoshBruce\Site\HttpRequest::fromGlobals()
+ request: JoshBruce\Site\HttpRequest::with(
+ JoshBruce\Site\ServerGlobals::init(),
+ JoshBruce\Site\FileSystem::init()
+ )
)
);
exit;
diff --git a/src/Content/Markdown.php b/src/Content/Markdown.php
index 49429a04..5213a6e3 100644
--- a/src/Content/Markdown.php
+++ b/src/Content/Markdown.php
@@ -7,6 +7,7 @@
use Eightfold\Markdown\Markdown as MarkdownConverter;
use JoshBruce\Site\File;
+use JoshBruce\Site\FileSystemInterface;
use JoshBruce\Site\PageComponents\Data;
use JoshBruce\Site\PageComponents\DateBlock;
@@ -24,9 +25,9 @@ class Markdown
private string $body = '';
- public static function for(File $file): Markdown
+ public static function for(File $file, FileSystemInterface $in): Markdown
{
- return new Markdown($file);
+ return new Markdown($file, $in);
}
public static function markdownConverter(): MarkdownConverter
@@ -49,8 +50,10 @@ public static function markdownConverter(): MarkdownConverter
);
}
- private function __construct(private File $file)
- {
+ private function __construct(
+ private File $file,
+ private FileSystemInterface $fileSystem
+ ) {
}
public function html(): string
@@ -77,10 +80,13 @@ public function html(): string
$b = '';
$template = $templateMap[$templateKey];
if ($templateKey === 'loglist') {
- $b = $template::create($this->file);
+ $b = $template::create($this->file(), $this->fileSystem());
} else {
- $b = $template::create($this->frontMatter());
+ $b = $template::create(
+ $this->frontMatter(),
+ $this->fileSystem()
+ );
}
@@ -118,11 +124,11 @@ public function pageTitle(): string
$titles = [];
$titles[] = $this->frontMatter()->title();
- $file = clone $this->file;
+ $file = clone $this->file();
while ($file->canGoUp()) {
$file = $file->up();
- $m = Markdown::for($file);
+ $m = Markdown::for($file, $this->fileSystem());
$titles[] = $m->frontMatter()->title();
}
@@ -131,16 +137,21 @@ public function pageTitle(): string
return implode(' | ', $titles);
}
- public function canonicalURl(): string
+ public function file(): File
{
- return $this->file->canonicalUrl();
+ return $this->file;
}
private function fileContent(): string
{
- if (strlen($this->fileContent) === 0 and $this->file->found()) {
- $this->fileContent = $this->file->contents();
+ if (strlen($this->fileContent) === 0 and $this->file()->found()) {
+ $this->fileContent = $this->file()->contents();
}
return $this->fileContent;
}
+
+ private function fileSystem(): FileSystemInterface
+ {
+ return $this->fileSystem;
+ }
}
diff --git a/src/Documents/Sitemap.php b/src/Documents/Sitemap.php
index 2cc565da..f46529d5 100644
--- a/src/Documents/Sitemap.php
+++ b/src/Documents/Sitemap.php
@@ -7,30 +7,31 @@
use Eightfold\XMLBuilder\Document;
use Eightfold\XMLBuilder\Element;
-use JoshBruce\Site\FileSystem;
+use JoshBruce\Site\FileSystemInterface;
use JoshBruce\Site\File;
use JoshBruce\Site\Content\Markdown;
class Sitemap
{
- public static function create(): string
+ public static function create(FileSystemInterface $fileSystem): string
{
- $finder = FileSystem::finder()->name('content.md')->sortByName()
+ $finder = $fileSystem->publishedContentFinder()->sortByName()
->notContains('redirect:')
->notContains('noindex:');
$markdown = [];
foreach ($finder as $file) {
$markdown[] = Markdown::for(
- File::at($file->getPathname())
+ File::at($file->getPathname(), $fileSystem),
+ $fileSystem
);
}
$urls = [];
foreach ($markdown as $m) {
$urls[] = Element::url(
- Element::loc($m->canonicalUrl())
+ Element::loc($m->file()->canonicalUrl())
);
}
diff --git a/src/File.php b/src/File.php
index 6c88eb21..536e0d46 100644
--- a/src/File.php
+++ b/src/File.php
@@ -6,19 +6,25 @@
use DirectoryIterator;
-use JoshBruce\Site\FileSystem;
+use JoshBruce\Site\FileSystemInterface;
class File
{
private string $contentFileName = '/content.md';
- public static function at(string $localPath): File
+ private string $contents = '';
+
+ private string $mimetype = '';
+
+ public static function at(string $localPath, FileSystemInterface $in): File
{
- return new File($localPath);
+ return new File($localPath, $in);
}
- private function __construct(private string $localPath)
- {
+ private function __construct(
+ private string $localPath,
+ private FileSystemInterface $fileSystem
+ ) {
}
public function isNotMarkdown(): bool
@@ -60,7 +66,7 @@ public function path(bool $full = true): string
}
// TODO: test and verify used - returning empty string not an option.
return str_replace(
- $this->contentRoot(),
+ $this->fileSystem()->publicRoot(),
'',
$this->localPath
);
@@ -76,39 +82,48 @@ public function up(): File
$parts = explode('/', $this->localPath);
$parts = array_slice($parts, 0, -2); // remove file name and one folder.
$localPath = implode('/', $parts);
- return File::at($localPath . $this->contentFileName);
+ return File::at(
+ $localPath . $this->contentFileName,
+ $this->fileSystem()
+ );
}
public function contents(): string
{
- $contents = file_get_contents($this->path());
- if ($contents === false) {
- return '';
+ if (strlen($this->contents) === 0) {
+ $contents = file_get_contents($this->path());
+ if ($contents === false) {
+ return '';
+ }
+ $this->contents = $contents;
}
- return $contents;
+ return $this->contents;
}
public function mimetype(): string
{
- $type = mime_content_type($this->path());
- if (is_bool($type) and $type === false) {
- return '';
- }
+ if (strlen($this->mimetype) === 0) {
+ $type = mime_content_type($this->path());
+ if (is_bool($type) and $type === false) {
+ return '';
+ }
- if ($type === 'text/plain') {
- $extensionMap = [
- 'md' => 'text/html',
- 'css' => 'text/css',
- 'js' => 'text/javascript',
- 'xml' => 'application/xml'
- ];
+ if ($type === 'text/plain') {
+ $extensionMap = [
+ 'md' => 'text/html',
+ 'css' => 'text/css',
+ 'js' => 'text/javascript',
+ 'xml' => 'application/xml'
+ ];
- $parts = explode('.', $this->path());
- $extension = array_pop($parts);
+ $parts = explode('.', $this->path());
+ $extension = array_pop($parts);
- $type = $extensionMap[$extension];
+ $type = $extensionMap[$extension];
+ }
+ $this->mimetype = $type;
}
- return $type;
+ return $this->mimetype;
}
public function canonicalUrl(): string
@@ -138,14 +153,15 @@ public function children(string $filesNamed): array
$folderName = array_pop($parts);
$files[$folderName] = File::at(
- $fullPathToFolder . '/' . $filesNamed
+ $fullPathToFolder . '/' . $filesNamed,
+ $this->fileSystem()
);
}
return $files;
}
- private function contentRoot(): string
+ private function fileSystem(): FileSystemInterface
{
- return FileSystem::publicRoot();
+ return $this->fileSystem;
}
}
diff --git a/src/FileSystem.php b/src/FileSystem.php
index e82af58d..3d098e02 100644
--- a/src/FileSystem.php
+++ b/src/FileSystem.php
@@ -8,16 +8,38 @@
use Symfony\Component\Finder\Finder;
-class FileSystem
+use JoshBruce\Site\FileSystemInterface;
+
+class FileSystem implements FileSystemInterface
{
- public static function publicRoot(): string
+ public static function init(): static
+ {
+ return new static(static::projectRoot());
+ }
+
+ public static function projectRoot(): string
+ {
+ $dir = __DIR__;
+ $parts = explode('/', $dir);
+ $parts = array_slice($parts, 0, -1);
+ return implode('/', $parts);
+ }
+
+ final private function __construct(protected string $projectRoot)
{
- return FileSystem::contentRoot() . '/public';
}
- public static function contentRoot(): string
+ public function hasRequiredFolders(): bool
{
- $parts = explode('/', self::projectRoot());
+ return file_exists($this->contentRoot()) and
+ file_exists($this->publicRoot()) and
+ is_dir($this->contentRoot()) and
+ is_dir($this->publicRoot());
+ }
+
+ public function contentRoot(): string
+ {
+ $parts = explode('/', static::projectRoot());
$parts[] = 'content';
$base = implode('/', $parts);
if (str_ends_with($base, '/')) {
@@ -26,41 +48,42 @@ public static function contentRoot(): string
return $base;
}
- public static function projectRoot(): string
+ public function publicRoot(): string
{
- $dir = __DIR__;
- $parts = explode('/', $dir);
- $parts = array_slice($parts, 0, -1);
- return implode('/', $parts);
+ return $this->contentRoot() . '/public';
}
- public static function finder(): Finder
+ public function publishedContentFinder(): Finder
{
- $finder = new Finder();
- return $finder->ignoreVCS(false)
- ->ignoreUnreadableDirs()
- ->ignoreDotFiles(false)
- ->ignoreVCSIgnored(true)
- ->notName('.gitignore')
- ->files()
- ->filter(fn($f) => self::isPublished($f))
- ->in(self::publicRoot());
+ return $this->finder()->in($this->publicRoot())->name('content.md')
+ ->filter(fn($f) => $this->isPublished($f));
}
- private static function isPublished(SplFileInfo $finderFile): bool
+ private function relativePath(string $path): string
{
- return ! self::isDraft($finderFile);
+ return str_replace($this->contentRoot(), '', $path);
}
- private static function isDraft(SplFileInfo $finderFile): bool
+ private function isPublished(SplFileInfo $finderFile): bool
+ {
+ return ! $this->isDraft($finderFile);
+ }
+
+ private function isDraft(SplFileInfo $finderFile): bool
{
$filePath = (string) $finderFile;
- $relativePath = self::relativePath($filePath);
+ $relativePath = $this->relativePath($filePath);
return str_contains($relativePath, '_');
}
- private static function relativePath(string $path): string
+ private function finder(): Finder
{
- return str_replace(self::contentRoot(), '', $path);
+ $finder = new Finder();
+ return $finder->ignoreVCS(false)
+ ->ignoreUnreadableDirs()
+ ->ignoreDotFiles(false)
+ ->ignoreVCSIgnored(true)
+ ->notName('.gitignore')
+ ->files();
}
}
diff --git a/src/FileSystemInterface.php b/src/FileSystemInterface.php
new file mode 100644
index 00000000..5a638bcd
--- /dev/null
+++ b/src/FileSystemInterface.php
@@ -0,0 +1,22 @@
+serverGlobals()->isMissingAppEnv()) {
- return true;
- }
+ public static function with(
+ ServerGlobals $serverGlobals,
+ FileSystemInterface $in
+ ): HttpRequest {
+ return new HttpRequest($serverGlobals, $in);
+ }
- if ($this->serverGlobals()->appEnvIsNot('production')) {
+ private function __construct(
+ private ServerGlobals $serverGlobals,
+ private FileSystemInterface $fileSystem
+ ) {
+ if ($this->serverGlobals()->appEnv() !== 'production') {
// use Whoops! for error display
$errorHandler = new ErrorHandler();
$errorHandler->pushHandler(
@@ -47,17 +50,37 @@ public function isMissingRequiredValues(): bool
);
$errorHandler->register();
}
- return false;
+ }
+
+ public function isMissingRequiredValues(): bool
+ {
+ return $this->serverGlobals()->isMissingRequiredValues();
}
public function isUnsupportedMethod(): bool
{
- return ! $this->isSupportedMethod();
+ $requestMethod = strtoupper($this->psrRequest()->getMethod());
+ $isSupported = in_array($requestMethod, $this->supportedMethods());
+
+ return ! $isSupported;
}
public function isNotFound(): bool
{
- return ! $this->isFound();
+ $isFound = file_exists($this->localPath()) and
+ is_file($this->localPath());
+ return ! $isFound;
+ }
+
+ public function localFile(): File
+ {
+ if (! isset($this->localFile)) {
+ $this->localFile = File::at(
+ localPath: $this->localPath(),
+ in: $this->fileSystem()
+ );
+ }
+ return $this->localFile;
}
public function isFile(): bool
@@ -67,7 +90,7 @@ public function isFile(): bool
public function isSitemap(): bool
{
- return $this->isFile() and $this->possibleFileName() === 'sitemap.xml';
+ return $this->possibleFileName() === 'sitemap.xml';
}
public function isNotSitemap(): bool
@@ -75,52 +98,46 @@ public function isNotSitemap(): bool
return ! $this->isSitemap();
}
- public function localFile(): File
+ public function fileSystem(): FileSystemInterface
{
- return File::at(localPath: $this->localPath());
- }
-
- private function isFound(): bool
- {
- return file_exists($this->localPath()) and is_file($this->localPath());
+ return $this->fileSystem;
}
private function localPath(): string
{
if (empty($this->localPath)) {
- $possibleFileName = $this->possibleFileName();
- $relativePath = $this->uriPath();
- if (empty($possibleFileName)) {
- $relativePath = $this->uriPath() . '/content.md';
-
- // } elseif (str_contains($relativePath, '.xml')) {
- // $relativePath = str_replace('.xml', '.md', $relativePath);
-
+ $relativePath = $this->psrPath();
+ if (empty($this->possibleFileName())) {
+ $relativePath = $this->psrPath() . '/content.md';
}
if (! str_starts_with($relativePath, '/')) {
$relativePath = "/{$relativePath}";
}
- $root = FileSystem::contentRoot();
+ $root = $this->fileSystem()->publicRoot();
- $this->localPath = "{$root}/public{$relativePath}";
+ $this->localPath = "{$root}{$relativePath}";
}
return $this->localPath;
}
private function possibleFileName(): string
{
- $parts = explode('/', $this->uriPath());
- $lastPart = array_slice($parts, -1);
- $possibleFileName = array_shift($lastPart);
- if (
- $possibleFileName === null or
- ! str_contains($possibleFileName, '.')
- ) {
- return '';
+ if (strlen($this->possibleFileName) === 0) {
+ $parts = explode('/', $this->psrPath());
+ $lastPart = array_slice($parts, -1);
+ $possibleFileName = array_shift($lastPart);
+ if (
+ $possibleFileName === null or
+ ! str_contains($possibleFileName, '.')
+ ) {
+ return '';
+ }
+
+ $this->possibleFileName = $possibleFileName;
}
- return $possibleFileName;
+ return $this->possibleFileName;
}
/**
@@ -131,15 +148,9 @@ private function supportedMethods(): array
return ['GET'];
}
- private function isSupportedMethod(): bool
- {
- $requestMethod = strtoupper($this->psrRequest()->getMethod());
- return in_array($requestMethod, $this->supportedMethods());
- }
-
private function serverGlobals(): ServerGlobals
{
- return ServerGlobals::init();
+ return $this->serverGlobals;
}
private function psrRequest(): RequestInterface
@@ -158,17 +169,12 @@ private function psrRequest(): RequestInterface
return $this->psrRequest;
}
- private function uriPath(): string
+ private function psrPath(): string
{
- $uriPath = $this->uri()->getPath();
- if ($uriPath === '/') {
- $uriPath = '';
+ $psrPath = $this->psrRequest()->getUri()->getPath();
+ if ($psrPath === '/') {
+ $psrPath = '';
}
- return $uriPath;
- }
-
- private function uri(): UriInterface
- {
- return $this->psrRequest()->getUri();
+ return $psrPath;
}
}
diff --git a/src/HttpResponse.php b/src/HttpResponse.php
index 7f9e7d36..3a753f64 100644
--- a/src/HttpResponse.php
+++ b/src/HttpResponse.php
@@ -14,7 +14,6 @@
use Eightfold\HTMLBuilder\Document;
use Eightfold\HTMLBuilder\Element;
-use JoshBruce\Site\FileSystem;
use JoshBruce\Site\File;
use JoshBruce\Site\Content\Markdown;
@@ -38,13 +37,13 @@ private function __construct(private HttpRequest $request)
public function statusCode(): int
{
- if ($this->request->isMissingRequiredValues()) {
+ if ($this->request()->isMissingRequiredValues()) {
return 500;
- } elseif ($this->request->isUnsupportedMethod()) {
+ } elseif ($this->request()->isUnsupportedMethod()) {
return 405;
- } elseif ($this->request->isNotFound()) {
+ } elseif ($this->request()->isNotFound()) {
return 404;
}
@@ -58,33 +57,34 @@ public function headers(): array
{
$headers = [];
if ($this->statusCode() === 200) {
- $headers['Content-Type'] = $this->request->localFile()->mimeType();
+ $headers['Content-Type'] = $this->request()->localFile()->mimeType();
} elseif ($this->statusCode() === 404) {
$headers['Content-Type'] = 'text/html';
}
-
return $headers;
}
public function body(): string
{
- $localFile = $this->request->localFile();
+ $localFile = $this->request()->localFile();
if (
$this->statusCode() === 200 and
$localFile->isNotMarkdown() and
- $this->request->isNotSitemap()
+ $this->request()->isNotSitemap()
) {
return $localFile->path();
} elseif ($this->statusCode() === 404) {
- $localPath = FileSystem::contentRoot() . '/public/error-404.md';
- $localFile = File::at($localPath);
+ $localPath = $this->request()->fileSystem()->publicRoot() .
+ '/error-404.md';
+ $localFile = File::at($localPath, $this->request()->fileSystem());
} elseif ($this->statusCode() === 405) {
- $localPath = FileSystem::contentRoot() . '/public/error-405.md';
- $localFile = File::at($localPath);
+ $localPath = $this->request()->fileSystem()->publicRoot() .
+ '/error-405.md';
+ $localFile = File::at($localPath, $this->request()->fileSystem());
}
@@ -92,16 +92,17 @@ public function body(): string
$pageTitle = '';
$html = '';
if ($localFile->isMarkdown()) {
- $markdown = Markdown::for(file: $localFile);
+ $markdown = Markdown::for(
+ file: $localFile,
+ in: $this->request()->fileSystem()
+ );
$template = $markdown->frontMatter()->template();
$pageTitle = $markdown->pageTitle();
$html = $markdown->html();
-
}
- if ($this->request->isSitemap()) {
- return Sitemap::create();
-
+ if ($this->request()->isSitemap()) {
+ return Sitemap::create($this->request()->fileSystem());
}
return Document::create(
@@ -138,7 +139,7 @@ public function body(): string
$html
)->props('typeof BlogPosting', 'vocab https://schema.org/'),
Element::a('top')->props('href #content-top', 'id go-to-top'),
- Navigation::create('main.md'),
+ Navigation::create('main.md', $this->request()->fileSystem()),
Element::footer(
Element::p(
'Copyright © 2004–' . date('Y') . ' Joshua C. Bruce. ' .
@@ -167,8 +168,8 @@ public function psrResponse(): ResponseInterface
$body = $this->body();
$stream = $psr17Factory->createStream($body);
if (
- $this->request->isFile() and
- $this->request->isNotSitemap()
+ $this->request()->isFile() and
+ $this->request()->isNotSitemap()
) {
$stream = $psr17Factory->createStreamFromFile($body);
}
@@ -181,4 +182,9 @@ public function psrResponse(): ResponseInterface
}
return $this->psrResponse;
}
+
+ private function request(): HttpRequest
+ {
+ return $this->request;
+ }
}
diff --git a/src/PageComponents/LogList.php b/src/PageComponents/LogList.php
index 687571c5..75ca92ca 100644
--- a/src/PageComponents/LogList.php
+++ b/src/PageComponents/LogList.php
@@ -4,6 +4,7 @@
namespace JoshBruce\Site\PageComponents;
+use JoshBruce\Site\FileSystemInterface;
use JoshBruce\Site\File;
use Eightfold\HTMLBuilder\Element;
@@ -12,8 +13,10 @@
class LogList
{
- public static function create(File $file): string
- {
+ public static function create(
+ File $file,
+ FileSystemInterface $fileSystem
+ ): string {
$fileSubfolders = $file->children(filesNamed: 'content.md');
if (count($fileSubfolders) === 0) {
return '';
@@ -23,7 +26,7 @@ public static function create(File $file): string
$logLinks = [];
foreach ($fileSubfolders as $key => $file) {
if (! str_starts_with(strval($key), '_') and $file->found()) {
- $markdown = Markdown::for($file);
+ $markdown = Markdown::for($file, $fileSystem);
$linkPath = str_replace(
'/content.md',
diff --git a/src/PageComponents/Navigation.php b/src/PageComponents/Navigation.php
index 93925813..325d5909 100644
--- a/src/PageComponents/Navigation.php
+++ b/src/PageComponents/Navigation.php
@@ -6,24 +6,26 @@
use Eightfold\HTMLBuilder\Element;
-use JoshBruce\Site\FileSystem;
+use JoshBruce\Site\FileSystemInterface;
use JoshBruce\Site\File;
use JoshBruce\Site\Content\Markdown;
class Navigation
{
- public static function create(string $fileName): string
- {
- $contentRoot = FileSystem::contentRoot();
+ public static function create(
+ string $fileName,
+ FileSystemInterface $fileSystem
+ ): string {
+ $contentRoot = $fileSystem->contentRoot();
$navigationPath = $contentRoot . '/navigation';
$filePath = $navigationPath . '/' . $fileName;
- $file = File::at($filePath);
+ $file = File::at($filePath, $fileSystem);
if ($file->isNotFound()) {
return '';
}
- $html = Markdown::for($file)->html();
+ $html = Markdown::for($file, $fileSystem)->html();
return Element::nav($html)->props('id main-nav')->build();
}
diff --git a/src/PageComponents/OriginalContentNotice.php b/src/PageComponents/OriginalContentNotice.php
index 067671a2..01a01c30 100644
--- a/src/PageComponents/OriginalContentNotice.php
+++ b/src/PageComponents/OriginalContentNotice.php
@@ -7,19 +7,21 @@
use Eightfold\HTMLBuilder\Element;
use JoshBruce\Site\File;
-use JoshBruce\Site\FileSystem;
+use JoshBruce\Site\FileSystemInterface;
use JoshBruce\Site\Content\Markdown;
use JoshBruce\Site\Content\FrontMatter;
class OriginalContentNotice
{
- public static function create(FrontMatter $frontMatter): string
- {
- $contentRoot = FileSystem::contentRoot();
+ public static function create(
+ FrontMatter $frontMatter,
+ FileSystemInterface $fileSystem
+ ): string {
+ $contentRoot = $fileSystem->contentRoot();
$noticesRoot = $contentRoot . '/notices';
- $file = File::at($noticesRoot . '/original.md');
+ $file = File::at($noticesRoot . '/original.md', $fileSystem);
if ($file->isNotFound()) {
return '';
}
@@ -27,7 +29,7 @@ public static function create(FrontMatter $frontMatter): string
$original = $frontMatter->original();
list($href, $platform) = explode(' ', $original, 2);
- $body = Markdown::for($file)->body();
+ $body = Markdown::for($file, $fileSystem)->body();
$matches = [];
$search = '/{!!platformlink!!}/';
diff --git a/src/ServerGlobals.php b/src/ServerGlobals.php
index 0f11fd80..980c1474 100644
--- a/src/ServerGlobals.php
+++ b/src/ServerGlobals.php
@@ -6,6 +6,11 @@
class ServerGlobals
{
+ /**
+ * @var array
+ */
+ private array $globals = [];
+
public static function init(): ServerGlobals
{
return new ServerGlobals();
@@ -13,49 +18,56 @@ public static function init(): ServerGlobals
private function __construct()
{
+ $this->globals = $_SERVER;
}
- public function appEnvIsNot(string $value): bool
+ public function withRequestUri(string $uri): ServerGlobals
{
- return $this->appEnv() !== $value;
+ $this->globals = [];
+
+ $_SERVER['REQUEST_URI'] = $uri;
+ return $this;
}
- public function isMissingAppEnv(): bool
+ public function requestUri(): string
{
- return ! $this->hasAppEnv();
+ $globals = $this->globals();
+ return strval($globals['REQUEST_URI']);
}
- public function isMissingAppUrl(): bool
+ public function withRequestMethod(string $method): ServerGlobals
{
- return ! $this->hasAppUrl();
+ $this->globals = [];
+
+ $_SERVER['REQUEST_METHOD'] = $method;
+ return $this;
}
- public function appUrl(): string
+ public function requestMethod(): string
{
- if ($this->hasAppUrl()) {
- $globals = $this->globals();
- return strval($globals['APP_URL']);
- }
- return '';
+ $globals = $this->globals();
+ return strval($globals['REQUEST_METHOD']);
}
- private function appEnv(): string
+ public function isMissingRequiredValues(): bool
{
- if ($this->hasAppEnv()) {
- $globals = $this->globals();
- return strval($globals['APP_ENV']);
- }
- return '';
+ return ! $this->hasRequiredValues();
}
- private function hasAppEnv(): bool
+ private function hasRequiredValues(): bool
{
- return array_key_exists('APP_ENV', $this->globals());
+ $globals = $this->globals();
+ return array_key_exists('APP_ENV', $globals) and
+ array_key_exists('APP_URL', $globals);
}
- private function hasAppUrl(): bool
+ public function appEnv(): string
{
- return array_key_exists('APP_URL', $this->globals());
+ if ($this->hasRequiredValues()) {
+ $globals = $this->globals();
+ return strval($globals['APP_ENV']);
+ }
+ return '';
}
/**
@@ -63,6 +75,9 @@ private function hasAppUrl(): bool
*/
private function globals(): array
{
- return $_SERVER;
+ if (count($this->globals) === 0) {
+ $this->globals = $_SERVER;
+ }
+ return $this->globals;
}
}
diff --git a/src/SiteStatic/Generator.php b/src/SiteStatic/Generator.php
index 95687883..87e9bf1c 100644
--- a/src/SiteStatic/Generator.php
+++ b/src/SiteStatic/Generator.php
@@ -15,6 +15,7 @@
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\Filesystem as LeagueFilesystem;
+use JoshBruce\Site\ServerGlobals;
use JoshBruce\Site\FileSystem;
use JoshBruce\Site\HttpResponse;
@@ -39,15 +40,14 @@ private function __construct(
private OutputInterface $output,
private string $destination = ''
) {
- $projectRoot = FileSystem::projectRoot();
+ $projectRoot = FileSystem::init()->projectRoot();
+ // $projectRoot = FileSystem::projectRoot();
Dotenv::createImmutable($projectRoot)->load();
- if (isset($_SERVER['APP_ENV'])) {
- $this->isNotTesting = $_SERVER['APP_ENV'] !== 'testing';
- }
+ // $this->isNotTesting = ServerGlobals::init()->appEnv() !== 'test';
- $this->contentRoot = FileSystem::contentRoot() . '/public';
+ $this->contentRoot = FileSystem::init()->publicRoot();
if (strlen($destination) === 0) {
$this->destination = $projectRoot . '/site-static-html/public';
@@ -83,34 +83,35 @@ private function compileContentFileFor(string $contentPath): void
$parts = array_slice($parts, 0, -1);
$requestUri = implode('/', $parts);
- $_SERVER['REQUEST_URI'] = $requestUri;
+ $globals = ServerGlobals::init()->withRequestUri($requestUri)
+ ->withRequestMethod('GET');
if (str_contains($destinationPath, '/error-404.html')) {
- $_SERVER['REQUEST_URI'] = '/low/probability/of/ex/is/ting';
+ $globals = $globals->withRequestUri('/low/prob/a/bil/it/ee');
} elseif (str_contains($destinationPath, '/error-405.html')) {
- $_SERVER['REQUEST_METHOD'] = 'DELETE';
+ $globals = $globals->withRequestMethod('DELETE');
} elseif (strlen($requestUri) === 0) {
- $_SERVER['REQUEST_URI'] = '/';
+ $globals = $globals->withRequestUri('/');
}
- // $_SERVER['REQUEST_URI'] = (strlen($requestUri) === 0)
- // ? '/'
- // : $requestUri;
+ $fileSystem = FileSystem::init();
- $html = HttpResponse::from(request: HttpRequest::fromGlobals())->body();
+ $html = HttpResponse::from(
+ request: HttpRequest::with(serverGlobals: $globals, in: $fileSystem)
+ )->body();
$this->leagueFileSystem()->write($destinationPath, $html);
- // $this->sourceFileConvertedMessage($contentPath, $destinationPath);
+ $this->sourceFileConvertedMessage($contentPath, $destinationPath);
}
private function copyFileFor(string $path): void
{
$destinationPath = $this->fileDestinationPathFor($path);
$this->leagueFileSystem()->copy($path, $destinationPath);
- // $this->sourceFileCopiedMessage($path, $destinationPath);
+ $this->sourceFileCopiedMessage($path, $destinationPath);
}
private function contentDestinationPathFor(string $path): string
diff --git a/tests/FileSystemTest.php b/tests/FileSystemTest.php
index 3cb762b6..392b5cce 100644
--- a/tests/FileSystemTest.php
+++ b/tests/FileSystemTest.php
@@ -1,181 +1,33 @@
projectRoot = implode('/', array_slice(explode('/', __DIR__), 0, -1));
-//
-// serverGlobals();
-//
-// $this->contentRoot = $this->projectRoot . '/content';
-// });
-//
-// test('content folder does exist', function() {
-// expect(
-// FileSystem::contentFolderIsMissing()
-// )->toBeFalse();
-// })->group('filesystem');
-//
-// it('can initialize folders', function() {
-// expect(
-// FileSystem::public()->path()
-// )->toBeString()->toBe(
-// "{$this->contentRoot}/public"
-// );
-//
-// expect(
-// FileSystem::navigation()->path()
-// )->toBeString()->toBe(
-// "{$this->contentRoot}/navigation"
-// );
-// })->group('filesystem');
-//
-// // it('can return folder tree', function() {
-// // $this->assertEquals(
-// // FileSystem::init(
-// // $this->contentRoot,
-// // 'public',
-// // 'finances',
-// // 'investment-policy'
-// // )->folderStack(),
-// // [
-// // FileSystem::init(
-// // $this->contentRoot,
-// // 'public',
-// // 'finances',
-// // 'investment-policy'
-// // ),
-// // FileSystem::init(
-// // $this->contentRoot,
-// // 'public',
-// // 'finances'
-// // ),
-// // FileSystem::init(
-// // $this->contentRoot,
-// // 'public'
-// // ),
-// // FileSystem::init(
-// // $this->contentRoot
-// // )
-// // ]
-// // );
-// //
-// // $this->assertEquals(
-// // FileSystem::init($this->contentRoot)
-// // ->with(folderPath: '/sub-folder')
-// // ->folderStack(fileName: 'content.md'),
-// // [
-// // FileSystem::init($this->contentRoot)->with(
-// // folderPath: '/sub-folder',
-// // fileName: 'content.md'
-// // ),
-// // FileSystem::init($this->contentRoot)
-// // ->with(folderPath: '', fileName: 'content.md')
-// // ]
-// // );
-// // })->group('filesystem', 'focus');
-// //
-// // it('can determine if path is content root', function() {
-// // expect(
-// // FileSystem::init($this->contentRoot)->isRoot()
-// // )->toBeTrue();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot, '/not-there')->isRoot()
-// // )->toBeFalse();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot)->isNotRoot()
-// // )->toBeFalse();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot, '/not-there')->isNotRoot()
-// // )->toBeTrue();
-// // })->group('filesystem');
-//
-// // it('can determine if path exists', function() {
-// // expect(
-// // FileSystem::init($this->contentRoot)->found()
-// // )->toBeTrue();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot . '/not-there')->found()
-// // )->toBeFalse();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot)->notFound()
-// // )->toBeFalse();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot . '/not-there')->notFound()
-// // )->toBeTrue();
-// // })->group('filesystem');
-// //
-// // it('can detect whether the root folder exists', function() {
-// // expect(
-// // FileSystem::init($this->contentRoot)->rootFolderIsMissing()
-// // )->toBeFalse();
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot . '/not-there')->rootFolderIsMissing()
-// // )->toBeTrue();
-// // })->group('filesystem');
-// //
-// // it('has correct file path', function() {
-// // expect(
-// // FileSystem::init($this->contentRoot, 'css', 'main.min.css')->path()
-// // )->toBe(
-// // "{$this->contentRoot}/css/main.min.css"
-// // );
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot)->navigation('main.md')->path()
-// // )->toBe(
-// // "{$this->contentRoot}/navigation/main.md"
-// // );
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot)->messages('original.md')->path()
-// // )->toBe(
-// // "{$this->contentRoot}/messages/original.md"
-// // );
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot)->messages()->path()
-// // )->toBe(
-// // "{$this->contentRoot}/messages"
-// // );
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot, 'public', 'legal', 'content.md')
-// // ->path(false)
-// // )->toBe(
-// // '/public/legal/content.md'
-// // );
-// // })->group('filesystem');
-// //
-// // it('has correct mimetypes', function() {
-// // // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/
-// // // MIME_types#textjavascript
-// // expect(
-// // FileSystem::init($this->contentRoot, 'gulpfile.js')->mimetype()
-// // )->toBe(
-// // 'text/javascript'
-// // );
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot, 'css', 'main.min.css')->mimetype()
-// // )->toBe(
-// // 'text/css'
-// // );
-// //
-// // expect(
-// // FileSystem::init($this->contentRoot, 'public', 'content.md')->mimetype()
-// // )->toBe(
-// // 'text/html'
-// // );
-// // })->group('filesystem');
+
+use JoshBruce\Site\Tests\TestFileSystem;
+
+beforeEach(function() {
+ $this->projectRoot = TestFileSystem::projectRoot();
+});
+
+it('can get published content', function() {
+ expect(
+ count(
+ TestFileSystem::init()->publishedContentFinder()
+ )
+ )->toBeInt()->toBe(2);
+})->group('filesystem');
+
+it('has required folders', function() {
+ expect(
+ TestFileSystem::init()->hasRequiredFolders()
+ )->toBeTrue();
+
+ expect(
+ TestFileSystem::init()->contentRoot()
+ )->toBe(
+ $this->projectRoot . '/content'
+ );
+
+ expect(
+ TestFileSystem::init()->publicRoot()
+ )->toBe(
+ $this->projectRoot . '/content/public'
+ );
+})->group('filesystem');
diff --git a/tests/FileTest.php b/tests/FileTest.php
index 956a10c5..1311aafb 100644
--- a/tests/FileTest.php
+++ b/tests/FileTest.php
@@ -4,31 +4,21 @@
use JoshBruce\Site\File;
-use JoshBruce\Site\FileSystem;
+use JoshBruce\Site\Tests\TestFileSystem;
test('can generate canonical URL', function() {
- expect(
- File::at(FileSystem::publicRoot() . '/content.md')->canonicalUrl()
- )->toBe(
- 'https://joshbruce.com'
- );
+ $fileSystem = TestFileSystem::init();
+ $publicRoot = $fileSystem->publicRoot();
expect(
- File::at(FileSystem::publicRoot())->canonicalUrl()
- )->toBe(
+ File::at($publicRoot . '/content.md', $fileSystem)->canonicalUrl()
+ )->toBe(
'https://joshbruce.com'
- );
+ );
expect(
- File::at(FileSystem::publicRoot() . '/web-development')->canonicalUrl()
- )->toBe(
- 'https://joshbruce.com/web-development'
- );
-
- expect(
- File::at(FileSystem::publicRoot() . '/web-development/content.md')
- ->canonicalUrl()
+ File::at($publicRoot, $fileSystem)->canonicalUrl()
)->toBe(
- 'https://joshbruce.com/web-development'
+ 'https://joshbruce.com'
);
})->group('file');
diff --git a/tests/GneratorTest.php b/tests/GneratorTest.php
new file mode 100644
index 00000000..d81d41a7
--- /dev/null
+++ b/tests/GneratorTest.php
@@ -0,0 +1,3 @@
+headers()
)->toBe(
['Content-Type' => 'text/html']
);
- serverGlobals('/assets/css/main.min.css');
-
expect(
HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()
+ ->withRequestUri('/assets/css/main.min.css'),
+ TestFileSystem::init()
+ )
)->headers()
)->toBe(
['Content-Type' => 'text/css']
);
-})->group('response');
+})->group('response', 'request');
test('expected titles', function() {
- serverGlobals();
-
$body = HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()->withRequestUri('/'),
+ TestFileSystem::init()
+ )
)->body();
expect(
- str_contains($body, "Josh Bruce's personal site")
+ str_contains($body, "Test content root")
)->toBeTrue();
- serverGlobals('/finances');
-
$body = HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()->withRequestUri('/published-sub'),
+ TestFileSystem::init()
+ )
)->body();
expect(
str_contains(
$body,
- "Finances | Josh Bruce's personal site"
+ "Sub-folder content title | Test content root"
)
)->toBeTrue();
- serverGlobals('/something-missing');
-
$body = HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()->withRequestUri('/something/invalid'),
+ TestFileSystem::init()
+ )
)->body();
expect(
@@ -63,94 +73,54 @@
"Page not found"
)
)->toBeTrue();
-})->group('response');
+})->group('response', 'request');
test('expected status codes', function() {
- serverGlobals();
-
expect(
HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()->withRequestUri('/'),
+ TestFileSystem::init()
+ )
)->statusCode()
)->toBeInt()->toBe(
200
);
- unset($_SERVER['APP_ENV']);
-
expect(
HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()->withRequestUri('/something/invalid'),
+ TestFileSystem::init()
+ )
)->statusCode()
)->toBeInt()->toBe(
- 500
+ 404
);
- serverGlobals();
-
- $_SERVER['REQUEST_METHOD'] = 'post';
-
expect(
HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()->withRequestMethod('post'),
+ TestFileSystem::init()
+ )
)->statusCode()
)->toBeInt()->toBe(
405
);
- serverGlobals('/not-valid');
+ unset($_SERVER['APP_URL']);
expect(
HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init(),
+ TestFileSystem::init()
+ )
)->statusCode()
)->toBeInt()->toBe(
- 404
+ 500
);
-})->group('response');
-//
-// test('can check request is valid', function() {
-// serverGlobals();
-//
-// expect(
-// HttpRequest::init()->isMissingRequiredValues()
-// )->toBeFalse();
-//
-// expect(
-// HttpRequest::init()->isUnsupportedMethod()
-// )->toBeFalse();
-//
-// expect(
-// HttpRequest::init()->isNotFound()
-// )->toBeFalse();
-//
-// serverGlobals('/not-valid');
-// unset($_SERVER['APP_ENV']);
-// $_SERVER['REQUEST_METHOD'] = 'post';
-//
-// expect(
-// HttpRequest::init()->isMissingRequiredValues()
-// )->toBeTrue();
-//
-// expect(
-// HttpRequest::init()->isUnsupportedMethod()
-// )->toBeTrue();
-//
-// expect(
-// HttpRequest::init()->isNotFound()
-// )->toBeTrue();
-// })->group('request');
-//
-// it('uses server globals', function() {
-// serverGlobals();
-//
-// expect(
-// ServerGlobals::init()->hasAppEnv()
-// )->toBeTrue();
-//
-// unset($_SERVER['APP_ENV']);
-//
-// expect(
-// ServerGlobals::init()->hasAppEnv()
-// )->toBeFalse();
-// })->group('globals');
+
+ $_SERVER['APP_URL'] = 'http://jb-site.test';
+})->group('response', 'request');
diff --git a/tests/Pest.php b/tests/Pest.php
index 0ac9c7af..19377289 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -11,8 +11,6 @@
|
*/
-// uses(Tests\TestCase::class)->in('Feature');
-
/*
|--------------------------------------------------------------------------
| Functions
@@ -23,29 +21,3 @@
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
-
-use JoshBruce\Site\Environment;
-use JoshBruce\Site\Server;
-
-function environment(string $requestUri = '/'): Environment
-{
- return Environment::init(server($requestUri));
-}
-
-function server(string $requestUri = '/'): Server
-{
- return Server::init(serverGlobals($requestUri));
-}
-
-function serverGlobals(string $requestUri = '/'): array
-{
- $_SERVER['APP_ENV'] = 'test';
- // $_SERVER['CONTENT_UP'] = 0;
- // $_SERVER['CONTENT_FOLDER'] = '/tests/test-content/content';
- // $_SERVER['REQUEST_SCHEME'] = 'http';
- // $_SERVER['HTTP_HOST'] = 'testing.com';
- $_SERVER['REQUEST_URI'] = $requestUri;
- $_SERVER['REQUEST_METHOD'] = 'GET';
-
- return $_SERVER;
-}
diff --git a/tests/ServerGlobalsTest.php b/tests/ServerGlobalsTest.php
new file mode 100644
index 00000000..744a8e91
--- /dev/null
+++ b/tests/ServerGlobalsTest.php
@@ -0,0 +1,46 @@
+withRequestUri('/something')->requestUri()
+ )->toBe(
+ '/something'
+ );
+
+ expect(
+ ServerGlobals::init()->withRequestMethod('GET')->requestMethod()
+ )->toBe(
+ 'GET'
+ );
+
+ expect(
+ ServerGlobals::init()->withRequestMethod('POST')->requestMethod()
+ )->toBe(
+ 'POST'
+ );
+
+ expect(
+ ServerGlobals::init()->isMissingRequiredValues()
+ )->toBeFalse();
+})->group('server-globals');
+
+test('only ServerGlobals references $_SERVER', function() {
+ $finder = new Finder();
+ $found = $finder->ignoreVCS(false)->files()->name('*.php')->in(
+ FileSystem::projectRoot() . '/src'
+ )->contains('$_SERVER');
+
+ foreach ($found as $f) {
+ $result = str_ends_with($f->getPathname(), 'ServerGlobals.php');
+ if (! $result){
+ var_dump($f->getPathname());
+ }
+ $this->assertTrue($result);
+ }
+})->group('server-globals');
diff --git a/tests/ServerTest.php b/tests/ServerTest.php
deleted file mode 100644
index d18626be..00000000
--- a/tests/ServerTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-projectRoot = implode('/', array_slice(explode('/', __DIR__), 0, -1));
-//
-// serverGlobals();
-//
-// $this->contentRoot = $this->projectRoot . $_SERVER['CONTENT_FOLDER'];
-// });
-//
-// it('has expected project roo', function() {
-// expect(
-// Server::projectRoot()
-// )->toBeString()->toBe(
-// $this->projectRoot
-// );
-// })->group('server');
-//
-// it('has expected file name and content root', function() {
-// expect(
-// Server::init(serverGlobals(), $this->projectRoot)->requestFileName()
-// )->toBe(
-// ''
-// );
-//
-// expect(
-// Server::init(serverGlobals(), $this->projectRoot)->contentRoot()
-// )->toBe(
-// __DIR__ . '/test-content/content'
-// );
-// })->group('server');
-//
-// it('limits request methods', function() {
-// expect(
-// Server::init(serverGlobals(), $this->projectRoot)
-// ->isRequestingUnsupportedMethod()
-// )->toBeFalse();
-//
-// $serverGlobals = serverGlobals();
-// $serverGlobals['REQUEST_METHOD'] = 'INVALID';
-//
-// expect(
-// Server::init($serverGlobals, $this->projectRoot)
-// ->isRequestingUnsupportedMethod()
-// )->toBeTrue();
-// })->group('server');
-//
-// it('has required variables', function() {
-// expect(
-// Server::init(serverGlobals(), $this->projectRoot)
-// ->isMissingRequiredValues()
-// )->toBeFalse();
-//
-// $serverGlobals = serverGlobals();
-// unset($serverGlobals['CONTENT_UP']);
-//
-// expect(
-// Server::init($serverGlobals, $this->projectRoot)
-// ->isMissingRequiredValues()
-// )->toBeTrue();
-// })->group('server');
diff --git a/tests/SitemapTest.php b/tests/SitemapTest.php
index edadd0b3..49375aee 100644
--- a/tests/SitemapTest.php
+++ b/tests/SitemapTest.php
@@ -7,11 +7,21 @@
use JoshBruce\Site\HttpRequest;
use JoshBruce\Site\HttpResponse;
+use JoshBruce\Site\ServerGlobals;
+
+use JoshBruce\Site\Tests\TestFileSystem;
+
+
it('can respond to sitemap request', function() {
- serverGlobals('sitemap.xml');
+ // serverGlobals('sitemap.xml');
$xml = HttpResponse::from(
- request: HttpRequest::fromGlobals()
+ request: HttpRequest::with(
+ ServerGlobals::init()
+ ->withRequestUri('/sitemap.xml')
+ ->withRequestMethod('GET'), // TODO: indicates fragile tests
+ TestFileSystem::init()
+ )
);
expect(
@@ -34,7 +44,7 @@
$xml->body()
)->toBe(<<
- https://joshbruce.comhttps://joshbruce.com/design-your-lifehttps://joshbruce.com/design-your-life/motivatorshttps://joshbruce.com/finances/budgetinghttps://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210301https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210315https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210401https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210415https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210501https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210515https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210601https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210615https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210701https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210715https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210801https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210815https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210901https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20210915https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20211001https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20211015https://joshbruce.com/finances/building-wealth-paycheck-to-paycheck/20211101https://joshbruce.com/finances/building-wealth-paycheck-to-paycheckhttps://joshbruce.com/financeshttps://joshbruce.com/finances/investment-policyhttps://joshbruce.com/health-and-wellnesshttps://joshbruce.com/legalhttps://joshbruce.com/software-developmenthttps://joshbruce.com/software-development/why-dont-you-usehttps://joshbruce.com/web-development/2021-site-in-depthhttps://joshbruce.com/web-developmenthttps://joshbruce.com/web-development/modern-web-developmenthttps://joshbruce.com/web-development/my-history-on-the-webhttps://joshbruce.com/web-development/on-constraintshttps://joshbruce.com/web-development/on-constraints/internet-bandwidthhttps://joshbruce.com/web-development/refactoring-re-engineering-and-rebuildinghttps://joshbruce.com/web-development/site-statshttps://joshbruce.com/web-development/static-dynamic-and-interactive
+ https://joshbruce.comhttps://joshbruce.com/published-sub
xml
);
-})->group('sitemap', 'focus');
+})->group('request', 'response', 'sitemap');
diff --git a/tests/TestFileSystem.php b/tests/TestFileSystem.php
new file mode 100644
index 00000000..1bf6effc
--- /dev/null
+++ b/tests/TestFileSystem.php
@@ -0,0 +1,17 @@
+ a{
+ position:var(--relative);
+ text-align:var(--center);
+ font-size:var(--s-font);
+ display:var(--block);
+ margin:var(--l-spacer);
+ font-weight:var(--light-font);
+ padding:var(--m-spacer);
+}
+body > a[id=content-top]:before{
+ display:var(--inline-block);
+ content:var(--icon-decrease);
+ position:var(--absolute);
+ left:var(--0-spacer);
+}
+body > a[id=content-top]:after{
+ display:var(--inline-block);
+ content:var(--icon-decrease);
+ position:var(--absolute);
+ right:var(--0-spacer);
+}
+body > a[id=go-to-top]:before{
+ display:var(--inline-block);
+ content:var(--icon-increase);
+ position:var(--absolute);
+ left:var(--0-spacer);
+}
+body > a[id=go-to-top]:after{
+ display:var(--inline-block);
+ content:var(--icon-increase);
+ position:var(--absolute);
+ right:var(--0-spacer);
+}
+
+a:hover{
+ cursor:var(--pointer);
+}
+
+a{
+ font-weight:var(--bold-font);
+ -webkit-text-decoration:var(--none);
+ text-decoration:var(--none);
+ color:var(--key);
+ transition:var(--transition-color);
+}
+a:hover{
+ color:var(--gray-darker);
+}
+
+[is=dateblock]{
+ margin-bottom:var(--l-spacer);
+}
+[is=dateblock] p{
+ margin:var(--0-spacer);
+ font-size:var(--xs-font);
+}
+[is=dateblock] p > time{
+ font-weight:var(--bold-font);
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6{
+ margin-top:var(--l-spacer);
+ margin-bottom:var(--xs-spacer);
+ font-family:var(--head-font);
+ font-size:clamp(30pt, 9.375vw, 35pt);
+ line-height:var(--l-line-height);
+}
+h1 + ol, h1 + ul, h1 + p,
+h2 + ol,
+h2 + ul,
+h2 + p,
+h3 + ol,
+h3 + ul,
+h3 + p,
+h4 + ol,
+h4 + ul,
+h4 + p,
+h5 + ol,
+h5 + ul,
+h5 + p,
+h6 + ol,
+h6 + ul,
+h6 + p{
+ margin-top:var(--0-spacer);
+ padding-top:var(--0-spacer);
+}
+
+h1{
+ margin-top:var(--m-spacer);
+}
+
+h2{
+ font-size:var(--xl-font);
+}
+
+h3,
+h4,
+h5,
+h6{
+ font-size:var(--l-font);
+ text-align:var(--center);
+}
+
+hr{
+ width:33%;
+ border:var(--2-px) var(--solid) var(--gray-darkest);
+ border-radius:var(--1-px);
+}
+
+code{
+ font-family:var(--code-font);
+ font-size:var(--s-font);
+}
+
+.toc{
+ padding:var(--none);
+ list-style:var(--none);
+}
+.toc li{
+ border-bottom:var(--1-px) var(--solid) var(--gray-darker);
+}
+.toc li:last-child{
+ border-bottom:var(--none);
+}
+.toc h3{
+ margin:var(--xs-spacer);
+ text-align:var(--left);
+ font-size:var(--l-font);
+}
+.toc h3 a{
+ display:var(--block);
+ padding:var(--m-spacer);
+}
+.toc h3 small{
+ font-size:var(--s-font);
+ display:var(--block);
+ color:var(--gray-darker);
+ font-weight:var(--light-font);
+}
+
+main, article{
+ padding:var(--0-spacer) var(--m-spacer);
+ padding-top:var(--m-spacer);
+}
+main img,
+main iframe[src*="https://jsfiddle.net"], article img,
+article iframe[src*="https://jsfiddle.net"]{
+ display:var(--block);
+ margin:var(--margin-centered);
+ width:80%;
+ border:var(--3-px) var(--solid) var(--gray-darker);
+}
+main iframe[src*="https://jsfiddle.net"], article iframe[src*="https://jsfiddle.net"]{
+ min-height:300px;
+}
+main abbr, article abbr{
+ -webkit-text-decoration:var(--none);
+ text-decoration:var(--none);
+ border-bottom:var(--1-px) var(--dashed) var(--gray-darker);
+}
+main dl > dt, article dl > dt{
+ margin-top:var(--m-spacer);
+}
+main dl > dd, article dl > dd{
+ margin-bottom:var(--s-spacer);
+}
+main details, article details{
+ border:var(--1-px) var(--solid) var(--key);
+ border-radius:var(--3-px);
+ margin-top:var(--m-spacer);
+ padding:0.5em 0.5em 0;
+ font-weight:var(--bold-font);
+}
+main details summary, article details summary{
+ color:var(--key);
+ cursor:var(--pointer);
+ margin:-0.5em -0.5em 0;
+ padding:0.5em;
+}
+
+nav > ul{
+ margin:var(--0-spacer);
+ padding:var(--0-spacer);
+ list-style:var(--none);
+}
+nav > ul > li:first-of-type{
+ margin-bottom:var(--m-spacer);
+}
+nav > ul ul{
+ list-style:var(--none);
+ margin:var(--0-spacer) var(--0-spacer);
+ padding:var(--0-spacer) var(--0-spacer);
+}
+nav > ul ul a{
+ font-size:var(--s-font);
+}
+nav > ul ul li:last-of-type a{
+ padding-bottom:var(--s-spacer);
+}
+nav > ul a{
+ display:var(--block);
+ overflow:var(--hidden);
+ text-align:var(--center);
+ position:var(--relative);
+}
+nav > ul abbr{
+ border-color:var(--gray-lightest);
+}
+
+@media (prefers-reduced-motion: no-preference){
+ html{
+ scroll-behavior:var(--smooth);
+ }
+
+ nav > ul a:after{
+ content:"";
+ position:var(--absolute);
+ width:25%;
+ border-top:var(--1-px) var(--solid) var(--gray-darker);
+ opacity:0.25;
+ top:60%;
+ left:-100%;
+ transition-delay:all 0.75s;
+ transition:all 0.75s ease-out;
+ }
+ nav > ul a:hover:after{
+ left:100%;
+ }
+}
+@media (prefers-color-scheme: dark){
+ body{
+ background:var(--gray-darkest);
+ color:var(--gray-lightest);
+ font-weight:var(--medium-font);
+ }
+
+ hr{
+ border:1px solid var(--gray-lightest);
+ }
+
+ a{
+ color:var(--key-light);
+ font-weight:var(--x-bold-font);
+ }
+ a:hover{
+ color:var(--gray-lightest);
+ }
+
+ [is=dateblock] p > time, b, strong{
+ font-weight:var(--x-bold-font);
+ }
+
+ nav > ul a:after{
+ border-top:var(--1-px) var(--solid) var(--gray-lightest);
+ }
+
+ main img,
+main iframe[src*="https://jsfiddle.net"], article img,
+article iframe[src*="https://jsfiddle.net"]{
+ border:3px solid var(--gray-lightest);
+ }
+ main details, article details{
+ font-weight:var(--bold-font);
+ }
+ main details summary, article details summary{
+ color:var(--key-light);
+ }
+}
+blockquote{
+ margin:var(--m-spacer);
+ font-style:var(--italic);
+}
+
+.heading-permalink{
+ display:var(--inline-block);
+ margin-right:var(--2xs-spacer);
+}
+
+a[rel~=noreferrer]:after{
+ content:var(--icon-new-window);
+ display:var(--inline-block);
+ margin-left:var(--2xs-spacer);
+ position:var(--relative);
+ top:var(--icon-lift-spacer);
+ font-size:var(--xs-font);
+}
+
+footer{
+ margin-top:var(--l-spacer);
+}
+footer > p{
+ text-align:var(--center);
+ font-size:var(--xs-font);
+}
\ No newline at end of file
diff --git a/tests/test-content/content/public/assets/css/main.min.css b/tests/test-content/content/public/assets/css/main.min.css
new file mode 100644
index 00000000..be9e0d49
--- /dev/null
+++ b/tests/test-content/content/public/assets/css/main.min.css
@@ -0,0 +1,2 @@
+@charset "UTF-8";:root{--header-font:"Gill Sans", "Gill Sans MT", Calibri, sans-serif;--body-font:"Open Sans", "Helvetica Neue", Verdana, sans-serif;--code-font:Consolas, monaco, monospace;--x-bold-font:600;--bold-font:500;--medium-font:300;--light-font:250;--2xl-font:clamp(30pt, 9.375vw, 35pt);--xl-font:clamp(25pt, 7.8125vw, 30pt);--l-font:clamp(20pt, 6.25vw, 25pt);--m-font:clamp(16pt, 5vw, 18pt);--s-font:clamp(13pt, 4.0625vw, 15pt);--xs-font:clamp(10pt, 3.125vw, 14pt);--l-line-height:calc(var(--2xl-font) * 1);--m-line-height:calc(var(--s-font) * 1.62);--s-line-height:calc(var(--s-font) * 1.25);--line-length:70ch;--key:#0A6276;--key-light:#5FCAF2;--gray-darkest:#030303;--gray-darker:#0F2124;--gray-lightest:#FCFDFD;--vw-0:0vw;--0-spacer:0;--xl-spacer:4rem;--l-spacer:2rem;--m-spacer:1rem;--s-spacer:0.75rem;--xs-spacer:0.5rem;--2xs-spacer:0.25rem;--3xs-spacer:0.1rem;--1-px:1px;--2-px:2px;--3-px:3px;--400-px:400px;--icon-new-window:"⧉";--icon-increase:"◭";--icon-decrease:"⧩";--icon-hold:"≅";--icon-lift-spacer:-0.5rem;--margin-centered:var(--0-spacer) var(--auto);--smooth:smooth;--auto:auto;--pointer:pointer;--none:none;--transition-color:color 0.25s;--center:center;--left:left;--inline-block:inline-block;--solid:solid;--dashed:dashed;--transparent:transparent;--relative:relative;--absolute:absolute;--italic:italic;--block:block;--both:both;--hidden:hidden}body{min-height:var(--vw-0);max-width:var(--line-length);margin:var(--margin-centered);background:var(--gray-lightest);color:var(--gray-darkest);font-family:var(--body-font);font-size:var(--m-font);line-height:var(--m-line-height)}body>a,code{font-size:var(--s-font)}body,body>a{font-weight:var(--light-font)}body>a{position:var(--relative);text-align:var(--center);display:var(--block);margin:var(--l-spacer);padding:var(--m-spacer)}body>a[id=content-top]:after,body>a[id=content-top]:before{display:var(--inline-block);content:var(--icon-decrease);position:var(--absolute)}body>a[id=content-top]:before{left:var(--0-spacer)}body>a[id=content-top]:after{right:var(--0-spacer)}body>a[id=go-to-top]:after,body>a[id=go-to-top]:before{display:var(--inline-block);content:var(--icon-increase);position:var(--absolute)}body>a[id=go-to-top]:before{left:var(--0-spacer)}body>a[id=go-to-top]:after{right:var(--0-spacer)}a:hover{cursor:var(--pointer);color:var(--gray-darker)}a{-webkit-text-decoration:var(--none);text-decoration:var(--none);color:var(--key);transition:var(--transition-color)}[is=dateblock]{margin-bottom:var(--l-spacer)}[is=dateblock] p{margin:var(--0-spacer);font-size:var(--xs-font)}[is=dateblock] p>time,a{font-weight:var(--bold-font)}h1,h2,h3,h4,h5,h6{margin-bottom:var(--xs-spacer);font-family:var(--head-font);font-size:clamp(30pt,9.375vw,35pt);line-height:var(--l-line-height)}h2,h3,h4,h5,h6{margin-top:var(--l-spacer)}h1+ol,h1+p,h1+ul,h2+ol,h2+p,h2+ul,h3+ol,h3+p,h3+ul,h4+ol,h4+p,h4+ul,h5+ol,h5+p,h5+ul,h6+ol,h6+p,h6+ul{margin-top:var(--0-spacer);padding-top:var(--0-spacer)}article dl>dt,h1,main dl>dt{margin-top:var(--m-spacer)}h2{font-size:var(--xl-font)}h3,h4,h5,h6{font-size:var(--l-font);text-align:var(--center)}hr{width:33%;border:var(--2-px) var(--solid) var(--gray-darkest);border-radius:var(--1-px)}code{font-family:var(--code-font)}.toc{padding:var(--none);list-style:var(--none)}.toc li{border-bottom:var(--1-px) var(--solid) var(--gray-darker)}.toc li:last-child{border-bottom:var(--none)}.toc h3{margin:var(--xs-spacer);text-align:var(--left);font-size:var(--l-font)}.toc h3 a{display:var(--block);padding:var(--m-spacer)}.toc h3 small{font-size:var(--s-font);display:var(--block);color:var(--gray-darker);font-weight:var(--light-font)}article,main{padding:var(--0-spacer) var(--m-spacer);padding-top:var(--m-spacer)}article iframe[src*="https://jsfiddle.net"],article img,main iframe[src*="https://jsfiddle.net"],main img{display:var(--block);margin:var(--margin-centered);width:80%;border:var(--3-px) var(--solid) var(--gray-darker)}article iframe[src*="https://jsfiddle.net"],main iframe[src*="https://jsfiddle.net"]{min-height:300px}article abbr,main abbr{-webkit-text-decoration:var(--none);text-decoration:var(--none);border-bottom:var(--1-px) var(--dashed) var(--gray-darker)}article dl>dd,main dl>dd{margin-bottom:var(--s-spacer)}article details,main details{border:var(--1-px) var(--solid) var(--key);border-radius:var(--3-px);margin-top:var(--m-spacer);padding:.5em .5em 0;font-weight:var(--bold-font)}article details summary,main details summary{color:var(--key);cursor:var(--pointer);margin:-.5em -.5em 0;padding:.5em}nav>ul{margin:var(--0-spacer);padding:var(--0-spacer);list-style:var(--none)}nav>ul>li:first-of-type{margin-bottom:var(--m-spacer)}nav>ul ul{list-style:var(--none);margin:var(--0-spacer) var(--0-spacer);padding:var(--0-spacer) var(--0-spacer)}nav>ul ul a{font-size:var(--s-font)}nav>ul ul li:last-of-type a{padding-bottom:var(--s-spacer)}footer>p,nav>ul a{text-align:var(--center)}nav>ul a{display:var(--block);overflow:var(--hidden);position:var(--relative)}nav>ul abbr{border-color:var(--gray-lightest)}@media (prefers-reduced-motion:no-preference){html{scroll-behavior:var(--smooth)}nav>ul a:after{content:"";position:var(--absolute);width:25%;border-top:var(--1-px) var(--solid) var(--gray-darker);opacity:.25;top:60%;left:-100%;transition-delay:all .75s;transition:all .75s ease-out}nav>ul a:hover:after{left:100%}}@media (prefers-color-scheme:dark){body{background:var(--gray-darkest);font-weight:var(--medium-font)}hr{border:1px solid var(--gray-lightest)}a:hover,body{color:var(--gray-lightest)}[is=dateblock] p>time,a,b,strong{font-weight:var(--x-bold-font)}nav>ul a:after{border-top:var(--1-px) var(--solid) var(--gray-lightest)}article iframe[src*="https://jsfiddle.net"],article img,main iframe[src*="https://jsfiddle.net"],main img{border:3px solid var(--gray-lightest)}article details,main details{font-weight:var(--bold-font)}a,article details summary,main details summary{color:var(--key-light)}}blockquote{margin:var(--m-spacer);font-style:var(--italic)}.heading-permalink{display:var(--inline-block);margin-right:var(--2xs-spacer)}a[rel~=noreferrer]:after{content:var(--icon-new-window);display:var(--inline-block);margin-left:var(--2xs-spacer);position:var(--relative);top:var(--icon-lift-spacer);font-size:var(--xs-font)}footer{margin-top:var(--l-spacer)}footer>p{font-size:var(--xs-font)}
+/*# sourceMappingURL=main.min.css.map */
diff --git a/tests/test-content/content/public/assets/css/main.min.css.map b/tests/test-content/content/public/assets/css/main.min.css.map
new file mode 100644
index 00000000..6dbb27c9
--- /dev/null
+++ b/tests/test-content/content/public/assets/css/main.min.css.map
@@ -0,0 +1 @@
+{"version":3,"sources":["main.css","../../../main.scss",""],"names":[],"mappings":"AAAA,gBAAgB,CCAhB,MACE,8DAAA,CACA,8DAAA,CACA,uCAAA,CAEA,iBAAA,CACA,eAAA,CACA,iBAAA,CACA,gBAAA,CAEA,qCAAA,CACA,qCAAA,CACA,kCAAA,CACA,+BAAA,CACA,oCAAA,CACA,oCAAA,CAEA,yCAAA,CACA,0CAAA,CACA,0CAAA,CAEA,kBAAA,CAEA,aAAA,CACA,mBAAA,CACA,sBAAA,CACA,qBAAA,CACA,uBAAA,CAEA,UAAA,CAEA,YAAA,CACA,gBAAA,CACA,eAAA,CACA,eAAA,CACA,kBAAA,CACA,kBAAA,CACA,oBAAA,CACA,mBAAA,CACA,UAAA,CACA,UAAA,CACA,UAAA,CACA,cAAA,CAEA,qBAAA,CACA,mBAAA,CACA,mBAAA,CACA,eAAA,CACA,0BAAA,CAEA,6CAAA,CAEA,eAAA,CACA,WAAA,CACA,iBAAA,CACA,WAAA,CACA,8BAAA,CACA,eAAA,CACA,WAAA,CACA,2BAAA,CACA,aAAA,CACA,eAAA,CACA,yBAAA,CACA,mBAAA,CACA,mBAAA,CACA,eAAA,CACA,aAAA,CACA,WAAA,CACA,eDRF,CCWA,KACE,sBAAA,CACA,4BAAA,CACA,6BAAA,CACA,+BAAA,CACA,yBAAA,CACA,4BAAA,CACA,uBAAA,CACA,gCDPF,CExEA,YDoME,sBAAA,ECpMF,YDwFI,4BAAA,EANF,AClFF,ODmFI,wBAAA,CACA,wBAAA,CAEA,oBAAA,CACA,sBAAA,CAEA,uBDRJ,CEjFA,2DDoGQ,2BAAA,CACA,4BAAA,CACA,uBAAA,EAVF,AC5FN,8BDgGQ,oBDTR,CCYM,6BAIE,qBDVR,CE7FA,uDDoHQ,2BAAA,CACA,4BAAA,CACA,uBAAA,EAVF,AC5GN,4BDgHQ,oBDbR,CCgBM,2BAIE,qBDdR,CCoBA,QACE,qBAAA,CAUE,uBAAA,CD3BJ,CCoBA,EAEE,mCAAA,CAAA,2BAAA,CACA,gBAAA,CACA,kCDjBF,CCwBA,eACE,6BDlBF,CCoBE,iBACE,sBAAA,CACA,wBDlBJ,CCoBI,wBACE,4BDlBN,CCuBA,kBAOE,8BAAA,CACA,4BAAA,CACA,kCAAA,CAEA,gCDrBF,CCUA,eAME,yBAAA,CDhBF,CCuBE,sGACE,0BAAA,CACA,2BDNJ,CCUA,4BACE,0BDPF,CCUA,GACE,wBDPF,CCUA,YAIE,uBAAA,CACA,wBDPF,CCUA,GACE,SAAA,CACA,mDAAA,CACA,yBDPF,CCUA,KACE,4BDNF,CCUA,KACE,mBAAA,CACA,sBDPF,CCSE,QACE,yDDPJ,CCSI,mBACE,yBDPN,CCWE,QACE,uBAAA,CACA,sBAAA,CACA,uBDTJ,CCWI,UACE,oBAAA,CACA,uBDTN,CCYI,cACE,uBAAA,CACA,oBAAA,CACA,wBAAA,CACA,6BDVN,CCeA,aACE,uCAAA,CACA,2BDZF,CCcE,0GAEE,oBAAA,CACA,6BAAA,CACA,SAAA,CACA,kDDXJ,CCcE,qFACE,gBDZJ,CCeE,uBACE,mCAAA,CAAA,2BAAA,CACA,0DDbJ,CCqBI,yBACE,6BDhBN,CCoBE,6BACE,0CAAA,CACA,yBAAA,CACA,0BAAA,CACA,mBAAA,CACA,4BDlBJ,CCoBI,6CACE,gBAAA,CACA,qBAAA,CACA,oBAAA,CACA,YDlBN,CCuBA,OACE,sBAAA,CACA,uBAAA,CACA,sBDpBF,CCsBE,wBACE,6BDpBJ,CCuBE,UACE,sBAAA,CACA,sCAAA,CACA,uCDrBJ,CCsBI,YACE,uBDpBN,CCuBI,4BACE,8BDrBN,CElRA,kBDkZI,uBAAA,EAvGF,AC3SF,SD4SI,oBAAA,CACA,sBAAA,CAEA,wBDvBJ,CC0BE,YACE,iCDxBJ,CC6BA,8CACE,KACE,6BD1BF,CC8BE,eACE,UAAA,CACA,wBAAA,CACA,SAAA,CACA,sDAAA,CACA,WAAA,CACA,OAAA,CACA,UAAA,CACA,yBAAA,CACA,4BD3BJ,CC8BE,qBACG,SD5BL,CACF,CCgCA,mCACE,KACE,8BAAA,CAEA,8BD9BF,CCiCA,GACE,qCD9BF,CCqCE,aACE,0BD9BJ,CCkCA,iCACM,8BD/BN,CCkCA,eACE,wDD/BF,CCmCE,0GAEE,qCD/BJ,CCkCE,6BACE,4BDhCJ,CCkCI,+CACE,sBDhCN,CACF,CCqCA,WACE,sBAAA,CACA,wBDnCF,CCsCA,mBACE,2BAAA,CACA,8BDnCF,CCsCA,yBACE,8BAAA,CACA,2BAAA,CACA,6BAAA,CACA,wBAAA,CACA,2BAAA,CACA,wBDnCF,CCsCA,OACE,0BDnCF,CCoCE,SAEE,wBDlCJ","file":"main.min.css","sourcesContent":["@charset \"UTF-8\";\n:root {\n --header-font: \"Gill Sans\", \"Gill Sans MT\", Calibri, sans-serif;\n --body-font: \"Open Sans\", \"Helvetica Neue\", Verdana, sans-serif;\n --code-font: Consolas, monaco, monospace;\n --x-bold-font: 600;\n --bold-font: 500;\n --medium-font: 300;\n --light-font: 250;\n --2xl-font: clamp(30pt, 9.375vw, 35pt);\n --xl-font: clamp(25pt, 7.8125vw, 30pt);\n --l-font: clamp(20pt, 6.25vw, 25pt);\n --m-font: clamp(16pt, 5vw, 18pt);\n --s-font: clamp(13pt, 4.0625vw, 15pt);\n --xs-font: clamp(10pt, 3.125vw, 14pt);\n --l-line-height: calc(var(--2xl-font) * 1);\n --m-line-height: calc(var(--s-font) * 1.62);\n --s-line-height: calc(var(--s-font) * 1.25);\n --line-length: 70ch;\n --key: #0A6276;\n --key-light: #5FCAF2;\n --gray-darkest: #030303;\n --gray-darker: #0F2124;\n --gray-lightest: #FCFDFD;\n --vw-0: 0vw;\n --0-spacer: 0;\n --xl-spacer: 4rem;\n --l-spacer: 2rem;\n --m-spacer: 1rem;\n --s-spacer: 0.75rem;\n --xs-spacer: 0.5rem;\n --2xs-spacer: 0.25rem;\n --3xs-spacer: 0.1rem;\n --1-px: 1px;\n --2-px: 2px;\n --3-px: 3px;\n --400-px: 400px;\n --icon-new-window: \"⧉\";\n --icon-increase: \"◭\";\n --icon-decrease: \"⧩\";\n --icon-hold: \"≅\";\n --icon-lift-spacer: -0.5rem;\n --margin-centered: var(--0-spacer) var(--auto);\n --smooth: smooth;\n --auto: auto;\n --pointer: pointer;\n --none: none;\n --transition-color: color 0.25s;\n --center: center;\n --left: left;\n --inline-block: inline-block;\n --solid: solid;\n --dashed: dashed;\n --transparent: transparent;\n --relative: relative;\n --absolute: absolute;\n --italic: italic;\n --block: block;\n --both: both;\n --hidden: hidden;\n}\n\nbody {\n min-height: var(--vw-0);\n max-width: var(--line-length);\n margin: var(--margin-centered);\n background: var(--gray-lightest);\n color: var(--gray-darkest);\n font-family: var(--body-font);\n font-size: var(--m-font);\n line-height: var(--m-line-height);\n font-weight: var(--light-font);\n}\nbody > a {\n position: var(--relative);\n text-align: var(--center);\n font-size: var(--s-font);\n display: var(--block);\n margin: var(--l-spacer);\n font-weight: var(--light-font);\n padding: var(--m-spacer);\n}\nbody > a[id=content-top]:before {\n display: var(--inline-block);\n content: var(--icon-decrease);\n position: var(--absolute);\n left: var(--0-spacer);\n}\nbody > a[id=content-top]:after {\n display: var(--inline-block);\n content: var(--icon-decrease);\n position: var(--absolute);\n right: var(--0-spacer);\n}\nbody > a[id=go-to-top]:before {\n display: var(--inline-block);\n content: var(--icon-increase);\n position: var(--absolute);\n left: var(--0-spacer);\n}\nbody > a[id=go-to-top]:after {\n display: var(--inline-block);\n content: var(--icon-increase);\n position: var(--absolute);\n right: var(--0-spacer);\n}\n\na:hover {\n cursor: var(--pointer);\n}\n\na {\n font-weight: var(--bold-font);\n text-decoration: var(--none);\n color: var(--key);\n transition: var(--transition-color);\n}\na:hover {\n color: var(--gray-darker);\n}\n\n[is=dateblock] {\n margin-bottom: var(--l-spacer);\n}\n[is=dateblock] p {\n margin: var(--0-spacer);\n font-size: var(--xs-font);\n}\n[is=dateblock] p > time {\n font-weight: var(--bold-font);\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n margin-top: var(--l-spacer);\n margin-bottom: var(--xs-spacer);\n font-family: var(--head-font);\n font-size: clamp(30pt, 9.375vw, 35pt);\n line-height: var(--l-line-height);\n}\nh1 + ol, h1 + ul, h1 + p,\nh2 + ol,\nh2 + ul,\nh2 + p,\nh3 + ol,\nh3 + ul,\nh3 + p,\nh4 + ol,\nh4 + ul,\nh4 + p,\nh5 + ol,\nh5 + ul,\nh5 + p,\nh6 + ol,\nh6 + ul,\nh6 + p {\n margin-top: var(--0-spacer);\n padding-top: var(--0-spacer);\n}\n\nh1 {\n margin-top: var(--m-spacer);\n}\n\nh2 {\n font-size: var(--xl-font);\n}\n\nh3,\nh4,\nh5,\nh6 {\n font-size: var(--l-font);\n text-align: var(--center);\n}\n\nhr {\n width: 33%;\n border: var(--2-px) var(--solid) var(--gray-darkest);\n border-radius: var(--1-px);\n}\n\ncode {\n font-family: var(--code-font);\n font-size: var(--s-font);\n}\n\n.toc {\n padding: var(--none);\n list-style: var(--none);\n}\n.toc li {\n border-bottom: var(--1-px) var(--solid) var(--gray-darker);\n}\n.toc li:last-child {\n border-bottom: var(--none);\n}\n.toc h3 {\n margin: var(--xs-spacer);\n text-align: var(--left);\n font-size: var(--l-font);\n}\n.toc h3 a {\n display: var(--block);\n padding: var(--m-spacer);\n}\n.toc h3 small {\n font-size: var(--s-font);\n display: var(--block);\n color: var(--gray-darker);\n font-weight: var(--light-font);\n}\n\nmain, article {\n padding: var(--0-spacer) var(--m-spacer);\n padding-top: var(--m-spacer);\n}\nmain img,\nmain iframe[src*=\"https://jsfiddle.net\"], article img,\narticle iframe[src*=\"https://jsfiddle.net\"] {\n display: var(--block);\n margin: var(--margin-centered);\n width: 80%;\n border: var(--3-px) var(--solid) var(--gray-darker);\n}\nmain iframe[src*=\"https://jsfiddle.net\"], article iframe[src*=\"https://jsfiddle.net\"] {\n min-height: 300px;\n}\nmain abbr, article abbr {\n text-decoration: var(--none);\n border-bottom: var(--1-px) var(--dashed) var(--gray-darker);\n}\nmain dl > dt, article dl > dt {\n margin-top: var(--m-spacer);\n}\nmain dl > dd, article dl > dd {\n margin-bottom: var(--s-spacer);\n}\nmain details, article details {\n border: var(--1-px) var(--solid) var(--key);\n border-radius: var(--3-px);\n margin-top: var(--m-spacer);\n padding: 0.5em 0.5em 0;\n font-weight: var(--bold-font);\n}\nmain details summary, article details summary {\n color: var(--key);\n cursor: var(--pointer);\n margin: -0.5em -0.5em 0;\n padding: 0.5em;\n}\n\nnav > ul {\n margin: var(--0-spacer);\n padding: var(--0-spacer);\n list-style: var(--none);\n}\nnav > ul > li:first-of-type {\n margin-bottom: var(--m-spacer);\n}\nnav > ul ul {\n list-style: var(--none);\n margin: var(--0-spacer) var(--0-spacer);\n padding: var(--0-spacer) var(--0-spacer);\n}\nnav > ul ul a {\n font-size: var(--s-font);\n}\nnav > ul ul li:last-of-type a {\n padding-bottom: var(--s-spacer);\n}\nnav > ul a {\n display: var(--block);\n overflow: var(--hidden);\n text-align: var(--center);\n position: var(--relative);\n}\nnav > ul abbr {\n border-color: var(--gray-lightest);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n html {\n scroll-behavior: var(--smooth);\n }\n\n nav > ul a:after {\n content: \"\";\n position: var(--absolute);\n width: 25%;\n border-top: var(--1-px) var(--solid) var(--gray-darker);\n opacity: 0.25;\n top: 60%;\n left: -100%;\n transition-delay: all 0.75s;\n transition: all 0.75s ease-out;\n }\n nav > ul a:hover:after {\n left: 100%;\n }\n}\n@media (prefers-color-scheme: dark) {\n body {\n background: var(--gray-darkest);\n color: var(--gray-lightest);\n font-weight: var(--medium-font);\n }\n\n hr {\n border: 1px solid var(--gray-lightest);\n }\n\n a {\n color: var(--key-light);\n font-weight: var(--x-bold-font);\n }\n a:hover {\n color: var(--gray-lightest);\n }\n\n [is=dateblock] p > time, b, strong {\n font-weight: var(--x-bold-font);\n }\n\n nav > ul a:after {\n border-top: var(--1-px) var(--solid) var(--gray-lightest);\n }\n\n main img,\nmain iframe[src*=\"https://jsfiddle.net\"], article img,\narticle iframe[src*=\"https://jsfiddle.net\"] {\n border: 3px solid var(--gray-lightest);\n }\n main details, article details {\n font-weight: var(--bold-font);\n }\n main details summary, article details summary {\n color: var(--key-light);\n }\n}\nblockquote {\n margin: var(--m-spacer);\n font-style: var(--italic);\n}\n\n.heading-permalink {\n display: var(--inline-block);\n margin-right: var(--2xs-spacer);\n}\n\na[rel~=noreferrer]:after {\n content: var(--icon-new-window);\n display: var(--inline-block);\n margin-left: var(--2xs-spacer);\n position: var(--relative);\n top: var(--icon-lift-spacer);\n font-size: var(--xs-font);\n}\n\nfooter {\n margin-top: var(--l-spacer);\n}\nfooter > p {\n text-align: var(--center);\n font-size: var(--xs-font);\n}\n\n/*# sourceMappingURL=file:///Users/alex/Public/joshbruce-repos/site-joshbruce.com/content/assets/sass/main.scss */",null,null]}
\ No newline at end of file
diff --git a/tests/test-content/content/public/assets/favicons/android-chrome-192x192.png b/tests/test-content/content/public/assets/favicons/android-chrome-192x192.png
new file mode 100644
index 00000000..b0cf6262
Binary files /dev/null and b/tests/test-content/content/public/assets/favicons/android-chrome-192x192.png differ
diff --git a/tests/test-content/content/public/assets/favicons/android-chrome-512x512.png b/tests/test-content/content/public/assets/favicons/android-chrome-512x512.png
new file mode 100644
index 00000000..59fcfd54
Binary files /dev/null and b/tests/test-content/content/public/assets/favicons/android-chrome-512x512.png differ
diff --git a/tests/test-content/content/public/assets/favicons/apple-touch-icon.png b/tests/test-content/content/public/assets/favicons/apple-touch-icon.png
new file mode 100644
index 00000000..74f72ee9
Binary files /dev/null and b/tests/test-content/content/public/assets/favicons/apple-touch-icon.png differ
diff --git a/tests/test-content/content/public/assets/favicons/favicon-16x16.png b/tests/test-content/content/public/assets/favicons/favicon-16x16.png
new file mode 100644
index 00000000..26c84693
Binary files /dev/null and b/tests/test-content/content/public/assets/favicons/favicon-16x16.png differ
diff --git a/tests/test-content/content/public/assets/favicons/favicon-32x32.png b/tests/test-content/content/public/assets/favicons/favicon-32x32.png
new file mode 100644
index 00000000..bbb0aba4
Binary files /dev/null and b/tests/test-content/content/public/assets/favicons/favicon-32x32.png differ
diff --git a/tests/test-content/content/public/assets/favicons/favicon.ico b/tests/test-content/content/public/assets/favicons/favicon.ico
new file mode 100644
index 00000000..7a22a602
Binary files /dev/null and b/tests/test-content/content/public/assets/favicons/favicon.ico differ
diff --git a/tests/test-content/content/public/assets/favicons/site.webmanifest b/tests/test-content/content/public/assets/favicons/site.webmanifest
new file mode 100644
index 00000000..45dc8a20
--- /dev/null
+++ b/tests/test-content/content/public/assets/favicons/site.webmanifest
@@ -0,0 +1 @@
+{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
\ No newline at end of file
diff --git a/tests/test-content/content/public/content.md b/tests/test-content/content/public/content.md
new file mode 100644
index 00000000..8866c764
--- /dev/null
+++ b/tests/test-content/content/public/content.md
@@ -0,0 +1,3 @@
+---
+title: Test content root
+---
diff --git a/tests/test-content/content/public/error-404.md b/tests/test-content/content/public/error-404.md
new file mode 100644
index 00000000..df5025b0
--- /dev/null
+++ b/tests/test-content/content/public/error-404.md
@@ -0,0 +1,9 @@
+---
+title: Page not found
+---
+
+# Oh, no
+
+I'm sorry, it appears the content you are looking for doesn't exist.
+
+It's either been moved, deleted, never existed, or some combination.
diff --git a/tests/test-content/content/public/published-folder-with-draft-content/_content.md b/tests/test-content/content/public/published-folder-with-draft-content/_content.md
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test-content/content/public/published-sub/content.md b/tests/test-content/content/public/published-sub/content.md
new file mode 100644
index 00000000..ef1a82a7
--- /dev/null
+++ b/tests/test-content/content/public/published-sub/content.md
@@ -0,0 +1,3 @@
+---
+title: Sub-folder content title
+---
diff --git a/tests/test-content/content/public/sitemap.xml b/tests/test-content/content/public/sitemap.xml
new file mode 100644
index 00000000..87ba159a
--- /dev/null
+++ b/tests/test-content/content/public/sitemap.xml
@@ -0,0 +1,3 @@
+---
+template: 'sitemap'
+---