@ -0,0 +1,40 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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,124 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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,11 @@
@@ -0,0 +1,11 @@
|
||||
# Unfold Bundle Routes |
||||
# Mount these routes for subdomain-based site rendering |
||||
|
||||
unfold_site: |
||||
path: /{path} |
||||
controller: App\UnfoldBundle\Controller\SiteController |
||||
requirements: |
||||
path: '.*' |
||||
defaults: |
||||
path: '' |
||||
|
||||
@ -0,0 +1,28 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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,322 @@
@@ -0,0 +1,322 @@
|
||||
<?php |
||||
|
||||
namespace App\UnfoldBundle\Theme; |
||||
|
||||
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); |
||||
} |
||||
|
||||
$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): callable |
||||
{ |
||||
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' => LightnCandy::FLAG_HANDLEBARS |
||||
| LightnCandy::FLAG_ERROR_EXCEPTION |
||||
| LightnCandy::FLAG_RUNTIMEPARTIAL, |
||||
'partials' => $this->loadPartials(), |
||||
'helpers' => $this->getHelpers(), |
||||
]); |
||||
|
||||
return LightnCandy::prepare($phpCode); |
||||
} |
||||
|
||||
/** |
||||
* 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 |
||||
'asset' => function ($path) { |
||||
return '/themes/default/assets/' . 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 @@
@@ -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'; |
||||
} |
||||
} |
||||
|
||||