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); + } +}