Move all files to old/drupal/

This commit is contained in:
Oliver Davies 2025-10-02 07:57:25 +01:00
parent 8203e983d5
commit 7d76bf2968
468 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,8 @@
opdavies_blog.settings:
type: config_object
label: 'Blog module configuration'
mapping:
integromat_webhook_url:
type: string
post_tweet_webhook_url:
type: string

View file

@ -0,0 +1,8 @@
name: Oliver Davies blog
type: module
core_version_requirement: ^8 || ^9
package: Custom
dependencies:
- drupal:node
- hook_event_dispatcher:hook_event_dispatcher
- paragraphs:paragraphs

View file

@ -0,0 +1,22 @@
<?php
/**
* @file
* Install, update and uninstall functions for opdavies_blog.
*/
declare(strict_types=1);
use Drupal\opdavies_blog\Repository\PostRepository;
/**
* Mark existing blog posts as sent to social media.
*/
function opdavies_blog_update_8001(): void {
$posts = \Drupal::service(PostRepository::class)->getAll();
foreach ($posts as $post) {
$post->markAsSentToSocialMedia();
$post->save();
}
}

View file

@ -0,0 +1,49 @@
<?php
/**
* @file
* Custom blog code.
*/
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
/**
* Implements hook_node_links_alter().
*/
function opdavies_blog_node_links_alter(array &$links, NodeInterface $node): void {
if (!method_exists($node, 'getExternalLink')) {
return;
}
if ($link = $node->getExternalLink()) {
$links['node']['#links']['node-readmore']['url'] = Url::fromUri($link['uri']);
$links['node']['#links']['node-readmore']['title'] = t('Read more<span class="visually-hidden"> about @title</span> (<span class="visually-hidden">on </span>@domain)', [
'@domain' => $link['title'],
'@title' => $node->label(),
]);
}
}
/**
* Implements hook_preprocess_HOOK().
*/
function opdavies_blog_preprocess_block(array &$variables): void {
// Add the 'markup' class to blocks.
if (in_array($variables['plugin_id'], ['views_block:featured_blog_posts-block_1'])) {
$variables['attributes']['class'][] = 'markup';
}
}
/**
* Implements hook_preprocess_HOOK().
*/
function opdavies_blog_preprocess_node(array &$variables): void {
if (!method_exists($variables['node'], 'getExternalLink')) {
return;
}
if ($link = $variables['node']->getExternalLink()) {
$variables['url'] = $link['uri'];
}
}

View file

