Commit 52881099 authored by Avris's avatar Avris

v1.0

parent 352e4abb
......@@ -3,8 +3,6 @@
A filesystem-based, git-oriented CMS
focusing only on what is (deemed by me as) "essential".
It's **NOT** production ready!
## Installation
composer require avris/esse
......@@ -16,11 +14,13 @@ Enable the bundle in `config/bundles.php`:
`config/routes.yaml`:
esse_image:
path: /image/{filename}_{size}.{extension}
controller: Avris\Esse\Controller\EsseController::image
path: '/image/{filename}_{size}.{extension}'
requirements: { filename:'.+' }
controller: 'Avris\Esse\Controller\EsseController::image'
esse_file:
path: /file/{filename}.{extension}
controller: Avris\Esse\Controller\EsseController::file
path: '/file/{filename}.{extension}'
requirements: { filename:'.+' }
controller: 'Avris\Esse\Controller\EsseController::file'
`.gitignore`:
......@@ -29,17 +29,180 @@ Enable the bundle in `config/bundles.php`:
(Optionally) overwrite default config in `config/packages/avris_esse`:
avris_esse:
contentDir: '%kernel.project_dir%/content'
entriesDir: '%kernel.project_dir%/content/entries'
imagesDir: '%kernel.project_dir%/content/images'
filesDir: '%kernel.project_dir%/content/files'
imageSizes:
big: { maxwidth: 960 }
small: { maxwidth: 480 }
micro: { maxwidth: 36, maxheight: 36}
## Usage
_Work in progress..._
### Basic concepts
Content is basically a set of entries ([SUML files](https://gitlab.com/Avris/SUML)). They can be of different types:
- `block` - just a generic string or an array
- `image`
- `file`
- any custom type you want
They are put in the `/content/entries`, `/content/images` and `/content/files` directories, respectively.
For instance, if you put the following content in `/content/entries/about/skills.suml`:
content:
en:
programming:
php: 'PHP'
js: 'JavaScript'
soft:
teamwork: 'Teamwork'
pl:
soft:
teamwork: 'Praca zespołowa'
Then you can fetch it like this:
public function foo(Esse $esse)
{
return $this->render('home/foo.html.twig', [
'skills' => $esse->get('about/skills'),
]);
}
You can also fetch a specific field inside of the content using `$esse->getPart('about/skills.soft.teamwork')`,
or in a template: `{{ esse('about/skills.soft.teamwork') }}`. It will be automatically translated to the current request locale,
using `%locale%` and then a generic "language" `_` as a fallback.
You can optionally add any metadata you want
(including `type` which defaults to `block`, `image` or `file` depending on which directory the file is in,
and `published` which defaults to `false`):
type: `article`
createdAt: 2020-03-11 12:34:56
content: [] # ...
Those can be accessed with `$entry->meta('createdAt')`.
### Images
With an image put in `/content/images/album.png` the following content of `/content/images/album.suml`:
type: 'image' # optional
filename: 'album.png'
alt: 'My photo album'
source: 'https://example.com/album.png'
Esse will give you that image with `$esse->getImage('album')` and under `https://127.0.0.1:8000/image/album_sm.png`
it will serve the `sm` version of that image (as defined in config, under `imageSizes`).
### Files
With an file put in `/content/files/foo.txt` the following content of `/content/files/foo.suml`:
type: 'file' # optional
filename: 'foo.txt'
published: true # optional
title: 'The Foo file'
Esse will give you that file with `$esse->getFile('foo')` and serve it under `https://127.0.0.1:8000/file/file.txt`.
### Modifiers
You can implement `Avris\Esse\Interfaces\EsseModifier` to modify any entry before it gets served by Esse, for example:
<?php
namespace App\Article;
use App\Service\ArticleProcessor;
use Avris\Esse\Entity\Entry;
use Avris\Esse\Interfaces\EsseModifier;
final class ArticleModifier implements EsseModifier
{
private ArticleProcessor $articleProcessor;
public function __construct(ArticleProcessor $articleProcessor)
{
$this->articleProcessor = $articleProcessor;
}
public function modifyEntry(Entry $entry): ?Entry
{
if ($entry->type()->toString() !== 'article') {
return $entry;
}
if (!$entry->published() || $entry->meta('publishedAt') > new \DateTimeImmutable()) {
return null;
}
$data = $entry->allMeta();
$data['content'] = [];
foreach ($entry->versions() as $version) {
$data['content'][$version] = $this->articleProcessor->process($entry->content($version));
}
return $entry->with($data);
}
}
### Indexes
You can implement `Avris\Esse\Interfaces\EsseIndex` to create a cached index of entries, for example:
<?php
namespace App\Article;
use Avris\Esse\Interfaces\EsseIndex;
final class TagIndex implements EsseIndex
{
public function id(): string
{
return 'tag';
}
public function build(iterable $rawFiles): array
{
$index = [];
foreach ($rawFiles as $key => $data) {
if (($data['type'] ?? null) !== 'article') {
continue;
}
foreach ($data['content'] ?? [] as $version => $content) {
foreach ($content['tags'] ?? [] as $tag) {
$tag = mb_strtolower($tag);
if (!isset($index[$tag])) {
$index[$tag] = [];
}
$index[$tag][] = $key;
}
}
}
return $index;
}
}
Example usage:
/**
* @Route("/tag/{tag}")
*/
public function tag(string $tag, Esse $esse)
{
return $this->renderFormat('tag', [
'articles' => $esse->fromIndex('tag', mb_strtolower($tag)),
]);
}
## Copyright
* **Author:** Andre Prusinowski [(Avris.it)](https://avris.it)
......
......@@ -10,9 +10,12 @@
"homepage": "https://avris.it"
}],
"require": {
"php": "^7.1",
"php": "^7.4",
"ext-json": "*",
"ext-gd": "*",
"symfony/dependency-injection": "^4.3|^5.0",
"avris/suml": "^0.3"
"avris/suml": "^0.3",
"symfony/string": "^5.0"
},
"autoload": {
"psr-4": { "Avris\\Esse\\": "./src" }
......
......@@ -6,14 +6,14 @@ use Avris\Esse\Service\Esse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\String\AbstractString;
use function Symfony\Component\String\u;
final class EsseController
{
/** @var Esse */
private $esse;
/** @var string */
private $publicImagesDir;
private Esse $esse;
private AbstractString $publicImagesDir;
public function __construct(Esse $esse, string $publicImagesDir)
{
......@@ -21,13 +21,13 @@ final class EsseController
$this->publicImagesDir = $this->esse->ensureDir($publicImagesDir);
}
public function image(string $filename, string $size, string $extension)
public function image(string $filename, string $size, string $extension): Response
{
$size = mb_strtolower(trim($size));
$extension = mb_strtolower(trim($extension));
$size = u($size)->trim()->lower();
$extension = u($extension)->trim()->lower();
$image = $this->esse->getImage($filename);
if (!$image) {
if (!$image || !$image->published()) {
throw new NotFoundHttpException();
}
......@@ -43,20 +43,16 @@ final class EsseController
$content
);
return new Response(
$content,
200,
['content-type' => $mime]
);
return new Response($content, 200, ['content-type' => $mime]);
}
public function file(string $filename, string $extension)
public function file(string $filename, string $extension): Response
{
$file = $this->esse->getFile($filename);
if (!$file || !$file->published() || $filename . '.' . $extension !== $file->filename()) {
if (!$file || !$file->published() || $filename . '.' . $extension !== $file->filename()->toString()) {
throw new NotFoundHttpException();
}
return new BinaryFileResponse($file->path());
return new BinaryFileResponse($this->esse->filesDir()->append('/')->append($file->filename()));
}
}
......@@ -28,7 +28,10 @@ final class AvrisEsseExtension extends Extension
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$container->getDefinition(Esse::class)->replaceArgument('$contentDir', $config['contentDir']);
$container->getDefinition(Esse::class)->replaceArgument('$entriesDir', $config['entriesDir']);
$container->getDefinition(Esse::class)->replaceArgument('$imagesDir', $config['imagesDir']);
$container->getDefinition(Esse::class)->replaceArgument('$filesDir', $config['filesDir']);
$container->getDefinition(Esse::class)->replaceArgument('$imageSizes', $config['imageSizes']);
}
}
......@@ -13,8 +13,14 @@ final class Configuration implements ConfigurationInterface
$treeBuilder->getRootNode()
->children()
->scalarNode('contentDir')
->defaultValue('%kernel.project_dir%/content')
->scalarNode('entriesDir')
->defaultValue('%kernel.project_dir%/content/entries')
->end()
->scalarNode('imagesDir')
->defaultValue('%kernel.project_dir%/content/images')
->end()
->scalarNode('filesDir')
->defaultValue('%kernel.project_dir%/content/files')
->end()
->arrayNode('imageSizes')
->arrayPrototype()
......
......@@ -2,33 +2,47 @@
namespace Avris\Esse\Entity;
final class Entry implements \JsonSerializable
{
/** @var string */
private $key;
use Symfony\Component\String\AbstractString;
/** @var array */
private $meta;
use function Symfony\Component\String\u;
/** @var array */
private $content;
class Entry implements \JsonSerializable
{
private AbstractString $key;
private AbstractString $type;
private bool $published;
private array $meta;
private array $content;
public function __construct(string $key, array $data)
{
$this->key = $key;
$this->key = u($key);
$this->content = $data['content'] ?? [];
unset($data['content']);
$this->type = u($data['type'] ?? 'block');
unset($data['type']);
$this->published = $data['published'] ?? true;
unset($data['published']);
$this->meta = $data;
}
public function key(): string
public function key(): AbstractString
{
return $this->key;
}
public function keyParts(): array
{
return explode('/', $this->key);
return $this->key->split('/');
}
public function published(): bool
{
return $this->published;
}
public function meta(string $key, $default = null)
......@@ -41,9 +55,9 @@ final class Entry implements \JsonSerializable
return $this->meta;
}
public function type(): string
public function type(): AbstractString
{
return $this->meta['type'] ?? 'block';
return $this->type;
}
public function content(string $locale, $default = null)
......@@ -56,10 +70,17 @@ final class Entry implements \JsonSerializable
return array_keys($this->content);
}
public function jsonSerialize()
public function with(array $data): self
{
return new static($this->key, $data + ['type' => $this->type, 'published' => $this->published]);
}
public function jsonSerialize(): array
{
return [
'key' => $this->key,
'type' => $this->type(),
'published' => $this->published,
'meta' => $this->meta,
'content' => $this->content,
];
......
......@@ -2,18 +2,16 @@
namespace Avris\Esse\Entity;
use Traversable;
final class EntryList implements \IteratorAggregate, \Countable, \JsonSerializable
{
/** @var Entry[] */
private $entries = [];
private array $entries = [];
public function append(Entry $entry): self
{
$newList = new self();
$newList->entries = $this->entries;
$newList->entries[$entry->key()] = $entry;
$newList->entries[$entry->key()->toString()] = $entry;
return $newList;
}
......@@ -35,12 +33,12 @@ final class EntryList implements \IteratorAggregate, \Countable, \JsonSerializab
return $newList;
}
public function getIterator()
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->entries);
}
public function count()
public function count(): int
{
return count($this->entries);
}
......@@ -50,7 +48,7 @@ final class EntryList implements \IteratorAggregate, \Countable, \JsonSerializab
return $this->entries;
}
public function jsonSerialize()
public function jsonSerialize(): array
{
return $this->entries;
}
......
......@@ -2,91 +2,58 @@
namespace Avris\Esse\Entity;
final class File implements \JsonSerializable
{
/** @var string */
private $key;
/** @var string */
private $urlBase;
/** @var string */
private $filename;
/** @var string */
private $basename;
/** @var string */
private $extension;
/** @var string */
private $path;
/** @var string|null */
private $title;
use Symfony\Component\String\AbstractString;
/** @var bool */
private $published;
use function Symfony\Component\String\u;
public function __construct(string $key, string $dir, string $urlBase, array $data)
{
$this->key = $key;
$this->urlBase = $urlBase;
$this->filename = $data['filename'];
$dotPos = mb_strrpos($this->filename, '.');
if ($dotPos === false) {
throw new \RuntimeException('Empty file extension is not allowed');
}
$this->basename = mb_substr($this->filename, 0, $dotPos);
$this->extension = mb_strtolower(mb_substr($this->filename, $dotPos + 1));
$this->path = $dir . '/' . $this->filename;
$this->title = $data['title'] ?? null;
$this->published = $data['published'] ?? false;
}
class File extends Entry implements \JsonSerializable
{
private AbstractString $filename;
private AbstractString $basename;
private AbstractString $extension;
public function key(): string
public function __construct(string $key, array $data)
{
return $this->key;
parent::__construct($key, $data);
$this->filename = u($data['filename']);
unset($data['filename']);
$this->basename = $this->filename->prepend('/')->afterLast('/');
$this->extension = $this->basename->afterLast('.')->lower();
}
public function filename(): string
public function filename(): AbstractString
{
return $this->filename;
}
public function path(): string
public function basename(): AbstractString
{
return $this->path;
return $this->basename;
}
public function url(): string
public function extension(): AbstractString
{
return sprintf('%s/file/%s.%s', $this->urlBase, $this->basename, $this->extension);
return $this->extension;
}
public function title(): ?string
public function url(): AbstractString
{
return $this->title;
return $this->type()->append('/')->append($this->filename);
}
public function published(): bool
public function title(): ?string
{
return $this->published;
return $this->meta('title');
}
public function jsonSerialize()
public function jsonSerialize(): array
{
return [
'key' => $this->key,
return array_merge(parent::jsonSerialize(), [
'filename' => $this->filename,
'basename' => $this->basename,
'extension' => $this->extension,
'path' => $this->path,
'path' => $this->path(),
'url' => $this->url(),
'title' => $this->title,
'published' => $this->published,
];
]);
}
}
......@@ -2,81 +2,32 @@
namespace Avris\Esse\Entity;
final class Image implements \JsonSerializable
{
/** @var string */
private $key;
/** @var string */
private $urlBase;
/** @var string */
private $filename;
/** @var string */
private $basename;
/** @var string */
private $extension;
/** @var string|null */
private $alt;
/** @var string|null */
private $source;
public function __construct(string $key, string $urlBase, array $data)
{
$this->key = $key;
$this->urlBase = $urlBase;
$this->filename = $data['filename'];
$dotPos = mb_strrpos($this->filename, '.');
if ($dotPos === false) {
throw new \RuntimeException('Empty image extension is not allowed');
}
$this->basename = mb_substr($this->filename, 0, $dotPos);
$this->extension = mb_strtolower(mb_substr($this->filename, $dotPos + 1));
$this->alt = $data['alt'] ?? null;
$this->source = $data['source'] ?? null;
}
public function key(): string
{
return $this->key;
}
public function filename(): string
{
return $this->filename;
}
use Symfony\Component\String\AbstractString;
class Image extends File implements \JsonSerializable
{
public function alt(): ?string
{
return $this->alt;
return $this->meta('alt');
}
public function source(): ?string
{
return $this->source;
return $this->meta('source');
}
public function url(string $size): string
public function urlForSize(string $size): AbstractString
{
return sprintf('%s/image/%s_%s.%s', $this->urlBase, $this->basename, $size, $this->extension);
return $this->type()->append('/')
->append($this->filename()->beforeLast('.'))
->append('_')->append($size)
->append('.')->append($this->extension());
}
public function jsonSerialize()
public function jsonSerialize(): array
{
return [
'key' => $this->key,
'filename' => $this->filename,
'basename' => $this->basename,
'extension' => $this->extension,
'alt' => $this->alt,
'source' => $this->source,
'url' => $this->url('<size>'),
];
return array_merge(parent::jsonSerialize(), [
'url' => $this->urlForSize('<size>'),
]);
}
}
......@@ -6,5 +6,5 @@ interface EsseIndex
{
public function id(): string;
public function build(iterable $files) : array;
public function build(iterable $rawFiles) : array;
}
......@@ -3,14 +3,8 @@
namespace Avris\Esse\Interfaces;
use Avris\Esse\Entity\Entry;
use Avris\Esse\Entity\File;
use Avris\Esse\Entity\Image;
interface EsseModifier
{
public function modifyEntry(Entry $entry): ?Entry;
public function modifyImage(Image $image): ?Image;
public function modifyFile(File $file): ?File;
}
......@@ -2,7 +2,9 @@ services:
Avris\Esse\Service\Esse:
arguments:
$namespace: '%kernel.project_dir%'
$contentDir: '%kernel.project_dir%/content'
$entriesDir: '%kernel.project_dir%/content/entries'
$imagesDir: '%kernel.project_dir%/content/images'
$filesDir: '%kernel.project_dir%/content/files'
$imageSizes: []
$env: '%kernel.environment%'
$cache: '@Symfony\Contracts\Cache\CacheInterface'
......@@ -10,6 +12,10 @@ services:
$indexes: !tagged esse.index
$requestStack: '@Symfony\Component\HttpFoundation\RequestStack'
$fallbackLocale: '%locale%'
Avris\Esse\Service\EsseTwigExtension:
arguments:
$esse: '@Avris\Esse\Service\Esse'
tags: ['twig.extension']
Avris\Esse\Controller\EsseController:
......
This diff is collapsed.
<?php
namespace Avris\Esse\Service;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class EsseTwigExtension extends AbstractExtension
{
private Esse $esse;
public function __construct(Esse $esse)
{
$this->esse = $esse;
}
public function getFunctions()
{
return [
new TwigFunction('esse', [$this->esse, 'getPart'], ['is_safe' => ['html']]),
];
}
}
Markdown is supported
0% or