Compare commits
No commits in common. '01b0fb9246257b60f52f55a0f665ca748239e46e' and '8e23500be7d0414c36a21cf0f1ab96428c8330bc' have entirely different histories.
01b0fb9246
...
8e23500be7
@ -1,40 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
declare(strict_types=1); |
|
||||||
|
|
||||||
namespace DoctrineMigrations; |
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema; |
|
||||||
use Doctrine\Migrations\AbstractMigration; |
|
||||||
|
|
||||||
/** |
|
||||||
* Create unfold_site table for subdomain to naddr mapping |
|
||||||
*/ |
|
||||||
final class Version20260109120000 extends AbstractMigration |
|
||||||
{ |
|
||||||
public function getDescription(): string |
|
||||||
{ |
|
||||||
return 'Create unfold_site table for Unfold subdomain to naddr mapping'; |
|
||||||
} |
|
||||||
|
|
||||||
public function up(Schema $schema): void |
|
||||||
{ |
|
||||||
$this->addSql('CREATE TABLE unfold_site ( |
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, |
|
||||||
subdomain VARCHAR(255) NOT NULL, |
|
||||||
naddr VARCHAR(500) NOT NULL, |
|
||||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, |
|
||||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, |
|
||||||
PRIMARY KEY(id) |
|
||||||
)'); |
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_UNFOLD_SITE_SUBDOMAIN ON unfold_site (subdomain)'); |
|
||||||
$this->addSql("COMMENT ON COLUMN unfold_site.created_at IS '(DC2Type:datetime_immutable)'"); |
|
||||||
$this->addSql("COMMENT ON COLUMN unfold_site.updated_at IS '(DC2Type:datetime_immutable)'"); |
|
||||||
} |
|
||||||
|
|
||||||
public function down(Schema $schema): void |
|
||||||
{ |
|
||||||
$this->addSql('DROP TABLE unfold_site'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,99 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\Entity; |
|
||||||
|
|
||||||
use App\Repository\UnfoldSiteRepository; |
|
||||||
use Doctrine\DBAL\Types\Types; |
|
||||||
use Doctrine\ORM\Mapping as ORM; |
|
||||||
|
|
||||||
/** |
|
||||||
* Maps subdomains to Nostr magazine naddrs for Unfold rendering |
|
||||||
*/ |
|
||||||
#[ORM\Entity(repositoryClass: UnfoldSiteRepository::class)] |
|
||||||
#[ORM\Table(name: 'unfold_site')] |
|
||||||
#[ORM\HasLifecycleCallbacks] |
|
||||||
class UnfoldSite |
|
||||||
{ |
|
||||||
#[ORM\Id] |
|
||||||
#[ORM\GeneratedValue] |
|
||||||
#[ORM\Column(type: Types::INTEGER)] |
|
||||||
private ?int $id = null; |
|
||||||
|
|
||||||
#[ORM\Column(length: 255, unique: true)] |
|
||||||
private string $subdomain; |
|
||||||
|
|
||||||
#[ORM\Column(length: 500)] |
|
||||||
private string $naddr; |
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] |
|
||||||
private \DateTimeImmutable $createdAt; |
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] |
|
||||||
private \DateTimeImmutable $updatedAt; |
|
||||||
|
|
||||||
public function __construct() |
|
||||||
{ |
|
||||||
$this->createdAt = new \DateTimeImmutable(); |
|
||||||
$this->updatedAt = new \DateTimeImmutable(); |
|
||||||
} |
|
||||||
|
|
||||||
#[ORM\PreUpdate] |
|
||||||
public function onPreUpdate(): void |
|
||||||
{ |
|
||||||
$this->updatedAt = new \DateTimeImmutable(); |
|
||||||
} |
|
||||||
|
|
||||||
public function getId(): ?int |
|
||||||
{ |
|
||||||
return $this->id; |
|
||||||
} |
|
||||||
|
|
||||||
public function getSubdomain(): string |
|
||||||
{ |
|
||||||
return $this->subdomain; |
|
||||||
} |
|
||||||
|
|
||||||
public function setSubdomain(string $subdomain): static |
|
||||||
{ |
|
||||||
$this->subdomain = $subdomain; |
|
||||||
|
|
||||||
return $this; |
|
||||||
} |
|
||||||
|
|
||||||
public function getNaddr(): string |
|
||||||
{ |
|
||||||
return $this->naddr; |
|
||||||
} |
|
||||||
|
|
||||||
public function setNaddr(string $naddr): static |
|
||||||
{ |
|
||||||
$this->naddr = $naddr; |
|
||||||
|
|
||||||
return $this; |
|
||||||
} |
|
||||||
|
|
||||||
public function getCreatedAt(): \DateTimeImmutable |
|
||||||
{ |
|
||||||
return $this->createdAt; |
|
||||||
} |
|
||||||
|
|
||||||
public function setCreatedAt(\DateTimeImmutable $createdAt): static |
|
||||||
{ |
|
||||||
$this->createdAt = $createdAt; |
|
||||||
|
|
||||||
return $this; |
|
||||||
} |
|
||||||
|
|
||||||
public function getUpdatedAt(): \DateTimeImmutable |
|
||||||
{ |
|
||||||
return $this->updatedAt; |
|
||||||
} |
|
||||||
|
|
||||||
public function setUpdatedAt(\DateTimeImmutable $updatedAt): static |
|
||||||
{ |
|
||||||
$this->updatedAt = $updatedAt; |
|
||||||
|
|
||||||
return $this; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,27 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\Repository; |
|
||||||
|
|
||||||
use App\Entity\UnfoldSite; |
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
|
||||||
use Doctrine\Persistence\ManagerRegistry; |
|
||||||
|
|
||||||
/** |
|
||||||
* @extends ServiceEntityRepository<UnfoldSite> |
|
||||||
*/ |
|
||||||
class UnfoldSiteRepository extends ServiceEntityRepository |
|
||||||
{ |
|
||||||
public function __construct(ManagerRegistry $registry) |
|
||||||
{ |
|
||||||
parent::__construct($registry, UnfoldSite::class); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Find an UnfoldSite by its subdomain |
|
||||||
*/ |
|
||||||
public function findBySubdomain(string $subdomain): ?UnfoldSite |
|
||||||
{ |
|
||||||
return $this->findOneBy(['subdomain' => $subdomain]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,85 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Config; |
|
||||||
|
|
||||||
/** |
|
||||||
* Unfold App Data from NIP-78 event (kind 30078) |
|
||||||
* |
|
||||||
* This is the configuration stored on Nostr that defines an Unfold site. |
|
||||||
* The UnfoldSite entity maps subdomains to the naddr of this app data event. |
|
||||||
* |
|
||||||
* Event structure: |
|
||||||
* - kind: 30078 |
|
||||||
* - content: "Unfold App Config for 'Magazine Name'" (human-readable title) |
|
||||||
* - tags: |
|
||||||
* - ["d", "<unique-site-identifier>"] |
|
||||||
* - ["a", "<magazine-naddr>"] // Required: root magazine event (kind 30040) |
|
||||||
* - ["theme", "<theme-name>"] // Optional: defaults to "default" |
|
||||||
* - ["alt", "Unfold App Config"] // Alt text for clients that don't understand this event |
|
||||||
*/ |
|
||||||
readonly class AppData |
|
||||||
{ |
|
||||||
public function __construct( |
|
||||||
public string $naddr, // naddr of this app data event |
|
||||||
public string $magazineNaddr, // naddr of root magazine event (kind 30040) |
|
||||||
public string $theme = 'default', |
|
||||||
public string $title = '', // human-readable title from content |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Create AppData from a raw NIP-78 event object |
|
||||||
*/ |
|
||||||
public static function fromEvent(object $event, string $naddr): self |
|
||||||
{ |
|
||||||
$tags = $event->tags ?? []; |
|
||||||
$magazineNaddr = ''; |
|
||||||
$theme = 'default'; |
|
||||||
|
|
||||||
foreach ($tags as $tag) { |
|
||||||
if (!is_array($tag) || count($tag) < 2) { |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
match ($tag[0]) { |
|
||||||
'a' => $magazineNaddr = $tag[1], |
|
||||||
'theme' => $theme = $tag[1], |
|
||||||
default => null, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
if (empty($magazineNaddr)) { |
|
||||||
throw new \InvalidArgumentException('AppData event missing required "a" tag with magazine naddr'); |
|
||||||
} |
|
||||||
|
|
||||||
return new self( |
|
||||||
naddr: $naddr, |
|
||||||
magazineNaddr: $magazineNaddr, |
|
||||||
theme: $theme, |
|
||||||
title: $event->content ?? '', |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build tags array for publishing |
|
||||||
* |
|
||||||
* @param string $identifier The d-tag identifier for this app data event |
|
||||||
*/ |
|
||||||
public static function buildTags(string $identifier, string $magazineNaddr, string $theme = 'default'): array |
|
||||||
{ |
|
||||||
return [ |
|
||||||
['d', $identifier], |
|
||||||
['a', $magazineNaddr], |
|
||||||
['theme', $theme], |
|
||||||
['alt', 'Unfold App Config'], |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build content string for publishing |
|
||||||
*/ |
|
||||||
public static function buildContent(string $magazineName): string |
|
||||||
{ |
|
||||||
return "Unfold App Config for '{$magazineName}'"; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,75 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Config; |
|
||||||
|
|
||||||
/** |
|
||||||
* Site configuration derived from AppData + root magazine event (kind 30040) |
|
||||||
*/ |
|
||||||
readonly class SiteConfig |
|
||||||
{ |
|
||||||
/** |
|
||||||
* @param string $naddr The naddr of the root magazine event |
|
||||||
* @param string $title Site title from event |
|
||||||
* @param string $description Site description |
|
||||||
* @param string|null $logo Logo/image URL |
|
||||||
* @param array<string> $categories List of category event coordinates (kind:pubkey:d-tag) |
|
||||||
* @param string $pubkey Owner's hex pubkey |
|
||||||
* @param string $theme Theme name from AppData |
|
||||||
*/ |
|
||||||
public function __construct( |
|
||||||
public string $naddr, |
|
||||||
public string $title, |
|
||||||
public string $description, |
|
||||||
public ?string $logo, |
|
||||||
public array $categories, |
|
||||||
public string $pubkey, |
|
||||||
public string $theme = 'default', |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Create SiteConfig from a raw Nostr event object and AppData |
|
||||||
*/ |
|
||||||
public static function fromEvent(object $event, string $naddr, string $theme = 'default'): self |
|
||||||
{ |
|
||||||
$tags = $event->tags ?? []; |
|
||||||
$title = ''; |
|
||||||
$description = ''; |
|
||||||
$logo = null; |
|
||||||
$categories = []; |
|
||||||
|
|
||||||
foreach ($tags as $tag) { |
|
||||||
if (!is_array($tag) || count($tag) < 2) { |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
match ($tag[0]) { |
|
||||||
'title', 'name' => $title = $tag[1], |
|
||||||
'description', 'summary' => $description = $tag[1], |
|
||||||
'image', 'thumb', 'logo' => $logo = $tag[1], |
|
||||||
'a' => $categories[] = $tag[1], // category coordinate (30040:pubkey:slug) |
|
||||||
default => null, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
// Fallback: try content as JSON for title/description |
|
||||||
if (empty($title) && !empty($event->content)) { |
|
||||||
$content = json_decode($event->content, true); |
|
||||||
if (is_array($content)) { |
|
||||||
$title = $content['title'] ?? $content['name'] ?? ''; |
|
||||||
$description = $description ?: ($content['description'] ?? ''); |
|
||||||
$logo = $logo ?: ($content['image'] ?? $content['logo'] ?? null); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return new self( |
|
||||||
naddr: $naddr, |
|
||||||
title: $title, |
|
||||||
description: $description, |
|
||||||
logo: $logo, |
|
||||||
categories: $categories, |
|
||||||
pubkey: $event->pubkey ?? '', |
|
||||||
theme: $theme, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,143 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Config; |
|
||||||
|
|
||||||
use App\Enum\KindsEnum; |
|
||||||
use App\Service\NostrClient; |
|
||||||
use nostriphant\NIP19\Bech32; |
|
||||||
use nostriphant\NIP19\Data\NAddr; |
|
||||||
use Psr\Cache\CacheItemPoolInterface; |
|
||||||
use Psr\Log\LoggerInterface; |
|
||||||
|
|
||||||
/** |
|
||||||
* Loads SiteConfig by: |
|
||||||
* 1. Fetching AppData event (kind 30078) via naddr |
|
||||||
* 2. Extracting magazineNaddr and theme from AppData |
|
||||||
* 3. Fetching root magazine event (kind 30040) via magazineNaddr |
|
||||||
* 4. Building SiteConfig from magazine event + theme |
|
||||||
*/ |
|
||||||
class SiteConfigLoader |
|
||||||
{ |
|
||||||
private const CACHE_TTL = 120; // 2 minutes |
|
||||||
|
|
||||||
public function __construct( |
|
||||||
private readonly NostrClient $nostrClient, |
|
||||||
private readonly CacheItemPoolInterface $unfoldCache, |
|
||||||
private readonly LoggerInterface $logger, |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Load SiteConfig by AppData naddr string |
|
||||||
* |
|
||||||
* @param string $appDataNaddr naddr of the NIP-78 AppData event (kind 30078) |
|
||||||
* @throws \InvalidArgumentException if naddr is invalid |
|
||||||
* @throws \RuntimeException if events cannot be fetched |
|
||||||
*/ |
|
||||||
public function load(string $appDataNaddr): SiteConfig |
|
||||||
{ |
|
||||||
// Check cache first |
|
||||||
$cacheKey = 'site_config_' . md5($appDataNaddr); |
|
||||||
$cacheItem = $this->unfoldCache->getItem($cacheKey); |
|
||||||
|
|
||||||
if ($cacheItem->isHit()) { |
|
||||||
$this->logger->debug('SiteConfig cache hit', ['appDataNaddr' => $appDataNaddr]); |
|
||||||
return $cacheItem->get(); |
|
||||||
} |
|
||||||
|
|
||||||
// 1. Load AppData event (kind 30078) |
|
||||||
$appData = $this->loadAppData($appDataNaddr); |
|
||||||
|
|
||||||
// 2. Load magazine event using magazineNaddr from AppData |
|
||||||
$magazineDecoded = $this->decodeNaddr($appData->magazineNaddr); |
|
||||||
$magazineEvent = $this->nostrClient->getEventByNaddr($magazineDecoded); |
|
||||||
|
|
||||||
if ($magazineEvent === null) { |
|
||||||
throw new \RuntimeException(sprintf( |
|
||||||
'Could not fetch magazine event for naddr: %s', |
|
||||||
$appData->magazineNaddr |
|
||||||
)); |
|
||||||
} |
|
||||||
|
|
||||||
// 3. Build SiteConfig from magazine event + theme from AppData |
|
||||||
$siteConfig = SiteConfig::fromEvent($magazineEvent, $appData->magazineNaddr, $appData->theme); |
|
||||||
|
|
||||||
// Cache it |
|
||||||
$cacheItem->set($siteConfig); |
|
||||||
$cacheItem->expiresAfter(self::CACHE_TTL); |
|
||||||
$this->unfoldCache->save($cacheItem); |
|
||||||
|
|
||||||
$this->logger->info('Loaded and cached SiteConfig', [ |
|
||||||
'appDataNaddr' => $appDataNaddr, |
|
||||||
'magazineNaddr' => $appData->magazineNaddr, |
|
||||||
'theme' => $appData->theme, |
|
||||||
'title' => $siteConfig->title, |
|
||||||
'categories' => count($siteConfig->categories), |
|
||||||
]); |
|
||||||
|
|
||||||
return $siteConfig; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Load AppData from NIP-78 event |
|
||||||
*/ |
|
||||||
private function loadAppData(string $naddr): AppData |
|
||||||
{ |
|
||||||
$decoded = $this->decodeNaddr($naddr); |
|
||||||
|
|
||||||
// Verify it's a kind 30078 event |
|
||||||
if ($decoded['kind'] !== KindsEnum::APP_DATA->value) { |
|
||||||
throw new \InvalidArgumentException(sprintf( |
|
||||||
'Expected AppData event (kind %d), got kind %d', |
|
||||||
KindsEnum::APP_DATA->value, |
|
||||||
$decoded['kind'] |
|
||||||
)); |
|
||||||
} |
|
||||||
|
|
||||||
$event = $this->nostrClient->getEventByNaddr($decoded); |
|
||||||
|
|
||||||
if ($event === null) { |
|
||||||
throw new \RuntimeException(sprintf('Could not fetch AppData event for naddr: %s', $naddr)); |
|
||||||
} |
|
||||||
|
|
||||||
return AppData::fromEvent($event, $naddr); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Decode naddr string to array with kind, pubkey, identifier, relays |
|
||||||
* |
|
||||||
* @throws \InvalidArgumentException if naddr is invalid |
|
||||||
*/ |
|
||||||
private function decodeNaddr(string $naddr): array |
|
||||||
{ |
|
||||||
try { |
|
||||||
$decoded = new Bech32($naddr); |
|
||||||
|
|
||||||
if ($decoded->type !== 'naddr') { |
|
||||||
throw new \InvalidArgumentException(sprintf('Expected naddr, got %s', $decoded->type)); |
|
||||||
} |
|
||||||
|
|
||||||
/** @var NAddr $data */ |
|
||||||
$data = $decoded->data; |
|
||||||
|
|
||||||
return [ |
|
||||||
'kind' => $data->kind, |
|
||||||
'pubkey' => $data->pubkey, |
|
||||||
'identifier' => $data->identifier, |
|
||||||
'relays' => $data->relays ?? [], |
|
||||||
]; |
|
||||||
} catch (\Throwable $e) { |
|
||||||
throw new \InvalidArgumentException(sprintf('Invalid naddr: %s (%s)', $naddr, $e->getMessage()), 0, $e); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Invalidate cached SiteConfig for a given AppData naddr |
|
||||||
*/ |
|
||||||
public function invalidate(string $appDataNaddr): void |
|
||||||
{ |
|
||||||
$cacheKey = 'site_config_' . md5($appDataNaddr); |
|
||||||
$this->unfoldCache->deleteItem($cacheKey); |
|
||||||
$this->logger->info('Invalidated SiteConfig cache', ['appDataNaddr' => $appDataNaddr]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,62 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Content; |
|
||||||
|
|
||||||
/** |
|
||||||
* Category data derived from category event (kind 30040) |
|
||||||
*/ |
|
||||||
readonly class CategoryData |
|
||||||
{ |
|
||||||
/** |
|
||||||
* @param string $slug Category slug (d tag) |
|
||||||
* @param string $title Category title |
|
||||||
* @param string $coordinate Category coordinate (kind:pubkey:slug) |
|
||||||
* @param array<string> $articleCoordinates List of article coordinates (kind:pubkey:slug) |
|
||||||
*/ |
|
||||||
public function __construct( |
|
||||||
public string $slug, |
|
||||||
public string $title, |
|
||||||
public string $coordinate, |
|
||||||
public array $articleCoordinates = [], |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Create CategoryData from a raw Nostr event object |
|
||||||
*/ |
|
||||||
public static function fromEvent(object $event, string $coordinate): self |
|
||||||
{ |
|
||||||
$tags = $event->tags ?? []; |
|
||||||
$slug = ''; |
|
||||||
$title = ''; |
|
||||||
$articleCoordinates = []; |
|
||||||
|
|
||||||
foreach ($tags as $tag) { |
|
||||||
if (!is_array($tag) || count($tag) < 2) { |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
match ($tag[0]) { |
|
||||||
'd' => $slug = $tag[1], |
|
||||||
'title', 'name' => $title = $tag[1], |
|
||||||
'a' => $articleCoordinates[] = $tag[1], // article coordinate |
|
||||||
default => null, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
// Fallback: try content as JSON for title |
|
||||||
if (empty($title) && !empty($event->content)) { |
|
||||||
$content = json_decode($event->content, true); |
|
||||||
if (is_array($content)) { |
|
||||||
$title = $content['title'] ?? $content['name'] ?? ''; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return new self( |
|
||||||
slug: $slug, |
|
||||||
title: $title, |
|
||||||
coordinate: $coordinate, |
|
||||||
articleCoordinates: $articleCoordinates, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,241 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Content; |
|
||||||
|
|
||||||
use App\Enum\KindsEnum; |
|
||||||
use App\Service\NostrClient; |
|
||||||
use App\UnfoldBundle\Config\SiteConfig; |
|
||||||
use Psr\Cache\CacheItemPoolInterface; |
|
||||||
use Psr\Log\LoggerInterface; |
|
||||||
|
|
||||||
/** |
|
||||||
* Provides content by traversing the magazine event tree |
|
||||||
*/ |
|
||||||
class ContentProvider |
|
||||||
{ |
|
||||||
private const CACHE_TTL = 300; // 5 minutes |
|
||||||
|
|
||||||
public function __construct( |
|
||||||
private readonly NostrClient $nostrClient, |
|
||||||
private readonly CacheItemPoolInterface $unfoldCache, |
|
||||||
private readonly LoggerInterface $logger, |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get all categories for a site |
|
||||||
* |
|
||||||
* @return CategoryData[] |
|
||||||
*/ |
|
||||||
public function getCategories(SiteConfig $site): array |
|
||||||
{ |
|
||||||
$cacheKey = 'categories_' . md5($site->naddr); |
|
||||||
$cacheItem = $this->unfoldCache->getItem($cacheKey); |
|
||||||
|
|
||||||
if ($cacheItem->isHit()) { |
|
||||||
return $cacheItem->get(); |
|
||||||
} |
|
||||||
|
|
||||||
$categories = []; |
|
||||||
|
|
||||||
foreach ($site->categories as $coordinate) { |
|
||||||
$category = $this->fetchCategoryByCoordinate($coordinate); |
|
||||||
if ($category !== null) { |
|
||||||
$categories[] = $category; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
$cacheItem->set($categories); |
|
||||||
$cacheItem->expiresAfter(self::CACHE_TTL); |
|
||||||
$this->unfoldCache->save($cacheItem); |
|
||||||
|
|
||||||
return $categories; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get posts for a specific category |
|
||||||
* |
|
||||||
* @return PostData[] |
|
||||||
*/ |
|
||||||
public function getCategoryPosts(string $categoryCoordinate): array |
|
||||||
{ |
|
||||||
$cacheKey = 'category_posts_' . md5($categoryCoordinate); |
|
||||||
$cacheItem = $this->unfoldCache->getItem($cacheKey); |
|
||||||
|
|
||||||
if ($cacheItem->isHit()) { |
|
||||||
return $cacheItem->get(); |
|
||||||
} |
|
||||||
|
|
||||||
// First fetch the category to get article coordinates |
|
||||||
$category = $this->fetchCategoryByCoordinate($categoryCoordinate); |
|
||||||
if ($category === null) { |
|
||||||
return []; |
|
||||||
} |
|
||||||
|
|
||||||
$posts = []; |
|
||||||
foreach ($category->articleCoordinates as $articleCoordinate) { |
|
||||||
$post = $this->fetchPostByCoordinate($articleCoordinate); |
|
||||||
if ($post !== null) { |
|
||||||
$posts[] = $post; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Sort by published date descending |
|
||||||
usort($posts, fn(PostData $a, PostData $b) => $b->publishedAt <=> $a->publishedAt); |
|
||||||
|
|
||||||
$cacheItem->set($posts); |
|
||||||
$cacheItem->expiresAfter(self::CACHE_TTL); |
|
||||||
$this->unfoldCache->save($cacheItem); |
|
||||||
|
|
||||||
return $posts; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get all posts for the home page (aggregated from all categories) |
|
||||||
* |
|
||||||
* @return PostData[] |
|
||||||
*/ |
|
||||||
public function getHomePosts(SiteConfig $site, int $limit = 10): array |
|
||||||
{ |
|
||||||
$cacheKey = 'home_posts_' . md5($site->naddr) . '_' . $limit; |
|
||||||
$cacheItem = $this->unfoldCache->getItem($cacheKey); |
|
||||||
|
|
||||||
if ($cacheItem->isHit()) { |
|
||||||
return $cacheItem->get(); |
|
||||||
} |
|
||||||
|
|
||||||
$allPosts = []; |
|
||||||
$categories = $this->getCategories($site); |
|
||||||
|
|
||||||
foreach ($categories as $category) { |
|
||||||
$categoryPosts = $this->getCategoryPosts($category->coordinate); |
|
||||||
$allPosts = array_merge($allPosts, $categoryPosts); |
|
||||||
} |
|
||||||
|
|
||||||
// Remove duplicates by coordinate |
|
||||||
$uniquePosts = []; |
|
||||||
foreach ($allPosts as $post) { |
|
||||||
$uniquePosts[$post->coordinate] = $post; |
|
||||||
} |
|
||||||
$allPosts = array_values($uniquePosts); |
|
||||||
|
|
||||||
// Sort by published date descending and limit |
|
||||||
usort($allPosts, fn(PostData $a, PostData $b) => $b->publishedAt <=> $a->publishedAt); |
|
||||||
$posts = array_slice($allPosts, 0, $limit); |
|
||||||
|
|
||||||
$cacheItem->set($posts); |
|
||||||
$cacheItem->expiresAfter(self::CACHE_TTL); |
|
||||||
$this->unfoldCache->save($cacheItem); |
|
||||||
|
|
||||||
return $posts; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get a single post by slug |
|
||||||
*/ |
|
||||||
public function getPost(string $slug, SiteConfig $site): ?PostData |
|
||||||
{ |
|
||||||
// Search through all categories for the post |
|
||||||
$categories = $this->getCategories($site); |
|
||||||
|
|
||||||
foreach ($categories as $category) { |
|
||||||
foreach ($category->articleCoordinates as $coordinate) { |
|
||||||
// Check if coordinate ends with the slug |
|
||||||
if (str_ends_with($coordinate, ':' . $slug)) { |
|
||||||
return $this->fetchPostByCoordinate($coordinate); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch a category event by coordinate |
|
||||||
*/ |
|
||||||
private function fetchCategoryByCoordinate(string $coordinate): ?CategoryData |
|
||||||
{ |
|
||||||
$decoded = $this->parseCoordinate($coordinate); |
|
||||||
if ($decoded === null) { |
|
||||||
$this->logger->warning('Invalid category coordinate', ['coordinate' => $coordinate]); |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
$event = $this->nostrClient->getEventByNaddr($decoded); |
|
||||||
if ($event === null) { |
|
||||||
$this->logger->warning('Category event not found', ['coordinate' => $coordinate]); |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return CategoryData::fromEvent($event, $coordinate); |
|
||||||
} catch (\Exception $e) { |
|
||||||
$this->logger->error('Error fetching category', [ |
|
||||||
'coordinate' => $coordinate, |
|
||||||
'error' => $e->getMessage(), |
|
||||||
]); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch a post event by coordinate |
|
||||||
*/ |
|
||||||
private function fetchPostByCoordinate(string $coordinate): ?PostData |
|
||||||
{ |
|
||||||
$decoded = $this->parseCoordinate($coordinate); |
|
||||||
if ($decoded === null) { |
|
||||||
$this->logger->warning('Invalid post coordinate', ['coordinate' => $coordinate]); |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
$event = $this->nostrClient->getEventByNaddr($decoded); |
|
||||||
if ($event === null) { |
|
||||||
$this->logger->warning('Post event not found', ['coordinate' => $coordinate]); |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return PostData::fromEvent($event); |
|
||||||
} catch (\Exception $e) { |
|
||||||
$this->logger->error('Error fetching post', [ |
|
||||||
'coordinate' => $coordinate, |
|
||||||
'error' => $e->getMessage(), |
|
||||||
]); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Parse a coordinate string (kind:pubkey:identifier) into naddr-like array |
|
||||||
*/ |
|
||||||
private function parseCoordinate(string $coordinate): ?array |
|
||||||
{ |
|
||||||
$parts = explode(':', $coordinate, 3); |
|
||||||
if (count($parts) !== 3) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return [ |
|
||||||
'kind' => (int) $parts[0], |
|
||||||
'pubkey' => $parts[1], |
|
||||||
'identifier' => $parts[2], |
|
||||||
'relays' => [], |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Invalidate all caches for a site |
|
||||||
*/ |
|
||||||
public function invalidateSiteCache(SiteConfig $site): void |
|
||||||
{ |
|
||||||
$this->unfoldCache->deleteItem('categories_' . md5($site->naddr)); |
|
||||||
$this->unfoldCache->deleteItem('home_posts_' . md5($site->naddr) . '_10'); |
|
||||||
|
|
||||||
foreach ($site->categories as $coordinate) { |
|
||||||
$this->unfoldCache->deleteItem('category_posts_' . md5($coordinate)); |
|
||||||
} |
|
||||||
|
|
||||||
$this->logger->info('Invalidated site content cache', ['naddr' => $site->naddr]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,82 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Content; |
|
||||||
|
|
||||||
/** |
|
||||||
* Post/article data derived from article event (kind 30023) |
|
||||||
*/ |
|
||||||
readonly class PostData |
|
||||||
{ |
|
||||||
/** |
|
||||||
* @param string $slug Article slug (d tag) |
|
||||||
* @param string $title Article title |
|
||||||
* @param string $summary Article summary/excerpt |
|
||||||
* @param string $content Article content (markdown) |
|
||||||
* @param string|null $image Featured image URL |
|
||||||
* @param int $publishedAt Unix timestamp |
|
||||||
* @param string $pubkey Author's hex pubkey |
|
||||||
* @param string $coordinate Article coordinate (kind:pubkey:slug) |
|
||||||
*/ |
|
||||||
public function __construct( |
|
||||||
public string $slug, |
|
||||||
public string $title, |
|
||||||
public string $summary, |
|
||||||
public string $content, |
|
||||||
public ?string $image, |
|
||||||
public int $publishedAt, |
|
||||||
public string $pubkey, |
|
||||||
public string $coordinate, |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Create PostData from a raw Nostr event object |
|
||||||
*/ |
|
||||||
public static function fromEvent(object $event): self |
|
||||||
{ |
|
||||||
$tags = $event->tags ?? []; |
|
||||||
$slug = ''; |
|
||||||
$title = ''; |
|
||||||
$summary = ''; |
|
||||||
$image = null; |
|
||||||
$publishedAt = $event->created_at ?? time(); |
|
||||||
|
|
||||||
foreach ($tags as $tag) { |
|
||||||
if (!is_array($tag) || count($tag) < 2) { |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
match ($tag[0]) { |
|
||||||
'd' => $slug = $tag[1], |
|
||||||
'title' => $title = $tag[1], |
|
||||||
'summary' => $summary = $tag[1], |
|
||||||
'image', 'thumb' => $image = $tag[1], |
|
||||||
'published_at' => $publishedAt = (int) $tag[1], |
|
||||||
default => null, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
$kind = $event->kind ?? 30023; |
|
||||||
$pubkey = $event->pubkey ?? ''; |
|
||||||
$coordinate = "{$kind}:{$pubkey}:{$slug}"; |
|
||||||
|
|
||||||
return new self( |
|
||||||
slug: $slug, |
|
||||||
title: $title, |
|
||||||
summary: $summary, |
|
||||||
content: $event->content ?? '', |
|
||||||
image: $image, |
|
||||||
publishedAt: $publishedAt, |
|
||||||
pubkey: $pubkey, |
|
||||||
coordinate: $coordinate, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get formatted publish date |
|
||||||
*/ |
|
||||||
public function getPublishedDate(string $format = 'F j, Y'): string |
|
||||||
{ |
|
||||||
return date($format, $this->publishedAt); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,108 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Controller; |
|
||||||
|
|
||||||
use App\UnfoldBundle\Config\SiteConfigLoader; |
|
||||||
use App\UnfoldBundle\Content\ContentProvider; |
|
||||||
use App\UnfoldBundle\Http\HostResolver; |
|
||||||
use App\UnfoldBundle\Http\RouteMatcher; |
|
||||||
use App\UnfoldBundle\Theme\ContextBuilder; |
|
||||||
use App\UnfoldBundle\Theme\HandlebarsRenderer; |
|
||||||
use Psr\Log\LoggerInterface; |
|
||||||
use Symfony\Component\HttpFoundation\Request; |
|
||||||
use Symfony\Component\HttpFoundation\Response; |
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; |
|
||||||
|
|
||||||
/** |
|
||||||
* Main controller for Unfold site rendering |
|
||||||
*/ |
|
||||||
class SiteController |
|
||||||
{ |
|
||||||
public function __construct( |
|
||||||
private readonly HostResolver $hostResolver, |
|
||||||
private readonly SiteConfigLoader $siteConfigLoader, |
|
||||||
private readonly ContentProvider $contentProvider, |
|
||||||
private readonly RouteMatcher $routeMatcher, |
|
||||||
private readonly ContextBuilder $contextBuilder, |
|
||||||
private readonly HandlebarsRenderer $renderer, |
|
||||||
private readonly LoggerInterface $logger, |
|
||||||
) {} |
|
||||||
|
|
||||||
public function __invoke(Request $request): Response |
|
||||||
{ |
|
||||||
// 1. Resolve host to UnfoldSite |
|
||||||
$unfoldSite = $this->hostResolver->resolve(); |
|
||||||
|
|
||||||
if ($unfoldSite === null) { |
|
||||||
throw new NotFoundHttpException('Site not found for this subdomain'); |
|
||||||
} |
|
||||||
|
|
||||||
// 2. Load SiteConfig from AppData event (which fetches magazine and theme) |
|
||||||
try { |
|
||||||
$siteConfig = $this->siteConfigLoader->load($unfoldSite->getNaddr()); |
|
||||||
} catch (\RuntimeException $e) { |
|
||||||
$this->logger->error('Failed to load site config', [ |
|
||||||
'subdomain' => $unfoldSite->getSubdomain(), |
|
||||||
'naddr' => $unfoldSite->getNaddr(), |
|
||||||
'error' => $e->getMessage(), |
|
||||||
]); |
|
||||||
throw new NotFoundHttpException('Site configuration could not be loaded'); |
|
||||||
} |
|
||||||
|
|
||||||
// 3. Set theme from SiteConfig (theme comes from AppData event) |
|
||||||
$this->renderer->setTheme($siteConfig->theme); |
|
||||||
|
|
||||||
// 4. Get categories for route matching |
|
||||||
$categories = $this->contentProvider->getCategories($siteConfig); |
|
||||||
|
|
||||||
// 5. Match route |
|
||||||
$path = $request->getPathInfo(); |
|
||||||
$route = $this->routeMatcher->match($path, $siteConfig, $categories); |
|
||||||
|
|
||||||
// 6. Build context and render based on page type |
|
||||||
return match ($route['type']) { |
|
||||||
RouteMatcher::PAGE_HOME => $this->renderHome($siteConfig, $categories), |
|
||||||
RouteMatcher::PAGE_CATEGORY => $this->renderCategory($siteConfig, $categories, $route), |
|
||||||
RouteMatcher::PAGE_POST => $this->renderPost($siteConfig, $categories, $route), |
|
||||||
default => throw new NotFoundHttpException('Page not found'), |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
private function renderHome($siteConfig, array $categories): Response |
|
||||||
{ |
|
||||||
$posts = $this->contentProvider->getHomePosts($siteConfig); |
|
||||||
$context = $this->contextBuilder->buildHomeContext($siteConfig, $categories, $posts); |
|
||||||
|
|
||||||
$html = $this->renderer->render('index', $context); |
|
||||||
|
|
||||||
return new Response($html); |
|
||||||
} |
|
||||||
|
|
||||||
private function renderCategory($siteConfig, array $categories, array $route): Response |
|
||||||
{ |
|
||||||
$category = $route['category']; |
|
||||||
$posts = $this->contentProvider->getCategoryPosts($category->coordinate); |
|
||||||
$context = $this->contextBuilder->buildCategoryContext($siteConfig, $categories, $category, $posts); |
|
||||||
|
|
||||||
$html = $this->renderer->render('category', $context); |
|
||||||
|
|
||||||
return new Response($html); |
|
||||||
} |
|
||||||
|
|
||||||
private function renderPost($siteConfig, array $categories, array $route): Response |
|
||||||
{ |
|
||||||
$slug = $route['slug']; |
|
||||||
$post = $this->contentProvider->getPost($slug, $siteConfig); |
|
||||||
|
|
||||||
if ($post === null) { |
|
||||||
throw new NotFoundHttpException('Article not found'); |
|
||||||
} |
|
||||||
|
|
||||||
$context = $this->contextBuilder->buildPostContext($siteConfig, $categories, $post); |
|
||||||
|
|
||||||
$html = $this->renderer->render('post', $context); |
|
||||||
|
|
||||||
return new Response($html); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,72 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Controller; |
|
||||||
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse; |
|
||||||
use Symfony\Component\HttpFoundation\Request; |
|
||||||
use Symfony\Component\HttpFoundation\Response; |
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; |
|
||||||
use Symfony\Component\Mime\MimeTypes; |
|
||||||
|
|
||||||
/** |
|
||||||
* Serves static assets from theme directories |
|
||||||
*/ |
|
||||||
class ThemeAssetController |
|
||||||
{ |
|
||||||
private readonly string $themesBasePath; |
|
||||||
private readonly MimeTypes $mimeTypes; |
|
||||||
|
|
||||||
public function __construct( |
|
||||||
private readonly string $projectDir, |
|
||||||
) { |
|
||||||
$this->themesBasePath = $projectDir . '/src/UnfoldBundle/Resources/themes'; |
|
||||||
$this->mimeTypes = new MimeTypes(); |
|
||||||
} |
|
||||||
|
|
||||||
public function __invoke(Request $request, string $theme, string $path): Response |
|
||||||
{ |
|
||||||
// Sanitize theme name - only allow alphanumeric, dash, underscore |
|
||||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $theme)) { |
|
||||||
throw new NotFoundHttpException('Invalid theme name'); |
|
||||||
} |
|
||||||
|
|
||||||
// Prevent directory traversal |
|
||||||
$path = ltrim($path, '/'); |
|
||||||
if (str_contains($path, '..') || str_starts_with($path, '/')) { |
|
||||||
throw new NotFoundHttpException('Invalid path'); |
|
||||||
} |
|
||||||
|
|
||||||
// Build full file path |
|
||||||
$filePath = $this->themesBasePath . '/' . $theme . '/assets/' . $path; |
|
||||||
|
|
||||||
// Verify file exists and is within theme directory |
|
||||||
$realPath = realpath($filePath); |
|
||||||
$realThemePath = realpath($this->themesBasePath . '/' . $theme); |
|
||||||
|
|
||||||
if ($realPath === false || $realThemePath === false) { |
|
||||||
throw new NotFoundHttpException('Asset not found'); |
|
||||||
} |
|
||||||
|
|
||||||
if (!str_starts_with($realPath, $realThemePath)) { |
|
||||||
throw new NotFoundHttpException('Invalid asset path'); |
|
||||||
} |
|
||||||
|
|
||||||
if (!is_file($realPath)) { |
|
||||||
throw new NotFoundHttpException('Asset not found'); |
|
||||||
} |
|
||||||
|
|
||||||
// Determine content type |
|
||||||
$extension = pathinfo($realPath, PATHINFO_EXTENSION); |
|
||||||
$mimeType = $this->mimeTypes->getMimeTypes($extension)[0] ?? 'application/octet-stream'; |
|
||||||
|
|
||||||
$response = new BinaryFileResponse($realPath); |
|
||||||
$response->headers->set('Content-Type', $mimeType); |
|
||||||
|
|
||||||
// Cache for 1 week in production |
|
||||||
$response->setMaxAge(604800); |
|
||||||
$response->setPublic(); |
|
||||||
|
|
||||||
return $response; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,124 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Controller; |
|
||||||
|
|
||||||
use App\Entity\UnfoldSite; |
|
||||||
use App\Repository\UnfoldSiteRepository; |
|
||||||
use Doctrine\ORM\EntityManagerInterface; |
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
|
||||||
use Symfony\Component\HttpFoundation\Request; |
|
||||||
use Symfony\Component\HttpFoundation\Response; |
|
||||||
use Symfony\Component\Routing\Attribute\Route; |
|
||||||
|
|
||||||
/** |
|
||||||
* Admin controller for managing UnfoldSite records (subdomain ↔ naddr mapping) |
|
||||||
*/ |
|
||||||
#[Route('/admin/unfold')] |
|
||||||
class UnfoldAdminController extends AbstractController |
|
||||||
{ |
|
||||||
public function __construct( |
|
||||||
private readonly UnfoldSiteRepository $unfoldSiteRepository, |
|
||||||
private readonly EntityManagerInterface $entityManager, |
|
||||||
) {} |
|
||||||
|
|
||||||
#[Route('', name: 'unfold_admin_index', methods: ['GET'])] |
|
||||||
public function index(): Response |
|
||||||
{ |
|
||||||
$sites = $this->unfoldSiteRepository->findAll(); |
|
||||||
|
|
||||||
return $this->render('@Unfold/admin/index.html.twig', [ |
|
||||||
'sites' => $sites, |
|
||||||
]); |
|
||||||
} |
|
||||||
|
|
||||||
#[Route('/new', name: 'unfold_admin_new', methods: ['GET', 'POST'])] |
|
||||||
public function new(Request $request): Response |
|
||||||
{ |
|
||||||
if ($request->isMethod('POST')) { |
|
||||||
$subdomain = trim($request->request->get('subdomain', '')); |
|
||||||
$naddr = trim($request->request->get('naddr', '')); |
|
||||||
|
|
||||||
if (empty($subdomain) || empty($naddr)) { |
|
||||||
$this->addFlash('error', 'Subdomain and naddr are required.'); |
|
||||||
return $this->redirectToRoute('unfold_admin_new'); |
|
||||||
} |
|
||||||
|
|
||||||
// Check if subdomain already exists |
|
||||||
if ($this->unfoldSiteRepository->findBySubdomain($subdomain)) { |
|
||||||
$this->addFlash('error', 'Subdomain already exists.'); |
|
||||||
return $this->redirectToRoute('unfold_admin_new'); |
|
||||||
} |
|
||||||
|
|
||||||
$site = new UnfoldSite(); |
|
||||||
$site->setSubdomain($subdomain); |
|
||||||
$site->setNaddr($naddr); |
|
||||||
|
|
||||||
$this->entityManager->persist($site); |
|
||||||
$this->entityManager->flush(); |
|
||||||
|
|
||||||
$this->addFlash('success', 'Site created successfully.'); |
|
||||||
return $this->redirectToRoute('unfold_admin_index'); |
|
||||||
} |
|
||||||
|
|
||||||
return $this->render('@Unfold/admin/new.html.twig'); |
|
||||||
} |
|
||||||
|
|
||||||
#[Route('/{id}/edit', name: 'unfold_admin_edit', methods: ['GET', 'POST'])] |
|
||||||
public function edit(Request $request, int $id): Response |
|
||||||
{ |
|
||||||
$site = $this->unfoldSiteRepository->find($id); |
|
||||||
|
|
||||||
if (!$site) { |
|
||||||
throw $this->createNotFoundException('Site not found.'); |
|
||||||
} |
|
||||||
|
|
||||||
if ($request->isMethod('POST')) { |
|
||||||
$subdomain = trim($request->request->get('subdomain', '')); |
|
||||||
$naddr = trim($request->request->get('naddr', '')); |
|
||||||
|
|
||||||
if (empty($subdomain) || empty($naddr)) { |
|
||||||
$this->addFlash('error', 'Subdomain and naddr are required.'); |
|
||||||
return $this->redirectToRoute('unfold_admin_edit', ['id' => $id]); |
|
||||||
} |
|
||||||
|
|
||||||
// Check if subdomain already exists (excluding current site) |
|
||||||
$existing = $this->unfoldSiteRepository->findBySubdomain($subdomain); |
|
||||||
if ($existing && $existing->getId() !== $site->getId()) { |
|
||||||
$this->addFlash('error', 'Subdomain already exists.'); |
|
||||||
return $this->redirectToRoute('unfold_admin_edit', ['id' => $id]); |
|
||||||
} |
|
||||||
|
|
||||||
$site->setSubdomain($subdomain); |
|
||||||
$site->setNaddr($naddr); |
|
||||||
|
|
||||||
$this->entityManager->flush(); |
|
||||||
|
|
||||||
$this->addFlash('success', 'Site updated successfully.'); |
|
||||||
return $this->redirectToRoute('unfold_admin_index'); |
|
||||||
} |
|
||||||
|
|
||||||
return $this->render('@Unfold/admin/edit.html.twig', [ |
|
||||||
'site' => $site, |
|
||||||
]); |
|
||||||
} |
|
||||||
|
|
||||||
#[Route('/{id}/delete', name: 'unfold_admin_delete', methods: ['POST'])] |
|
||||||
public function delete(Request $request, int $id): Response |
|
||||||
{ |
|
||||||
$site = $this->unfoldSiteRepository->find($id); |
|
||||||
|
|
||||||
if (!$site) { |
|
||||||
throw $this->createNotFoundException('Site not found.'); |
|
||||||
} |
|
||||||
|
|
||||||
// CSRF check |
|
||||||
if ($this->isCsrfTokenValid('delete' . $id, $request->request->get('_token'))) { |
|
||||||
$this->entityManager->remove($site); |
|
||||||
$this->entityManager->flush(); |
|
||||||
$this->addFlash('success', 'Site deleted successfully.'); |
|
||||||
} |
|
||||||
|
|
||||||
return $this->redirectToRoute('unfold_admin_index'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,37 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\DependencyInjection; |
|
||||||
|
|
||||||
use Symfony\Component\Config\Definition\Builder\TreeBuilder; |
|
||||||
use Symfony\Component\Config\Definition\ConfigurationInterface; |
|
||||||
|
|
||||||
/** |
|
||||||
* UnfoldBundle configuration schema |
|
||||||
*/ |
|
||||||
class Configuration implements ConfigurationInterface |
|
||||||
{ |
|
||||||
public function getConfigTreeBuilder(): TreeBuilder |
|
||||||
{ |
|
||||||
$treeBuilder = new TreeBuilder('unfold'); |
|
||||||
|
|
||||||
$treeBuilder->getRootNode() |
|
||||||
->children() |
|
||||||
->scalarNode('themes_path') |
|
||||||
->defaultValue('%kernel.project_dir%/src/UnfoldBundle/Resources/themes') |
|
||||||
->info('Path to theme directories') |
|
||||||
->end() |
|
||||||
->scalarNode('default_theme') |
|
||||||
->defaultValue('default') |
|
||||||
->info('Default theme to use') |
|
||||||
->end() |
|
||||||
->scalarNode('cache_pool') |
|
||||||
->defaultValue('unfold.cache') |
|
||||||
->info('Cache pool service ID') |
|
||||||
->end() |
|
||||||
->end() |
|
||||||
; |
|
||||||
|
|
||||||
return $treeBuilder; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,31 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\DependencyInjection; |
|
||||||
|
|
||||||
use Symfony\Component\Config\FileLocator; |
|
||||||
use Symfony\Component\DependencyInjection\ContainerBuilder; |
|
||||||
use Symfony\Component\DependencyInjection\Extension\Extension; |
|
||||||
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; |
|
||||||
|
|
||||||
/** |
|
||||||
* UnfoldBundle extension for Symfony DI |
|
||||||
*/ |
|
||||||
class UnfoldExtension extends Extension |
|
||||||
{ |
|
||||||
public function load(array $configs, ContainerBuilder $container): void |
|
||||||
{ |
|
||||||
$configuration = new Configuration(); |
|
||||||
$config = $this->processConfiguration($configuration, $configs); |
|
||||||
|
|
||||||
// Set parameters from configuration |
|
||||||
$container->setParameter('unfold.themes_path', $config['themes_path']); |
|
||||||
$container->setParameter('unfold.default_theme', $config['default_theme']); |
|
||||||
$container->setParameter('unfold.cache_pool', $config['cache_pool']); |
|
||||||
} |
|
||||||
|
|
||||||
public function getAlias(): string |
|
||||||
{ |
|
||||||
return 'unfold'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,75 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Http; |
|
||||||
|
|
||||||
use App\Entity\UnfoldSite; |
|
||||||
use App\Repository\UnfoldSiteRepository; |
|
||||||
use Symfony\Component\HttpFoundation\RequestStack; |
|
||||||
|
|
||||||
/** |
|
||||||
* Resolves HTTP Host header to an UnfoldSite (subdomain → naddr mapping) |
|
||||||
*/ |
|
||||||
class HostResolver |
|
||||||
{ |
|
||||||
public function __construct( |
|
||||||
private readonly UnfoldSiteRepository $unfoldSiteRepository, |
|
||||||
private readonly RequestStack $requestStack, |
|
||||||
) {} |
|
||||||
|
|
||||||
/** |
|
||||||
* Extract subdomain from current request's Host header and look up UnfoldSite |
|
||||||
*/ |
|
||||||
public function resolve(): ?UnfoldSite |
|
||||||
{ |
|
||||||
$request = $this->requestStack->getCurrentRequest(); |
|
||||||
if ($request === null) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
$host = $request->getHost(); |
|
||||||
$subdomain = $this->extractSubdomain($host); |
|
||||||
|
|
||||||
if ($subdomain === null) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return $this->unfoldSiteRepository->findBySubdomain($subdomain); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Resolve by explicit subdomain (useful for testing or direct lookup) |
|
||||||
*/ |
|
||||||
public function resolveBySubdomain(string $subdomain): ?UnfoldSite |
|
||||||
{ |
|
||||||
return $this->unfoldSiteRepository->findBySubdomain($subdomain); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Extract subdomain from a full host string |
|
||||||
* e.g., "support.example.com" → "support" |
|
||||||
* "example.com" → null |
|
||||||
* "localhost" → null |
|
||||||
*/ |
|
||||||
private function extractSubdomain(string $host): ?string |
|
||||||
{ |
|
||||||
// Remove port if present |
|
||||||
$host = strtok($host, ':'); |
|
||||||
|
|
||||||
// Split by dots |
|
||||||
$parts = explode('.', $host); |
|
||||||
|
|
||||||
// Need at least 3 parts for a subdomain (sub.domain.tld) |
|
||||||
// Or 2 parts for local dev (sub.localhost) |
|
||||||
if (count($parts) >= 3) { |
|
||||||
return $parts[0]; |
|
||||||
} |
|
||||||
|
|
||||||
// Handle local development: sub.localhost |
|
||||||
if (count($parts) === 2 && $parts[1] === 'localhost') { |
|
||||||
return $parts[0]; |
|
||||||
} |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,62 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Http; |
|
||||||
|
|
||||||
use App\UnfoldBundle\Config\SiteConfig; |
|
||||||
use App\UnfoldBundle\Content\CategoryData; |
|
||||||
|
|
||||||
/** |
|
||||||
* Matches URL paths to page types for Unfold sites |
|
||||||
*/ |
|
||||||
class RouteMatcher |
|
||||||
{ |
|
||||||
public const PAGE_HOME = 'home'; |
|
||||||
public const PAGE_CATEGORY = 'category'; |
|
||||||
public const PAGE_POST = 'post'; |
|
||||||
public const PAGE_NOT_FOUND = 'not_found'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Match a URL path to a page type and extract parameters |
|
||||||
* |
|
||||||
* @return array{type: string, slug?: string, category?: CategoryData} |
|
||||||
*/ |
|
||||||
public function match(string $path, SiteConfig $site, array $categories): array |
|
||||||
{ |
|
||||||
$path = '/' . ltrim($path, '/'); |
|
||||||
|
|
||||||
// Home page |
|
||||||
if ($path === '/' || $path === '') { |
|
||||||
return ['type' => self::PAGE_HOME]; |
|
||||||
} |
|
||||||
|
|
||||||
// Post page: /a/{slug} |
|
||||||
if (preg_match('#^/a/([^/]+)$#', $path, $matches)) { |
|
||||||
return [ |
|
||||||
'type' => self::PAGE_POST, |
|
||||||
'slug' => $matches[1], |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
// Category page: /{slug} |
|
||||||
if (preg_match('#^/([^/]+)/?$#', $path, $matches)) { |
|
||||||
$slug = $matches[1]; |
|
||||||
|
|
||||||
// Find category by slug |
|
||||||
foreach ($categories as $category) { |
|
||||||
if ($category->slug === $slug) { |
|
||||||
return [ |
|
||||||
'type' => self::PAGE_CATEGORY, |
|
||||||
'slug' => $slug, |
|
||||||
'category' => $category, |
|
||||||
]; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Slug doesn't match any category |
|
||||||
return ['type' => self::PAGE_NOT_FOUND]; |
|
||||||
} |
|
||||||
|
|
||||||
return ['type' => self::PAGE_NOT_FOUND]; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,21 +0,0 @@ |
|||||||
# Unfold Bundle Routes |
|
||||||
# Mount these routes for subdomain-based site rendering |
|
||||||
|
|
||||||
# Theme assets route - must come before catch-all |
|
||||||
unfold_theme_assets: |
|
||||||
path: /assets/themes/{theme}/{path} |
|
||||||
controller: App\UnfoldBundle\Controller\ThemeAssetController |
|
||||||
requirements: |
|
||||||
theme: '[a-zA-Z0-9_-]+' |
|
||||||
path: '.+' |
|
||||||
methods: [GET] |
|
||||||
|
|
||||||
# Main site controller - catch-all |
|
||||||
unfold_site: |
|
||||||
path: /{path} |
|
||||||
controller: App\UnfoldBundle\Controller\SiteController |
|
||||||
requirements: |
|
||||||
path: '.*' |
|
||||||
defaults: |
|
||||||
path: '' |
|
||||||
|
|
||||||
@ -1,28 +0,0 @@ |
|||||||
b-cov |
|
||||||
*.seed |
|
||||||
*.log |
|
||||||
*.csv |
|
||||||
*.dat |
|
||||||
*.out |
|
||||||
*.pid |
|
||||||
*.gz |
|
||||||
|
|
||||||
pids |
|
||||||
logs |
|
||||||
results |
|
||||||
|
|
||||||
npm-debug.log |
|
||||||
node_modules |
|
||||||
package-lock.json |
|
||||||
|
|
||||||
.idea/* |
|
||||||
*.iml |
|
||||||
projectFilesBackup |
|
||||||
|
|
||||||
.DS_Store |
|
||||||
|
|
||||||
dist/ |
|
||||||
|
|
||||||
config.json |
|
||||||
changelog.md |
|
||||||
changelog.md.bk |
|
||||||
@ -1,22 +0,0 @@ |
|||||||
Copyright (c) 2013-2023 Ghost Foundation |
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person |
|
||||||
obtaining a copy of this software and associated documentation |
|
||||||
files (the "Software"), to deal in the Software without |
|
||||||
restriction, including without limitation the rights to use, |
|
||||||
copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
||||||
copies of the Software, and to permit persons to whom the |
|
||||||
Software is furnished to do so, subject to the following |
|
||||||
conditions: |
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be |
|
||||||
included in all copies or substantial portions of the Software. |
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES |
|
||||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
|
||||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR |
|
||||||
OTHER DEALINGS IN THE SOFTWARE. |
|
||||||
@ -1,69 +0,0 @@ |
|||||||
# Casper |
|
||||||
|
|
||||||
The default theme for [Ghost](http://github.com/tryghost/ghost/). This is the latest development version of Casper! If you're just looking to download the latest release, head over to the [releases](https://github.com/TryGhost/Casper/releases) page. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# First time using a Ghost theme? |
|
||||||
|
|
||||||
Ghost uses a simple templating language called [Handlebars](http://handlebarsjs.com/) for its themes. |
|
||||||
|
|
||||||
This theme has lots of code comments to help explain what's going on just by reading the code. Once you feel comfortable with how everything works, we also have full [theme API documentation](https://ghost.org/docs/themes/) which explains every possible Handlebars helper and template. |
|
||||||
|
|
||||||
**The main files are:** |
|
||||||
|
|
||||||
- `default.hbs` - The parent template file, which includes your global header/footer |
|
||||||
- `index.hbs` - The main template to generate a list of posts, usually the home page |
|
||||||
- `post.hbs` - The template used to render individual posts |
|
||||||
- `page.hbs` - Used for individual pages |
|
||||||
- `tag.hbs` - Used for tag archives, eg. "all posts tagged with `news`" |
|
||||||
- `author.hbs` - Used for author archives, eg. "all posts written by Jamie" |
|
||||||
|
|
||||||
One neat trick is that you can also create custom one-off templates by adding the slug of a page to a template file. For example: |
|
||||||
|
|
||||||
- `page-about.hbs` - Custom template for an `/about/` page |
|
||||||
- `tag-news.hbs` - Custom template for `/tag/news/` archive |
|
||||||
- `author-ali.hbs` - Custom template for `/author/ali/` archive |
|
||||||
|
|
||||||
|
|
||||||
# Development |
|
||||||
|
|
||||||
Casper styles are compiled using Gulp/PostCSS to polyfill future CSS spec. You'll need [Node](https://nodejs.org/), [Yarn](https://yarnpkg.com/) and [Gulp](https://gulpjs.com) installed globally. After that, from the theme's root directory: |
|
||||||
|
|
||||||
```bash |
|
||||||
# install dependencies |
|
||||||
yarn install |
|
||||||
|
|
||||||
# run development server |
|
||||||
yarn dev |
|
||||||
``` |
|
||||||
|
|
||||||
Now you can edit `/assets/css/` files, which will be compiled to `/assets/built/` automatically. |
|
||||||
|
|
||||||
The `zip` Gulp task packages the theme files into `dist/<theme-name>.zip`, which you can then upload to your site. |
|
||||||
|
|
||||||
```bash |
|
||||||
# create .zip file |
|
||||||
yarn zip |
|
||||||
``` |
|
||||||
|
|
||||||
# PostCSS Features Used |
|
||||||
|
|
||||||
- Autoprefixer - Don't worry about writing browser prefixes of any kind, it's all done automatically with support for the latest 2 major versions of every browser. |
|
||||||
- [Color Mod](https://github.com/jonathantneal/postcss-color-mod-function) |
|
||||||
|
|
||||||
|
|
||||||
# SVG Icons |
|
||||||
|
|
||||||
Casper uses inline SVG icons, included via Handlebars partials. You can find all icons inside `/partials/icons`. To use an icon just include the name of the relevant file, eg. To include the SVG icon in `/partials/icons/rss.hbs` - use `{{> "icons/rss"}}`. |
|
||||||
|
|
||||||
You can add your own SVG icons in the same manner. |
|
||||||
|
|
||||||
|
|
||||||
# Copyright & License |
|
||||||
|
|
||||||
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). |
|
||||||
@ -1,2 +0,0 @@ |
|||||||
a,abbr,acronym,address,applet,article,aside,audio,big,blockquote,body,canvas,caption,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,ul,var,video{border:0;font:inherit;font-size:100%;margin:0;padding:0;vertical-align:baseline}body{line-height:1}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:"";content:none}img{display:block;height:auto;max-width:100%}html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;box-sizing:border-box;font-family:sans-serif}*,:after,:before{box-sizing:inherit}a{background-color:transparent}a:active,a:hover{outline:0}b,strong{font-weight:700}dfn,em,i{font-style:italic}h1{font-size:2em;margin:.67em 0}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}mark{background-color:#fdffb6}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}kbd{background:#f6f8fa;border:1px solid rgba(124,139,154,.25);border-radius:6px;box-shadow:inset 0 -1px 0 rgba(124,139,154,.25);font-family:var(--font-mono);font-size:1.5rem;padding:3px 5px}@media (max-width:600px){kbd{font-size:1.3rem}}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{border:none;overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input:focus{outline:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}legend{border:0;padding:0}textarea{overflow:auto}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}html{-webkit-tap-highlight-color:rgba(0,0,0,0);font-size:62.5%}body{text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-moz-font-feature-settings:"liga" on;background:#fff;color:var(--color-darkgrey);font-family:var(--gh-font-body,var(--font-sans));font-size:1.6rem;font-style:normal;font-weight:400;letter-spacing:0;line-height:1.6em}::-moz-selection{background:#daf2fd;text-shadow:none}::selection{background:#daf2fd;text-shadow:none}hr{border:0;border-top:1px solid #f0f0f0;display:block;height:1px;margin:2.5em 0 3.5em;padding:0;position:relative;width:100%}audio,canvas,iframe,img,svg,video{vertical-align:middle}fieldset{border:0;margin:0;padding:0}textarea{resize:vertical}::not(.gh-content) blockquote,::not(.gh-content) dl,::not(.gh-content) ol,::not(.gh-content) p,::not(.gh-content) ul{margin:0 0 1.5em}ol,ul{padding-left:1.3em;padding-right:1.5em}ol ol,ol ul,ul ol,ul ul{margin:.5em 0}ol,ul{max-width:100%}li{line-height:1.6em;padding-left:.3em}li+li{margin-top:.5em}dt{color:#daf2fd;float:left;font-weight:500;margin:0 20px 0 0;text-align:right;width:120px}dd{margin:0 0 5px;text-align:left}blockquote{border-left:#daf2fd;margin:1.5em 0;padding:0 1.6em}blockquote small{display:inline-block;font-size:.9em;margin:.8em 0 .8em 1.5em;opacity:.8}blockquote small:before{content:"\2014 \00A0"}blockquote cite{font-weight:700}blockquote cite a{font-weight:400}a{color:#15171a;text-decoration:none}h1,h2,h3,h4,h5,h6{text-rendering:optimizeLegibility;font-family:var(--gh-font-heading,var(--font-sans));font-weight:600;letter-spacing:-.01em;line-height:1.15;margin-top:0}h1{font-size:4.8rem;font-weight:700;letter-spacing:-.015em;margin:0 0 .5em}@media (max-width:600px){h1{font-size:2.8rem}}h2{font-size:2.8rem;font-weight:700;margin:1.5em 0 .5em}@media (max-width:600px){h2{font-size:2.3rem}}h3{font-size:2.4rem;font-weight:600;margin:1.5em 0 .5em}@media (max-width:600px){h3{font-size:1.7rem}}h4{font-size:2rem;margin:1.5em 0 .5em}@media (max-width:600px){h4{font-size:1.7rem}}h5{font-size:2rem}h5,h6{margin:1.5em 0 .5em}h6{font-size:1.8rem} |
|
||||||
/*# sourceMappingURL=global.css.map */ |
|
||||||
@ -1,468 +0,0 @@ |
|||||||
/* Reset |
|
||||||
/* ---------------------------------------------------------- */ |
|
||||||
|
|
||||||
html, |
|
||||||
body, |
|
||||||
div, |
|
||||||
span, |
|
||||||
applet, |
|
||||||
object, |
|
||||||
iframe, |
|
||||||
h1, |
|
||||||
h2, |
|
||||||
h3, |
|
||||||
h4, |
|
||||||
h5, |
|
||||||
h6, |
|
||||||
p, |
|
||||||
blockquote, |
|
||||||
pre, |
|
||||||
a, |
|
||||||
abbr, |
|
||||||
acronym, |
|
||||||
address, |
|
||||||
big, |
|
||||||
cite, |
|
||||||
code, |
|
||||||
del, |
|
||||||
dfn, |
|
||||||
em, |
|
||||||
img, |
|
||||||
ins, |
|
||||||
kbd, |
|
||||||
q, |
|
||||||
s, |
|
||||||
samp, |
|
||||||
small, |
|
||||||
strike, |
|
||||||
strong, |
|
||||||
sub, |
|
||||||
sup, |
|
||||||
tt, |
|
||||||
var, |
|
||||||
dl, |
|
||||||
dt, |
|
||||||
dd, |
|
||||||
ol, |
|
||||||
ul, |
|
||||||
li, |
|
||||||
fieldset, |
|
||||||
form, |
|
||||||
label, |
|
||||||
legend, |
|
||||||
table, |
|
||||||
caption, |
|
||||||
tbody, |
|
||||||
tfoot, |
|
||||||
thead, |
|
||||||
tr, |
|
||||||
th, |
|
||||||
td, |
|
||||||
article, |
|
||||||
aside, |
|
||||||
canvas, |
|
||||||
details, |
|
||||||
embed, |
|
||||||
figure, |
|
||||||
figcaption, |
|
||||||
footer, |
|
||||||
header, |
|
||||||
hgroup, |
|
||||||
menu, |
|
||||||
nav, |
|
||||||
output, |
|
||||||
ruby, |
|
||||||
section, |
|
||||||
summary, |
|
||||||
time, |
|
||||||
mark, |
|
||||||
audio, |
|
||||||
video { |
|
||||||
margin: 0; |
|
||||||
padding: 0; |
|
||||||
border: 0; |
|
||||||
font: inherit; |
|
||||||
font-size: 100%; |
|
||||||
vertical-align: baseline; |
|
||||||
} |
|
||||||
body { |
|
||||||
line-height: 1; |
|
||||||
} |
|
||||||
blockquote, |
|
||||||
q { |
|
||||||
quotes: none; |
|
||||||
} |
|
||||||
blockquote:before, |
|
||||||
blockquote:after, |
|
||||||
q:before, |
|
||||||
q:after { |
|
||||||
content: ""; |
|
||||||
content: none; |
|
||||||
} |
|
||||||
table { |
|
||||||
border-spacing: 0; |
|
||||||
border-collapse: collapse; |
|
||||||
} |
|
||||||
img { |
|
||||||
display: block; |
|
||||||
max-width: 100%; |
|
||||||
height: auto; |
|
||||||
} |
|
||||||
html { |
|
||||||
box-sizing: border-box; |
|
||||||
font-family: sans-serif; |
|
||||||
|
|
||||||
-ms-text-size-adjust: 100%; |
|
||||||
-webkit-text-size-adjust: 100%; |
|
||||||
} |
|
||||||
*, |
|
||||||
*:before, |
|
||||||
*:after { |
|
||||||
box-sizing: inherit; |
|
||||||
} |
|
||||||
a { |
|
||||||
background-color: transparent; |
|
||||||
} |
|
||||||
a:active, |
|
||||||
a:hover { |
|
||||||
outline: 0; |
|
||||||
} |
|
||||||
b, |
|
||||||
strong { |
|
||||||
font-weight: bold; |
|
||||||
} |
|
||||||
i, |
|
||||||
em, |
|
||||||
dfn { |
|
||||||
font-style: italic; |
|
||||||
} |
|
||||||
h1 { |
|
||||||
margin: 0.67em 0; |
|
||||||
font-size: 2em; |
|
||||||
} |
|
||||||
small { |
|
||||||
font-size: 80%; |
|
||||||
} |
|
||||||
sub, |
|
||||||
sup { |
|
||||||
position: relative; |
|
||||||
font-size: 75%; |
|
||||||
line-height: 0; |
|
||||||
vertical-align: baseline; |
|
||||||
} |
|
||||||
sup { |
|
||||||
top: -0.5em; |
|
||||||
} |
|
||||||
sub { |
|
||||||
bottom: -0.25em; |
|
||||||
} |
|
||||||
img { |
|
||||||
border: 0; |
|
||||||
} |
|
||||||
svg:not(:root) { |
|
||||||
overflow: hidden; |
|
||||||
} |
|
||||||
mark { |
|
||||||
background-color: #fdffb6; |
|
||||||
} |
|
||||||
code, |
|
||||||
kbd, |
|
||||||
pre, |
|
||||||
samp { |
|
||||||
font-family: monospace, monospace; |
|
||||||
font-size: 1em; |
|
||||||
} |
|
||||||
kbd { |
|
||||||
padding: 3px 5px; |
|
||||||
font-family: var(--font-mono); |
|
||||||
font-size: 1.5rem; |
|
||||||
background: #f6f8fa; |
|
||||||
border: 1px solid rgba(124, 139, 154, 0.25); |
|
||||||
border-radius: 6px; |
|
||||||
box-shadow: inset 0 -1px 0 rgba(124, 139, 154, 0.25); |
|
||||||
} |
|
||||||
@media (max-width: 600px) { |
|
||||||
kbd { |
|
||||||
font-size: 1.3rem; |
|
||||||
} |
|
||||||
} |
|
||||||
button, |
|
||||||
input, |
|
||||||
optgroup, |
|
||||||
select, |
|
||||||
textarea { |
|
||||||
margin: 0; /* 3 */ |
|
||||||
color: inherit; /* 1 */ |
|
||||||
font: inherit; /* 2 */ |
|
||||||
} |
|
||||||
button { |
|
||||||
overflow: visible; |
|
||||||
border: none; |
|
||||||
} |
|
||||||
button, |
|
||||||
select { |
|
||||||
text-transform: none; |
|
||||||
} |
|
||||||
button, |
|
||||||
html input[type="button"], |
|
||||||
/* 1 */ |
|
||||||
input[type="reset"], |
|
||||||
input[type="submit"] { |
|
||||||
cursor: pointer; /* 3 */ |
|
||||||
|
|
||||||
-webkit-appearance: button; /* 2 */ |
|
||||||
} |
|
||||||
button[disabled], |
|
||||||
html input[disabled] { |
|
||||||
cursor: default; |
|
||||||
} |
|
||||||
button::-moz-focus-inner, |
|
||||||
input::-moz-focus-inner { |
|
||||||
padding: 0; |
|
||||||
border: 0; |
|
||||||
} |
|
||||||
input { |
|
||||||
line-height: normal; |
|
||||||
} |
|
||||||
input:focus { |
|
||||||
outline: none; |
|
||||||
} |
|
||||||
input[type="checkbox"], |
|
||||||
input[type="radio"] { |
|
||||||
box-sizing: border-box; /* 1 */ |
|
||||||
padding: 0; /* 2 */ |
|
||||||
} |
|
||||||
input[type="number"]::-webkit-inner-spin-button, |
|
||||||
input[type="number"]::-webkit-outer-spin-button { |
|
||||||
height: auto; |
|
||||||
} |
|
||||||
input[type="search"] { |
|
||||||
box-sizing: content-box; /* 2 */ |
|
||||||
|
|
||||||
-webkit-appearance: textfield; /* 1 */ |
|
||||||
} |
|
||||||
input[type="search"]::-webkit-search-cancel-button, |
|
||||||
input[type="search"]::-webkit-search-decoration { |
|
||||||
-webkit-appearance: none; |
|
||||||
} |
|
||||||
legend { |
|
||||||
padding: 0; /* 2 */ |
|
||||||
border: 0; /* 1 */ |
|
||||||
} |
|
||||||
textarea { |
|
||||||
overflow: auto; |
|
||||||
} |
|
||||||
table { |
|
||||||
border-spacing: 0; |
|
||||||
border-collapse: collapse; |
|
||||||
} |
|
||||||
td, |
|
||||||
th { |
|
||||||
padding: 0; |
|
||||||
} |
|
||||||
|
|
||||||
/* ========================================================================== |
|
||||||
Base styles: opinionated defaults |
|
||||||
========================================================================== */ |
|
||||||
|
|
||||||
html { |
|
||||||
font-size: 62.5%; |
|
||||||
|
|
||||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0); |
|
||||||
} |
|
||||||
body { |
|
||||||
color: var(--color-darkgrey); |
|
||||||
font-family: var(--gh-font-body, var(--font-sans)); |
|
||||||
font-size: 1.6rem; |
|
||||||
line-height: 1.6em; |
|
||||||
font-weight: 400; |
|
||||||
font-style: normal; |
|
||||||
letter-spacing: 0; |
|
||||||
text-rendering: optimizeLegibility; |
|
||||||
background: #fff; |
|
||||||
|
|
||||||
-webkit-font-smoothing: antialiased; |
|
||||||
-moz-osx-font-smoothing: grayscale; |
|
||||||
-moz-font-feature-settings: "liga" on; |
|
||||||
} |
|
||||||
|
|
||||||
::selection { |
|
||||||
text-shadow: none; |
|
||||||
background: #daf2fd; |
|
||||||
} |
|
||||||
|
|
||||||
hr { |
|
||||||
position: relative; |
|
||||||
display: block; |
|
||||||
width: 100%; |
|
||||||
margin: 2.5em 0 3.5em; |
|
||||||
padding: 0; |
|
||||||
height: 1px; |
|
||||||
border: 0; |
|
||||||
border-top: 1px solid #f0f0f0; |
|
||||||
} |
|
||||||
|
|
||||||
audio, |
|
||||||
canvas, |
|
||||||
iframe, |
|
||||||
img, |
|
||||||
svg, |
|
||||||
video { |
|
||||||
vertical-align: middle; |
|
||||||
} |
|
||||||
|
|
||||||
fieldset { |
|
||||||
margin: 0; |
|
||||||
padding: 0; |
|
||||||
border: 0; |
|
||||||
} |
|
||||||
|
|
||||||
textarea { |
|
||||||
resize: vertical; |
|
||||||
} |
|
||||||
|
|
||||||
::not(.gh-content) p, |
|
||||||
::not(.gh-content) ul, |
|
||||||
::not(.gh-content) ol, |
|
||||||
::not(.gh-content) dl, |
|
||||||
::not(.gh-content) blockquote { |
|
||||||
margin: 0 0 1.5em 0; |
|
||||||
} |
|
||||||
|
|
||||||
ol, |
|
||||||
ul { |
|
||||||
padding-left: 1.3em; |
|
||||||
padding-right: 1.5em; |
|
||||||
} |
|
||||||
|
|
||||||
ol ol, |
|
||||||
ul ul, |
|
||||||
ul ol, |
|
||||||
ol ul { |
|
||||||
margin: 0.5em 0; |
|
||||||
} |
|
||||||
|
|
||||||
ul, |
|
||||||
ol { |
|
||||||
max-width: 100%; |
|
||||||
} |
|
||||||
|
|
||||||
li { |
|
||||||
padding-left: 0.3em; |
|
||||||
line-height: 1.6em; |
|
||||||
} |
|
||||||
|
|
||||||
li + li { |
|
||||||
margin-top: 0.5em; |
|
||||||
} |
|
||||||
|
|
||||||
dt { |
|
||||||
float: left; |
|
||||||
margin: 0 20px 0 0; |
|
||||||
width: 120px; |
|
||||||
color: #daf2fd; |
|
||||||
font-weight: 500; |
|
||||||
text-align: right; |
|
||||||
} |
|
||||||
|
|
||||||
dd { |
|
||||||
margin: 0 0 5px 0; |
|
||||||
text-align: left; |
|
||||||
} |
|
||||||
|
|
||||||
blockquote { |
|
||||||
margin: 1.5em 0; |
|
||||||
padding: 0 1.6em 0 1.6em; |
|
||||||
border-left: #daf2fd; |
|
||||||
} |
|
||||||
|
|
||||||
blockquote small { |
|
||||||
display: inline-block; |
|
||||||
margin: 0.8em 0 0.8em 1.5em; |
|
||||||
font-size: 0.9em; |
|
||||||
opacity: 0.8; |
|
||||||
} |
|
||||||
/* Quotation marks */ |
|
||||||
blockquote small:before { |
|
||||||
content: "\2014 \00A0"; |
|
||||||
} |
|
||||||
|
|
||||||
blockquote cite { |
|
||||||
font-weight: bold; |
|
||||||
} |
|
||||||
blockquote cite a { |
|
||||||
font-weight: normal; |
|
||||||
} |
|
||||||
|
|
||||||
a { |
|
||||||
color: #15171A; |
|
||||||
text-decoration: none; |
|
||||||
} |
|
||||||
|
|
||||||
h1, |
|
||||||
h2, |
|
||||||
h3, |
|
||||||
h4, |
|
||||||
h5, |
|
||||||
h6 { |
|
||||||
margin-top: 0; |
|
||||||
line-height: 1.15; |
|
||||||
font-family: var(--gh-font-heading, var(--font-sans)); |
|
||||||
font-weight: 600; |
|
||||||
text-rendering: optimizeLegibility; |
|
||||||
letter-spacing: -0.01em; |
|
||||||
} |
|
||||||
|
|
||||||
h1 { |
|
||||||
margin: 0 0 0.5em 0; |
|
||||||
font-size: 4.8rem; |
|
||||||
font-weight: 700; |
|
||||||
letter-spacing: -0.015em; |
|
||||||
} |
|
||||||
@media (max-width: 600px) { |
|
||||||
h1 { |
|
||||||
font-size: 2.8rem; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
h2 { |
|
||||||
margin: 1.5em 0 0.5em 0; |
|
||||||
font-size: 2.8rem; |
|
||||||
font-weight: 700; |
|
||||||
} |
|
||||||
@media (max-width: 600px) { |
|
||||||
h2 { |
|
||||||
font-size: 2.3rem; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
h3 { |
|
||||||
margin: 1.5em 0 0.5em 0; |
|
||||||
font-size: 2.4rem; |
|
||||||
font-weight: 600; |
|
||||||
} |
|
||||||
@media (max-width: 600px) { |
|
||||||
h3 { |
|
||||||
font-size: 1.7rem; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
h4 { |
|
||||||
margin: 1.5em 0 0.5em 0; |
|
||||||
font-size: 2rem; |
|
||||||
} |
|
||||||
@media (max-width: 600px) { |
|
||||||
h4 { |
|
||||||
font-size: 1.7rem; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
h5 { |
|
||||||
margin: 1.5em 0 0.5em 0; |
|
||||||
font-size: 2rem; |
|
||||||
} |
|
||||||
|
|
||||||
h6 { |
|
||||||
margin: 1.5em 0 0.5em 0; |
|
||||||
font-size: 1.8rem; |
|
||||||
} |
|
||||||
|
Before Width: | Height: | Size: 547 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 866 B |
@ -1,85 +0,0 @@ |
|||||||
(function () { |
|
||||||
const mediaQuery = window.matchMedia('(max-width: 767px)'); |
|
||||||
|
|
||||||
const head = document.querySelector('.gh-head'); |
|
||||||
const menu = head.querySelector('.gh-head-menu'); |
|
||||||
const nav = menu.querySelector('.nav'); |
|
||||||
if (!nav) return; |
|
||||||
|
|
||||||
const logo = document.querySelector('.gh-head-logo'); |
|
||||||
const navHTML = nav.innerHTML; |
|
||||||
|
|
||||||
if (mediaQuery.matches) { |
|
||||||
const items = nav.querySelectorAll('li'); |
|
||||||
items.forEach(function (item, index) { |
|
||||||
item.style.transitionDelay = 0.03 * (index + 1) + 's'; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
var windowClickListener; |
|
||||||
const makeDropdown = function () { |
|
||||||
if (mediaQuery.matches) return; |
|
||||||
const submenuItems = []; |
|
||||||
|
|
||||||
while ((nav.offsetWidth + 64) > menu.offsetWidth) { |
|
||||||
if (nav.lastElementChild) { |
|
||||||
submenuItems.unshift(nav.lastElementChild); |
|
||||||
nav.lastElementChild.remove(); |
|
||||||
} else { |
|
||||||
return; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (!submenuItems.length) { |
|
||||||
document.body.classList.add('is-dropdown-loaded'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const toggle = document.createElement('button'); |
|
||||||
toggle.setAttribute('class', 'nav-more-toggle'); |
|
||||||
toggle.setAttribute('aria-label', 'More'); |
|
||||||
toggle.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor"><path d="M21.333 16c0-1.473 1.194-2.667 2.667-2.667v0c1.473 0 2.667 1.194 2.667 2.667v0c0 1.473-1.194 2.667-2.667 2.667v0c-1.473 0-2.667-1.194-2.667-2.667v0zM13.333 16c0-1.473 1.194-2.667 2.667-2.667v0c1.473 0 2.667 1.194 2.667 2.667v0c0 1.473-1.194 2.667-2.667 2.667v0c-1.473 0-2.667-1.194-2.667-2.667v0zM5.333 16c0-1.473 1.194-2.667 2.667-2.667v0c1.473 0 2.667 1.194 2.667 2.667v0c0 1.473-1.194 2.667-2.667 2.667v0c-1.473 0-2.667-1.194-2.667-2.667v0z"></path></svg>'; |
|
||||||
|
|
||||||
const wrapper = document.createElement('div'); |
|
||||||
wrapper.setAttribute('class', 'gh-dropdown'); |
|
||||||
|
|
||||||
if (submenuItems.length >= 10) { |
|
||||||
document.body.classList.add('is-dropdown-mega'); |
|
||||||
wrapper.style.gridTemplateRows = 'repeat(' + Math.ceil(submenuItems.length / 2) + ', 1fr)'; |
|
||||||
} else { |
|
||||||
document.body.classList.remove('is-dropdown-mega'); |
|
||||||
} |
|
||||||
|
|
||||||
submenuItems.forEach(function (child) { |
|
||||||
wrapper.appendChild(child); |
|
||||||
}); |
|
||||||
|
|
||||||
toggle.appendChild(wrapper); |
|
||||||
nav.appendChild(toggle); |
|
||||||
|
|
||||||
document.body.classList.add('is-dropdown-loaded'); |
|
||||||
|
|
||||||
toggle.addEventListener('click', function () { |
|
||||||
document.body.classList.toggle('is-dropdown-open'); |
|
||||||
}); |
|
||||||
|
|
||||||
windowClickListener = function (e) { |
|
||||||
if (!toggle.contains(e.target) && document.body.classList.contains('is-dropdown-open')) { |
|
||||||
document.body.classList.remove('is-dropdown-open'); |
|
||||||
} |
|
||||||
}; |
|
||||||
window.addEventListener('click', windowClickListener); |
|
||||||
} |
|
||||||
|
|
||||||
imagesLoaded(head, function () { |
|
||||||
makeDropdown(); |
|
||||||
}); |
|
||||||
|
|
||||||
window.addEventListener('resize', function () { |
|
||||||
setTimeout(function () { |
|
||||||
window.removeEventListener('click', windowClickListener); |
|
||||||
nav.innerHTML = navHTML; |
|
||||||
makeDropdown(); |
|
||||||
}, 1); |
|
||||||
}); |
|
||||||
})(); |
|
||||||
@ -1,114 +0,0 @@ |
|||||||
/* eslint-env browser */ |
|
||||||
|
|
||||||
/** |
|
||||||
* Infinite Scroll |
|
||||||
* Used on all pages where there is a list of posts (homepage, tag index, etc). |
|
||||||
* |
|
||||||
* When the page is scrolled to 300px from the bottom, the next page of posts |
|
||||||
* is fetched by following the the <link rel="next" href="..."> that is output |
|
||||||
* by {{ghost_head}}. |
|
||||||
* |
|
||||||
* The individual post items are extracted from the fetched pages by looking for |
|
||||||
* a wrapper element with the class "post-card". Any found elements are appended |
|
||||||
* to the element with the class "post-feed" in the currently viewed page. |
|
||||||
*/ |
|
||||||
|
|
||||||
(function (window, document) { |
|
||||||
if (document.documentElement.classList.contains('no-infinite-scroll')) return; |
|
||||||
|
|
||||||
// next link element
|
|
||||||
var nextElement = document.querySelector('link[rel=next]'); |
|
||||||
if (!nextElement) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// post feed element
|
|
||||||
var feedElement = document.querySelector('.post-feed'); |
|
||||||
if (!feedElement) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
var buffer = 300; |
|
||||||
|
|
||||||
var ticking = false; |
|
||||||
var loading = false; |
|
||||||
|
|
||||||
var lastScrollY = window.scrollY; |
|
||||||
var lastWindowHeight = window.innerHeight; |
|
||||||
var lastDocumentHeight = document.documentElement.scrollHeight; |
|
||||||
|
|
||||||
function onPageLoad() { |
|
||||||
if (this.status === 404) { |
|
||||||
window.removeEventListener('scroll', onScroll); |
|
||||||
window.removeEventListener('resize', onResize); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// append contents
|
|
||||||
var postElements = this.response.querySelectorAll('article.post-card'); |
|
||||||
postElements.forEach(function (item) { |
|
||||||
// document.importNode is important, without it the item's owner
|
|
||||||
// document will be different which can break resizing of
|
|
||||||
// `object-fit: cover` images in Safari
|
|
||||||
feedElement.appendChild(document.importNode(item, true)); |
|
||||||
}); |
|
||||||
|
|
||||||
// set next link
|
|
||||||
var resNextElement = this.response.querySelector('link[rel=next]'); |
|
||||||
if (resNextElement) { |
|
||||||
nextElement.href = resNextElement.href; |
|
||||||
} else { |
|
||||||
window.removeEventListener('scroll', onScroll); |
|
||||||
window.removeEventListener('resize', onResize); |
|
||||||
} |
|
||||||
|
|
||||||
// sync status
|
|
||||||
lastDocumentHeight = document.documentElement.scrollHeight; |
|
||||||
ticking = false; |
|
||||||
loading = false; |
|
||||||
} |
|
||||||
|
|
||||||
function onUpdate() { |
|
||||||
// return if already loading
|
|
||||||
if (loading) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// return if not scroll to the bottom
|
|
||||||
if (lastScrollY + lastWindowHeight <= lastDocumentHeight - buffer) { |
|
||||||
ticking = false; |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
loading = true; |
|
||||||
|
|
||||||
var xhr = new window.XMLHttpRequest(); |
|
||||||
xhr.responseType = 'document'; |
|
||||||
|
|
||||||
xhr.addEventListener('load', onPageLoad); |
|
||||||
|
|
||||||
xhr.open('GET', nextElement.href); |
|
||||||
xhr.send(null); |
|
||||||
} |
|
||||||
|
|
||||||
function requestTick() { |
|
||||||
ticking || window.requestAnimationFrame(onUpdate); |
|
||||||
ticking = true; |
|
||||||
} |
|
||||||
|
|
||||||
function onScroll() { |
|
||||||
lastScrollY = window.scrollY; |
|
||||||
requestTick(); |
|
||||||
} |
|
||||||
|
|
||||||
function onResize() { |
|
||||||
lastWindowHeight = window.innerHeight; |
|
||||||
lastDocumentHeight = document.documentElement.scrollHeight; |
|
||||||
requestTick(); |
|
||||||
} |
|
||||||
|
|
||||||
window.addEventListener('scroll', onScroll, {passive: true}); |
|
||||||
window.addEventListener('resize', onResize); |
|
||||||
|
|
||||||
requestTick(); |
|
||||||
})(window, document); |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
/*jshint browser:true */ |
|
||||||
/*! |
|
||||||
* FitVids 1.3 |
|
||||||
* |
|
||||||
* |
|
||||||
* Copyright 2017, Chris Coyier + Dave Rupert + Ghost Foundation |
|
||||||
* This is an unofficial release, ported by John O'Nolan |
|
||||||
* Credit to Thierry Koblentz - http://www.alistapart.com/articles/creating-intrinsic-ratios-for-video/
|
|
||||||
* Released under the MIT license |
|
||||||
* |
|
||||||
*/ |
|
||||||
|
|
||||||
;(function( $ ){ |
|
||||||
|
|
||||||
'use strict'; |
|
||||||
|
|
||||||
$.fn.fitVids = function( options ) { |
|
||||||
var settings = { |
|
||||||
customSelector: null, |
|
||||||
ignore: null |
|
||||||
}; |
|
||||||
|
|
||||||
if(!document.getElementById('fit-vids-style')) { |
|
||||||
// appendStyles: https://github.com/toddmotto/fluidvids/blob/master/dist/fluidvids.js
|
|
||||||
var head = document.head || document.getElementsByTagName('head')[0]; |
|
||||||
var css = '.fluid-width-video-container{flex-grow: 1;width:100%;}.fluid-width-video-wrapper{width:100%;position:relative;padding:0;}.fluid-width-video-wrapper iframe,.fluid-width-video-wrapper object,.fluid-width-video-wrapper embed {position:absolute;top:0;left:0;width:100%;height:100%;}'; |
|
||||||
var div = document.createElement("div"); |
|
||||||
div.innerHTML = '<p>x</p><style id="fit-vids-style">' + css + '</style>'; |
|
||||||
head.appendChild(div.childNodes[1]); |
|
||||||
} |
|
||||||
|
|
||||||
if ( options ) { |
|
||||||
$.extend( settings, options ); |
|
||||||
} |
|
||||||
|
|
||||||
return this.each(function(){ |
|
||||||
var selectors = [ |
|
||||||
'iframe[src*="player.vimeo.com"]', |
|
||||||
'iframe[src*="youtube.com"]', |
|
||||||
'iframe[src*="youtube-nocookie.com"]', |
|
||||||
'iframe[src*="kickstarter.com"][src*="video.html"]', |
|
||||||
'object', |
|
||||||
'embed' |
|
||||||
]; |
|
||||||
|
|
||||||
if (settings.customSelector) { |
|
||||||
selectors.push(settings.customSelector); |
|
||||||
} |
|
||||||
|
|
||||||
var ignoreList = '.fitvidsignore'; |
|
||||||
|
|
||||||
if(settings.ignore) { |
|
||||||
ignoreList = ignoreList + ', ' + settings.ignore; |
|
||||||
} |
|
||||||
|
|
||||||
var $allVideos = $(this).find(selectors.join(',')); |
|
||||||
$allVideos = $allVideos.not('object object'); // SwfObj conflict patch
|
|
||||||
$allVideos = $allVideos.not(ignoreList); // Disable FitVids on this video.
|
|
||||||
|
|
||||||
$allVideos.each(function(){ |
|
||||||
var $this = $(this); |
|
||||||
if($this.parents(ignoreList).length > 0) { |
|
||||||
return; // Disable FitVids on this video.
|
|
||||||
} |
|
||||||
if (this.tagName.toLowerCase() === 'embed' && $this.parent('object').length || $this.parent('.fluid-width-video-wrapper').length) { return; } |
|
||||||
if ((!$this.css('height') && !$this.css('width')) && (isNaN($this.attr('height')) || isNaN($this.attr('width')))) |
|
||||||
{ |
|
||||||
$this.attr('height', 9); |
|
||||||
$this.attr('width', 16); |
|
||||||
} |
|
||||||
var height = ( this.tagName.toLowerCase() === 'object' || ($this.attr('height') && !isNaN(parseInt($this.attr('height'), 10))) ) ? parseInt($this.attr('height'), 10) : $this.height(), |
|
||||||
width = !isNaN(parseInt($this.attr('width'), 10)) ? parseInt($this.attr('width'), 10) : $this.width(), |
|
||||||
aspectRatio = height / width; |
|
||||||
if(!$this.attr('name')){ |
|
||||||
var videoName = 'fitvid' + $.fn.fitVids._count; |
|
||||||
$this.attr('name', videoName); |
|
||||||
$.fn.fitVids._count++; |
|
||||||
} |
|
||||||
$this.wrap('<div class="fluid-width-video-container"><div class="fluid-width-video-wrapper"></div></div>').parent('.fluid-width-video-wrapper').css('padding-top', (aspectRatio * 100)+'%'); |
|
||||||
$this.removeAttr('height').removeAttr('width'); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
// Internal counter for unique video names.
|
|
||||||
$.fn.fitVids._count = 0; |
|
||||||
|
|
||||||
// Works with either jQuery or Zepto
|
|
||||||
})( window.jQuery || window.Zepto ); |
|
||||||
@ -1,109 +0,0 @@ |
|||||||
function lightbox(trigger) { |
|
||||||
var onThumbnailsClick = function (e) { |
|
||||||
e.preventDefault(); |
|
||||||
|
|
||||||
var items = []; |
|
||||||
var index = 0; |
|
||||||
|
|
||||||
var prevSibling = e.target.closest('.kg-card').previousElementSibling; |
|
||||||
|
|
||||||
while (prevSibling && (prevSibling.classList.contains('kg-image-card') || prevSibling.classList.contains('kg-gallery-card'))) { |
|
||||||
var prevItems = []; |
|
||||||
|
|
||||||
prevSibling.querySelectorAll('img').forEach(function (item) { |
|
||||||
prevItems.push({ |
|
||||||
src: item.getAttribute('src'), |
|
||||||
msrc: item.getAttribute('src'), |
|
||||||
w: item.getAttribute('width'), |
|
||||||
h: item.getAttribute('height'), |
|
||||||
el: item, |
|
||||||
}) |
|
||||||
|
|
||||||
index += 1; |
|
||||||
}); |
|
||||||
prevSibling = prevSibling.previousElementSibling; |
|
||||||
|
|
||||||
items = prevItems.concat(items); |
|
||||||
} |
|
||||||
|
|
||||||
if (e.target.classList.contains('kg-image')) { |
|
||||||
items.push({ |
|
||||||
src: e.target.getAttribute('src'), |
|
||||||
msrc: e.target.getAttribute('src'), |
|
||||||
w: e.target.getAttribute('width'), |
|
||||||
h: e.target.getAttribute('height'), |
|
||||||
el: e.target, |
|
||||||
}); |
|
||||||
} else { |
|
||||||
var reachedCurrentItem = false; |
|
||||||
|
|
||||||
e.target.closest('.kg-gallery-card').querySelectorAll('img').forEach(function (item) { |
|
||||||
items.push({ |
|
||||||
src: item.getAttribute('src'), |
|
||||||
msrc: item.getAttribute('src'), |
|
||||||
w: item.getAttribute('width'), |
|
||||||
h: item.getAttribute('height'), |
|
||||||
el: item, |
|
||||||
}); |
|
||||||
|
|
||||||
if (!reachedCurrentItem && item !== e.target) { |
|
||||||
index += 1; |
|
||||||
} else { |
|
||||||
reachedCurrentItem = true; |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
var nextSibling = e.target.closest('.kg-card').nextElementSibling; |
|
||||||
|
|
||||||
while (nextSibling && (nextSibling.classList.contains('kg-image-card') || nextSibling.classList.contains('kg-gallery-card'))) { |
|
||||||
nextSibling.querySelectorAll('img').forEach(function (item) { |
|
||||||
items.push({ |
|
||||||
src: item.getAttribute('src'), |
|
||||||
msrc: item.getAttribute('src'), |
|
||||||
w: item.getAttribute('width'), |
|
||||||
h: item.getAttribute('height'), |
|
||||||
el: item, |
|
||||||
}) |
|
||||||
}); |
|
||||||
nextSibling = nextSibling.nextElementSibling; |
|
||||||
} |
|
||||||
|
|
||||||
var pswpElement = document.querySelectorAll('.pswp')[0]; |
|
||||||
|
|
||||||
var options = { |
|
||||||
bgOpacity: 0.9, |
|
||||||
closeOnScroll: true, |
|
||||||
fullscreenEl: false, |
|
||||||
history: false, |
|
||||||
index: index, |
|
||||||
shareEl: false, |
|
||||||
zoomEl: false, |
|
||||||
getThumbBoundsFn: function(index) { |
|
||||||
var thumbnail = items[index].el, |
|
||||||
pageYScroll = window.pageYOffset || document.documentElement.scrollTop, |
|
||||||
rect = thumbnail.getBoundingClientRect(); |
|
||||||
|
|
||||||
return {x:rect.left, y:rect.top + pageYScroll, w:rect.width}; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
var gallery = new PhotoSwipe(pswpElement, PhotoSwipeUI_Default, items, options); |
|
||||||
gallery.init(); |
|
||||||
|
|
||||||
return false; |
|
||||||
}; |
|
||||||
|
|
||||||
var triggers = document.querySelectorAll(trigger); |
|
||||||
triggers.forEach(function (trig) { |
|
||||||
trig.addEventListener('click', function (e) { |
|
||||||
onThumbnailsClick(e); |
|
||||||
}); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
(function () { |
|
||||||
lightbox( |
|
||||||
'.kg-image-card > .kg-image[width][height], .kg-gallery-image > img' |
|
||||||
); |
|
||||||
})(); |
|
||||||
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@ -1,76 +0,0 @@ |
|||||||
{{!< default}} |
|
||||||
{{!-- The tag above means - insert everything in this file into the {body} of the default.hbs template --}} |
|
||||||
|
|
||||||
<main id="site-main" class="site-main outer"> |
|
||||||
<div class="inner posts"> |
|
||||||
|
|
||||||
<div class="post-feed"> |
|
||||||
|
|
||||||
{{#author}} |
|
||||||
<section class="post-card post-card-large"> |
|
||||||
|
|
||||||
{{#if cover_image}} |
|
||||||
<div class="post-card-image-link"> |
|
||||||
{{!-- This is a responsive image, it loads different sizes depending on device |
|
||||||
https://medium.freecodecamp.org/a-guide-to-responsive-images-with-ready-to-use-templates-c400bd65c433 --}} |
|
||||||
<img class="post-card-image" |
|
||||||
srcset="{{img_url cover_image size="s"}} 300w, |
|
||||||
{{img_url cover_image size="m"}} 600w, |
|
||||||
{{img_url cover_image size="l"}} 1000w, |
|
||||||
{{img_url cover_image size="xl"}} 2000w" |
|
||||||
sizes="(max-width: 1000px) 400px, 800px" |
|
||||||
src="{{img_url cover_image size="m"}}" |
|
||||||
alt="{{title}}" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<div class="post-card-content"> |
|
||||||
<div class="post-card-content-link"> |
|
||||||
|
|
||||||
{{#if profile_image}} |
|
||||||
<img class="author-profile-pic" src="{{profile_image}}" alt="{{name}}" /> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<header class="post-card-header"> |
|
||||||
<h2 class="post-card-title">{{name}}</h2> |
|
||||||
</header> |
|
||||||
|
|
||||||
{{#if bio}} |
|
||||||
<div class="post-card-excerpt">{{bio}}</div> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<footer class="author-profile-footer"> |
|
||||||
{{#if location}} |
|
||||||
<div class="author-profile-location">{{location}}</div> |
|
||||||
{{/if}} |
|
||||||
<div class="author-profile-meta"> |
|
||||||
{{#if website}} |
|
||||||
<a class="author-profile-social-link" href="{{website}}" target="_blank" rel="noopener">{{website}}</a> |
|
||||||
{{/if}} |
|
||||||
{{#if twitter}} |
|
||||||
<a class="author-profile-social-link" href="{{twitter_url}}" target="_blank" rel="noopener">{{> "icons/twitter"}}</a> |
|
||||||
{{/if}} |
|
||||||
{{#if facebook}} |
|
||||||
<a class="author-profile-social-link" href="{{facebook_url}}" target="_blank" rel="noopener">{{> "icons/facebook"}}</a> |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
</footer> |
|
||||||
|
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
</section> |
|
||||||
{{/author}} |
|
||||||
|
|
||||||
{{#foreach posts}} |
|
||||||
{{!-- The tag below includes the markup for each post - partials/post-card.hbs --}} |
|
||||||
{{> "post-card"}} |
|
||||||
{{/foreach}} |
|
||||||
|
|
||||||
</div> |
|
||||||
|
|
||||||
{{pagination}} |
|
||||||
|
|
||||||
</div> |
|
||||||
</main> |
|
||||||
@ -1,120 +0,0 @@ |
|||||||
<!DOCTYPE html> |
|
||||||
<html lang="{{@site.locale}}"{{#match @custom.color_scheme "Dark"}} class="dark-mode"{{else match @custom.color_scheme "Auto"}} class="auto-color"{{/match}}> |
|
||||||
<head> |
|
||||||
|
|
||||||
{{!-- Basic meta - advanced meta is output with {ghost_head} below --}} |
|
||||||
<title>{{meta_title}}</title> |
|
||||||
<meta charset="utf-8" /> |
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> |
|
||||||
<meta name="HandheldFriendly" content="True" /> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
||||||
|
|
||||||
{{!-- Preload scripts --}} |
|
||||||
<link rel="preload" as="style" href="{{asset "built/screen.css"}}" /> |
|
||||||
<link rel="preload" as="script" href="{{asset "built/casper.js"}}" /> |
|
||||||
|
|
||||||
{{!-- Theme assets - use the {asset} helper to reference styles & scripts, |
|
||||||
this will take care of caching and cache-busting automatically --}} |
|
||||||
<link rel="stylesheet" type="text/css" href="{{asset "built/screen.css"}}" /> |
|
||||||
|
|
||||||
{{!-- This tag outputs all your advanced SEO meta, structured data, and other important settings, |
|
||||||
it should always be the last tag before the closing head tag --}} |
|
||||||
{{ghost_head}} |
|
||||||
|
|
||||||
</head> |
|
||||||
<body class="{{body_class}} is-head-{{#match @custom.navigation_layout "Logo on cover"}}left-logo{{else match @custom.navigation_layout "Logo in the middle"}}middle-logo{{else}}stacked{{/match}}{{#match @custom.title_font "=" "Elegant serif"}} has-serif-title{{/match}}{{#match @custom.body_font "=" "Modern sans-serif"}} has-sans-body{{/match}}{{#if @custom.show_publication_cover}} has-cover{{/if}}"> |
|
||||||
<div class="viewport"> |
|
||||||
|
|
||||||
<header id="gh-head" class="gh-head outer{{#match @custom.header_style "Hidden"}} is-header-hidden{{/match}}"> |
|
||||||
<div class="gh-head-inner inner"> |
|
||||||
<div class="gh-head-brand"> |
|
||||||
<a class="gh-head-logo{{#unless @site.logo}} no-image{{/unless}}" href="{{@site.url}}"> |
|
||||||
{{#if @site.logo}} |
|
||||||
<img src="{{@site.logo}}" alt="{{@site.title}}"> |
|
||||||
{{else}} |
|
||||||
{{@site.title}} |
|
||||||
{{/if}} |
|
||||||
</a> |
|
||||||
<button class="gh-search gh-icon-btn" aria-label="Search this site" data-ghost-search>{{> "icons/search"}}</button> |
|
||||||
<button class="gh-burger" aria-label="Main Menu"></button> |
|
||||||
</div> |
|
||||||
|
|
||||||
<nav class="gh-head-menu"> |
|
||||||
{{navigation}} |
|
||||||
{{#unless @site.members_enabled}} |
|
||||||
{{#match @custom.navigation_layout "Stacked"}} |
|
||||||
<button class="gh-search gh-icon-btn" aria-label="Search this site" data-ghost-search>{{> "icons/search"}}</button> |
|
||||||
{{/match}} |
|
||||||
{{/unless}} |
|
||||||
</nav> |
|
||||||
|
|
||||||
<div class="gh-head-actions"> |
|
||||||
{{#unless @site.members_enabled}} |
|
||||||
{{^match @custom.navigation_layout "Stacked"}} |
|
||||||
<button class="gh-search gh-icon-btn" aria-label="Search this site" data-ghost-search>{{> "icons/search"}}</button> |
|
||||||
{{/match}} |
|
||||||
{{else}} |
|
||||||
<button class="gh-search gh-icon-btn" aria-label="Search this site" data-ghost-search>{{> "icons/search"}}</button> |
|
||||||
<div class="gh-head-members"> |
|
||||||
{{#unless @member}} |
|
||||||
{{#unless @site.members_invite_only}} |
|
||||||
<a class="gh-head-link" href="#/portal/signin" data-portal="signin">Sign in</a> |
|
||||||
<a class="gh-head-button" href="#/portal/signup" data-portal="signup">Subscribe</a> |
|
||||||
{{else}} |
|
||||||
<a class="gh-head-button" href="#/portal/signin" data-portal="signin">Sign in</a> |
|
||||||
{{/unless}} |
|
||||||
{{else}} |
|
||||||
<a class="gh-head-button" href="#/portal/account" data-portal="account">Account</a> |
|
||||||
{{/unless}} |
|
||||||
</div> |
|
||||||
{{/unless}} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</header> |
|
||||||
|
|
||||||
<div class="site-content"> |
|
||||||
{{!-- All other templates get inserted here, index.hbs, post.hbs, etc --}} |
|
||||||
{{{body}}} |
|
||||||
</div> |
|
||||||
|
|
||||||
{{!-- The global footer at the very bottom of the screen --}} |
|
||||||
<footer class="site-footer outer"> |
|
||||||
<div class="inner"> |
|
||||||
<section class="copyright"><a href="{{@site.url}}">{{@site.title}}</a> © {{date format="YYYY"}}</section> |
|
||||||
<nav class="site-footer-nav"> |
|
||||||
{{navigation type="secondary"}} |
|
||||||
</nav> |
|
||||||
<div class="gh-powered-by"><a href="https://ghost.org/" target="_blank" rel="noopener">Powered by Ghost</a></div> |
|
||||||
</div> |
|
||||||
</footer> |
|
||||||
|
|
||||||
</div> |
|
||||||
{{!-- /.viewport --}} |
|
||||||
|
|
||||||
{{#is "post, page"}} |
|
||||||
{{> "lightbox"}} |
|
||||||
{{/is}} |
|
||||||
|
|
||||||
{{!-- Scripts - handle member signups, responsive videos, infinite scroll, floating headers, and galleries --}} |
|
||||||
<script |
|
||||||
src="https://code.jquery.com/jquery-3.5.1.min.js" |
|
||||||
integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" |
|
||||||
crossorigin="anonymous"> |
|
||||||
</script> |
|
||||||
<script src="{{asset "built/casper.js"}}"></script> |
|
||||||
<script> |
|
||||||
$(document).ready(function () { |
|
||||||
// Mobile Menu Trigger |
|
||||||
$('.gh-burger').click(function () { |
|
||||||
$('body').toggleClass('gh-head-open'); |
|
||||||
}); |
|
||||||
// FitVids - Makes video embeds responsive |
|
||||||
$(".gh-content").fitVids(); |
|
||||||
}); |
|
||||||
</script> |
|
||||||
|
|
||||||
{{!-- Ghost outputs required functional scripts with this tag - it should always be the last thing before the closing body tag --}} |
|
||||||
{{ghost_foot}} |
|
||||||
|
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,37 +0,0 @@ |
|||||||
{{!< default}} |
|
||||||
|
|
||||||
{{!-- |
|
||||||
|
|
||||||
There are two error files in this theme, one for 404s and one for all other errors. |
|
||||||
This file is the former, and handles all 404 Page Not Found errors. |
|
||||||
|
|
||||||
The 404 error is the most common error that a visitor might see, for example when |
|
||||||
following a broken link |
|
||||||
|
|
||||||
Keep this template as lightweight as you can! |
|
||||||
|
|
||||||
--}} |
|
||||||
|
|
||||||
<section class="outer error-content"> |
|
||||||
<div class="inner"> |
|
||||||
<section class="error-message"> |
|
||||||
<h1 class="error-code">{{statusCode}}</h1> |
|
||||||
<p class="error-description">{{message}}</p> |
|
||||||
<a class="error-link" href="{{@site.url}}">Go to the front page →</a> |
|
||||||
</section> |
|
||||||
</div> |
|
||||||
</section> |
|
||||||
|
|
||||||
{{!-- Given that people landing on this page didn't find what they |
|
||||||
were looking for, let's give them some alternative stuff to read. --}} |
|
||||||
<aside class="read-more-wrap outer"> |
|
||||||
<div class="read-more inner"> |
|
||||||
{{#get "posts" include="authors" limit="3" as |more_posts|}} |
|
||||||
{{#if more_posts}} |
|
||||||
{{#foreach more_posts}} |
|
||||||
{{> "post-card"}} |
|
||||||
{{/foreach}} |
|
||||||
{{/if}} |
|
||||||
{{/get}} |
|
||||||
</div> |
|
||||||
</aside> |
|
||||||
@ -1,74 +0,0 @@ |
|||||||
{{!-- |
|
||||||
|
|
||||||
There are two error files in this theme, one for 404s and one for all other errors. |
|
||||||
This file is the latter, and handles all 400/500 errors that might occur. |
|
||||||
|
|
||||||
Because 500 errors in particular usually happen when a server is struggling, this |
|
||||||
template is as simple as possible. No template dependencies, no JS, no API calls. |
|
||||||
This is to prevent rendering the error-page itself compounding the issue causing |
|
||||||
the error in the first place. |
|
||||||
|
|
||||||
Keep this template as lightweight as you can! |
|
||||||
|
|
||||||
--}} |
|
||||||
|
|
||||||
<!DOCTYPE html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<meta charset="utf-8" /> |
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> |
|
||||||
<title>{{meta_title}}</title> |
|
||||||
<meta name="HandheldFriendly" content="True" /> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
||||||
<link rel="stylesheet" type="text/css" href="{{asset "built/screen.css"}}" /> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
<div class="site-wrapper"> |
|
||||||
|
|
||||||
<header class="site-header no-image"> |
|
||||||
<div class="site-nav-main outer"> |
|
||||||
<div class="inner"> |
|
||||||
<nav class="site-nav-center"> |
|
||||||
{{#if @site.logo}} |
|
||||||
<a class="site-nav-logo" href="{{@site.url}}"><img src="{{img_url @site.logo size="xs"}}" |
|
||||||
alt="{{@site.title}}" /></a> |
|
||||||
{{else}} |
|
||||||
<a class="site-nav-logo" href="{{@site.url}}">{{@site.title}}</a> |
|
||||||
{{/if}} |
|
||||||
</nav> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</header> |
|
||||||
|
|
||||||
<main class="outer error-content"> |
|
||||||
<div class="inner"> |
|
||||||
|
|
||||||
<section class="error-message"> |
|
||||||
<h1 class="error-code">{{statusCode}}</h1> |
|
||||||
<p class="error-description">{{message}}</p> |
|
||||||
<a class="error-link" href="{{@site.url}}">Go to the front page →</a> |
|
||||||
</section> |
|
||||||
|
|
||||||
{{#if errorDetails}} |
|
||||||
<section class="error-stack"> |
|
||||||
<h3>Theme errors</h3> |
|
||||||
<ul class="error-stack-list"> |
|
||||||
{{#foreach errorDetails}} |
|
||||||
<li> |
|
||||||
<em class="error-stack-function">{{{rule}}}</em> |
|
||||||
|
|
||||||
{{#foreach failures}} |
|
||||||
<p><span class="error-stack-file">Ref: {{ref}}</span></p> |
|
||||||
<p><span class="error-stack-file">Message: {{message}}</span></p> |
|
||||||
{{/foreach}} |
|
||||||
</li> |
|
||||||
{{/foreach}} |
|
||||||
</ul> |
|
||||||
</section> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
</div> |
|
||||||
</main> |
|
||||||
</div> |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,176 +0,0 @@ |
|||||||
const {series, watch, src, dest, parallel} = require('gulp'); |
|
||||||
const pump = require('pump'); |
|
||||||
const path = require('path'); |
|
||||||
const releaseUtils = require('@tryghost/release-utils'); |
|
||||||
const inquirer = require('inquirer'); |
|
||||||
|
|
||||||
// gulp plugins and utils
|
|
||||||
const livereload = require('gulp-livereload'); |
|
||||||
const postcss = require('gulp-postcss'); |
|
||||||
const zip = require('gulp-zip'); |
|
||||||
const concat = require('gulp-concat'); |
|
||||||
const uglify = require('gulp-uglify'); |
|
||||||
const beeper = require('beeper'); |
|
||||||
const fs = require('fs'); |
|
||||||
|
|
||||||
// postcss plugins
|
|
||||||
const autoprefixer = require('autoprefixer'); |
|
||||||
const colorFunction = require('postcss-color-mod-function'); |
|
||||||
const cssnano = require('cssnano'); |
|
||||||
const easyimport = require('postcss-easy-import'); |
|
||||||
|
|
||||||
const REPO = 'TryGhost/Casper'; |
|
||||||
const REPO_READONLY = 'TryGhost/Casper'; |
|
||||||
const CHANGELOG_PATH = path.join(process.cwd(), '.', 'changelog.md'); |
|
||||||
|
|
||||||
function serve(done) { |
|
||||||
livereload.listen(); |
|
||||||
done(); |
|
||||||
} |
|
||||||
|
|
||||||
const handleError = (done) => { |
|
||||||
return function (err) { |
|
||||||
if (err) { |
|
||||||
beeper(); |
|
||||||
} |
|
||||||
return done(err); |
|
||||||
}; |
|
||||||
}; |
|
||||||
|
|
||||||
function hbs(done) { |
|
||||||
pump([ |
|
||||||
src(['*.hbs', 'partials/**/*.hbs']), |
|
||||||
livereload() |
|
||||||
], handleError(done)); |
|
||||||
} |
|
||||||
|
|
||||||
function css(done) { |
|
||||||
pump([ |
|
||||||
src('assets/css/*.css', {sourcemaps: true}), |
|
||||||
postcss([ |
|
||||||
easyimport, |
|
||||||
colorFunction(), |
|
||||||
autoprefixer(), |
|
||||||
cssnano() |
|
||||||
]), |
|
||||||
dest('assets/built/', {sourcemaps: '.'}), |
|
||||||
livereload() |
|
||||||
], handleError(done)); |
|
||||||
} |
|
||||||
|
|
||||||
function js(done) { |
|
||||||
pump([ |
|
||||||
src([ |
|
||||||
// pull in lib files first so our own code can depend on it
|
|
||||||
'assets/js/lib/*.js', |
|
||||||
'assets/js/*.js' |
|
||||||
], {sourcemaps: true}), |
|
||||||
concat('casper.js'), |
|
||||||
uglify(), |
|
||||||
dest('assets/built/', {sourcemaps: '.'}), |
|
||||||
livereload() |
|
||||||
], handleError(done)); |
|
||||||
} |
|
||||||
|
|
||||||
function zipper(done) { |
|
||||||
const filename = require('./package.json').name + '.zip'; |
|
||||||
|
|
||||||
pump([ |
|
||||||
src([ |
|
||||||
'**', |
|
||||||
'!node_modules', '!node_modules/**', |
|
||||||
'!dist', '!dist/**', |
|
||||||
'!yarn-error.log', |
|
||||||
'!yarn.lock', |
|
||||||
'!gulpfile.js' |
|
||||||
]), |
|
||||||
zip(filename), |
|
||||||
dest('dist/') |
|
||||||
], handleError(done)); |
|
||||||
} |
|
||||||
|
|
||||||
const cssWatcher = () => watch('assets/css/**', css); |
|
||||||
const jsWatcher = () => watch('assets/js/**', js); |
|
||||||
const hbsWatcher = () => watch(['*.hbs', 'partials/**/*.hbs'], hbs); |
|
||||||
const watcher = parallel(cssWatcher, jsWatcher, hbsWatcher); |
|
||||||
const build = series(css, js); |
|
||||||
|
|
||||||
exports.build = build; |
|
||||||
exports.zip = series(build, zipper); |
|
||||||
exports.default = series(build, serve, watcher); |
|
||||||
|
|
||||||
exports.release = async () => { |
|
||||||
// @NOTE: https://yarnpkg.com/lang/en/docs/cli/version/
|
|
||||||
// require(./package.json) can run into caching issues, this re-reads from file everytime on release
|
|
||||||
let packageJSON = JSON.parse(fs.readFileSync('./package.json')); |
|
||||||
const newVersion = packageJSON.version; |
|
||||||
|
|
||||||
if (!newVersion || newVersion === '') { |
|
||||||
console.log(`Invalid version: ${newVersion}`); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
console.log(`\nCreating release for ${newVersion}...`); |
|
||||||
|
|
||||||
const githubToken = process.env.GST_TOKEN; |
|
||||||
|
|
||||||
if (!githubToken) { |
|
||||||
console.log('Please configure your environment with a GitHub token located in GST_TOKEN'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const result = await inquirer.prompt([{ |
|
||||||
type: 'input', |
|
||||||
name: 'compatibleWithGhost', |
|
||||||
message: 'Which version of Ghost is it compatible with?', |
|
||||||
default: '5.0.0' |
|
||||||
}]); |
|
||||||
|
|
||||||
const compatibleWithGhost = result.compatibleWithGhost; |
|
||||||
|
|
||||||
const releasesResponse = await releaseUtils.releases.get({ |
|
||||||
userAgent: 'Casper', |
|
||||||
uri: `https://api.github.com/repos/${REPO_READONLY}/releases` |
|
||||||
}); |
|
||||||
|
|
||||||
if (!releasesResponse || !releasesResponse) { |
|
||||||
console.log('No releases found. Skipping...'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
let previousVersion = releasesResponse[0].tag_name || releasesResponse[0].name; |
|
||||||
console.log(`Previous version: ${previousVersion}`); |
|
||||||
|
|
||||||
const changelog = new releaseUtils.Changelog({ |
|
||||||
changelogPath: CHANGELOG_PATH, |
|
||||||
folder: path.join(process.cwd(), '.') |
|
||||||
}); |
|
||||||
|
|
||||||
changelog |
|
||||||
.write({ |
|
||||||
githubRepoPath: `https://github.com/${REPO}`, |
|
||||||
lastVersion: previousVersion |
|
||||||
}) |
|
||||||
.sort() |
|
||||||
.clean(); |
|
||||||
|
|
||||||
const newReleaseResponse = await releaseUtils.releases.create({ |
|
||||||
draft: true, |
|
||||||
preRelease: false, |
|
||||||
tagName: 'v' + newVersion, |
|
||||||
releaseName: newVersion, |
|
||||||
userAgent: 'Casper', |
|
||||||
uri: `https://api.github.com/repos/${REPO}/releases`, |
|
||||||
github: { |
|
||||||
token: githubToken |
|
||||||
}, |
|
||||||
content: [`**Compatible with Ghost ≥ ${compatibleWithGhost}**\n\n`], |
|
||||||
changelogPath: CHANGELOG_PATH |
|
||||||
}); |
|
||||||
console.log(`\nRelease draft generated: ${newReleaseResponse.releaseUrl}\n`); |
|
||||||
} catch (err) { |
|
||||||
console.error(err); |
|
||||||
process.exit(1); |
|
||||||
} |
|
||||||
}; |
|
||||||
@ -1,54 +0,0 @@ |
|||||||
{{!< default}} |
|
||||||
{{!-- The tag above means: insert everything in this file |
|
||||||
into the {body} of the default.hbs template --}} |
|
||||||
|
|
||||||
<div class="site-header-content outer{{#match @custom.header_style "Left aligned"}} left-aligned{{/match}}{{#unless @custom.show_publication_cover}}{{#match @custom.header_style "Hidden"}} no-content{{/match}}{{/unless}}"> |
|
||||||
|
|
||||||
{{#if @custom.show_publication_cover}} |
|
||||||
{{#if @site.cover_image}} |
|
||||||
{{!-- This is a responsive image, it loads different sizes depending on device |
|
||||||
https://medium.freecodecamp.org/a-guide-to-responsive-images-with-ready-to-use-templates-c400bd65c433 --}} |
|
||||||
<img class="site-header-cover" |
|
||||||
srcset="{{img_url @site.cover_image size="s"}} 300w, |
|
||||||
{{img_url @site.cover_image size="m"}} 600w, |
|
||||||
{{img_url @site.cover_image size="l"}} 1000w, |
|
||||||
{{img_url @site.cover_image size="xl"}} 2000w" |
|
||||||
sizes="100vw" |
|
||||||
src="{{img_url @site.cover_image size="xl"}}" |
|
||||||
alt="{{@site.title}}" |
|
||||||
/> |
|
||||||
{{/if}} |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
{{#match @custom.header_style "!=" "Hidden"}} |
|
||||||
<div class="site-header-inner inner"> |
|
||||||
{{#match @custom.navigation_layout "Logo on cover"}} |
|
||||||
{{#if @site.logo}} |
|
||||||
<img class="site-logo" src="{{@site.logo}}" alt="{{@site.title}}"> |
|
||||||
{{else}} |
|
||||||
<h1 class="site-title">{{@site.title}}</h1> |
|
||||||
{{/if}} |
|
||||||
{{/match}} |
|
||||||
{{#if @site.description}} |
|
||||||
<p class="site-description">{{@site.description}}</p> |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
{{/match}} |
|
||||||
|
|
||||||
</div> |
|
||||||
|
|
||||||
{{!-- The main content area --}} |
|
||||||
<main id="site-main" class="site-main outer"> |
|
||||||
<div class="inner posts"> |
|
||||||
|
|
||||||
<div class="post-feed"> |
|
||||||
{{#foreach posts}} |
|
||||||
{{!-- The tag below includes the markup for each post - partials/post-card.hbs --}} |
|
||||||
{{> "post-card"}} |
|
||||||
{{/foreach}} |
|
||||||
</div> |
|
||||||
|
|
||||||
{{pagination}} |
|
||||||
|
|
||||||
</div> |
|
||||||
</main> |
|
||||||
@ -1,179 +0,0 @@ |
|||||||
{ |
|
||||||
"name": "casper", |
|
||||||
"description": "A clean, minimal default theme for the Ghost publishing platform", |
|
||||||
"demo": "https://demo.ghost.io", |
|
||||||
"version": "5.8.1", |
|
||||||
"engines": { |
|
||||||
"ghost": ">=5.0.0" |
|
||||||
}, |
|
||||||
"license": "MIT", |
|
||||||
"screenshots": { |
|
||||||
"desktop": "assets/screenshot-desktop.jpg", |
|
||||||
"mobile": "assets/screenshot-mobile.jpg" |
|
||||||
}, |
|
||||||
"scripts": { |
|
||||||
"dev": "gulp", |
|
||||||
"zip": "gulp zip", |
|
||||||
"test": "gscan .", |
|
||||||
"test:ci": "gscan --fatal --verbose .", |
|
||||||
"pretest": "gulp build", |
|
||||||
"preship": "yarn test", |
|
||||||
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version && git push --follow-tags; else echo \"Uncomitted changes found.\" && exit 1; fi", |
|
||||||
"postship": "git fetch && gulp release" |
|
||||||
}, |
|
||||||
"author": { |
|
||||||
"name": "Ghost Foundation", |
|
||||||
"email": "hello@ghost.org", |
|
||||||
"url": "https://ghost.org/" |
|
||||||
}, |
|
||||||
"gpm": { |
|
||||||
"type": "theme", |
|
||||||
"categories": [ |
|
||||||
"Minimal", |
|
||||||
"Magazine" |
|
||||||
] |
|
||||||
}, |
|
||||||
"keywords": [ |
|
||||||
"ghost", |
|
||||||
"theme", |
|
||||||
"ghost-theme" |
|
||||||
], |
|
||||||
"repository": { |
|
||||||
"type": "git", |
|
||||||
"url": "https://github.com/TryGhost/Casper.git" |
|
||||||
}, |
|
||||||
"bugs": "https://github.com/TryGhost/Casper/issues", |
|
||||||
"contributors": "https://github.com/TryGhost/Casper/graphs/contributors", |
|
||||||
"devDependencies": { |
|
||||||
"@tryghost/release-utils": "0.8.1", |
|
||||||
"autoprefixer": "10.4.7", |
|
||||||
"beeper": "2.1.0", |
|
||||||
"cssnano": "5.1.12", |
|
||||||
"gscan": "4.43.1", |
|
||||||
"gulp": "4.0.2", |
|
||||||
"gulp-concat": "2.6.1", |
|
||||||
"gulp-livereload": "4.0.2", |
|
||||||
"gulp-postcss": "9.0.1", |
|
||||||
"gulp-uglify": "3.0.2", |
|
||||||
"gulp-zip": "5.1.0", |
|
||||||
"inquirer": "8.2.4", |
|
||||||
"postcss": "8.2.13", |
|
||||||
"postcss-color-mod-function": "3.0.3", |
|
||||||
"postcss-easy-import": "4.0.0", |
|
||||||
"pump": "3.0.0" |
|
||||||
}, |
|
||||||
"browserslist": [ |
|
||||||
"defaults" |
|
||||||
], |
|
||||||
"config": { |
|
||||||
"posts_per_page": 25, |
|
||||||
"image_sizes": { |
|
||||||
"xxs": { |
|
||||||
"width": 30 |
|
||||||
}, |
|
||||||
"xs": { |
|
||||||
"width": 100 |
|
||||||
}, |
|
||||||
"s": { |
|
||||||
"width": 300 |
|
||||||
}, |
|
||||||
"m": { |
|
||||||
"width": 600 |
|
||||||
}, |
|
||||||
"l": { |
|
||||||
"width": 1000 |
|
||||||
}, |
|
||||||
"xl": { |
|
||||||
"width": 2000 |
|
||||||
} |
|
||||||
}, |
|
||||||
"card_assets": true, |
|
||||||
"custom": { |
|
||||||
"navigation_layout": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Logo on cover", |
|
||||||
"Logo in the middle", |
|
||||||
"Stacked" |
|
||||||
], |
|
||||||
"default": "Logo on cover" |
|
||||||
}, |
|
||||||
"title_font": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Modern sans-serif", |
|
||||||
"Elegant serif" |
|
||||||
], |
|
||||||
"default": "Modern sans-serif" |
|
||||||
}, |
|
||||||
"body_font": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Modern sans-serif", |
|
||||||
"Elegant serif" |
|
||||||
], |
|
||||||
"default": "Elegant serif" |
|
||||||
}, |
|
||||||
"show_publication_cover": { |
|
||||||
"type": "boolean", |
|
||||||
"default": true, |
|
||||||
"group": "homepage" |
|
||||||
}, |
|
||||||
"header_style": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Center aligned", |
|
||||||
"Left aligned", |
|
||||||
"Hidden" |
|
||||||
], |
|
||||||
"default": "Center aligned", |
|
||||||
"group": "homepage" |
|
||||||
}, |
|
||||||
"feed_layout": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Classic", |
|
||||||
"Grid", |
|
||||||
"List" |
|
||||||
], |
|
||||||
"default": "Classic", |
|
||||||
"group": "homepage" |
|
||||||
}, |
|
||||||
"color_scheme": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Light", |
|
||||||
"Dark", |
|
||||||
"Auto" |
|
||||||
], |
|
||||||
"default": "Light" |
|
||||||
}, |
|
||||||
"post_image_style": { |
|
||||||
"type": "select", |
|
||||||
"options": [ |
|
||||||
"Wide", |
|
||||||
"Full", |
|
||||||
"Small", |
|
||||||
"Hidden" |
|
||||||
], |
|
||||||
"default": "Wide", |
|
||||||
"group": "post" |
|
||||||
}, |
|
||||||
"email_signup_text": { |
|
||||||
"type": "text", |
|
||||||
"default": "Sign up for more like this.", |
|
||||||
"group": "post" |
|
||||||
}, |
|
||||||
"show_recent_posts_footer": { |
|
||||||
"type": "boolean", |
|
||||||
"default": true, |
|
||||||
"group": "post" |
|
||||||
} |
|
||||||
} |
|
||||||
}, |
|
||||||
"renovate": { |
|
||||||
"extends": [ |
|
||||||
"@tryghost:theme" |
|
||||||
] |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,47 +0,0 @@ |
|||||||
{{!< default}} |
|
||||||
|
|
||||||
{{!-- The tag above means: insert everything in this file |
|
||||||
into the {body} tag of the default.hbs template --}} |
|
||||||
|
|
||||||
|
|
||||||
{{#post}} |
|
||||||
{{!-- Everything inside the #post block pulls data from the page --}} |
|
||||||
|
|
||||||
<main id="site-main" class="site-main"> |
|
||||||
<article class="article {{post_class}}"> |
|
||||||
|
|
||||||
{{#match @page.show_title_and_feature_image}} |
|
||||||
<header class="article-header gh-canvas"> |
|
||||||
|
|
||||||
<h1 class="article-title">{{title}}</h1> |
|
||||||
|
|
||||||
{{#if feature_image}} |
|
||||||
<figure class="article-image"> |
|
||||||
{{!-- This is a responsive image, it loads different sizes depending on device |
|
||||||
https://medium.freecodecamp.org/a-guide-to-responsive-images-with-ready-to-use-templates-c400bd65c433 --}} |
|
||||||
<img |
|
||||||
srcset="{{img_url feature_image size="s"}} 300w, |
|
||||||
{{img_url feature_image size="m"}} 600w, |
|
||||||
{{img_url feature_image size="l"}} 1000w, |
|
||||||
{{img_url feature_image size="xl"}} 2000w" |
|
||||||
sizes="(min-width: 1400px) 1400px, 92vw" |
|
||||||
src="{{img_url feature_image size="xl"}}" |
|
||||||
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}" |
|
||||||
/> |
|
||||||
{{#if feature_image_caption}} |
|
||||||
<figcaption>{{feature_image_caption}}</figcaption> |
|
||||||
{{/if}} |
|
||||||
</figure> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
</header> |
|
||||||
{{/match}} |
|
||||||
|
|
||||||
<section class="gh-content gh-canvas"> |
|
||||||
{{content}} |
|
||||||
</section> |
|
||||||
|
|
||||||
</article> |
|
||||||
</main> |
|
||||||
|
|
||||||
{{/post}} |
|
||||||
|
Before Width: | Height: | Size: 308 B |
|
Before Width: | Height: | Size: 531 B |
|
Before Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 923 B |
|
Before Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 263 B |
|
Before Width: | Height: | Size: 248 B |
|
Before Width: | Height: | Size: 752 B |
@ -1,41 +0,0 @@ |
|||||||
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true"> |
|
||||||
<div class="pswp__bg"></div> |
|
||||||
|
|
||||||
<div class="pswp__scroll-wrap"> |
|
||||||
<div class="pswp__container"> |
|
||||||
<div class="pswp__item"></div> |
|
||||||
<div class="pswp__item"></div> |
|
||||||
<div class="pswp__item"></div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="pswp__ui pswp__ui--hidden"> |
|
||||||
<div class="pswp__top-bar"> |
|
||||||
<div class="pswp__counter"></div> |
|
||||||
|
|
||||||
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button> |
|
||||||
<button class="pswp__button pswp__button--share" title="Share"></button> |
|
||||||
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button> |
|
||||||
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button> |
|
||||||
|
|
||||||
<div class="pswp__preloader"> |
|
||||||
<div class="pswp__preloader__icn"> |
|
||||||
<div class="pswp__preloader__cut"> |
|
||||||
<div class="pswp__preloader__donut"></div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap"> |
|
||||||
<div class="pswp__share-tooltip"></div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)"></button> |
|
||||||
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)"></button> |
|
||||||
|
|
||||||
<div class="pswp__caption"> |
|
||||||
<div class="pswp__caption__center"></div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
@ -1,78 +0,0 @@ |
|||||||
{{!-- This is a partial file used to generate a post "card" |
|
||||||
which templates loop over to generate a list of posts. --}} |
|
||||||
|
|
||||||
<article class="post-card {{post_class}}{{#match @custom.feed_layout "Classic"}}{{#is "home"}}{{#has index="0"}} post-card-large{{/has}}{{#has index="1,2"}} dynamic{{/has}}{{/is}}{{/match}}{{#match @custom.feed_layout "Grid"}} keep-ratio{{/match}}{{#match @custom.feed_layout "List"}}{{^is "tag, author"}} post-card-large{{/is}}{{/match}}{{#unless access}} post-access-{{visibility}}{{/unless}}"> |
|
||||||
|
|
||||||
{{#if feature_image}} |
|
||||||
<a class="post-card-image-link" href="{{url}}"> |
|
||||||
|
|
||||||
{{!-- This is a responsive image, it loads different sizes depending on device |
|
||||||
https://medium.freecodecamp.org/a-guide-to-responsive-images-with-ready-to-use-templates-c400bd65c433 --}} |
|
||||||
<img class="post-card-image" |
|
||||||
srcset="{{img_url feature_image size="s"}} 300w, |
|
||||||
{{img_url feature_image size="m"}} 600w, |
|
||||||
{{img_url feature_image size="l"}} 1000w, |
|
||||||
{{img_url feature_image size="xl"}} 2000w" |
|
||||||
sizes="(max-width: 1000px) 400px, 800px" |
|
||||||
src="{{img_url feature_image size="m"}}" |
|
||||||
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}" |
|
||||||
loading="lazy" |
|
||||||
/> |
|
||||||
|
|
||||||
{{#unless access}} |
|
||||||
{{^has visibility="public"}} |
|
||||||
<div class="post-card-access"> |
|
||||||
{{> "icons/lock"}} |
|
||||||
{{#has visibility="members"}} |
|
||||||
Members only |
|
||||||
{{else}} |
|
||||||
Paid-members only |
|
||||||
{{/has}} |
|
||||||
</div> |
|
||||||
{{/has}} |
|
||||||
{{/unless}} |
|
||||||
|
|
||||||
</a> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<div class="post-card-content"> |
|
||||||
|
|
||||||
<a class="post-card-content-link" href="{{url}}"> |
|
||||||
<header class="post-card-header"> |
|
||||||
<div class="post-card-tags"> |
|
||||||
{{#primary_tag}} |
|
||||||
<span class="post-card-primary-tag">{{name}}</span> |
|
||||||
{{/primary_tag}} |
|
||||||
{{#if featured}} |
|
||||||
<span class="post-card-featured">{{> "icons/fire"}} Featured</span> |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
<h2 class="post-card-title"> |
|
||||||
{{#unless access}} |
|
||||||
{{^has visibility="public"}} |
|
||||||
{{#unless feature_image}} |
|
||||||
{{> "icons/lock"}} |
|
||||||
{{/unless}} |
|
||||||
{{/has}} |
|
||||||
{{/unless}} |
|
||||||
{{title}} |
|
||||||
</h2> |
|
||||||
</header> |
|
||||||
{{#if excerpt}} |
|
||||||
<div class="post-card-excerpt">{{excerpt}}</div> |
|
||||||
{{/if}} |
|
||||||
</a> |
|
||||||
|
|
||||||
<footer class="post-card-meta"> |
|
||||||
<time class="post-card-meta-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMM YYYY"}}</time> |
|
||||||
{{#if reading_time}} |
|
||||||
<span class="post-card-meta-length">{{reading_time}}</span> |
|
||||||
{{/if}} |
|
||||||
{{#if @site.comments_enabled}} |
|
||||||
{{comment_count}} |
|
||||||
{{/if}} |
|
||||||
</footer> |
|
||||||
|
|
||||||
</div> |
|
||||||
|
|
||||||
</article> |
|
||||||
@ -1,140 +0,0 @@ |
|||||||
{{!< default}} |
|
||||||
|
|
||||||
{{!-- The tag above means: insert everything in this file |
|
||||||
into the {body} tag of the default.hbs template --}} |
|
||||||
|
|
||||||
|
|
||||||
{{#post}} |
|
||||||
{{!-- Everything inside the #post block pulls data from the post --}} |
|
||||||
|
|
||||||
<main id="site-main" class="site-main"> |
|
||||||
<article class="article {{post_class}} {{#match @custom.post_image_style "Full"}}image-full{{else match @custom.post_image_style "=" "Small"}}image-small{{/match}}"> |
|
||||||
|
|
||||||
<header class="article-header gh-canvas"> |
|
||||||
|
|
||||||
<div class="article-tag post-card-tags"> |
|
||||||
{{#primary_tag}} |
|
||||||
<span class="post-card-primary-tag"> |
|
||||||
<a href="{{url}}">{{name}}</a> |
|
||||||
</span> |
|
||||||
{{/primary_tag}} |
|
||||||
{{#if featured}} |
|
||||||
<span class="post-card-featured">{{> "icons/fire"}} Featured</span> |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
|
|
||||||
<h1 class="article-title">{{title}}</h1> |
|
||||||
|
|
||||||
{{#if custom_excerpt}} |
|
||||||
<p class="article-excerpt">{{custom_excerpt}}</p> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<div class="article-byline"> |
|
||||||
<section class="article-byline-content"> |
|
||||||
|
|
||||||
<ul class="author-list instapaper_ignore"> |
|
||||||
{{#foreach authors}} |
|
||||||
<li class="author-list-item"> |
|
||||||
{{#if profile_image}} |
|
||||||
<a href="{{url}}" class="author-avatar" aria-label="Read more of {{name}}"> |
|
||||||
<img class="author-profile-image" src="{{img_url profile_image size="xs"}}" alt="{{name}}" /> |
|
||||||
</a> |
|
||||||
{{else}} |
|
||||||
<a href="{{url}}" class="author-avatar author-profile-image" aria-label="Read more of {{name}}">{{> "icons/avatar"}}</a> |
|
||||||
{{/if}} |
|
||||||
</li> |
|
||||||
{{/foreach}} |
|
||||||
</ul> |
|
||||||
|
|
||||||
<div class="article-byline-meta"> |
|
||||||
<h4 class="author-name">{{authors}}</h4> |
|
||||||
<div class="byline-meta-content"> |
|
||||||
<time class="byline-meta-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMM YYYY"}}</time> |
|
||||||
{{#if reading_time}} |
|
||||||
<span class="byline-reading-time"><span class="bull">•</span> {{reading_time}}</span> |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
</section> |
|
||||||
</div> |
|
||||||
|
|
||||||
{{#match @custom.post_image_style "!=" "Hidden"}} |
|
||||||
{{#if feature_image}} |
|
||||||
<figure class="article-image"> |
|
||||||
{{!-- This is a responsive image, it loads different sizes depending on device |
|
||||||
https://medium.freecodecamp.org/a-guide-to-responsive-images-with-ready-to-use-templates-c400bd65c433 --}} |
|
||||||
<img |
|
||||||
srcset="{{img_url feature_image size="s"}} 300w, |
|
||||||
{{img_url feature_image size="m"}} 600w, |
|
||||||
{{img_url feature_image size="l"}} 1000w, |
|
||||||
{{img_url feature_image size="xl"}} 2000w" |
|
||||||
sizes="(min-width: 1400px) 1400px, 92vw" |
|
||||||
src="{{img_url feature_image size="xl"}}" |
|
||||||
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}" |
|
||||||
/> |
|
||||||
{{#if feature_image_caption}} |
|
||||||
<figcaption>{{feature_image_caption}}</figcaption> |
|
||||||
{{/if}} |
|
||||||
</figure> |
|
||||||
{{/if}} |
|
||||||
{{/match}} |
|
||||||
|
|
||||||
</header> |
|
||||||
|
|
||||||
<section class="gh-content gh-canvas"> |
|
||||||
{{content}} |
|
||||||
</section> |
|
||||||
|
|
||||||
{{#if comments}} |
|
||||||
<section class="article-comments gh-canvas"> |
|
||||||
{{comments}} |
|
||||||
</section> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
</article> |
|
||||||
</main> |
|
||||||
|
|
||||||
{{!-- A signup call to action is displayed here, unless viewed as a logged-in member --}} |
|
||||||
{{#if @site.members_enabled}} |
|
||||||
{{#unless @member}} |
|
||||||
{{#unless @site.comments_enabled}} |
|
||||||
{{#if access}} |
|
||||||
<section class="footer-cta outer"> |
|
||||||
<div class="inner"> |
|
||||||
{{#if @custom.email_signup_text}}<h2 class="footer-cta-title">{{@custom.email_signup_text}}</h2>{{/if}} |
|
||||||
<a class="footer-cta-button" href="#/portal" data-portal> |
|
||||||
<div class="footer-cta-input">Enter your email</div> |
|
||||||
<span>Subscribe</span> |
|
||||||
</a> |
|
||||||
</div> |
|
||||||
</section> |
|
||||||
{{/if}} |
|
||||||
{{/unless}} |
|
||||||
{{/unless}} |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
|
|
||||||
{{!-- Read more links, just above the footer --}} |
|
||||||
{{#if @custom.show_recent_posts_footer}} |
|
||||||
{{!-- The {#get} helper below fetches some of the latest posts here |
|
||||||
so that people have something else to read when they finish this one. |
|
||||||
|
|
||||||
This query gets the latest 3 posts on the site, but adds a filter to |
|
||||||
exclude the post we're currently on from being included. --}} |
|
||||||
{{#get "posts" filter="id:-{{id}}" limit="3" as |more_posts|}} |
|
||||||
|
|
||||||
{{#if more_posts}} |
|
||||||
<aside class="read-more-wrap outer"> |
|
||||||
<div class="read-more inner"> |
|
||||||
{{#foreach more_posts}} |
|
||||||
{{> "post-card"}} |
|
||||||
{{/foreach}} |
|
||||||
</div> |
|
||||||
</aside> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
{{/get}} |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
{{/post}} |
|
||||||
@ -1,55 +0,0 @@ |
|||||||
{{!< default}} |
|
||||||
{{!-- The tag above means - insert everything in this file into the {body} of the default.hbs template --}} |
|
||||||
|
|
||||||
<main id="site-main" class="site-main outer"> |
|
||||||
<div class="inner posts"> |
|
||||||
<div class="post-feed"> |
|
||||||
|
|
||||||
{{#tag}} |
|
||||||
<section class="post-card post-card-large"> |
|
||||||
|
|
||||||
{{#if feature_image}} |
|
||||||
<div class="post-card-image-link"> |
|
||||||
{{!-- This is a responsive image, it loads different sizes depending on device |
|
||||||
https://medium.freecodecamp.org/a-guide-to-responsive-images-with-ready-to-use-templates-c400bd65c433 --}} |
|
||||||
<img class="post-card-image" |
|
||||||
srcset="{{img_url feature_image size="s"}} 300w, |
|
||||||
{{img_url feature_image size="m"}} 600w, |
|
||||||
{{img_url feature_image size="l"}} 1000w, |
|
||||||
{{img_url feature_image size="xl"}} 2000w" |
|
||||||
sizes="(max-width: 1000px) 400px, 800px" |
|
||||||
src="{{img_url feature_image size="m"}}" |
|
||||||
alt="{{title}}" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<div class="post-card-content"> |
|
||||||
<div class="post-card-content-link"> |
|
||||||
<header class="post-card-header"> |
|
||||||
<h2 class="post-card-title">{{name}}</h2> |
|
||||||
</header> |
|
||||||
<div class="post-card-excerpt"> |
|
||||||
{{#if description}} |
|
||||||
{{description}} |
|
||||||
{{else}} |
|
||||||
A collection of {{plural ../pagination.total empty='zero posts' singular='% post' plural='% posts'}} |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
</section> |
|
||||||
{{/tag}} |
|
||||||
|
|
||||||
{{#foreach posts}} |
|
||||||
{{!-- The tag below includes the markup for each post - partials/post-card.hbs --}} |
|
||||||
{{> "post-card"}} |
|
||||||
{{/foreach}} |
|
||||||
|
|
||||||
</div> |
|
||||||
|
|
||||||
{{pagination}} |
|
||||||
|
|
||||||
</div> |
|
||||||
</main> |
|
||||||
@ -1,61 +0,0 @@ |
|||||||
<!DOCTYPE html> |
|
||||||
<html lang="en"> |
|
||||||
<head> |
|
||||||
<meta charset="utf-8"> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
||||||
<title>{{category.title}} - {{@site.title}}</title> |
|
||||||
<meta name="description" content="{{@site.description}}"> |
|
||||||
<style> |
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; } |
|
||||||
header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 40px; } |
|
||||||
.site-title { font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem; } |
|
||||||
.site-title a { color: inherit; text-decoration: none; } |
|
||||||
.site-description { color: #666; } |
|
||||||
nav { margin-top: 1rem; } |
|
||||||
nav a { color: #333; text-decoration: none; margin-right: 1.5rem; } |
|
||||||
nav a:hover { text-decoration: underline; } |
|
||||||
.category-header { margin-bottom: 2rem; } |
|
||||||
.category-header h1 { font-size: 2rem; } |
|
||||||
.post-list { display: grid; gap: 2rem; } |
|
||||||
.post-card { border: 1px solid #eee; border-radius: 8px; overflow: hidden; } |
|
||||||
.post-card img { width: 100%; height: 200px; object-fit: cover; } |
|
||||||
.post-card-content { padding: 1.5rem; } |
|
||||||
.post-card h2 { font-size: 1.25rem; margin-bottom: 0.5rem; } |
|
||||||
.post-card h2 a { color: inherit; text-decoration: none; } |
|
||||||
.post-card h2 a:hover { text-decoration: underline; } |
|
||||||
.post-card p { color: #666; font-size: 0.95rem; } |
|
||||||
.post-meta { font-size: 0.85rem; color: #999; margin-top: 1rem; } |
|
||||||
footer { border-top: 1px solid #eee; padding-top: 20px; margin-top: 40px; text-align: center; color: #666; font-size: 0.9rem; } |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
{{> header}} |
|
||||||
|
|
||||||
<main> |
|
||||||
<div class="category-header"> |
|
||||||
<h1>{{category.title}}</h1> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="post-list"> |
|
||||||
{{#each posts}} |
|
||||||
<article class="post-card"> |
|
||||||
{{#if feature_image}} |
|
||||||
<img src="{{feature_image}}" alt="{{title}}"> |
|
||||||
{{/if}} |
|
||||||
<div class="post-card-content"> |
|
||||||
<h2><a href="{{url}}">{{title}}</a></h2> |
|
||||||
<p>{{excerpt}}</p> |
|
||||||
<div class="post-meta"> |
|
||||||
{{published_at_formatted}} · {{reading_time}} min read |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</article> |
|
||||||
{{/each}} |
|
||||||
</div> |
|
||||||
</main> |
|
||||||
|
|
||||||
{{> footer}} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
|
|
||||||
@ -1,55 +0,0 @@ |
|||||||
<!DOCTYPE html> |
|
||||||
</html> |
|
||||||
</body> |
|
||||||
{{> footer}} |
|
||||||
|
|
||||||
</main> |
|
||||||
</div> |
|
||||||
{{/each}} |
|
||||||
</article> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{{published_at_formatted}} · {{reading_time}} min read |
|
||||||
<div class="post-meta"> |
|
||||||
<p>{{excerpt}}</p> |
|
||||||
<h2><a href="{{url}}">{{title}}</a></h2> |
|
||||||
<div class="post-card-content"> |
|
||||||
{{/if}} |
|
||||||
<img src="{{feature_image}}" alt="{{title}}"> |
|
||||||
{{#if feature_image}} |
|
||||||
<article class="post-card"> |
|
||||||
{{#each posts}} |
|
||||||
<div class="post-list"> |
|
||||||
<main> |
|
||||||
|
|
||||||
{{> header}} |
|
||||||
<body> |
|
||||||
</head> |
|
||||||
</style> |
|
||||||
footer { border-top: 1px solid #eee; padding-top: 20px; margin-top: 40px; text-align: center; color: #666; font-size: 0.9rem; } |
|
||||||
.post-meta { font-size: 0.85rem; color: #999; margin-top: 1rem; } |
|
||||||
.post-card p { color: #666; font-size: 0.95rem; } |
|
||||||
.post-card h2 a:hover { text-decoration: underline; } |
|
||||||
.post-card h2 a { color: inherit; text-decoration: none; } |
|
||||||
.post-card h2 { font-size: 1.25rem; margin-bottom: 0.5rem; } |
|
||||||
.post-card-content { padding: 1.5rem; } |
|
||||||
.post-card img { width: 100%; height: 200px; object-fit: cover; } |
|
||||||
.post-card { border: 1px solid #eee; border-radius: 8px; overflow: hidden; } |
|
||||||
.post-list { display: grid; gap: 2rem; } |
|
||||||
nav a:hover { text-decoration: underline; } |
|
||||||
nav a { color: #333; text-decoration: none; margin-right: 1.5rem; } |
|
||||||
nav { margin-top: 1rem; } |
|
||||||
.site-description { color: #666; } |
|
||||||
.site-title a { color: inherit; text-decoration: none; } |
|
||||||
.site-title { font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem; } |
|
||||||
header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 40px; } |
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; } |
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
||||||
<style> |
|
||||||
<meta name="description" content="{{@site.description}}"> |
|
||||||
<title>{{@site.title}}</title> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
||||||
<meta charset="utf-8"> |
|
||||||
<head> |
|
||||||
<html lang="en"> |
|
||||||
|
|
||||||
@ -1,4 +0,0 @@ |
|||||||
<footer> |
|
||||||
<p>© {{@site.title}}. Powered by <a href="https://nostr.com" target="_blank">Nostr</a>.</p> |
|
||||||
</footer> |
|
||||||
|
|
||||||
@ -1,19 +0,0 @@ |
|||||||
<header> |
|
||||||
<div class="site-title"> |
|
||||||
{{#if @site.logo}} |
|
||||||
<a href="/"><img src="{{@site.logo}}" alt="{{@site.title}}" height="40"></a> |
|
||||||
{{else}} |
|
||||||
<a href="/">{{@site.title}}</a> |
|
||||||
{{/if}} |
|
||||||
</div> |
|
||||||
{{#if @site.description}} |
|
||||||
<p class="site-description">{{@site.description}}</p> |
|
||||||
{{/if}} |
|
||||||
<nav> |
|
||||||
<a href="/">Home</a> |
|
||||||
{{#each @site.navigation}} |
|
||||||
<a href="{{url}}">{{label}}</a> |
|
||||||
{{/each}} |
|
||||||
</nav> |
|
||||||
</header> |
|
||||||
|
|
||||||
@ -1,68 +0,0 @@ |
|||||||
<!DOCTYPE html> |
|
||||||
<html lang="en"> |
|
||||||
<head> |
|
||||||
<meta charset="utf-8"> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
||||||
<title>{{post.title}} - {{@site.title}}</title> |
|
||||||
<meta name="description" content="{{post.excerpt}}"> |
|
||||||
{{#if post.feature_image}} |
|
||||||
<meta property="og:image" content="{{post.feature_image}}"> |
|
||||||
{{/if}} |
|
||||||
<style> |
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; } |
|
||||||
header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 40px; } |
|
||||||
.site-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; } |
|
||||||
.site-title a { color: inherit; text-decoration: none; } |
|
||||||
nav { margin-top: 1rem; } |
|
||||||
nav a { color: #333; text-decoration: none; margin-right: 1.5rem; font-size: 0.9rem; } |
|
||||||
nav a:hover { text-decoration: underline; } |
|
||||||
article { margin-bottom: 3rem; } |
|
||||||
.post-header { margin-bottom: 2rem; } |
|
||||||
.post-header h1 { font-size: 2.5rem; line-height: 1.2; margin-bottom: 1rem; } |
|
||||||
.post-meta { color: #666; font-size: 0.9rem; } |
|
||||||
.post-feature-image { margin: 2rem 0; } |
|
||||||
.post-feature-image img { width: 100%; border-radius: 8px; } |
|
||||||
.post-content { font-size: 1.1rem; } |
|
||||||
.post-content p { margin-bottom: 1.5rem; } |
|
||||||
.post-content h2 { font-size: 1.5rem; margin: 2rem 0 1rem; } |
|
||||||
.post-content h3 { font-size: 1.25rem; margin: 1.5rem 0 1rem; } |
|
||||||
.post-content ul, .post-content ol { margin-bottom: 1.5rem; padding-left: 2rem; } |
|
||||||
.post-content li { margin-bottom: 0.5rem; } |
|
||||||
.post-content blockquote { border-left: 3px solid #333; padding-left: 1rem; margin: 1.5rem 0; font-style: italic; color: #666; } |
|
||||||
.post-content pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; margin-bottom: 1.5rem; } |
|
||||||
.post-content code { background: #f5f5f5; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; } |
|
||||||
.post-content pre code { background: none; padding: 0; } |
|
||||||
.post-content a { color: #0066cc; } |
|
||||||
.post-content img { max-width: 100%; height: auto; border-radius: 4px; } |
|
||||||
footer { border-top: 1px solid #eee; padding-top: 20px; margin-top: 40px; text-align: center; color: #666; font-size: 0.9rem; } |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
{{> header}} |
|
||||||
|
|
||||||
<main> |
|
||||||
<article> |
|
||||||
<div class="post-header"> |
|
||||||
<h1>{{post.title}}</h1> |
|
||||||
<div class="post-meta"> |
|
||||||
{{post.published_at_formatted}} · {{post.reading_time}} min read |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{{#if post.feature_image}} |
|
||||||
<div class="post-feature-image"> |
|
||||||
<img src="{{post.feature_image}}" alt="{{post.title}}"> |
|
||||||
</div> |
|
||||||
{{/if}} |
|
||||||
|
|
||||||
<div class="post-content"> |
|
||||||
{{{post.html}}} |
|
||||||
</div> |
|
||||||
</article> |
|
||||||
</main> |
|
||||||
|
|
||||||
{{> footer}} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
|
|
||||||
@ -1,172 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Theme; |
|
||||||
|
|
||||||
use App\UnfoldBundle\Config\SiteConfig; |
|
||||||
use App\UnfoldBundle\Content\CategoryData; |
|
||||||
use App\UnfoldBundle\Content\PostData; |
|
||||||
|
|
||||||
/** |
|
||||||
* Builds Ghost-compatible context for Handlebars templates |
|
||||||
*/ |
|
||||||
class ContextBuilder |
|
||||||
{ |
|
||||||
/** |
|
||||||
* Build context for home page |
|
||||||
* |
|
||||||
* @param CategoryData[] $categories |
|
||||||
* @param PostData[] $posts |
|
||||||
*/ |
|
||||||
public function buildHomeContext(SiteConfig $site, array $categories, array $posts): array |
|
||||||
{ |
|
||||||
return [ |
|
||||||
'@site' => $this->buildSiteContext($site, $categories), |
|
||||||
'posts' => array_map([$this, 'buildPostListItemContext'], $posts), |
|
||||||
'pagination' => $this->buildPaginationContext(count($posts)), |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build context for category page |
|
||||||
* |
|
||||||
* @param CategoryData[] $categories |
|
||||||
* @param PostData[] $posts |
|
||||||
*/ |
|
||||||
public function buildCategoryContext( |
|
||||||
SiteConfig $site, |
|
||||||
array $categories, |
|
||||||
CategoryData $category, |
|
||||||
array $posts |
|
||||||
): array { |
|
||||||
return [ |
|
||||||
'@site' => $this->buildSiteContext($site, $categories), |
|
||||||
'category' => [ |
|
||||||
'slug' => $category->slug, |
|
||||||
'title' => $category->title, |
|
||||||
'url' => '/' . $category->slug, |
|
||||||
], |
|
||||||
'posts' => array_map([$this, 'buildPostListItemContext'], $posts), |
|
||||||
'pagination' => $this->buildPaginationContext(count($posts)), |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build context for post page |
|
||||||
* |
|
||||||
* @param CategoryData[] $categories |
|
||||||
*/ |
|
||||||
public function buildPostContext(SiteConfig $site, array $categories, PostData $post): array |
|
||||||
{ |
|
||||||
return [ |
|
||||||
'@site' => $this->buildSiteContext($site, $categories), |
|
||||||
'post' => $this->buildSinglePostContext($post), |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build @site context (Ghost-compatible) |
|
||||||
* |
|
||||||
* @param CategoryData[] $categories |
|
||||||
*/ |
|
||||||
private function buildSiteContext(SiteConfig $site, array $categories): array |
|
||||||
{ |
|
||||||
$navigation = array_map(fn(CategoryData $cat) => [ |
|
||||||
'label' => $cat->title, |
|
||||||
'url' => '/' . $cat->slug, |
|
||||||
'slug' => $cat->slug, |
|
||||||
], $categories); |
|
||||||
|
|
||||||
return [ |
|
||||||
'title' => $site->title, |
|
||||||
'description' => $site->description, |
|
||||||
'logo' => $site->logo, |
|
||||||
'url' => '/', |
|
||||||
'navigation' => $navigation, |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build post context for list views |
|
||||||
*/ |
|
||||||
private function buildPostListItemContext(PostData $post): array |
|
||||||
{ |
|
||||||
return [ |
|
||||||
'id' => $post->coordinate, |
|
||||||
'slug' => $post->slug, |
|
||||||
'title' => $post->title, |
|
||||||
'excerpt' => $post->summary, |
|
||||||
'url' => '/a/' . $post->slug, |
|
||||||
'feature_image' => $post->image, |
|
||||||
'published_at' => date('c', $post->publishedAt), |
|
||||||
'published_at_formatted' => $post->getPublishedDate(), |
|
||||||
'reading_time' => $this->estimateReadingTime($post->content), |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build full post context for detail page |
|
||||||
*/ |
|
||||||
private function buildSinglePostContext(PostData $post): array |
|
||||||
{ |
|
||||||
return [ |
|
||||||
'id' => $post->coordinate, |
|
||||||
'slug' => $post->slug, |
|
||||||
'title' => $post->title, |
|
||||||
'excerpt' => $post->summary, |
|
||||||
'html' => $this->markdownToHtml($post->content), |
|
||||||
'url' => '/a/' . $post->slug, |
|
||||||
'feature_image' => $post->image, |
|
||||||
'published_at' => date('c', $post->publishedAt), |
|
||||||
'published_at_formatted' => $post->getPublishedDate(), |
|
||||||
'reading_time' => $this->estimateReadingTime($post->content), |
|
||||||
'primary_author' => [ |
|
||||||
'id' => $post->pubkey, |
|
||||||
'name' => 'Author', // TODO: fetch author metadata |
|
||||||
'slug' => substr($post->pubkey, 0, 8), |
|
||||||
], |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Build pagination context |
|
||||||
*/ |
|
||||||
private function buildPaginationContext(int $totalPosts, int $page = 1, int $perPage = 10): array |
|
||||||
{ |
|
||||||
$totalPages = max(1, ceil($totalPosts / $perPage)); |
|
||||||
|
|
||||||
return [ |
|
||||||
'page' => $page, |
|
||||||
'pages' => $totalPages, |
|
||||||
'total' => $totalPosts, |
|
||||||
'limit' => $perPage, |
|
||||||
'prev' => $page > 1 ? $page - 1 : null, |
|
||||||
'next' => $page < $totalPages ? $page + 1 : null, |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Estimate reading time in minutes |
|
||||||
*/ |
|
||||||
private function estimateReadingTime(string $content): int |
|
||||||
{ |
|
||||||
$wordCount = str_word_count(strip_tags($content)); |
|
||||||
$readingTime = ceil($wordCount / 200); // Assume 200 words per minute |
|
||||||
|
|
||||||
return max(1, (int) $readingTime); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Convert markdown to HTML (basic implementation) |
|
||||||
* TODO: Use proper markdown parser |
|
||||||
*/ |
|
||||||
private function markdownToHtml(string $markdown): string |
|
||||||
{ |
|
||||||
// For now, just wrap in paragraph tags and handle basic formatting |
|
||||||
// This should be replaced with a proper markdown parser like league/commonmark |
|
||||||
$html = htmlspecialchars($markdown, ENT_QUOTES, 'UTF-8'); |
|
||||||
$html = nl2br($html); |
|
||||||
|
|
||||||
return $html; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,333 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle\Theme; |
|
||||||
|
|
||||||
use LightnCandy\Flags; |
|
||||||
use LightnCandy\LightnCandy; |
|
||||||
use Psr\Log\LoggerInterface; |
|
||||||
|
|
||||||
/** |
|
||||||
* Renders Handlebars templates using LightnCandy |
|
||||||
*/ |
|
||||||
class HandlebarsRenderer |
|
||||||
{ |
|
||||||
private string $themesBasePath; |
|
||||||
private string $cachePath; |
|
||||||
private string $currentTheme = 'default'; |
|
||||||
private array $compiledTemplates = []; |
|
||||||
|
|
||||||
public function __construct( |
|
||||||
private readonly LoggerInterface $logger, |
|
||||||
private readonly string $projectDir, |
|
||||||
) { |
|
||||||
$this->themesBasePath = $projectDir . '/src/UnfoldBundle/Resources/themes'; |
|
||||||
$this->cachePath = $projectDir . '/var/cache/unfold/templates'; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Set the current theme to use for rendering |
|
||||||
*/ |
|
||||||
public function setTheme(string $theme): void |
|
||||||
{ |
|
||||||
if ($this->currentTheme !== $theme) { |
|
||||||
$this->currentTheme = $theme; |
|
||||||
$this->compiledTemplates = []; // Clear cache when theme changes |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get the current theme |
|
||||||
*/ |
|
||||||
public function getTheme(): string |
|
||||||
{ |
|
||||||
return $this->currentTheme; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get list of available themes |
|
||||||
* |
|
||||||
* @return string[] |
|
||||||
*/ |
|
||||||
public function getAvailableThemes(): array |
|
||||||
{ |
|
||||||
$themes = []; |
|
||||||
|
|
||||||
if (!is_dir($this->themesBasePath)) { |
|
||||||
return ['default']; |
|
||||||
} |
|
||||||
|
|
||||||
foreach (scandir($this->themesBasePath) as $item) { |
|
||||||
if ($item === '.' || $item === '..') { |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
$themePath = $this->themesBasePath . '/' . $item; |
|
||||||
if (is_dir($themePath) && file_exists($themePath . '/index.hbs')) { |
|
||||||
$themes[] = $item; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return $themes ?: ['default']; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get the current theme path |
|
||||||
*/ |
|
||||||
private function getThemePath(): string |
|
||||||
{ |
|
||||||
return $this->themesBasePath . '/' . $this->currentTheme; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Render a template with the given context |
|
||||||
*/ |
|
||||||
public function render(string $templateName, array $context, ?string $theme = null): string |
|
||||||
{ |
|
||||||
if ($theme !== null) { |
|
||||||
$this->setTheme($theme); |
|
||||||
} |
|
||||||
|
|
||||||
// Add asset path prefix to context for runtime use |
|
||||||
$context['@assetPath'] = '/assets/themes/' . $this->currentTheme; |
|
||||||
|
|
||||||
$renderer = $this->getCompiledTemplate($templateName); |
|
||||||
|
|
||||||
try { |
|
||||||
return $renderer($context); |
|
||||||
} catch (\Throwable $e) { |
|
||||||
$this->logger->error('Error rendering template', [ |
|
||||||
'template' => $templateName, |
|
||||||
'theme' => $this->currentTheme, |
|
||||||
'error' => $e->getMessage(), |
|
||||||
]); |
|
||||||
|
|
||||||
// Return a basic error page |
|
||||||
return $this->renderError($templateName, $e->getMessage()); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get or compile a template |
|
||||||
*/ |
|
||||||
private function getCompiledTemplate(string $templateName): callable |
|
||||||
{ |
|
||||||
$cacheKey = $this->currentTheme . '/' . $templateName; |
|
||||||
|
|
||||||
if (isset($this->compiledTemplates[$cacheKey])) { |
|
||||||
return $this->compiledTemplates[$cacheKey]; |
|
||||||
} |
|
||||||
|
|
||||||
$templateFile = $this->getThemePath() . '/' . $templateName . '.hbs'; |
|
||||||
$cacheFile = $this->cachePath . '/' . $this->currentTheme . '/' . $templateName . '.php'; |
|
||||||
|
|
||||||
// Check if we need to recompile |
|
||||||
if ($this->needsRecompile($templateFile, $cacheFile)) { |
|
||||||
$this->compileTemplate($templateName, $templateFile, $cacheFile); |
|
||||||
} |
|
||||||
|
|
||||||
// Load the compiled template |
|
||||||
if (file_exists($cacheFile)) { |
|
||||||
$this->compiledTemplates[$cacheKey] = require $cacheFile; |
|
||||||
} else { |
|
||||||
// Fallback: compile in memory |
|
||||||
$this->compiledTemplates[$cacheKey] = $this->compileInMemory($templateFile); |
|
||||||
} |
|
||||||
|
|
||||||
return $this->compiledTemplates[$cacheKey]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Check if template needs recompilation |
|
||||||
*/ |
|
||||||
private function needsRecompile(string $templateFile, string $cacheFile): bool |
|
||||||
{ |
|
||||||
if (!file_exists($cacheFile)) { |
|
||||||
return true; |
|
||||||
} |
|
||||||
|
|
||||||
if (!file_exists($templateFile)) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
return filemtime($templateFile) > filemtime($cacheFile); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Compile a template and save to cache file |
|
||||||
*/ |
|
||||||
private function compileTemplate(string $templateName, string $templateFile, string $cacheFile): void |
|
||||||
{ |
|
||||||
if (!file_exists($templateFile)) { |
|
||||||
$this->logger->warning('Template file not found, using fallback', [ |
|
||||||
'template' => $templateName, |
|
||||||
'file' => $templateFile, |
|
||||||
]); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
$template = file_get_contents($templateFile); |
|
||||||
|
|
||||||
$phpCode = LightnCandy::compile($template, [ |
|
||||||
'flags' => LightnCandy::FLAG_HANDLEBARS |
|
||||||
| LightnCandy::FLAG_ERROR_EXCEPTION |
|
||||||
| LightnCandy::FLAG_BESTPERFORMANCE |
|
||||||
| LightnCandy::FLAG_RUNTIMEPARTIAL, |
|
||||||
'partials' => $this->loadPartials(), |
|
||||||
'helpers' => $this->getHelpers(), |
|
||||||
]); |
|
||||||
|
|
||||||
// Ensure cache directory exists |
|
||||||
$cacheDir = dirname($cacheFile); |
|
||||||
if (!is_dir($cacheDir)) { |
|
||||||
mkdir($cacheDir, 0755, true); |
|
||||||
} |
|
||||||
|
|
||||||
// Wrap in a return statement for require |
|
||||||
$phpCode = '<?php return ' . $phpCode . ';'; |
|
||||||
file_put_contents($cacheFile, $phpCode); |
|
||||||
|
|
||||||
$this->logger->debug('Compiled template', ['template' => $templateName]); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Compile template in memory (fallback) |
|
||||||
*/ |
|
||||||
private function compileInMemory(string $templateFile): \Closure |
|
||||||
{ |
|
||||||
if (!file_exists($templateFile)) { |
|
||||||
// Return a basic fallback renderer |
|
||||||
return fn(array $context) => $this->renderFallback($context); |
|
||||||
} |
|
||||||
|
|
||||||
$template = file_get_contents($templateFile); |
|
||||||
|
|
||||||
$phpCode = LightnCandy::compile($template, [ |
|
||||||
'flags' => Flags::FLAG_HANDLEBARS |
|
||||||
| Flags::FLAG_ERROR_EXCEPTION |
|
||||||
| Flags::FLAG_RUNTIMEPARTIAL, |
|
||||||
'partials' => $this->loadPartials(), |
|
||||||
'helpers' => $this->getHelpers(), |
|
||||||
]); |
|
||||||
|
|
||||||
$tmpFile = tempnam(sys_get_temp_dir(), 'lc_'); |
|
||||||
file_put_contents($tmpFile, '<?php return ' . $phpCode . ';'); |
|
||||||
$renderer = require $tmpFile; |
|
||||||
unlink($tmpFile); |
|
||||||
|
|
||||||
return $renderer; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Load all partials from the theme |
|
||||||
*/ |
|
||||||
private function loadPartials(): array |
|
||||||
{ |
|
||||||
$partialsDir = $this->getThemePath() . '/partials'; |
|
||||||
$partials = []; |
|
||||||
|
|
||||||
if (!is_dir($partialsDir)) { |
|
||||||
return $partials; |
|
||||||
} |
|
||||||
|
|
||||||
foreach (glob($partialsDir . '/*.hbs') as $file) { |
|
||||||
$name = basename($file, '.hbs'); |
|
||||||
$partials[$name] = file_get_contents($file); |
|
||||||
} |
|
||||||
|
|
||||||
return $partials; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get custom Handlebars helpers |
|
||||||
*/ |
|
||||||
private function getHelpers(): array |
|
||||||
{ |
|
||||||
return [ |
|
||||||
// Date formatting helper |
|
||||||
'date' => function ($date, $format = 'F j, Y') { |
|
||||||
if (is_numeric($date)) { |
|
||||||
return date($format, $date); |
|
||||||
} |
|
||||||
return date($format, strtotime($date)); |
|
||||||
}, |
|
||||||
|
|
||||||
// URL helper |
|
||||||
'url' => function ($path) { |
|
||||||
return '/' . ltrim($path, '/'); |
|
||||||
}, |
|
||||||
|
|
||||||
// Asset URL helper - uses @assetPath from runtime context |
|
||||||
'asset' => function ($path, $options = null) { |
|
||||||
// Get asset path from context (passed in render method) |
|
||||||
$assetPath = $options['data']['root']['@assetPath'] ?? '/assets/themes/default'; |
|
||||||
return $assetPath . '/' . ltrim($path, '/'); |
|
||||||
}, |
|
||||||
|
|
||||||
// Truncate helper |
|
||||||
'truncate' => function ($text, $length = 100) { |
|
||||||
if (strlen($text) <= $length) { |
|
||||||
return $text; |
|
||||||
} |
|
||||||
return substr($text, 0, $length) . '...'; |
|
||||||
}, |
|
||||||
]; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Render a basic fallback page |
|
||||||
*/ |
|
||||||
private function renderFallback(array $context): string |
|
||||||
{ |
|
||||||
$site = $context['@site'] ?? []; |
|
||||||
$title = $site['title'] ?? 'Unfold Site'; |
|
||||||
$posts = $context['posts'] ?? []; |
|
||||||
$post = $context['post'] ?? null; |
|
||||||
|
|
||||||
$html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' . htmlspecialchars($title) . '</title></head><body>'; |
|
||||||
$html .= '<h1>' . htmlspecialchars($title) . '</h1>'; |
|
||||||
|
|
||||||
if ($post) { |
|
||||||
$html .= '<article><h2>' . htmlspecialchars($post['title'] ?? '') . '</h2>'; |
|
||||||
$html .= '<div>' . ($post['html'] ?? '') . '</div></article>'; |
|
||||||
} elseif (!empty($posts)) { |
|
||||||
$html .= '<ul>'; |
|
||||||
foreach ($posts as $p) { |
|
||||||
$html .= '<li><a href="' . htmlspecialchars($p['url'] ?? '') . '">' . htmlspecialchars($p['title'] ?? '') . '</a></li>'; |
|
||||||
} |
|
||||||
$html .= '</ul>'; |
|
||||||
} |
|
||||||
|
|
||||||
$html .= '</body></html>'; |
|
||||||
|
|
||||||
return $html; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Render error page |
|
||||||
*/ |
|
||||||
private function renderError(string $template, string $error): string |
|
||||||
{ |
|
||||||
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error</title></head><body>' |
|
||||||
. '<h1>Template Error</h1>' |
|
||||||
. '<p>Failed to render template: ' . htmlspecialchars($template) . '</p>' |
|
||||||
. '<pre>' . htmlspecialchars($error) . '</pre>' |
|
||||||
. '</body></html>'; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Clear template cache |
|
||||||
*/ |
|
||||||
public function clearCache(): void |
|
||||||
{ |
|
||||||
$this->compiledTemplates = []; |
|
||||||
|
|
||||||
if (is_dir($this->cachePath)) { |
|
||||||
foreach (glob($this->cachePath . '/*.php') as $file) { |
|
||||||
unlink($file); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
$this->logger->info('Cleared template cache'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,20 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
namespace App\UnfoldBundle; |
|
||||||
|
|
||||||
use Symfony\Component\HttpKernel\Bundle\Bundle; |
|
||||||
|
|
||||||
/** |
|
||||||
* UnfoldBundle — Nostr Website Rendering Runtime |
|
||||||
* |
|
||||||
* Renders websites from Nostr content by resolving subdomains to magazine naddrs, |
|
||||||
* fetching content from the event tree, and rendering via Handlebars templates. |
|
||||||
*/ |
|
||||||
class UnfoldBundle extends Bundle |
|
||||||
{ |
|
||||||
public function getPath(): string |
|
||||||
{ |
|
||||||
return \dirname(__DIR__) . '/UnfoldBundle'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||