@ -0,0 +1,15 @@
parameters:
container.autowiring.strict_mode: true
services:
Drupal\Core\Config\ConfigFactoryInterface:
alias: config.factory
Drupal\Core\Entity\EntityTypeManagerInterface:
alias: entity_type.manager
Drupal\Core\Queue\QueueFactory:
alias: queue
GuzzleHttp\ClientInterface:
alias: http_client

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Entity\Node;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\Entity\Term;
use Illuminate\Support\Collection;
final class Post {
public const FIELD_EXTERNAL_LINK = 'field_external_link';
public const FIELD_HAS_TWEET = 'field_has_tweet';
public const FIELD_SEND_TO_SOCIAL_MEDIA = 'field_send_to_social_media';
public const FIELD_SENT_TO_SOCIAL_MEDIA = 'field_sent_to_social_media';
public const FIELD_TAGS = 'field_tags';
private NodeInterface $node;
public function __construct(EntityInterface $node) {
$this->node = $node;
}
public function bundle(): string {
return 'post';
}
public function get(string $name): FieldItemListInterface {
return $this->node->get($name);
}
public function getExternalLink(): ?array {
return ($link = $this->get(self::FIELD_EXTERNAL_LINK)->get(0))
? $link->getValue()
: NULL;
}
public function getNode(): NodeInterface {
return $this->node;
}
/**
* @return Collection|Term[]
*/
public function getTags(): Collection {
return new Collection($this->get(self::FIELD_TAGS)->referencedEntities());
}
public function hasBeenSentToSocialMedia(): bool {
return (bool) $this->get(self::FIELD_SENT_TO_SOCIAL_MEDIA)->getString();
}
public function hasTweet(): bool {
return (bool) $this->get(self::FIELD_HAS_TWEET)->getString();
}
public function id(): int {
return (int) $this->node->id();
}
public function isExternalPost(): bool {
return (bool) $this->getExternalLink();
}
public function label(): string {
return $this->node->label();
}
public function markAsSentToSocialMedia(): self {
$this->set(self::FIELD_SENT_TO_SOCIAL_MEDIA, TRUE);
return $this;
}
public function save(): void {
$this->node->save();
}
public function set(string $name, $value): void {
$this->node->set($name, $value);
}
public function setTags(array $tags): void {
$this->set(self::FIELD_TAGS, $tags);
}
public function shouldSendToSocialMedia(): bool {
return (bool) $this->get(self::FIELD_SEND_TO_SOCIAL_MEDIA)->getString();
}
public function url(string $type, array $options = []): string {
return $this->node->url($type, $options);
}
public static function createFromNode(EntityInterface $node): self {
// TODO: ensure that this is a node and a `post` type.
return new self($node);
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\EventSubscriber;
use Drupal\Core\Queue\QueueFactory;
use Drupal\core_event_dispatcher\Event\Entity\AbstractEntityEvent;
use Drupal\hook_event_dispatcher\HookEventDispatcherInterface;
use Drupal\opdavies_blog\Entity\Node\Post;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class PushPostToSocialMediaOnceItIsPublished implements EventSubscriberInterface {
private QueueFactory $queueFactory;
public function __construct(QueueFactory $queueFactory) {
$this->queueFactory = $queueFactory;
}
/**
* @inheritDoc
*/
public static function getSubscribedEvents() {
return [
HookEventDispatcherInterface::ENTITY_INSERT => 'pushPost',
HookEventDispatcherInterface::ENTITY_UPDATE => 'pushPost',
];
}
public function pushPost(AbstractEntityEvent $event): void {
$entity = $event->getEntity();
if ($entity->getEntityTypeId() != 'node') {
return;
}
/** @var Post $entity */
if ($entity->bundle() != 'post') {
return;
}
$queue = $this->queueFactory->get('opdavies_blog.push_post_to_social_media');
$queue->createQueue();
$queue->createItem([
'post' => $entity,
]);
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\EventSubscriber;
use Drupal\core_event_dispatcher\Event\Entity\AbstractEntityEvent;
use Drupal\hook_event_dispatcher\HookEventDispatcherInterface;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\taxonomy\TermInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class SortTagsAlphabeticallyWhenPostIsSaved implements EventSubscriberInterface {
/**
* @inheritDoc
*/
public static function getSubscribedEvents() {
return [
HookEventDispatcherInterface::ENTITY_PRE_SAVE => 'sortTags',
];
}
public function sortTags(AbstractEntityEvent $event): void {
$entity = $event->getEntity();
if ($entity->getEntityTypeId() != 'node') {
return;
}
if ($entity->bundle() != 'post') {
return;
}
$post = Post::createFromNode($entity);
$sortedTags = $post->getTags()
->sortBy(fn(TermInterface $tag) => $tag->label());
$post->setTags($sortedTags->toArray());
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\Finder\Finder;
final class OpdaviesBlogServiceProvider implements ServiceProviderInterface {
public function register(ContainerBuilder $container): void {
foreach (['EventSubscriber', 'Repository', 'Service', 'UseCase'] as $directory) {
$files = Finder::create()
->in(__DIR__ . '/' . $directory)
->files()
->name('*.php');
foreach ($files as $file) {
$class = 'Drupal\opdavies_blog\\' . $directory . '\\' .
str_replace('/', '\\', substr($file->getRelativePathname(), 0, -4));
if ($container->hasDefinition($class)) {
continue;
}
$definition = new Definition($class);
$definition->setAutowired(TRUE);
if ($directory == 'EventSubscriber') {
$definition->addTag('event_subscriber');
}
$container->setDefinition($class, $definition);
}
}
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace Drupal\opdavies_blog\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Link;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Routing\CurrentRouteMatch;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\opdavies_blog\Repository\RelatedPostsRepository;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Illuminate\Support\Collection;
/**
* @Block(
* id = "opdavies_blog_related_posts",
* admin_label = @Translation("Related Posts"),
* category = @Translation("Blog")
* )
*/
class RelatedPostsBlock extends BlockBase implements ContainerFactoryPluginInterface {
private CurrentRouteMatch $currentRouteMatch;
private RelatedPostsRepository $relatedPostsRepository;
public function __construct(
array $configuration,
string $pluginId,
array $pluginDefinition,
CurrentRouteMatch $currentRouteMatch,
RelatedPostsRepository $relatedPostsRepository
) {
parent::__construct($configuration, $pluginId, $pluginDefinition);
$this->currentRouteMatch = $currentRouteMatch;
$this->relatedPostsRepository = $relatedPostsRepository;
}
public static function create(
ContainerInterface $container,
array $configuration,
$pluginId,
$pluginDefinition
): self {
return new self(
$configuration,
$pluginId,
$pluginDefinition,
$container->get('current_route_match'),
$container->get(RelatedPostsRepository::class)
);
}
public function build(): array {
$currentPost = $this->currentRouteMatch->getParameter('node');
/** @var Collection|Post[] $relatedPosts */
$relatedPosts = $this->relatedPostsRepository->getFor($currentPost);
if ($relatedPosts->isEmpty()) {
return [];
}
$build['content'] = [
'#items' => $relatedPosts
->sortByDesc(fn(Post $post) => $post->getNode()->getCreatedTime())
->map(fn(Post $post) => $this->generateLinkToPost($post))
->slice(0, 3)
->toArray(),
'#theme' => 'item_list',
];
return $build;
}
public function getCacheMaxAge(): int {
return 604800;
}
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
}
public function getCacheTags(): array {
/** @var Post $post */
$post = $this->currentRouteMatch->getParameter('node');
return Cache::mergeTags(parent::getCacheTags(), ["node:{$post->id()}"]);
}
private function generateLinkToPost(Post $post): Link {
return Link::createFromRoute(
$post->label(),
'entity.node.canonical',
['node' => $post->id()]
);
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Plugin\QueueWorker;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\opdavies_blog\Service\PostPusher\IftttPostPusher;
use Drupal\opdavies_blog\Service\PostPusher\IntegromatPostPusher;
use Drupal\opdavies_blog\Service\PostPusher\PostPusher;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @QueueWorker(
* id = "opdavies_blog.push_post_to_social_media",
* title = "Push a blog post to social media",
* cron = {"time": 30}
* )
*/
final class PostPusherQueueWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {
private EntityStorageInterface $nodeStorage;
/**
* @var array|PostPusher[]
*/
private array $postPushers;
public function __construct(
array $configuration,
string $pluginId,
array $pluginDefinition,
EntityStorageInterface $nodeStorage,
array $postPushers
) {
parent::__construct($configuration, $pluginId, $pluginDefinition);
$this->nodeStorage = $nodeStorage;
$this->postPushers = $postPushers;
}
public static function create(
ContainerInterface $container,
array $configuration,
$pluginId,
$pluginDefinition
) {
return new static(
$configuration,
$pluginId,
$pluginDefinition,
$container->get('entity_type.manager')->getStorage('node'),
[
$container->get(IftttPostPusher::class),
$container->get(IntegromatPostPusher::class),
]
);
}
public function processItem($data): void {
/** @var Post $post */
['post' => $post] = $data;
if (!$this->shouldBePushed($post)) {
return;
}
if (!$post->getNode()->isLatestRevision()) {
$node = $this->nodeStorage->load($post->id());
$post = Post::createFromNode($node);
if (!$this->shouldBePushed($post)) {
return;
}
}
foreach ($this->postPushers as $pusher) {
$pusher->push($post);
}
$post->set(Post::FIELD_SENT_TO_SOCIAL_MEDIA, TRUE);
$post->save();
}
private function shouldBePushed(Post $post): bool {
if ($post->isExternalPost()) {
return FALSE;
}
if (!$post->getNode()->isPublished()) {
return FALSE;
}
if (!$post->shouldSendToSocialMedia()) {
return FALSE;
}
if ($post->hasBeenSentToSocialMedia()) {
return FALSE;
}
return TRUE;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Repository;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Illuminate\Support\Collection;
final class PostRepository {
private EntityStorageInterface $nodeStorage;
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->nodeStorage = $entityTypeManager->getStorage('node');
}
public function getAll(): Collection {
return new Collection(
$this->nodeStorage->loadByProperties([
'type' => 'post',
])
);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Repository;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\node\NodeInterface;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\taxonomy\TermInterface;
use Illuminate\Support\Collection;
final class RelatedPostsRepository {
private EntityStorageInterface $nodeStorage;
public function __construct(
EntityTypeManagerInterface $entityTypeManager
) {
$this->nodeStorage = $entityTypeManager->getStorage('node');
}
public function getFor(Post $post): Collection {
$tags = $post->get('field_tags')->referencedEntities();
if (!$tags) {
return new Collection();
}
$tagIds = (new Collection($tags))
->map(fn(TermInterface $tag) => $tag->id())
->values();
/** @var array $postIds */
$postIds = $this->query($post, $tagIds)->execute();
$posts = $this->nodeStorage->loadMultiple($postIds);
return new Collection(array_values($posts));
}
private function query(Post $post, Collection $tagIds): QueryInterface {
$query = $this->nodeStorage->getQuery();
// Ensure that the current node ID is not returned as a related post.
$query->condition('nid', $post->id(), '!=');
// Only return posts with the same tags.
$query->condition('field_tags', $tagIds->toArray(), 'IN');
$query->condition('status', NodeInterface::PUBLISHED);
return $query;
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Service\PostPusher;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\opdavies_blog\UseCase\ConvertPostToTweet;
use GuzzleHttp\ClientInterface;
use Webmozart\Assert\Assert;
final class IftttPostPusher extends WebhookPostPusher {
use StringTranslationTrait;
private ConfigFactoryInterface $configFactory;
public function __construct(
ConvertPostToTweet $convertPostToTweet,
ClientInterface $client,
ConfigFactoryInterface $configFactory
) {
$this->convertPostToTweet = $convertPostToTweet;
$this->configFactory = $configFactory;
parent::__construct($convertPostToTweet, $client);
}
public function push(Post $post): void {
$url = $this->configFactory
->get('opdavies_blog.settings')
->get('post_tweet_webhook_url');
Assert::notNull($url, 'Cannot push the post if there is no URL.');
$this->client->post($url, [
'form_params' => [
'value1' => $this->t('Blogged: @text', ['@text' => ($this->convertPostToTweet)($post)])
->render(),
],
]);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Service\PostPusher;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\opdavies_blog\UseCase\ConvertPostToTweet;
use GuzzleHttp\ClientInterface;
use Webmozart\Assert\Assert;
final class IntegromatPostPusher extends WebhookPostPusher {
use StringTranslationTrait;
private ConfigFactoryInterface $configFactory;
public function __construct(
ConvertPostToTweet $convertPostToTweet,
ClientInterface $client,
ConfigFactoryInterface $configFactory
) {
$this->convertPostToTweet = $convertPostToTweet;
$this->configFactory = $configFactory;
parent::__construct($convertPostToTweet, $client);
}
public function push(Post $post): void {
$url = $this->configFactory
->get('opdavies_blog.settings')
->get('integromat_webhook_url');
Assert::notNull($url, 'Cannot push the post if there is no URL.');
$this->client->post($url, [
'form_params' => [
'text' => $this->t('@text', ['@text' => ($this->convertPostToTweet)($post)])
->render(),
],
]);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Service\PostPusher;
use Drupal\opdavies_blog\Entity\Node\Post;
final class NullPostPusher implements PostPusher {
public function push(Post $post): void {}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Service\PostPusher;
use Drupal\opdavies_blog\Entity\Node\Post;
interface PostPusher {
public function push(Post $post): void;
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\Service\PostPusher;
use Drupal\opdavies_blog\UseCase\ConvertPostToTweet;
use GuzzleHttp\ClientInterface;
abstract class WebhookPostPusher implements PostPusher {
protected ConvertPostToTweet $convertPostToTweet;
protected ClientInterface $client;
public function __construct(
ConvertPostToTweet $convertPostToTweet,
ClientInterface $client
) {
$this->convertPostToTweet = $convertPostToTweet;
$this->client = $client;
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog\UseCase;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\taxonomy\Entity\Term;
use Illuminate\Support\Collection;
final class ConvertPostToTweet {
private Post $post;
public function __invoke(Post $post): string {
$this->post = $post;
$parts = [
$post->label(),
$post->url('canonical', ['absolute' => TRUE]),
$this->convertTermsToHashtags(),
];
return implode(PHP_EOL . PHP_EOL, $parts);
}
private function convertTermsToHashtags(): string {
return $this->post
->getTags()
->filter(fn(Term $term) => !$this->tagsToRemove()
->contains($term->label()))
->map(fn(Term $term) => $this->convertTermToHashtag($term))
->implode(' ');
}
private function tagsToRemove(): Collection {
// TODO: Move these values into configuration/settings.php.
return new Collection([
'Drupal Planet',
]);
}
private function convertTermToHashtag(Term $tag): string {
return '#' . (new Collection(explode(' ', $tag->label())))
->map(fn(string $word): string => ucfirst($word))
->implode('');
}
}

View file

@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_external_link
- node.type.post
module:
- link
id: node.post.field_external_link
field_name: field_external_link
entity_type: node
bundle: post
label: 'External link'
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
link_type: 16
title: 2
field_type: link

View file

@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_has_tweet
- node.type.post
id: node.post.field_has_tweet
field_name: field_has_tweet
entity_type: node
bundle: post
label: 'Has tweet'
description: 'Check to include Twitter''s widget.js script for this page.'
required: false
translatable: false
default_value:
-
value: 0
default_value_callback: ''
settings:
on_label: 'Yes'
off_label: 'No'
field_type: boolean

View file

@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_send_to_social_media
- node.type.post
id: node.post.field_send_to_social_media
field_name: field_send_to_social_media
entity_type: node
bundle: post
label: 'Send to social media'
description: 'Automatically send this post to Twitter and LinkedIn.'
required: false
translatable: false
default_value:
-
value: 1
default_value_callback: ''
settings:
on_label: 'On'
off_label: 'Off'
field_type: boolean

View file

@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_sent_to_social_media
- node.type.post
id: node.post.field_sent_to_social_media
field_name: field_sent_to_social_media
entity_type: node
bundle: post
label: 'Sent to social media'
description: ''
required: false
translatable: false
default_value:
-
value: 0
default_value_callback: ''
settings:
on_label: 'On'
off_label: 'Off'
field_type: boolean

View file

@ -0,0 +1,28 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_tags
- node.type.post
- taxonomy.vocabulary.tags
id: node.post.field_tags
field_name: field_tags
entity_type: node
bundle: post
label: Tags
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
handler: 'default:taxonomy_term'
handler_settings:
target_bundles:
tags: tags
sort:
field: name
direction: asc
auto_create: true
auto_create_bundle: ''
field_type: entity_reference

View file

@ -0,0 +1,18 @@
langcode: en
status: true
dependencies:
module:
- link
- node
id: node.field_external_link
field_name: field_external_link
entity_type: node
type: link
settings: { }
module: link
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,17 @@
langcode: en
status: true
dependencies:
module:
- node
id: node.field_has_tweet
field_name: field_has_tweet
entity_type: node
type: boolean
settings: { }
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,17 @@
langcode: en
status: true
dependencies:
module:
- node
id: node.field_send_to_social_media
field_name: field_send_to_social_media
entity_type: node
type: boolean
settings: { }
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,17 @@
langcode: en
status: true
dependencies:
module:
- node
id: node.field_sent_to_social_media
field_name: field_sent_to_social_media
entity_type: node
type: boolean
settings: { }
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,19 @@
langcode: en
status: true
dependencies:
module:
- node
- taxonomy
id: node.field_tags
field_name: field_tags
entity_type: node
type: entity_reference
settings:
target_type: taxonomy_term
module: core
locked: false
cardinality: -1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,11 @@
langcode: en
status: true
dependencies: { }
third_party_settings: { }
name: 'Blog post'
type: post
description: 'A single blog post.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: true

View file

@ -0,0 +1,7 @@
langcode: en
status: true
dependencies: { }
name: Tags
vid: tags
description: 'Tags for categorising blog posts.'
weight: 0

View file

@ -0,0 +1,4 @@
name: Oliver Davies Posts Test
type: module
core_version_requirement: ^8 || ^9
hidden: true

View file

@ -0,0 +1,9 @@
parameters:
container.autowiring.strict_mode: true
services:
Drupal\Core\Entity\EntityTypeManagerInterface:
alias: entity_type.manager
Drupal\opdavies_blog\Service\PostPusher\PostPusher:
class: Drupal\opdavies_blog\Service\PostPusher\NullPostPusher

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog_test\Factory;
use Assert\Assert;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\node\Entity\Node;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\TermInterface;
use Illuminate\Support\Collection;
final class PostFactory {
private EntityStorageInterface $termStorage;
private Collection $tags;
private string $title = 'This is a test blog post';
public function __construct(
EntityTypeManagerInterface $entityTypeManager
) {
$this->termStorage = $entityTypeManager->getStorage('taxonomy_term');
$this->tags = new Collection();
}
public function create(array $overrides = []): Post {
$this->tags->each(function (TermInterface $tag): void {
Assert::that($tag->bundle())->same('tags');
});
$values = [
'title' => $this->title,
'type' => 'post',
Post::FIELD_TAGS => $this->tags->toArray(),
];
$post = Node::create($values + $overrides);
return Post::createFromNode($post);
}
public function setTitle(string $title): self {
Assert::that($title)->notEmpty();
$this->title = $title;
return $this;
}
public function withTags(array $tags): self {
$this->tags = new Collection();
foreach ($tags as $tag) {
Assert::that($tag)->notEmpty()->string();
$this->tags->push($this->createOrReferenceTag($tag));
}
return $this;
}
private function createOrReferenceTag(string $tag): EntityInterface {
$existingTags = $this->termStorage->loadByProperties(['name' => $tag]);
if ($existingTags) {
return reset($existingTags);
}
return Term::create(['vid' => 'tags', 'name' => $tag]);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_blog_test;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\Finder\Finder;
final class OpdaviesBlogTestServiceProvider implements ServiceProviderInterface {
public function register(ContainerBuilder $container): void {
foreach (['Factory'] as $directory) {
$files = Finder::create()
->in(__DIR__ . '/' . $directory)
->files()
->name('*.php');
foreach ($files as $file) {
$class = 'Drupal\opdavies_blog_test\\' . $directory . '\\' .
str_replace('/', '\\', substr($file->getRelativePathname(), 0, -4));
if ($container->hasDefinition($class)) {
continue;
}
$definition = new Definition($class);
$definition->setAutowired(TRUE);
$container->setDefinition($class, $definition);
}
}
}
}

View file

@ -0,0 +1,49 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_blog\Kernel\Entity\Node;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\opdavies_blog_test\Factory\PostFactory;
final class PostTest extends EntityKernelTestBase {
public static $modules = [
// Core.
'node',
'link',
'taxonomy',
// Custom.
'opdavies_blog',
'opdavies_blog_test',
];
private PostFactory $postFactory;
/** @test */
public function it_can_determine_if_a_post_contains_a_tweet(): void {
$post = $this->postFactory->create();
$post->save();
$this->assertFalse($post->hasTweet());
$post = $this->postFactory->create([Post::FIELD_HAS_TWEET => TRUE]);
$post->save();
$this->assertTrue($post->hasTweet());
}
protected function setUp() {
parent::setUp();
$this->postFactory = $this->container->get(PostFactory::class);
$this->installEntitySchema('taxonomy_term');
$this->installConfig(['opdavies_blog_test']);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Drupal\Tests\opdavies_blog\Kernel;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
abstract class PostTestBase extends EntityKernelTestBase {
use NodeCreationTrait;
use TaxonomyTestTrait;
public static $modules = [
// Core.
'node',
'taxonomy',
'link',
// Contrib.
'hook_event_dispatcher',
'core_event_dispatcher',
// Custom.
'opdavies_blog_test',
'opdavies_blog',
];
protected function setUp() {
parent::setUp();
$this->installConfig([
'filter',
'opdavies_blog_test',
]);
$this->installEntitySchema('taxonomy_vocabulary');
$this->installEntitySchema('taxonomy_term');
}
}

View file

@ -0,0 +1,77 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_blog\Kernel;
use Drupal\Core\Queue\QueueInterface;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\node\NodeInterface;
use Drupal\opdavies_blog\Entity\Node\Post;
final class PushToSocialMediaTest extends EntityKernelTestBase {
use NodeCreationTrait;
public static $modules = [
// Core.
'node',
'taxonomy',
'link',
// Contrib.
'hook_event_dispatcher',
'core_event_dispatcher',
// Custom.
'opdavies_blog',
'opdavies_blog_test',
];
private QueueInterface $queue;
/** @test */
public function it_queues_a_post_when_it_is_created(): void {
$this->assertSame(0, $this->queue->numberOfItems());
$this->createNode([
'title' => 'Ignoring PHPCS sniffs within PHPUnit tests',
'type' => 'post',
]);
$this->assertSame(1, $this->queue->numberOfItems());
$item = $this->queue->claimItem();
/** @var Post $post */
$post = $item->data['post'];
$this->assertNotNull($post);
$this->assertInstanceOf(NodeInterface::class, $post);
$this->assertSame('post', $post->bundle());
$this->assertSame('Ignoring PHPCS sniffs within PHPUnit tests', $post->getTitle());
}
/** @test */
public function it_queues_a_post_when_it_is_updated(): void {
$this->markTestSkipped();
}
/** @test */
public function it_pushes_a_post_when_the_queue_is_processed(): void {
$this->markTestSkipped();
}
protected function setUp() {
parent::setUp();
$this->installConfig(['filter', 'opdavies_blog_test']);
$this->installSchema('node', ['node_access']);
$this->queue = $this->container->get('queue')
->get('opdavies_blog.push_post_to_social_media');
}
}

View file

@ -0,0 +1,67 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_blog\Kernel;
use Drupal\opdavies_blog\Repository\RelatedPostsRepository;
use Drupal\opdavies_blog_test\Factory\PostFactory;
final class RelatedPostsTest extends PostTestBase {
private PostFactory $postFactory;
private RelatedPostsRepository $relatedPostsRepository;
/** @test */
public function it_returns_related_posts(): void {
$postA = $this->postFactory
->setTitle('Post A')
->withTags(['Drupal 8'])
->create();
$postA->save();
$postB = $this->postFactory
->setTitle('Post B')
->withTags(['Drupal 8'])
->create();
$postB->save();
$relatedPosts = $this->relatedPostsRepository->getFor($postA);
$this->assertCount(1, $relatedPosts);
$this->assertSame('Post B', $relatedPosts->first()->label());
}
/** @test */
public function unpublished_posts_are_not_returned(): void {
$this->markTestSkipped();
}
/** @test */
public function it_returns_an_empty_collection_if_there_are_no_related_posts(): void {
$postA = $this->postFactory
->setTitle('Drupal 8 post')
->withTags(['Drupal 8'])
->create();
$postA->save();
$postB = $this->postFactory
->setTitle('Drupal 9 post')
->withTags(['Drupal 9'])
->create();
$postB->save();
$relatedPosts = $this->relatedPostsRepository->getFor($postA);
$this->assertEmpty($relatedPosts);
}
protected function setUp() {
parent::setUp();
$this->postFactory = $this->container->get(PostFactory::class);
$this->relatedPostsRepository = $this->container->get(RelatedPostsRepository::class);
}
}

View file

@ -0,0 +1,41 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_blog\Kernel;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
use Drupal\opdavies_blog\Entity\Node\Post;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\taxonomy\TermInterface;
use Drupal\taxonomy\VocabularyInterface;
final class ReorderBlogTagsTest extends PostTestBase {
/** @test */
public function it_reorders_tags_on_blog_posts_to_be_arranged_alphabetically(): void {
/** @var VocabularyInterface $vocabulary */
$vocabulary = Vocabulary::load('tags');
$this->createTerm($vocabulary, ['name' => 'Drupal']);
$this->createTerm($vocabulary, ['name' => 'PHP']);
$this->createTerm($vocabulary, ['name' => 'Symfony']);
$post = $this->createNode([
'type' => 'post',
Post::FIELD_TAGS => [3, 1, 2],
]);
$node = Node::load($post->id());
$post = Post::createFromNode($node);
$this->assertSame(
['Drupal', 'PHP', 'Symfony'],
$post->getTags()
->map(fn(TermInterface $tag) => $tag->label())
->toArray()
);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Drupal\Tests\opdavies_blog\Kernel\UseCase;
use Drupal\opdavies_blog\UseCase\ConvertPostToTweet;
use Drupal\opdavies_blog_test\Factory\PostFactory;
use Drupal\Tests\opdavies_blog\Kernel\PostTestBase;
final class ConvertPostToTweetTest extends PostTestBase {
private ConvertPostToTweet $convertPostToTweet;
public function testConvertPostToTweet(): void {
$post = $this->postFactory
->setTitle('Creating a custom PHPUnit command for DDEV')
->withTags(['Automated testing', 'DDEV', 'Drupal', 'Drupal 8', 'PHP'])
->create();
$post->save();
$expected = <<<EOF
Creating a custom PHPUnit command for DDEV
http://localhost/node/1
#AutomatedTesting #DDEV #Drupal #Drupal8 #PHP
EOF;
$this->assertSame($expected, ($this->convertPostToTweet)($post));
}
public function testCertainTermsAreNotAddedAsHashtags(): void {
$post = $this->postFactory
->setTitle('Drupal Planet should not be added as a hashtag')
->withTags(['Drupal', 'Drupal Planet', 'PHP'])
->create();
$post->save();
$expected = <<<EOF
Drupal Planet should not be added as a hashtag
http://localhost/node/1
#Drupal #PHP
EOF;
$this->assertSame($expected, ($this->convertPostToTweet)($post));
}
public function testSomeTagsAreNotAutomaticallyCapitalised(): void {
$this->markTestSkipped();
}
protected function setUp() {
parent::setUp();
$this->convertPostToTweet = $this->container->get(ConvertPostToTweet::class);
$this->postFactory = $this->container->get(PostFactory::class);
}
}