diff --git a/.github/workflows/build_static.yaml b/.github/workflows/build_static.yaml
index 47b95009..54365f6c 100644
--- a/.github/workflows/build_static.yaml
+++ b/.github/workflows/build_static.yaml
@@ -1,6 +1,7 @@
name: 'Build static'
on:
+ workflow_dispatch: ~
push:
branches:
- master
diff --git a/README.md b/README.md
index fe3ae6c3..1a367f72 100644
--- a/README.md
+++ b/README.md
@@ -70,5 +70,6 @@ Stenope is not a ready-to-use bloging system: but you could quickly _write your
-- [Data manipulation: writing a custom Processor](doc/processors.md)
+- [Lazy / Micro Stenope](doc/cookbooks/lazy-stenope.md)
+- [Data manipulation: writing a custom Processor](doc/cookbooks/processors.md)
diff --git a/config/services.php b/config/services.php
index a024fa37..96a7e4e5 100644
--- a/config/services.php
+++ b/config/services.php
@@ -17,6 +17,7 @@
use Stenope\Bundle\Command\DebugCommand;
use Stenope\Bundle\ContentManager;
use Stenope\Bundle\ContentManagerInterface;
+use Stenope\Bundle\Controller\GenericContentController;
use Stenope\Bundle\Decoder\HtmlDecoder;
use Stenope\Bundle\Decoder\MarkdownDecoder;
use Stenope\Bundle\DependencyInjection\tags;
@@ -29,6 +30,7 @@
use Stenope\Bundle\Processor\AssetsProcessor;
use Stenope\Bundle\Processor\CodeHighlightProcessor;
use Stenope\Bundle\Processor\ExtractTitleFromHtmlContentProcessor;
+use Stenope\Bundle\Processor\GenericContentTypesProcessor;
use Stenope\Bundle\Processor\HtmlAnchorProcessor;
use Stenope\Bundle\Processor\HtmlExternalLinksProcessor;
use Stenope\Bundle\Processor\HtmlIdProcessor;
@@ -42,6 +44,7 @@
use Stenope\Bundle\Routing\RouteInfoCollection;
use Stenope\Bundle\Routing\UrlGenerator;
use Stenope\Bundle\Serializer\Normalizer\SkippingInstantiatedObjectDenormalizer;
+use Stenope\Bundle\Serializer\Normalizer\StdClassDenormalizer;
use Stenope\Bundle\Service\AssetUtils;
use Stenope\Bundle\Service\Git\LastModifiedFetcher;
use Stenope\Bundle\Service\NaiveHtmlCrawlerManager;
@@ -53,6 +56,7 @@
use Symfony\Component\Asset\Packages;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Mime\MimeTypesInterface;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\String\Slugger\SluggerInterface;
@@ -142,6 +146,9 @@
// Serializer
->set(SkippingInstantiatedObjectDenormalizer::class)->tag('serializer.normalizer')
+ ->set(StdClassDenormalizer::class)->args([
+ service(PropertyAccessorInterface::class),
+ ])->tag('serializer.normalizer')
// Decoders
->set(MarkdownDecoder::class)
@@ -188,7 +195,11 @@
// HTML Crawler Manager
->set(NaiveHtmlCrawlerManager::class)
->set(SharedHtmlCrawlerManager::class)
- ->alias(HtmlCrawlerManagerInterface::class, NaiveHtmlCrawlerManager::class);
+ ->alias(HtmlCrawlerManagerInterface::class, NaiveHtmlCrawlerManager::class)
+
+ // Controller
+ ->set(GenericContentController::class)->public()->autowire()->autoconfigure()
+ ;
// Tagged processors:
$container->services()->defaults()->tag(tags\content_processor)
@@ -199,6 +210,7 @@
'$logger' => service(LoggerInterface::class)->nullOnInvalid(),
]),
])
+ ->set(GenericContentTypesProcessor::class)
->set(SlugProcessor::class)
->set(HtmlIdProcessor::class)
->args([
diff --git a/doc/app/src/Controller/DocController.php b/doc/app/src/Controller/DocController.php
index 0decc660..39e71ddf 100644
--- a/doc/app/src/Controller/DocController.php
+++ b/doc/app/src/Controller/DocController.php
@@ -28,7 +28,9 @@ public function index()
}
/**
- * @Route("/{page}", name="page")
+ * @Route("/{page}", name="page", requirements={
+ * "page": ".+[^/]$",
+ * })
*/
public function page(Page $page)
{
diff --git a/doc/cookbooks/lazy-stenope.md b/doc/cookbooks/lazy-stenope.md
new file mode 100644
index 00000000..c5239814
--- /dev/null
+++ b/doc/cookbooks/lazy-stenope.md
@@ -0,0 +1,232 @@
+# Lazy Stenope
+
+Lazy or Micro Stenope is a lightweight opinionated alternative on how to create
+a Symfony application for generating a static site, with minimal knowledge, code
+and requirements.
+
+Most notably, for basic usages, it's only required to know about Twig
+(templating), but **you don't need to write custom code to serve your content**.
+Start right away by writing the content in the format you like, write the
+templates to render them and map the pages together, so it'll be dumped and
+accessible from the static generated version.
+
+It makes Stenope an alternative comparable to some other generation tools like
+Hugo, **where you only need to write content and templates**, while still being
+able to feel at home and tweak your app with your Symfony's knowledge if needed.
+
+## How to start
+
+Right after creating a Symfony app and requiring Stenope using Composer,
+configure the Lazy Stenope stack in your `src/Kernel.php`:
+
+```diff
+configureStenope($container);
+ }
+
+ protected function configureRoutes(RoutingConfigurator $routes): void
+ {
+ // [...]
++ $this->configureStenopeRoutes($routes);
+ }
+}
+
+```
+
+It'll register two routes for showing and listing contents:
+
+| Route | Path (default) | Description |
+| - | - | - |
+| `stenope_show` | `/{slug}` | Show a specific content |
+| `stenope_list` | `/{type}/list` | List all contents of a given type and sub-types |
+
+as well as preconfiguring Stenope to load contents from a `content` directory on
+your filesystem.
+The equivalent config is:
+
+```yaml
+stenope:
+ providers:
+ Stenope\Bundle\Content\GenericContent: '%kernel.project_dir%/content'
+ resolve_links:
+ Stenope\Bundle\Content\GenericContent: { route: stenope_show, slug: slug }
+```
+
+Since your application would need an entrypoint, you can create a basic home
+route and template using:
+
+```yaml
+# config/routes.yaml
+home:
+ path: /
+ controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
+ defaults:
+ template: home.html.twig
+```
+
+and the following templates structures:
+
+```treeview
+templates/
+├── base.html.twig # base layout for your site
+└── home.html.twig
+```
+
+## Writing contents
+
+You can write content in [any supported format](../supported-formats.md), with
+any data inside ; on the contrary of the "full-stack" Stenope approach, there is
+no truly enforced structured model to which will be mapped your content.
+Instead, a `GenericContent` is used and accepts any property.
+Any structured data you write in your content is mapped to a property defined at
+runtime on this content object.
+
+E.g, such content:
+
+```md
+---
+title: "Sunt amicitiaes desiderium ferox, placidus liberies."
+date: 2021-09-10
+draft: true
+---
+
+Always cosmically develop the wonderful sinner.
+```
+
+will instantiate a `GenericContent` object with `title`, `date` and `draft`
+properties from the markdown header metadata. Specific to
+the [markdown format](../supported-formats.md#markdown), the `content` property
+is created as well, using the main file content.
+
+Additionally, it always defines these 3 properties:
+
+
+| property | description |
+| - | - |
+| `slug` | the content identifier, i.e: its file path. |
+| `type` | the content main type, i.e: its folder (e.g: a `users/john.md` content is of type `users`) |
+| `types` | the content types, i.e: its folder hierarchy (e.g: a `users/legacy/sarah.md` content has types `users/legacy` and `users`) |
+
+## Writing templates
+
+Lazy-Stenope discovers the template files to use for your contents by
+convention.
+
+### Show templates
+
+At first, it'll attempt to find a template matching the exact directory
+structure of your content, so a `users/john.md` content could be rendered using
+a `templates/users/john.html.twig` template.
+
+If this template is not found, it'll attempt to look at the content type(s) and
+search for a `show.html.twig` template. This way, you can use a same template to
+render similar types of contents, based on the directory structure and
+organization of your contents.
+
+Eventually, if no matching template is found so far, it'll make a last attempt
+to load a generic `templates/stenope/show.html.twig` template.
+
+You can also explicit the template to use for a content using the `template`
+property:
+
+```md
+---
+template: 'other/an-explicit-template.html.twig'
+title: "Sunt amicitiaes desiderium ferox, placidus liberies."
+---
+
+Always cosmically develop the wonderful sinner.
+```
+
+To sum up, it'll attempt to load a template in the following order:
+
+1. Explicit template path from the `template` property
+1. `templates/{slug}.html.twig`
+1. `templates/{type}/show.html.twig`
+1. `templates/{parentType}/show.html.twig`
+1. `templates/stenope/show.html.twig`
+
+#### Available variables
+
+The following variables are exposed to the template:
+
+| variable | description |
+| - | - |
+| `content` | the `GenericContent` object instantiated from your content file |
+
+#### Generate a link
+
+You can generate a link to a content using its slug and:
+
+```twig
+John's profile
+```
+
+### List templates
+
+Contents inside a directory structure have one or more types registered, which
+you can use to categorize and differentiate some common types of contents (e.g:
+a `users/legacy/sarah.md` content has types `users/legacy` and `users`).
+
+Typed contents can be listed on a page by rendering
+a `templates/{type}/list.html.twig`
+template, so contents inside a `users` directory could be rendered using
+a `templates/users/list.html.twig` template.
+
+Eventually, if no matching template is found so far, it'll make a last attempt
+to load a generic `templates/stenope/show.html.twig` template.
+
+To sum up, it'll attempt to load a template in the following order:
+
+1. `templates/{type}/list.html.twig`
+1. `templates/stenope/show.html.twig`
+
+#### Available variables
+
+The following variables are exposed to the template:
+
+| variable | description |
+| - | - |
+| `type` | current type in the URL |
+| `types` | the types hierarchy for the type in the URL |
+| `contents` | the objects instantiated from your content files, filtered by the type in the URL |
+
+#### Generate a link
+
+You can generate a link to a content using its slug and:
+
+```twig
+List users
+```
+
+### Template directory structure reference
+
+```treeview
+templates/
+├── base.html.twig
+├── projects.html.twig ➜ renders the `projects.{yaml,json,md,…}` content
+├── home.html.twig
+├── stenope/
+│ ├── list.html.twig ➜ renders a listing for a type with no matching list template
+│ └── show.html.twig ➜ render a content without matching template
+└── users/
+ ├── legacy/
+ │ ├── list.html.twig ➜ renders a listing for a content from the `users/legacy` directory
+ │ └── show.html.twig ➜ renders a content from the `users/legacy` directory
+ ├── list.html.twig ➜ renders a listing for a content from the `users` directory
+ └── show.html.twig ➜ renders a content from the `users` directory
+```
diff --git a/doc/processors.md b/doc/cookbooks/processors.md
similarity index 100%
rename from doc/processors.md
rename to doc/cookbooks/processors.md
diff --git a/src/Content/GenericContent.php b/src/Content/GenericContent.php
new file mode 100644
index 00000000..4feb650d
--- /dev/null
+++ b/src/Content/GenericContent.php
@@ -0,0 +1,32 @@
+
+ */
+
+namespace Stenope\Bundle\Content;
+
+class GenericContent extends \stdClass
+{
+ public string $slug;
+ public ?string $template = null;
+ public ?string $type = null;
+ /** @var string[] */
+ public array $types = [];
+
+ public static function expandTypes(string $type): array
+ {
+ $types = [$base = $type];
+
+ while (true) {
+ if ('.' === $base = \dirname($base)) {
+ break;
+ }
+ $types[] = $base;
+ }
+
+ return $types;
+ }
+}
diff --git a/src/ContentManager.php b/src/ContentManager.php
index 2ee57689..dacb8be7 100644
--- a/src/ContentManager.php
+++ b/src/ContentManager.php
@@ -28,6 +28,8 @@
class ContentManager implements ContentManagerInterface
{
+ public const STENOPE_CONTEXT = 'stenope_context';
+
private DecoderInterface $decoder;
private DenormalizerInterface $denormalizer;
private PropertyAccessorInterface $propertyAccessor;
@@ -229,6 +231,7 @@ private function load(Content $content)
$this->crawlers->saveAll($content, $data);
$data = $this->denormalizer->denormalize($data, $content->getType(), $content->getFormat(), [
+ self::STENOPE_CONTEXT => true,
SkippingInstantiatedObjectDenormalizer::SKIP => true,
]);
diff --git a/src/Controller/GenericContentController.php b/src/Controller/GenericContentController.php
new file mode 100644
index 00000000..62250234
--- /dev/null
+++ b/src/Controller/GenericContentController.php
@@ -0,0 +1,109 @@
+
+ */
+
+namespace Stenope\Bundle\Controller;
+
+use Stenope\Bundle\Content\GenericContent;
+use Stenope\Bundle\ContentManagerInterface;
+use Stenope\Bundle\Exception\ContentNotFoundException;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Twig\Environment;
+
+class GenericContentController extends AbstractController
+{
+ private ContentManagerInterface $contentManager;
+ private Environment $twig;
+
+ public function __construct(ContentManagerInterface $contentManager, Environment $twig)
+ {
+ $this->contentManager = $contentManager;
+ $this->twig = $twig;
+ }
+
+ public function list(string $type)
+ {
+ $contents = $this->contentManager->getContents(
+ GenericContent::class,
+ 'slug',
+ sprintf('"%s" in _.types', $type)
+ );
+
+ if (empty($contents)) {
+ throw new NotFoundHttpException(sprintf('No content found for type "%s"', $type));
+ }
+
+ return $this->render($this->getListTemplate($type), [
+ 'contents' => $contents,
+ 'type' => $type,
+ 'types' => GenericContent::expandTypes($type),
+ ]);
+ }
+
+ public function show(string $slug)
+ {
+ try {
+ $content = $this->contentManager->getContent(GenericContent::class, $slug);
+ } catch (ContentNotFoundException $exception) {
+ throw new NotFoundHttpException(sprintf('No content found for slug "%s"', $slug));
+ }
+
+ return $this->render($this->getShowTemplate($content), [
+ 'content' => $content,
+ ]);
+ }
+
+ private function getListTemplate(string $type): string
+ {
+ $attempts = [];
+
+ foreach (GenericContent::expandTypes($type) as $previousType) {
+ if ($this->twig->getLoader()->exists($attempts[] = $template = "$previousType/list.html.twig")) {
+ return $template;
+ }
+ }
+
+ if ($this->twig->getLoader()->exists($attempts[] = $template = 'stenope/list.html.twig')) {
+ return $template;
+ }
+
+ throw new \LogicException(sprintf(
+ 'No template available to render the "%s" type of contents. Attempted %s. Please create one of these.',
+ $type,
+ json_encode($attempts, JSON_UNESCAPED_SLASHES)
+ ));
+ }
+
+ private function getShowTemplate(GenericContent $content): string
+ {
+ if ($content->template) {
+ return $content->template;
+ }
+
+ $attempts = [];
+ if ($this->twig->getLoader()->exists($attempts[] = $template = "$content->slug.html.twig")) {
+ return $template;
+ }
+
+ foreach ($content->types as $type) {
+ if ($content->type && $this->twig->getLoader()->exists($attempts[] = $template = "$type/show.html.twig")) {
+ return $template;
+ }
+ }
+
+ if ($this->twig->getLoader()->exists($attempts[] = $template = 'stenope/show.html.twig')) {
+ return $template;
+ }
+
+ throw new \LogicException(sprintf(
+ 'No template available to render the "%s" content. Attempted %s. Please create one of these, or provide explicitly the template to use with the "template" property.',
+ $content->slug,
+ json_encode($attempts, JSON_UNESCAPED_SLASHES)
+ ));
+ }
+}
diff --git a/src/MicroStenopeKernelTrait.php b/src/MicroStenopeKernelTrait.php
new file mode 100644
index 00000000..4e638a8f
--- /dev/null
+++ b/src/MicroStenopeKernelTrait.php
@@ -0,0 +1,52 @@
+
+ */
+
+namespace Stenope\Bundle;
+
+use Stenope\Bundle\Content\GenericContent;
+use Stenope\Bundle\Controller\GenericContentController;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
+
+trait MicroStenopeKernelTrait
+{
+ protected function configureStenope(ContainerConfigurator $container): void
+ {
+ $container->extension('stenope', [
+ 'providers' => [
+ GenericContent::class => '%kernel.project_dir%/content',
+ ],
+ 'resolve_links' => [
+ GenericContent::class => [
+ 'route' => 'stenope_show',
+ 'slug' => 'slug',
+ ],
+ ],
+ ]);
+ }
+
+ protected function configureStenopeRoutes(
+ RoutingConfigurator $routes,
+ string $showPath = '/{slug}',
+ string $listPath = '/{type}/list',
+ string $prefix = ''
+ ): void {
+ $routes
+ ->add('stenope_list', "$prefix/$listPath")
+ ->controller(GenericContentController::class . '::list')
+ ->requirements([
+ 'type' => '.+',
+ ])
+ ->add('stenope_show', "$prefix/$showPath")
+ ->controller(GenericContentController::class . '::show')
+ ->requirements([
+ 'slug' => '.+[^/]$',
+ ])
+ ;
+ }
+}
diff --git a/src/Processor/GenericContentTypesProcessor.php b/src/Processor/GenericContentTypesProcessor.php
new file mode 100644
index 00000000..e951467d
--- /dev/null
+++ b/src/Processor/GenericContentTypesProcessor.php
@@ -0,0 +1,29 @@
+
+ */
+
+namespace Stenope\Bundle\Processor;
+
+use Stenope\Bundle\Behaviour\ProcessorInterface;
+use Stenope\Bundle\Content;
+use Stenope\Bundle\Content\GenericContent;
+
+class GenericContentTypesProcessor implements ProcessorInterface
+{
+ public function __invoke(array &$data, Content $content): void
+ {
+ if (!is_a($content->getType(), GenericContent::class, true)) {
+ return;
+ }
+
+ if (isset($data['type']) || !str_contains($content->getSlug(), '/')) {
+ return;
+ }
+
+ $data['types'] = GenericContent::expandTypes($data['type'] = \dirname($content->getSlug()));
+ }
+}
diff --git a/src/Serializer/Normalizer/StdClassDenormalizer.php b/src/Serializer/Normalizer/StdClassDenormalizer.php
new file mode 100644
index 00000000..c4e87c5b
--- /dev/null
+++ b/src/Serializer/Normalizer/StdClassDenormalizer.php
@@ -0,0 +1,55 @@
+
+ */
+
+namespace Stenope\Bundle\Serializer\Normalizer;
+
+use Stenope\Bundle\ContentManager;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
+
+class StdClassDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface
+{
+ use DenormalizerAwareTrait;
+
+ private const PROCESSING = 'std_class_processing';
+ private PropertyAccessorInterface $propertyAccessor;
+
+ public function __construct(PropertyAccessorInterface $propertyAccessor)
+ {
+ $this->propertyAccessor = $propertyAccessor;
+ }
+
+ public function denormalize($data, string $type, string $format = null, array $context = [])
+ {
+ $context[self::PROCESSING] = true;
+
+ /** @var \stdClass $object */
+ $object = $this->denormalizer->denormalize($data, $type, $format, $context);
+
+ foreach ($data as $key => $value) {
+ if (!$this->propertyAccessor->isReadable($object, $key)) {
+ $object->$key = $value;
+ }
+ }
+
+ return $object;
+ }
+
+ public function supportsDenormalization($data, string $type, string $format = null, array $context = [])
+ {
+ if (isset($context[self::PROCESSING]) || !isset($context[ContentManager::STENOPE_CONTEXT])) {
+ return false;
+ }
+
+ return is_a($type, \stdClass::class, true);
+ }
+}