zap/app/Models/Page.php
2025-08-20 19:31:08 -04:00

231 lines
7.6 KiB
PHP

<?php
namespace App\Models;
use App\Render\BladeRenderer;
use App\Render\MarkdownRenderer;
use App\Render\Renderer;
use App\SiteConfiguration;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\FrontMatter\FrontMatterExtension;
use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter;
use League\CommonMark\MarkdownConverter;
use function Illuminate\Filesystem\join_paths;
// TODO: Load page's frontmatter and replace things like
// title and layout from it. Also set any default,
// values for things like layout from the config
// if they're not present on the page.
class Page
{
public string $filename;
public string $fileBaseName; // Name of the file with extension removed
public string $fileExtension;
public string $directory;
public string $title;
public string $slug;
public bool $isIndex = false;
public bool $isNestedIndex = false;
public string $key;
protected Filesystem $sourceDisk;
public ?string $layout;
public string $entityName; // The name of this page. Ex: projects/myproject = myproject
protected string $content;
// Collection data
public ?string $collection = null;
public array $collectionHierarchy = []; // All collections matched from least to most
public array $collectionProps = [];
public function __construct(public string $path)
{
$this->sourceDisk = Storage::disk('source');
$this->filename = basename($this->path);
$this->fileExtension = (str_ends_with($this->filename, '.blade.php')) ? 'blade.php' : pathinfo($this->filename, PATHINFO_EXTENSION);
$this->fileExtension = str($this->fileExtension)->trim('.')->toString();
$this->fileBaseName = str($this->filename)->remove("." . $this->fileExtension)->toString();
$this->title = str($this->filename)->remove("." . $this->fileExtension)->title()->toString();
$this->slug = str($this->title)->slug();
$this->directory = dirname($this->path);
$this->key = str($this->path)->remove("." . $this->fileExtension)->replace("/", ".")->lower()->toString();
if ($this->fileBaseName == "index") {
$this->isIndex = true;
$this->isNestedIndex = true;
}
$this->entityName = $this->isIndex ? basename($this->directory) : $this->slug;
$this->detectCollection();
$this->fillAdditionalData();
}
public function __get($name)
{
if (!empty($this->$name)) {
return $this->name;
}
return $this->collectionProps[$name] ?? null;
}
public function detectCollection()
{
$key_parts = explode('.', $this->key);
$collections = SiteConfiguration::collections();
// Turn collections into an array containing
// the parts and the length so we can easily
// filter likely matches.
$collections = collect($collections)->map(function ($name) {
$parts = explode('.', $name);
return [
'name' => $name,
'parts' => $parts,
'length' => count($parts)
];
})
->filter(fn($col) => $col['length'] <= count($key_parts))
->sortBy('length');
foreach ($collections as $c) {
$parts = $c['parts'];
// Get a subset of max length key parts
$key_compare = array_slice($key_parts, 0, count($parts));
$props = [];
$match = true;
foreach ($key_compare as $i => $v) {
$check = $parts[$i];
// Check if it's a parameter like [project_name]
if (str($check)->isMatch('/\[[a-z_]+\]/')) {
$key = str($check)->remove(['[', ']'])->toString();
$props[$key] = $v;
continue;
}
if ($v !== $check) {
$match = false;
break;
}
}
if ($match) {
$this->collectionHierarchy[] = $c['name'];
$this->collection = $c['name'];
$this->collectionProps = $props;
}
}
// After detecting the collection, we can also
// determine if the file is an index in that
// collection. Example:
// collection = projects
// file path = projects/myproject.md
if (str($this->key)->remove($this->collection)->remove(".") == $this->fileBaseName) {
$this->isIndex = true;
}
}
public function applyCollectionSettings(string $collection)
{
$config = SiteConfiguration::getConfig()['collections'][$collection];
$fill = ['layout'];
foreach ($fill as $field) {
$this->$field = $config[$field];
}
}
/**
* Grab data from the file's front matter
* as well as the config and overwrite
* or fill in any missing attributes.
*
* @return void
*/
public function fillAdditionalData()
{
// Apply collection settings in a hierarchical
// manner, with the settings from the most
// matched collection taking precedence
// over the least.
foreach ($this->collectionHierarchy as $collection) {
$this->applyCollectionSettings($collection);
}
// After the hierarchy, also apply settings
// from the detected collection to be
// safe.
if (!empty($this->collection)) {
$this->applyCollectionSettings($collection);
}
// Grab front matter data if any. Apply any settings
// from it since it takes precedence over
// collection settings.
if ($this->fileExtension == 'md') {
// Build the markdown parser
$mdEnvironment = new Environment();
$mdEnvironment->addExtension(new CommonMarkCoreExtension);
$mdEnvironment->addExtension(new FrontMatterExtension);
$parser = new MarkdownConverter($mdEnvironment);
$parsed = $parser->convert($this->content());
if ($parsed instanceof RenderedContentWithFrontMatter) {
$frontMatter = $parsed->getFrontMatter();
foreach ($frontMatter as $field => $value) {
$this->$field = $value;
}
}
}
// If layout is still empty just set it to main
if (empty($this->layout)) $this->layout = 'main';
}
public function renderer(): Renderer
{
return ($this->fileExtension == 'blade.php') ? new BladeRenderer : new MarkdownRenderer;
}
public function render(): string
{
return $this->renderer()->render($this);
}
public function outputPath(): string
{
$baseDirectory = $this->directory;
if ($this->isIndex) {
return join_paths($baseDirectory, "index.html");
}
return join_paths($baseDirectory, "{$this->slug}", "index.html");
}
public function url(): string
{
$baseDirectory = $this->directory;
if ($this->isIndex && $this->isNestedIndex) {
return str($this->directory)->prepend("/");
}
return str(join_paths($baseDirectory, "{$this->slug}"))->prepend("/");
}
public function content(): string
{
if (!empty($this->content)) {
return $this->content;
}
$content = Storage::disk('source')->get($this->path);
$this->content = $content;
return $content;
}
}