Compare commits
5 Commits
8e23500be7
...
01b0fb9246
| Author | SHA1 | Date |
|---|---|---|
|
|
01b0fb9246 | 2 days ago |
|
|
47e55d7aa8 | 2 days ago |
|
|
66e01a51c4 | 2 days ago |
|
|
6ed2d01fa7 | 2 days ago |
|
|
f65a56791a | 2 days ago |
@ -0,0 +1,40 @@ |
|||||||
|
<?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'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,99 @@ |
|||||||
|
<?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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<?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]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,85 @@ |
|||||||
|
<?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}'"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,75 @@ |
|||||||
|
<?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, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,143 @@ |
|||||||
|
<?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]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,62 @@ |
|||||||
|
<?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, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,241 @@ |
|||||||
|
<?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]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<?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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,108 @@ |
|||||||
|
<?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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,72 @@ |
|||||||
|
<?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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,124 @@ |
|||||||
|
<?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'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,37 @@ |
|||||||
|
<?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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,31 @@ |
|||||||
|
<?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'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,75 @@ |
|||||||
|
<?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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,62 @@ |
|||||||
|
<?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]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,21 @@ |
|||||||
|
# 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: '' |
||||||
|
|
||||||
@ -0,0 +1,28 @@ |
|||||||
|
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 |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
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. |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
# 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). |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
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 */ |
||||||
@ -0,0 +1,468 @@ |
|||||||
|
/* 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; |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 547 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 866 B |
@ -0,0 +1,85 @@ |
|||||||
|
(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); |
||||||
|
}); |
||||||
|
})(); |
||||||
@ -0,0 +1,114 @@ |
|||||||
|
/* 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); |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
/*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 ); |
||||||
@ -0,0 +1,109 @@ |
|||||||
|
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' |
||||||
|
); |
||||||
|
})(); |
||||||
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 60 KiB |
@ -0,0 +1,76 @@ |
|||||||
|
{{!< 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> |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
<!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> |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
{{!< 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> |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
{{!-- |
||||||
|
|
||||||
|
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> |
||||||
@ -0,0 +1,176 @@ |
|||||||
|
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); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
{{!< 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> |
||||||
@ -0,0 +1,179 @@ |
|||||||
|
{ |
||||||
|
"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" |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
{{!< 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}} |
||||||
|
After Width: | Height: | Size: 308 B |
|
After Width: | Height: | Size: 531 B |
|
After Width: | Height: | Size: 538 B |
|
After Width: | Height: | Size: 923 B |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 248 B |
|
After Width: | Height: | Size: 752 B |
@ -0,0 +1,41 @@ |
|||||||
|
<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> |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
{{!-- 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> |
||||||
@ -0,0 +1,140 @@ |
|||||||
|
{{!< 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}} |
||||||
@ -0,0 +1,55 @@ |
|||||||
|
{{!< 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> |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
<!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> |
||||||
|
|
||||||
@ -0,0 +1,55 @@ |
|||||||
|
<!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"> |
||||||
|
|
||||||
@ -0,0 +1,4 @@ |
|||||||
|
<footer> |
||||||
|
<p>© {{@site.title}}. Powered by <a href="https://nostr.com" target="_blank">Nostr</a>.</p> |
||||||
|
</footer> |
||||||
|
|
||||||
@ -0,0 +1,19 @@ |
|||||||
|
<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> |
||||||
|
|
||||||
@ -0,0 +1,68 @@ |
|||||||
|
<!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> |
||||||
|
|
||||||
@ -0,0 +1,172 @@ |
|||||||
|
<?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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,333 @@ |
|||||||
|
<?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'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,20 @@ |
|||||||
|
<?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'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||