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

View file

@ -0,0 +1,5 @@
name: Oliver Davies Recommendations
description: Custom code for recommendations.
type: module
core_version_requirement: ^8 || ^9
package: Oliver Davies

View file

@ -0,0 +1,22 @@
<?php
/**
* @file
* Oliver Davies Recommendations module.
*/
declare(strict_types=1);
/**
* Implements hook_preprocess_image_style().
*/
function opdavies_recommendations_preprocess_image_style(array &$variables): void {
if ($variables['style_name'] == 'recommendation') {
$image = &$variables['image'];
$image['#attributes']['class'][] = 'bg-gray-200';
$image['#attributes']['height'] = 100;
$image['#attributes']['loading'] = 'lazy';
$image['#attributes']['width'] = 100;
}
}

View file

@ -0,0 +1,5 @@
name: Oliver Davies Talks
description: Custom code for talks pages.
type: module
core_version_requirement: ^8 || ^9
package: Custom

View file

@ -0,0 +1,22 @@
<?php
/**
* @file
* Install, update and uninstall functions for opdavies_talks.
*/
declare(strict_types=1);
use Drupal\opdavies_talks\Repository\TalkRepository;
/**
* Set talk type for all existing talks.
*/
function opdavies_talks_update_8001(): void {
$talkRepository = \Drupal::service(TalkRepository::class);
foreach ($talkRepository->getAll() as $talk) {
$talk->set('field_type', 'talk');
$talk->save();
}
}

View file

@ -0,0 +1,74 @@
<?php
/**
* @file
* Custom code for talks pages.
*/
declare(strict_types=1);
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\opdavies_talks\Service\TalkCounter;
use Drupal\opdavies_talks\Service\TalkDateUpdater;
/**
* Implements hook_cron().
*/
function opdavies_talks_cron(): void {
$dateUpdater = Drupal::service(TalkDateUpdater::class);
$dateUpdater->__invoke();
}
/**
* Implements hook_views_data_alter().
*/
function opdavies_talks_views_data_alter(array &$data): void {
$data['node__field_event_date']['event_sort'] = [
'title' => t('Custom event sort'),
'group' => t('Content'),
'help' => t('Sort events by past/future, then distance from now.'),
'sort' => [
'field' => 'field_event_date_value',
'id' => 'event_sort',
],
];
}
/**
* Implements hook_token_info().
*/
function opdavies_talks_token_info(): array {
$info = [];
$info['types']['opdavies_talks'] = [
'name' => t('Oliver Davies Talks'),
'description' => t('Custom tokens for the Oliver Davies Talks module.'),
];
$info['tokens']['opdavies_talks']['talk_count'] = 'ddd';
return $info;
}
/**
* Implements hook_tokens().
*/
function opdavies_talks_tokens(string $type, array $tokens, array $data, array $options, BubbleableMetadata $bubbleableMetadata): array {
$replacements = [];
if ($type == 'opdavies_talks') {
/** @var TalkCounter $talkCounter */
$talkCounter = Drupal::service(TalkCounter::class);
foreach ($tokens as $name => $original) {
switch ($name) {
case 'talk_count':
$replacements[$original] = $talkCounter->getCount();
break;
}
}
}
return $replacements;
}

View file

@ -0,0 +1,6 @@
services:
Drupal\Component\Datetime\TimeInterface:
alias: datetime.time
Drupal\Core\Entity\EntityTypeManagerInterface:
alias: entity_type.manager

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\Collection;
use Drupal\node\NodeInterface;
use Drupal\opdavies_talks\Entity\Node\Talk;
use Drupal\paragraphs\ParagraphInterface;
use Illuminate\Support\Collection;
final class TalkCollection extends Collection {
/**
* Return the events for the talks in the Collection.
*
* @return Collection|ParagraphInterface[]
*/
public function getEvents(): Collection {
return $this
->flatMap(fn(Talk $talk): Collection => $talk->getEvents());
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\Entity\Node;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface;
use Drupal\paragraphs\ParagraphInterface;
use Illuminate\Support\Collection;
final class Talk {
public const FIELD_EVENTS = 'field_events';
public const FIELD_EVENT_DATE = 'field_event_date';
private NodeInterface $node;
public function __construct(EntityInterface $node) {
$this->node = $node;
}
public function addEvent(ParagraphInterface $event): void {
$this->set(
self::FIELD_EVENTS,
$this->getEvents()->push($event)->toArray()
);
}
/**
* Find the date for the latest event.
*
* @return string|null
*/
public function findLatestEventDate(): ?string {
return $this->getEvents()
->map(fn(ParagraphInterface $event) => $event->get('field_date')
->getString())
->max();
}
public function get(string $name): FieldItemListInterface {
return $this->node->get($name);
}
public function getCreatedTime(): int {
return (int) $this->node->getCreatedTime();
}
public function getEvents(): Collection {
return Collection::make($this->get(self::FIELD_EVENTS)
->referencedEntities());
}
public function getNextDate(): ?int {
if ($this->get(self::FIELD_EVENT_DATE)->isEmpty()) {
return NULL;
}
return (int) $this->get(self::FIELD_EVENT_DATE)->getString();
}
public function id(): int {
return (int) $this->node->id();
}
public function label(): string {
return $this->node->label();
}
public function save(): void {
$this->node->save();
}
public function set(string $name, $value): void {
$this->node->set($name, $value);
}
public function setCreatedTime(int $timestamp): void {
$this->node->setCreatedTime($timestamp);
}
public function setEvents(array $events): void {
$this->set(self::FIELD_EVENTS, $events);
}
public function setNextDate(int $date): void {
$this->set(self::FIELD_EVENT_DATE, $date);
}
public static function createFromNode(EntityInterface $node): self {
// TODO: ensure that this is a node and a `talk` type.
return new self($node);
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\EventSubscriber;
use Carbon\Carbon;
use Drupal\core_event_dispatcher\Event\Entity\AbstractEntityEvent;
use Drupal\hook_event_dispatcher\HookEventDispatcherInterface;
use Drupal\opdavies_talks\Entity\Node\Talk;
use Drupal\paragraphs\ParagraphInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Illuminate\Support\Collection;
/**
* Update a talk node before it's saved.
*/
final class UpdateTalkNodeBeforeSave implements EventSubscriberInterface {
public static function getSubscribedEvents() {
return [
HookEventDispatcherInterface::ENTITY_PRE_SAVE => 'onEntityPreSave',
];
}
public function onEntityPreSave(AbstractEntityEvent $event): void {
if ($event->getEntity()->getEntityTypeId() != 'node') {
return;
}
if ($event->getEntity()->bundle() != 'talk') {
return;
}
$node = $event->getEntity();
$talk = Talk::createFromNode($node);
$this->reorderEvents($talk);
$this->updateCreatedDate($talk);
}
private function reorderEvents(Talk $talk): void {
$events = $talk->getEvents();
$eventsByDate = $this->sortEventsByDate($events);
// If the original event IDs don't match the sorted event IDs, update the event field to use the sorted ones.
if ($events->map->id() != $eventsByDate->map->id()) {
$talk->setEvents($eventsByDate->toArray());
}
}
private function sortEventsByDate(Collection $events): Collection {
return $events
->sortBy(fn(ParagraphInterface $event) => $event->get('field_date')
->getString())
->values();
}
private function updateCreatedDate(Talk $talk): void {
if (!$eventDate = $talk->findLatestEventDate()) {
return;
}
$talkDate = Carbon::parse($eventDate)->getTimestamp();
if ($talkDate == $talk->getCreatedTime()) {
return;
}
$talk->setCreatedTime($talkDate);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\Finder\Finder;
final class OpdaviesTalksServiceProvider implements ServiceProviderInterface {
public function register(ContainerBuilder $container): void {
foreach (['EventSubscriber', 'Repository', 'Service'] as $directory) {
$files = Finder::create()
->in(__DIR__ . '/' . $directory)
->files()
->name('*.php');
foreach ($files as $file) {
$class = 'Drupal\opdavies_talks\\' . $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,69 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\Plugin\views\sort;
use Carbon\Carbon;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\views\Annotation\ViewsSort;
use Drupal\views\Plugin\views\sort\Date;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @ViewsSort("event_sort")
*/
final class Event extends Date {
private TimeInterface $time;
public function __construct(
array $configuration,
string $pluginId,
array $pluginDefinition,
TimeInterface $time
) {
parent::__construct($configuration, $pluginId, $pluginDefinition);
$this->time = $time;
}
public static function create(
ContainerInterface $container,
array $configuration,
$pluginId,
$pluginDefinition
) {
return new static(
$configuration,
$pluginId,
$pluginDefinition,
$container->get('datetime.time')
);
}
public function query(): void {
$this->ensureMyTable();
$currentDate = Carbon::parse('today')->getTimestamp();
$dateAlias = "$this->tableAlias.$this->realField";
// Is this event in the past?
$this->query->addOrderBy(
NULL,
sprintf("%d > %s", $currentDate, $dateAlias),
$this->options['order'],
"in_past"
);
// How far in the past/future is this event?
$this->query->addOrderBy(
NULL,
sprintf('ABS(%s - %d)', $dateAlias, $currentDate),
$this->options['order'],
"distance_from_now"
);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\Repository;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\node\NodeInterface;
use Drupal\opdavies_talks\Collection\TalkCollection;
use Drupal\opdavies_talks\Entity\Node\Talk;
final class TalkRepository {
private EntityStorageInterface $nodeStorage;
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->nodeStorage = $entityTypeManager->getStorage('node');
}
public function findAll(): TalkCollection {
$talks = $this->nodeStorage->loadByProperties($this->defaultProperties());
return (new TalkCollection($talks))
->map(fn(NodeInterface $node): Talk => Talk::createFromNode($node));
}
public function findAllPublished(): TalkCollection {
$talks = $this->nodeStorage->loadByProperties(array_merge(
$this->defaultProperties(),
[
'status' => NodeInterface::PUBLISHED,
],
));
return (new TalkCollection($talks))
->map(fn(NodeInterface $node): Talk => Talk::createFromNode($node));
}
private function defaultProperties(): array {
return [
'type' => 'talk',
];
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\Service;
use Carbon\Carbon;
use Drupal\opdavies_talks\Repository\TalkRepository;
use Drupal\paragraphs\ParagraphInterface;
final class TalkCounter {
private TalkRepository $talkRepository;
public function __construct(TalkRepository $talkRepository) {
$this->talkRepository = $talkRepository;
}
public function getCount(): int {
$today = Carbon::today()->format('Y-m-d H:i:s');
return $this->talkRepository
->findAllPublished()
->getEvents()
->filter(fn(ParagraphInterface $event) => $event->get('field_date')
->getString() <= $today)
->count();
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Drupal\opdavies_talks\Service;
use Carbon\Carbon;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\opdavies_talks\Entity\Node\Talk;
use Drupal\opdavies_talks\Repository\TalkRepository;
use Drupal\paragraphs\ParagraphInterface;
final class TalkDateUpdater {
private TalkRepository $talkRepository;
private TimeInterface $time;
public function __construct(
TalkRepository $talkRepository,
TimeInterface $time
) {
$this->talkRepository = $talkRepository;
$this->time = $time;
}
public function __invoke(): void {
foreach ($this->talkRepository->findAll() as $talk) {
$this->updateNextEventDate($talk);
}
}
private function updateNextEventDate(Talk $talk): void {
if (!$nextDate = $this->findNextEventDate($talk)) {
return;
}
$nextDateTimestamp = Carbon::parse($nextDate)
->getTimestamp();
if ($nextDateTimestamp == $talk->getNextDate()) {
return;
}
$talk->setNextDate($nextDateTimestamp);
$talk->save();
}
private function findNextEventDate(Talk $talk): ?string {
$currentTime = Carbon::today()
->format(DateTimeItemInterface::DATE_STORAGE_FORMAT);
$dates = $talk->getEvents()
->map(fn(ParagraphInterface $event) => $event->get('field_date')
->getString())
->sort();
if ($dates->isEmpty()) {
return NULL;
}
// If a future date is found, return it.
if ($futureDate = $dates->first(fn(string $eventDate) => $eventDate > $currentTime)) {
return $futureDate;
}
// If no future date is found, return the last past date.
return $dates->last();
}
}

View file

@ -0,0 +1,21 @@
uuid: 5bb25694-3431-4c5e-9dc2-c7c46a91eab5
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_event_date
- node.type.talk
module:
- datetime
id: node.talk.field_event_date
field_name: field_event_date
entity_type: node
bundle: talk
label: 'Next event date'
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings: { }
field_type: datetime

View file

@ -0,0 +1,30 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_events
- node.type.talk
- paragraphs.paragraphs_type.event
module:
- entity_reference_revisions
id: node.talk.field_events
field_name: field_events
entity_type: node
bundle: talk
label: Events
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
handler: 'default:paragraph'
handler_settings:
negate: 0
target_bundles:
event: event
target_bundles_drag_drop:
event:
enabled: true
weight: 2
field_type: entity_reference_revisions

View file

@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
config:
- field.storage.paragraph.field_date
- paragraphs.paragraphs_type.event
module:
- datetime
id: paragraph.event.field_date
field_name: field_date
entity_type: paragraph
bundle: event
label: Date
description: ''
required: true
translatable: false
default_value: { }
default_value_callback: ''
settings: { }
field_type: datetime

View file

@ -0,0 +1,18 @@
langcode: en
status: true
dependencies:
config:
- field.storage.paragraph.field_name
- paragraphs.paragraphs_type.event
id: paragraph.event.field_name
field_name: field_name
entity_type: paragraph
bundle: event
label: 'Event name'
description: ''
required: true
translatable: false
default_value: { }
default_value_callback: ''
settings: { }
field_type: string

View file

@ -0,0 +1,20 @@
uuid: e718e9e3-0765-4cf4-b7e8-cccf41ee3d1a
langcode: en
status: true
dependencies:
module:
- datetime
- node
id: node.field_event_date
field_name: field_event_date
entity_type: node
type: datetime
settings:
datetime_type: date
module: datetime
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- entity_reference_revisions
- node
- paragraphs
id: node.field_events
field_name: field_events
entity_type: node
type: entity_reference_revisions
settings:
target_type: paragraph
module: entity_reference_revisions
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:
- datetime
- paragraphs
id: paragraph.field_date
field_name: field_date
entity_type: paragraph
type: datetime
settings:
datetime_type: date
module: datetime
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- paragraphs
id: paragraph.field_name
field_name: field_name
entity_type: paragraph
type: string
settings:
max_length: 255
is_ascii: false
case_sensitive: false
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View file

@ -0,0 +1,10 @@
langcode: en
status: true
dependencies: { }
name: Talk
type: talk
description: ''
help: ''
new_revision: true
preview_mode: 1
display_submitted: false

View file

@ -0,0 +1,9 @@
langcode: en
status: true
dependencies: { }
id: event
label: Event
icon_uuid: null
icon_default: null
description: ''
behavior_plugins: { }

View file

@ -0,0 +1,196 @@
langcode: en
status: true
dependencies:
config:
- core.entity_view_mode.node.teaser
- node.type.talk
- system.menu.main
module:
- node
- opdavies_talks
- user
id: talks
label: Talks
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Master
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: none
options:
items_per_page: 0
offset: 0
style:
type: html_list
options:
row_class: ''
default_row_class: true
uses_fields: false
type: ul
wrapper_class: ''
class: space-y-8
row:
type: 'entity:node'
options:
view_mode: teaser
fields:
title:
id: title
table: node_field_data
field: title
entity_type: node
entity_field: title
label: ''
alter:
alter_text: false
make_link: false
absolute: false
trim: false
word_boundary: false
ellipsis: false
strip_tags: false
html: false
hide_empty: false
empty_zero: false
settings:
link_to_entity: true
plugin_id: field
relationship: none
group_type: group
admin_label: ''
exclude: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_alter_empty: true
click_sort_column: value
type: string
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
filters:
status:
value: '1'
table: node_field_data
field: status
plugin_id: boolean
entity_type: node
entity_field: status
id: status
expose:
operator: ''
operator_limit_selection: false
operator_list: { }
group: 1
type:
id: type
table: node_field_data
field: type
value:
talk: talk
entity_type: node
entity_field: type
plugin_id: bundle
expose:
operator_limit_selection: false
operator_list: { }
sorts:
event_sort:
id: event_sort
table: node__field_event_date
field: event_sort
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
granularity: second
plugin_id: event_sort
title: Talks
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- 'user.node_grants:view'
- user.permissions
tags: { }
page_1:
display_plugin: page
id: page_1
display_title: Page
position: 1
display_options:
display_extenders: { }
path: talks
menu:
type: normal
title: Talks
description: ''
expanded: false
parent: ''
weight: -48
context: '0'
menu_name: main
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- 'user.node_grants:view'
- user.permissions
tags: { }

View file

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

View file

@ -0,0 +1,66 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_talks\Kernel;
use Carbon\Carbon;
use Drupal\node\NodeInterface;
use Drupal\opdavies_talks\Service\TalkCounter;
use PHPUnit\Framework\Assert;
final class CountPreviousTalksTest extends TalksTestBase {
private TalkCounter $talkCounter;
/** @test */
public function previous_talks_are_counted(): void {
$this->createTalk([
'field_events' => [
$this->createEvent(),
$this->createEvent(),
],
]);
$this->createTalk([
'field_events' => [
$this->createEvent(),
],
]);
Assert::assertSame(3, $this->talkCounter->getCount());
}
/** @test */
public function future_talks_are_not_counted(): void {
$this->createTalk([
'field_events' => [
$this->createEvent([
'field_date' => Carbon::now()->subDay(),
]),
$this->createEvent([
'field_date' => Carbon::now()->addDay(),
]),
],
]);
Assert::assertSame(1, $this->talkCounter->getCount());
}
/** @test */
public function unpublished_talks_are_not_counted(): void {
$this->createTalk([
'field_events' => [$this->createEvent()],
'status' => NodeInterface::NOT_PUBLISHED,
]);
Assert::assertSame(0, $this->talkCounter->getCount());
}
protected function setUp() {
parent::setUp();
$this->talkCounter = $this->container->get(TalkCounter::class);
}
}

View file

@ -0,0 +1,115 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_talks\Kernel;
use Carbon\Carbon;
use Drupal\paragraphs\ParagraphInterface;
final class ReorderEventsTest extends TalksTestBase {
/** @test */
public function the_events_are_ordered_by_date_when_a_talk_is_created(): void {
$events = [
$this->createEvent([
'field_date' => Carbon::today()->addWeeks(2),
'field_name' => 'Drupal Bristol',
]),
$this->createEvent([
'field_date' => Carbon::yesterday(),
'field_name' => 'DrupalCamp London',
]),
$this->createEvent([
'field_date' => Carbon::tomorrow(),
'field_name' => 'PHP UK conference',
]),
$this->createEvent([
'field_date' => Carbon::today()->addMonths(3),
'field_name' => 'CMS Philly',
]),
$this->createEvent([
'field_date' => Carbon::today()->subYear(),
'field_name' => 'PHP South Wales',
]),
];
$talk = $this->createTalk([
'field_events' => $events,
]);
$this->assertSame(
[
'PHP South Wales',
'DrupalCamp London',
'PHP UK conference',
'Drupal Bristol',
'CMS Philly',
],
$talk->getEvents()
->map(fn(ParagraphInterface $event) => $event->get('field_name')
->getString())
->toArray()
);
}
/** @test */
public function the_events_are_ordered_by_date_when_a_talk_is_updated(): void {
$events = [
$this->createEvent([
'field_date' => Carbon::today()->addWeeks(2),
'field_name' => 'Drupal Bristol',
]),
$this->createEvent([
'field_date' => Carbon::yesterday(),
'field_name' => 'DrupalCamp London',
]),
$this->createEvent([
'field_date' => Carbon::today()->addMonths(3),
'field_name' => 'CMS Philly',
]),
$this->createEvent([
'field_date' => Carbon::today()->subYear(),
'field_name' => 'PHP South Wales',
]),
];
$talk = $this->createTalk([
'field_events' => $events,
]);
$this->assertSame(
[
'PHP South Wales',
'DrupalCamp London',
'Drupal Bristol',
'CMS Philly',
],
$talk->getEvents()
->map(fn(ParagraphInterface $event) => $event->get('field_name')
->getString())
->toArray()
);
$talk->addEvent($this->createEvent([
'field_date' => Carbon::tomorrow(),
'field_name' => 'PHP UK conference',
]));
$talk->save();
$this->assertSame(
[
'PHP South Wales',
'DrupalCamp London',
'PHP UK conference',
'Drupal Bristol',
'CMS Philly',
],
$talk->getEvents()
->map(fn(ParagraphInterface $event) => $event->get('field_name')
->getString())
->toArray()
);
}
}

View file

@ -0,0 +1,73 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_talks\Kernel\Repository;
use Drupal\node\NodeInterface;
use Drupal\opdavies_talks\Entity\Node\Talk;
use Drupal\opdavies_talks\Repository\TalkRepository;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\opdavies_talks\Kernel\TalksTestBase;
final class TalkRepositoryTest extends TalksTestBase {
use NodeCreationTrait;
private TalkRepository $talkRepository;
/** @test */
public function get_all_talks(): void {
$this->createTalk(['title' => 'TDD - Test Driven Drupal']);
$this->createTalk(['title' => 'Taking Flight with Tailwind CSS']);
$this->createTalk(['title' => 'Upgrading to Drupal 9']);
$talks = $this->talkRepository->findAll();
$this->assertCount(3, $talks);
$this->assertSame(
[
1 => 'TDD - Test Driven Drupal',
2 => 'Taking Flight with Tailwind CSS',
3 => 'Upgrading to Drupal 9',
],
$talks->map(fn(Talk $talk) => $talk->label())->toArray()
);
}
/** @test */
public function get_all_published_talks(): void {
$this->createTalk([
'title' => 'TDD - Test Driven Drupal',
'status' => NodeInterface::PUBLISHED,
]);
$this->createTalk([
'title' => 'Taking Flight with Tailwind CSS',
'status' => NodeInterface::NOT_PUBLISHED,
]);
$talks = $this->talkRepository->findAllPublished();
$this->assertCount(1, $talks);
$this->assertSame('TDD - Test Driven Drupal', $talks->first()->label());
}
/** @test */
public function it_only_returns_talk_nodes(): void {
$this->createNode(['type' => 'page']);
$talks = $this->talkRepository->findAll();
$this->assertEmpty($talks);
}
protected function setUp() {
parent::setUp();
$this->installConfig(['filter']);
$this->talkRepository = $this->container->get(TalkRepository::class);
}
}

View file

@ -0,0 +1,111 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_talks\Kernel;
use Carbon\Carbon;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\node\Entity\Node;
use Drupal\opdavies_talks\Entity\Node\Talk;
use Drupal\opdavies_talks\Service\TalkDateUpdater;
final class TalkEventDateTest extends TalksTestBase {
/** @test */
public function talk_event_dates_are_set_to_the_next_future_date(): void {
$dateFormat = DateTimeItemInterface::DATE_STORAGE_FORMAT;
$talk = $this->createTalk([
'field_event_date' => NULL,
'field_events' => [
$this->createEvent([
'field_date' => Carbon::today()
->subWeeks(2)
->format($dateFormat),
]),
$this->createEvent([
'field_date' => Carbon::today()
->subDays(2)
->format($dateFormat),
]),
$this->createEvent([
'field_date' => Carbon::today()
->addDays(4)
->format($dateFormat),
]),
$this->createEvent([
'field_date' => Carbon::today()
->addDays(10)
->format($dateFormat),
]),
],
]);
$dateUpdater = $this->container->get(TalkDateUpdater::class);
$dateUpdater->__invoke();
$expected = Carbon::today()->addDays(4)->getTimestamp();
$node = Node::load($talk->id());
$talk = Talk::createFromNode($node);
$this->assertNextEventDateIs($talk, $expected);
}
/** @test */
public function talk_event_dates_are_set_to_the_last_past_date(): void {
$dateFormat = DateTimeItemInterface::DATE_STORAGE_FORMAT;
$talk = $this->createTalk([
'field_event_date' => NULL,
'field_events' => [
$this->createEvent([
'field_date' => Carbon::today()
->subDays(4)
->format($dateFormat),
]),
$this->createEvent([
'field_date' => Carbon::today()
->subDays(2)
->format($dateFormat),
]),
],
]);
$dateUpdater = $this->container->get(TalkDateUpdater::class);
$dateUpdater->__invoke();
$expected = Carbon::today()->subDays(2)->getTimestamp();
$node = Node::load($talk->id());
$talk = Talk::createFromNode($node);
$this->assertNextEventDateIs($talk, $expected);
}
/** @test */
public function next_event_date_is_empty_if_there_are_no_events(): void {
$talk = $this->createTalk([
'field_event_date' => NULL,
'field_events' => [],
]);
$dateUpdater = $this->container->get(TalkDateUpdater::class);
$dateUpdater->__invoke();
$node = Node::load($talk->id());
$talk = Talk::createFromNode($node);
$this->assertNoNextEventDate($talk);
}
private function assertNextEventDateIs(Talk $talk, $expected): void {
$this->assertSame($expected, $talk->getNextDate());
}
private function assertNoNextEventDate(Talk $talk): void {
$this->assertNull($talk->getNextDate());
}
}

View file

@ -0,0 +1,41 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_talks\Kernel;
use Carbon\Carbon;
use Drupal\views\ResultRow;
use Illuminate\Support\Collection;
final class TalksPageSortTest extends TalksTestBase {
public static $modules = [
'views',
'opdavies_talks',
];
/**
* @test
*/
public function upcoming_talks_are_shown_first_followed_by_past_talks_and_ordered_by_distance(): void {
$this->createTalk([
'field_event_date' => Carbon::today()->addDays(4)->getTimestamp(),
]);
$this->createTalk([
'field_event_date' => Carbon::today()->subDays(2)->getTimestamp(),
]);
$this->createTalk([
'field_event_date' => Carbon::today()->addDay()->getTimestamp(),
]);
$this->createTalk([
'field_event_date' => Carbon::today()->subDays(10)->getTimestamp(),
]);
$talkIds = (new Collection(views_get_view_result('talks')))
->map(fn(ResultRow $row) => (int) $row->_entity->id());
$this->assertSame([3, 1, 2, 4], $talkIds->toArray());
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace Drupal\Tests\opdavies_talks\Kernel;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\opdavies_talks\Entity\Node\Talk;
use Drupal\paragraphs\Entity\Paragraph;
use Drupal\paragraphs\ParagraphInterface;
abstract class TalksTestBase extends EntityKernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
// Core.
'node',
'file',
'datetime',
// Contrib.
'entity_reference_revisions',
'paragraphs',
'hook_event_dispatcher',
'core_event_dispatcher',
// Custom.
'opdavies_talks',
'opdavies_talks_test',
];
protected $strictConfigSchema = FALSE;
protected function createEvent(array $overrides = []): ParagraphInterface {
/** @var \Drupal\paragraphs\ParagraphInterface $event */
$event = Paragraph::create(array_merge([
'type' => 'event',
], $overrides));
$event->save();
return $event;
}
protected function createTalk(array $overrides = []): Talk {
$node = Node::create(array_merge([
'title' => 'Test Driven Drupal',
'type' => 'talk',
], $overrides));
$node->save();
return Talk::createFromNode($node);
}
protected function setUp() {
parent::setUp();
$this->installEntitySchema('paragraph');
$this->installSchema('node', ['node_access']);
$this->installConfig(['opdavies_talks_test']);
}
}

View file

@ -0,0 +1,47 @@
<?php
// phpcs:disable Drupal.Commenting.DocComment, Drupal.NamingConventions.ValidFunctionName
namespace Drupal\Tests\opdavies_talks\Kernel;
use Carbon\Carbon;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
final class UpdatesTalkCreatedDateTest extends TalksTestBase {
/** @test */
public function the_date_is_updated_when_a_talk_node_is_created(): void {
$eventDate = Carbon::today()->addWeek();
$eventDateFormat = $eventDate
->format(DateTimeItemInterface::DATE_STORAGE_FORMAT);
$eventDateTimestamp = $eventDate->getTimestamp();
$talk = $this->createTalk([
'field_events' => [
$this->createEvent(['field_date' => $eventDateFormat]),
],
]);
$this->assertEqual($eventDateTimestamp, $talk->getCreatedTime());
}
/** @test */
public function the_date_is_updated_when_a_talk_node_is_updated(): void {
$talk = $this->createTalk();
$originalCreatedTime = $talk->getCreatedTime();
$eventDate = Carbon::today()->addWeek();
$eventDateFormat = $eventDate
->format(DateTimeItemInterface::DATE_STORAGE_FORMAT);
$eventDateTimestamp = $eventDate->getTimestamp();
$talk->addEvent(
$this->createEvent(['field_date' => $eventDateFormat])
);
$talk->save();
$this->assertNotSame($originalCreatedTime, $talk->getCreatedTime());
$this->assertSame($eventDateTimestamp, $talk->getCreatedTime());
}
}

View file

@ -0,0 +1,16 @@
<?php
$databases['default']['default'] = [
'driver' => 'mysql',
'host' => 'mysql',
'database' => getenv('MYSQL_DATABASE'),
'username' => getenv('MYSQL_USER'),
'password' => getenv('MYSQL_PASSWORD'),
'port' => '3306',
'prefix' => '',
'collation' => 'utf8mb4_general_ci',
];
$settings['trusted_host_patterns'] = [
'^localhost$',
];

View file

@ -0,0 +1,776 @@
<?php
// @codingStandardsIgnoreFile
/**
* @file
* Drupal site-specific configuration file.
*
* IMPORTANT NOTE:
* This file may have been set to read-only by the Drupal installation program.
* If you make changes to this file, be sure to protect it again after making
* your modifications. Failure to remove write permissions to this file is a
* security risk.
*
* In order to use the selection rules below the multisite aliasing file named
* sites/sites.php must be present. Its optional settings will be loaded, and
* the aliases in the array $sites will override the default directory rules
* below. See sites/example.sites.php for more information about aliases.
*
* The configuration directory will be discovered by stripping the website's
* hostname from left to right and pathname from right to left. The first
* configuration file found will be used and any others will be ignored. If no
* other configuration file is found then the default configuration file at
* 'sites/default' will be used.
*
* For example, for a fictitious site installed at
* https://www.drupal.org:8080/mysite/test/, the 'settings.php' file is searched
* for in the following directories:
*
* - sites/8080.www.drupal.org.mysite.test
* - sites/www.drupal.org.mysite.test
* - sites/drupal.org.mysite.test
* - sites/org.mysite.test
*
* - sites/8080.www.drupal.org.mysite
* - sites/www.drupal.org.mysite
* - sites/drupal.org.mysite
* - sites/org.mysite
*
* - sites/8080.www.drupal.org
* - sites/www.drupal.org
* - sites/drupal.org
* - sites/org
*
* - sites/default
*
* Note that if you are installing on a non-standard port number, prefix the
* hostname with that number. For example,
* https://www.drupal.org:8080/mysite/test/ could be loaded from
* sites/8080.www.drupal.org.mysite.test/.
*
* @see example.sites.php
* @see \Drupal\Core\DrupalKernel::getSitePath()
*
* In addition to customizing application settings through variables in
* settings.php, you can create a services.yml file in the same directory to
* register custom, site-specific service definitions and/or swap out default
* implementations with custom ones.
*/
/**
* Database settings:
*
* The $databases array specifies the database connection or
* connections that Drupal may use. Drupal is able to connect
* to multiple databases, including multiple types of databases,
* during the same request.
*
* One example of the simplest connection array is shown below. To use the
* sample settings, copy and uncomment the code below between the @code and
* @endcode lines and paste it after the $databases declaration. You will need
* to replace the database username and password and possibly the host and port
* with the appropriate credentials for your database system.
*
* The next section describes how to customize the $databases array for more
* specific needs.
*
* @code
* $databases['default']['default'] = [
* 'database' => 'databasename',
* 'username' => 'sqlusername',
* 'password' => 'sqlpassword',
* 'host' => 'localhost',
* 'port' => '3306',
* 'driver' => 'mysql',
* 'prefix' => '',
* 'collation' => 'utf8mb4_general_ci',
* ];
* @endcode
*/
$databases = [];
/**
* Customizing database settings.
*
* Many of the values of the $databases array can be customized for your
* particular database system. Refer to the sample in the section above as a
* starting point.
*
* The "driver" property indicates what Drupal database driver the
* connection should use. This is usually the same as the name of the
* database type, such as mysql or sqlite, but not always. The other
* properties will vary depending on the driver. For SQLite, you must
* specify a database file name in a directory that is writable by the
* webserver. For most other drivers, you must specify a
* username, password, host, and database name.
*
* Transaction support is enabled by default for all drivers that support it,
* including MySQL. To explicitly disable it, set the 'transactions' key to
* FALSE.
* Note that some configurations of MySQL, such as the MyISAM engine, don't
* support it and will proceed silently even if enabled. If you experience
* transaction related crashes with such configuration, set the 'transactions'
* key to FALSE.
*
* For each database, you may optionally specify multiple "target" databases.
* A target database allows Drupal to try to send certain queries to a
* different database if it can but fall back to the default connection if not.
* That is useful for primary/replica replication, as Drupal may try to connect
* to a replica server when appropriate and if one is not available will simply
* fall back to the single primary server (The terms primary/replica are
* traditionally referred to as master/slave in database server documentation).
*
* The general format for the $databases array is as follows:
* @code
* $databases['default']['default'] = $info_array;
* $databases['default']['replica'][] = $info_array;
* $databases['default']['replica'][] = $info_array;
* $databases['extra']['default'] = $info_array;
* @endcode
*
* In the above example, $info_array is an array of settings described above.
* The first line sets a "default" database that has one primary database
* (the second level default). The second and third lines create an array
* of potential replica databases. Drupal will select one at random for a given
* request as needed. The fourth line creates a new database with a name of
* "extra".
*
* You can optionally set prefixes for some or all database table names
* by using the 'prefix' setting. If a prefix is specified, the table
* name will be prepended with its value. Be sure to use valid database
* characters only, usually alphanumeric and underscore. If no prefixes
* are desired, leave it as an empty string ''.
*
* To have all database names prefixed, set 'prefix' as a string:
* @code
* 'prefix' => 'main_',
* @endcode
*
* Per-table prefixes are deprecated as of Drupal 8.2, and will be removed in
* Drupal 9.0. After that, only a single prefix for all tables will be
* supported.
*
* To provide prefixes for specific tables, set 'prefix' as an array.
* The array's keys are the table names and the values are the prefixes.
* The 'default' element is mandatory and holds the prefix for any tables
* not specified elsewhere in the array. Example:
* @code
* 'prefix' => [
* 'default' => 'main_',
* 'users' => 'shared_',
* 'sessions' => 'shared_',
* 'role' => 'shared_',
* 'authmap' => 'shared_',
* ],
* @endcode
* You can also use a reference to a schema/database as a prefix. This may be
* useful if your Drupal installation exists in a schema that is not the default
* or you want to access several databases from the same code base at the same
* time.
* Example:
* @code
* 'prefix' => [
* 'default' => 'main.',
* 'users' => 'shared.',
* 'sessions' => 'shared.',
* 'role' => 'shared.',
* 'authmap' => 'shared.',
* ];
* @endcode
* NOTE: MySQL and SQLite's definition of a schema is a database.
*
* Advanced users can add or override initial commands to execute when
* connecting to the database server, as well as PDO connection settings. For
* example, to enable MySQL SELECT queries to exceed the max_join_size system
* variable, and to reduce the database connection timeout to 5 seconds:
* @code
* $databases['default']['default'] = [
* 'init_commands' => [
* 'big_selects' => 'SET SQL_BIG_SELECTS=1',
* ],
* 'pdo' => [
* PDO::ATTR_TIMEOUT => 5,
* ],
* ];
* @endcode
*
* WARNING: The above defaults are designed for database portability. Changing
* them may cause unexpected behavior, including potential data loss. See
* https://www.drupal.org/developing/api/database/configuration for more
* information on these defaults and the potential issues.
*
* More details can be found in the constructor methods for each driver:
* - \Drupal\Core\Database\Driver\mysql\Connection::__construct()
* - \Drupal\Core\Database\Driver\pgsql\Connection::__construct()
* - \Drupal\Core\Database\Driver\sqlite\Connection::__construct()
*
* Sample Database configuration format for PostgreSQL (pgsql):
* @code
* $databases['default']['default'] = [
* 'driver' => 'pgsql',
* 'database' => 'databasename',
* 'username' => 'sqlusername',
* 'password' => 'sqlpassword',
* 'host' => 'localhost',
* 'prefix' => '',
* ];
* @endcode
*
* Sample Database configuration format for SQLite (sqlite):
* @code
* $databases['default']['default'] = [
* 'driver' => 'sqlite',
* 'database' => '/path/to/databasefilename',
* ];
* @endcode
*/
/**
* Location of the site configuration files.
*
* The $settings['config_sync_directory'] specifies the location of file system
* directory used for syncing configuration data. On install, the directory is
* created. This is used for configuration imports.
*
* The default location for this directory is inside a randomly-named
* directory in the public files path. The setting below allows you to set
* its location.
*/
# $settings['config_sync_directory'] = '/directory/outside/webroot';
/**
* Settings:
*
* $settings contains environment-specific configuration, such as the files
* directory and reverse proxy address, and temporary configuration, such as
* security overrides.
*
* @see \Drupal\Core\Site\Settings::get()
*/
/**
* Salt for one-time login links, cancel links, form tokens, etc.
*
* This variable will be set to a random value by the installer. All one-time
* login links will be invalidated if the value is changed. Note that if your
* site is deployed on a cluster of web servers, you must ensure that this
* variable has the same value on each server.
*
* For enhanced security, you may set this variable to the contents of a file
* outside your document root; you should also ensure that this file is not
* stored with backups of your database.
*
* Example:
* @code
* $settings['hash_salt'] = file_get_contents('/home/example/salt.txt');
* @endcode
*/
if (file_exists($app_root . '/../salt.txt')) {
$settings['hash_salt'] = file_get_contents($app_root . '/../salt.txt');
}
/**
* Deployment identifier.
*
* Drupal's dependency injection container will be automatically invalidated and
* rebuilt when the Drupal core version changes. When updating contributed or
* custom code that changes the container, changing this identifier will also
* allow the container to be invalidated as soon as code is deployed.
*/
# $settings['deployment_identifier'] = \Drupal::VERSION;
/**
* Access control for update.php script.
*
* If you are updating your Drupal installation using the update.php script but
* are not logged in using either an account with the "Administer software
* updates" permission or the site maintenance account (the account that was
* created during installation), you will need to modify the access check
* statement below. Change the FALSE to a TRUE to disable the access check.
* After finishing the upgrade, be sure to open this file again and change the
* TRUE back to a FALSE!
*/
$settings['update_free_access'] = FALSE;
/**
* External access proxy settings:
*
* If your site must access the Internet via a web proxy then you can enter the
* proxy settings here. Set the full URL of the proxy, including the port, in
* variables:
* - $settings['http_client_config']['proxy']['http']: The proxy URL for HTTP
* requests.
* - $settings['http_client_config']['proxy']['https']: The proxy URL for HTTPS
* requests.
* You can pass in the user name and password for basic authentication in the
* URLs in these settings.
*
* You can also define an array of host names that can be accessed directly,
* bypassing the proxy, in $settings['http_client_config']['proxy']['no'].
*/
# $settings['http_client_config']['proxy']['http'] = 'http://proxy_user:proxy_pass@example.com:8080';
# $settings['http_client_config']['proxy']['https'] = 'http://proxy_user:proxy_pass@example.com:8080';
# $settings['http_client_config']['proxy']['no'] = ['127.0.0.1', 'localhost'];
/**
* Reverse Proxy Configuration:
*
* Reverse proxy servers are often used to enhance the performance
* of heavily visited sites and may also provide other site caching,
* security, or encryption benefits. In an environment where Drupal
* is behind a reverse proxy, the real IP address of the client should
* be determined such that the correct client IP address is available
* to Drupal's logging, statistics, and access management systems. In
* the most simple scenario, the proxy server will add an
* X-Forwarded-For header to the request that contains the client IP
* address. However, HTTP headers are vulnerable to spoofing, where a
* malicious client could bypass restrictions by setting the
* X-Forwarded-For header directly. Therefore, Drupal's proxy
* configuration requires the IP addresses of all remote proxies to be
* specified in $settings['reverse_proxy_addresses'] to work correctly.
*
* Enable this setting to get Drupal to determine the client IP from the
* X-Forwarded-For header. If you are unsure about this setting, do not have a
* reverse proxy, or Drupal operates in a shared hosting environment, this
* setting should remain commented out.
*
* In order for this setting to be used you must specify every possible
* reverse proxy IP address in $settings['reverse_proxy_addresses'].
* If a complete list of reverse proxies is not available in your
* environment (for example, if you use a CDN) you may set the
* $_SERVER['REMOTE_ADDR'] variable directly in settings.php.
* Be aware, however, that it is likely that this would allow IP
* address spoofing unless more advanced precautions are taken.
*/
# $settings['reverse_proxy'] = TRUE;
/**
* Specify every reverse proxy IP address in your environment.
* This setting is required if $settings['reverse_proxy'] is TRUE.
*/
# $settings['reverse_proxy_addresses'] = ['a.b.c.d', ...];
/**
* Reverse proxy trusted headers.
*
* Sets which headers to trust from your reverse proxy.
*
* Common values are:
* - \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_ALL
* - \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED
*
* Note the default value of
* @code
* \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_ALL | \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED
* @endcode
* is not secure by default. The value should be set to only the specific
* headers the reverse proxy uses. For example:
* @code
* \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_ALL
* @endcode
* This would trust the following headers:
* - X_FORWARDED_FOR
* - X_FORWARDED_HOST
* - X_FORWARDED_PROTO
* - X_FORWARDED_PORT
*
* @see \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_ALL
* @see \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED
* @see \Symfony\Component\HttpFoundation\Request::setTrustedProxies
*/
# $settings['reverse_proxy_trusted_headers'] = \Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_ALL | \Symfony\Component\HttpFoundation\Request::HEADER_FORWARDED;
/**
* Page caching:
*
* By default, Drupal sends a "Vary: Cookie" HTTP header for anonymous page
* views. This tells a HTTP proxy that it may return a page from its local
* cache without contacting the web server, if the user sends the same Cookie
* header as the user who originally requested the cached page. Without "Vary:
* Cookie", authenticated users would also be served the anonymous page from
* the cache. If the site has mostly anonymous users except a few known
* editors/administrators, the Vary header can be omitted. This allows for
* better caching in HTTP proxies (including reverse proxies), i.e. even if
* clients send different cookies, they still get content served from the cache.
* However, authenticated users should access the site directly (i.e. not use an
* HTTP proxy, and bypass the reverse proxy if one is used) in order to avoid
* getting cached pages from the proxy.
*/
# $settings['omit_vary_cookie'] = TRUE;
/**
* Cache TTL for client error (4xx) responses.
*
* Items cached per-URL tend to result in a large number of cache items, and
* this can be problematic on 404 pages which by their nature are unbounded. A
* fixed TTL can be set for these items, defaulting to one hour, so that cache
* backends which do not support LRU can purge older entries. To disable caching
* of client error responses set the value to 0. Currently applies only to
* page_cache module.
*/
# $settings['cache_ttl_4xx'] = 3600;
/**
* Expiration of cached forms.
*
* Drupal's Form API stores details of forms in a cache and these entries are
* kept for at least 6 hours by default. Expired entries are cleared by cron.
*
* @see \Drupal\Core\Form\FormCache::setCache()
*/
# $settings['form_cache_expiration'] = 21600;
/**
* Class Loader.
*
* If the APC extension is detected, the Symfony APC class loader is used for
* performance reasons. Detection can be prevented by setting
* class_loader_auto_detect to false, as in the example below.
*/
# $settings['class_loader_auto_detect'] = FALSE;
/*
* If the APC extension is not detected, either because APC is missing or
* because auto-detection has been disabled, auto-loading falls back to
* Composer's ClassLoader, which is good for development as it does not break
* when code is moved in the file system. You can also decorate the base class
* loader with another cached solution than the Symfony APC class loader, as
* all production sites should have a cached class loader of some sort enabled.
*
* To do so, you may decorate and replace the local $class_loader variable. For
* example, to use Symfony's APC class loader without automatic detection,
* uncomment the code below.
*/
/*
if ($settings['hash_salt']) {
$prefix = 'drupal.' . hash('sha256', 'drupal.' . $settings['hash_salt']);
$apc_loader = new \Symfony\Component\ClassLoader\ApcClassLoader($prefix, $class_loader);
unset($prefix);
$class_loader->unregister();
$apc_loader->register();
$class_loader = $apc_loader;
}
*/
/**
* Authorized file system operations:
*
* The Update Manager module included with Drupal provides a mechanism for
* site administrators to securely install missing updates for the site
* directly through the web user interface. On securely-configured servers,
* the Update manager will require the administrator to provide SSH or FTP
* credentials before allowing the installation to proceed; this allows the
* site to update the new files as the user who owns all the Drupal files,
* instead of as the user the webserver is running as. On servers where the
* webserver user is itself the owner of the Drupal files, the administrator
* will not be prompted for SSH or FTP credentials (note that these server
* setups are common on shared hosting, but are inherently insecure).
*
* Some sites might wish to disable the above functionality, and only update
* the code directly via SSH or FTP themselves. This setting completely
* disables all functionality related to these authorized file operations.
*
* @see https://www.drupal.org/node/244924
*
* Remove the leading hash signs to disable.
*/
# $settings['allow_authorize_operations'] = FALSE;
/**
* Default mode for directories and files written by Drupal.
*
* Value should be in PHP Octal Notation, with leading zero.
*/
# $settings['file_chmod_directory'] = 0775;
# $settings['file_chmod_file'] = 0664;
/**
* Public file base URL:
*
* An alternative base URL to be used for serving public files. This must
* include any leading directory path.
*
* A different value from the domain used by Drupal to be used for accessing
* public files. This can be used for a simple CDN integration, or to improve
* security by serving user-uploaded files from a different domain or subdomain
* pointing to the same server. Do not include a trailing slash.
*/
# $settings['file_public_base_url'] = 'http://downloads.example.com/files';
/**
* Public file path:
*
* A local file system path where public files will be stored. This directory
* must exist and be writable by Drupal. This directory must be relative to
* the Drupal installation directory and be accessible over the web.
*/
# $settings['file_public_path'] = 'sites/default/files';
/**
* Private file path:
*
* A local file system path where private files will be stored. This directory
* must be absolute, outside of the Drupal installation directory and not
* accessible over the web.
*
* Note: Caches need to be cleared when this value is changed to make the
* private:// stream wrapper available to the system.
*
* See https://www.drupal.org/documentation/modules/file for more information
* about securing private files.
*/
# $settings['file_private_path'] = '';
/**
* Temporary file path:
*
* A local file system path where temporary files will be stored. This directory
* must be absolute, outside of the Drupal installation directory and not
* accessible over the web.
*
* If this is not set, the default for the operating system will be used.
*
* @see \Drupal\Component\FileSystem\FileSystem::getOsTemporaryDirectory()
*/
# $settings['file_temp_path'] = '/tmp';
/**
* Session write interval:
*
* Set the minimum interval between each session write to database.
* For performance reasons it defaults to 180.
*/
# $settings['session_write_interval'] = 180;
/**
* String overrides:
*
* To override specific strings on your site with or without enabling the Locale
* module, add an entry to this list. This functionality allows you to change
* a small number of your site's default English language interface strings.
*
* Remove the leading hash signs to enable.
*
* The "en" part of the variable name, is dynamic and can be any langcode of
* any added language. (eg locale_custom_strings_de for german).
*/
# $settings['locale_custom_strings_en'][''] = [
# 'forum' => 'Discussion board',
# '@count min' => '@count minutes',
# ];
/**
* A custom theme for the offline page:
*
* This applies when the site is explicitly set to maintenance mode through the
* administration page or when the database is inactive due to an error.
* The template file should also be copied into the theme. It is located inside
* 'core/modules/system/templates/maintenance-page.html.twig'.
*
* Note: This setting does not apply to installation and update pages.
*/
# $settings['maintenance_theme'] = 'bartik';
/**
* PHP settings:
*
* To see what PHP settings are possible, including whether they can be set at
* runtime (by using ini_set()), read the PHP documentation:
* http://php.net/manual/ini.list.php
* See \Drupal\Core\DrupalKernel::bootEnvironment() for required runtime
* settings and the .htaccess file for non-runtime settings.
* Settings defined there should not be duplicated here so as to avoid conflict
* issues.
*/
/**
* If you encounter a situation where users post a large amount of text, and
* the result is stripped out upon viewing but can still be edited, Drupal's
* output filter may not have sufficient memory to process it. If you
* experience this issue, you may wish to uncomment the following two lines
* and increase the limits of these variables. For more information, see
* http://php.net/manual/pcre.configuration.php.
*/
# ini_set('pcre.backtrack_limit', 200000);
# ini_set('pcre.recursion_limit', 200000);
/**
* Configuration overrides.
*
* To globally override specific configuration values for this site,
* set them here. You usually don't need to use this feature. This is
* useful in a configuration file for a vhost or directory, rather than
* the default settings.php.
*
* Note that any values you provide in these variable overrides will not be
* viewable from the Drupal administration interface. The administration
* interface displays the values stored in configuration so that you can stage
* changes to other environments that don't have the overrides.
*
* There are particular configuration values that are risky to override. For
* example, overriding the list of installed modules in 'core.extension' is not
* supported as module install or uninstall has not occurred. Other examples
* include field storage configuration, because it has effects on database
* structure, and 'core.menu.static_menu_link_overrides' since this is cached in
* a way that is not config override aware. Also, note that changing
* configuration values in settings.php will not fire any of the configuration
* change events.
*/
# $config['system.site']['name'] = 'My Drupal site';
# $config['user.settings']['anonymous'] = 'Visitor';
/**
* Fast 404 pages:
*
* Drupal can generate fully themed 404 pages. However, some of these responses
* are for images or other resource files that are not displayed to the user.
* This can waste bandwidth, and also generate server load.
*
* The options below return a simple, fast 404 page for URLs matching a
* specific pattern:
* - $config['system.performance']['fast_404']['exclude_paths']: A regular
* expression to match paths to exclude, such as images generated by image
* styles, or dynamically-resized images. The default pattern provided below
* also excludes the private file system. If you need to add more paths, you
* can add '|path' to the expression.
* - $config['system.performance']['fast_404']['paths']: A regular expression to
* match paths that should return a simple 404 page, rather than the fully
* themed 404 page. If you don't have any aliases ending in htm or html you
* can add '|s?html?' to the expression.
* - $config['system.performance']['fast_404']['html']: The html to return for
* simple 404 pages.
*
* Remove the leading hash signs if you would like to alter this functionality.
*/
# $config['system.performance']['fast_404']['exclude_paths'] = '/\/(?:styles)|(?:system\/files)\//';
# $config['system.performance']['fast_404']['paths'] = '/\.(?:txt|png|gif|jpe?g|css|js|ico|swf|flv|cgi|bat|pl|dll|exe|asp)$/i';
# $config['system.performance']['fast_404']['html'] = '<!DOCTYPE html><html><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL "@path" was not found on this server.</p></body></html>';
/**
* Load services definition file.
*/
$settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.yml';
/**
* Override the default service container class.
*
* This is useful for example to trace the service container for performance
* tracking purposes, for testing a service container with an error condition or
* to test a service container that throws an exception.
*/
# $settings['container_base_class'] = '\Drupal\Core\DependencyInjection\Container';
/**
* Override the default yaml parser class.
*
* Provide a fully qualified class name here if you would like to provide an
* alternate implementation YAML parser. The class must implement the
* \Drupal\Component\Serialization\SerializationInterface interface.
*/
# $settings['yaml_parser_class'] = NULL;
/**
* Trusted host configuration.
*
* Drupal core can use the Symfony trusted host mechanism to prevent HTTP Host
* header spoofing.
*
* To enable the trusted host mechanism, you enable your allowable hosts
* in $settings['trusted_host_patterns']. This should be an array of regular
* expression patterns, without delimiters, representing the hosts you would
* like to allow.
*
* For example:
* @code
* $settings['trusted_host_patterns'] = [
* '^www\.example\.com$',
* ];
* @endcode
* will allow the site to only run from www.example.com.
*
* If you are running multisite, or if you are running your site from
* different domain names (eg, you don't redirect http://www.example.com to
* http://example.com), you should specify all of the host patterns that are
* allowed by your site.
*
* For example:
* @code
* $settings['trusted_host_patterns'] = [
* '^example\.com$',
* '^.+\.example\.com$',
* '^example\.org$',
* '^.+\.example\.org$',
* ];
* @endcode
* will allow the site to run off of all variants of example.com and
* example.org, with all subdomains included.
*/
/**
* The default list of directories that will be ignored by Drupal's file API.
*
* By default ignore node_modules and bower_components folders to avoid issues
* with common frontend tools and recursive scanning of directories looking for
* extensions.
*
* @see \Drupal\Core\File\FileSystemInterface::scanDirectory()
* @see \Drupal\Core\Extension\ExtensionDiscovery::scanDirectory()
*/
$settings['file_scan_ignore_directories'] = [
'node_modules',
'bower_components',
];
/**
* The default number of entities to update in a batch process.
*
* This is used by update and post-update functions that need to go through and
* change all the entities on a site, so it is useful to increase this number
* if your hosting configuration (i.e. RAM allocation, CPU speed) allows for a
* larger number of entities to be processed in a single batch run.
*/
$settings['entity_update_batch_size'] = 50;
/**
* Entity update backup.
*
* This is used to inform the entity storage handler that the backup tables as
* well as the original entity type and field storage definitions should be
* retained after a successful entity update process.
*/
$settings['entity_update_backup'] = TRUE;
/**
* Load local development override configuration, if available.
*
* Use settings.local.php to override variables on secondary (staging,
* development, etc) installations of this site. Typically used to disable
* caching, JavaScript/CSS compression, re-routing of outgoing emails, and
* other things that should not happen on development and testing sites.
*
* Keep this code block at the end of this file to take full effect.
*/
#
# if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
# include $app_root . '/' . $site_path . '/settings.local.php';
# }
$settings["config_sync_directory"] = '../config';
$config['opdavies_blog.settings']['integromat_webhook_url'] = getenv('INTEGROMAT_WEBHOOK_URL');
$config['opdavies_blog.settings']['post_tweet_webhook_url'] = getenv('IFTTT_WEBHOOK_URL');
if (file_exists($app_root . '/' . $site_path . '/settings.platformsh.php')) {
include $app_root . '/' . $site_path . '/settings.platformsh.php';
}
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
include $app_root . '/' . $site_path . '/settings.local.php';
}
if (file_exists('/.dockerenv') && file_exists($app_root . '/' . $site_path . '/settings.docker.php')) {
include $app_root . '/' . $site_path . '/settings.docker.php';
}

View file

@ -0,0 +1,5 @@
/*.log
/node_modules/
# Ignore compiled assets.
/build/

View file

@ -0,0 +1 @@
v12.20.1

View file

@ -0,0 +1 @@
--modules-folder /node_modules

View file

@ -0,0 +1,26 @@
@layer base {
h2 {
@apply font-bold
}
blockquote {
@apply pl-4 border-l-3 border-blue-primary dark:border-blue-400;
}
code {
@apply px-2 py-1 text-sm rounded-md text-gray-700 bg-gray-200 dark:bg-gray-800 dark:text-gray-100;
}
pre {
@apply p-4 my-8 overflow-auto rounded-md text-gray-700 bg-gray-200 dark:bg-gray-800 dark:text-gray-100;
}
iframe {
@apply w-full;
}
a:focus {
@apply outline-black dark:outline-white;
outline-offset: 2px !important;
}
}

View file

@ -0,0 +1,70 @@
@layer components {
#block-opdavies-branding img {
@apply w-16 h-16 text-blue-primary fill-current dark:text-blue-400 md:w-18 md:h-18
}
.link {
@apply underline text-blue-primary hover:text-blue-900 dark:text-blue-400 dark:hover:text-white;
text-decoration-thickness: 1px;
text-underline-offset: 0.1em;
}
.markdown {
> * + * {
@apply mt-4;
}
> *:first-child {
@apply mt-0;
}
h2 + * {
@apply mt-2;
}
h2 {
@apply mt-6;
}
h3 {
@apply mt-8;
}
h2 + h3 {
@apply mt-2
}
blockquote {
@apply my-8;
}
ul {
@apply pl-6 list-disc;
}
li {
@apply mt-1 first:mt-0;
}
a {
@apply link;
}
pre {
@apply my-8;
}
.media--type-image {
@apply my-8;
}
.speakerdeck-embed-wrapper,
.video-full {
@apply my-8 aspect-w-4 aspect-h-3;
}
}
.visually-hidden {
@apply sr-only;
}
}

View file

@ -0,0 +1,7 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import './base.pcss';
@import './components.pcss';
@import './utilities.pcss';

View file

@ -0,0 +1,15 @@
@layer utilities {
@variants dark {
.text-decoration-blue-400 {
text-decoration-color: theme('colors.blue.400');
}
.text-decoration-blue-primary {
text-decoration-color: theme('colors.blue.800');
}
.text-decoration-white {
text-decoration-color: theme('colors.white');
}
}
}

View file

@ -0,0 +1,21 @@
import 'alpinejs'
import 'focus-visible'
import 'highlight.js/styles/github.css'
import bash from 'highlight.js/lib/languages/bash'
import hljs from 'highlight.js/lib/core'
import ini from 'highlight.js/lib/languages/ini'
import javascript from 'highlight.js/lib/languages/javascript'
import php from 'highlight.js/lib/languages/php'
import yaml from 'highlight.js/lib/languages/yaml'
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('ini', ini);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('php', php);
hljs.registerLanguage('yaml', yaml);
document.addEventListener('DOMContentLoaded', event => {
document.querySelectorAll('pre code').forEach(block => {
hljs.highlightBlock(block)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,9 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="941.333"
height="672"
viewBox="0 0 706 504"
fill="white"
>
<path d="M456.5 1.1c-12.3 1.5-31 5.5-44.1 9.4-12.7 3.9-63.6 24.6-64.1 26.2-.2.5 1.4 1.7 3.4 2.7 2.1 1 8.9 5.1 15.1 9.2l11.2 7.5 14.5-6c22.9-9.5 37.3-14 57.5-17.8 7.2-1.3 14.7-1.7 31-1.8 18.6 0 23.1.3 33 2.3 22 4.5 46.1 13.9 64.5 25.2 39.3 24.2 69.9 65.3 86.9 116.5 16.3 49.2 13 100.2-9.4 145.3-21.8 43.7-49.2 68.8-101.5 92.9-13.4 6.2-120.1 51.3-121.3 51.3-.5 0-15.7-35.1-33.7-78l-32.8-78 3.1-12.8c4.4-18 5.6-29.5 4.9-48.5-.6-16.8-2-25.7-6.3-38.7-12-35.8-40.8-69.2-74-85.6l-7.2-3.6-4.2-10c-2.4-5.6-3.9-10.3-3.4-10.7.5-.5 9.7-4.5 20.4-9s19.9-8.6 20.4-9.1c1.5-1.5-18.6-10.1-32.3-13.9l-6.8-1.9-20.6 8.7c-11.4 4.8-20.9 8.9-21.2 9.2-.2.3 2 6.3 5 13.3 3 7 5.5 12.8 5.5 12.9 0 .1-7.5.2-16.7.2-11.8.1-19.4.6-25.6 1.8-27.3 5.5-50.5 17.6-70.4 37-21.8 21.2-36.7 49-43 80.2-2.4 12.3-2.4 44 0 57.2 3.6 19.4 11.9 40.4 22.3 56 6.9 10.4 21.1 25.4 31.2 33 29.9 22.5 70.8 33.2 106.2 27.8 18.9-2.8 39.3-10.6 54.1-20.5 13.5-9.1 29.1-23.8 37.6-35.5 1.2-1.7 2.4-2.8 2.7-2.5.3.3 15.6 36.6 34.1 80.5 18.5 44 33.8 80.1 33.9 80.3.8.8 144.9-60.8 162.1-69.3 45.5-22.4 73.4-47.1 95.7-84.7 28-47.4 37.5-99.7 27.8-153.5-6.8-37.6-25-79-48.6-110.3-33.2-44.1-83-74.2-138.4-83.6-11.4-1.9-46.9-2.7-58.5-1.3zM259.2 141.4c42.4 10.9 77.8 50 84.8 93.8 1.6 9.9.8 34.5-1.4 44.8-5.2 24-15.5 43-32.6 60-20.7 20.6-42.8 31.3-67.7 32.7-26.9 1.5-53.2-6.2-74.3-21.7-29.4-21.7-46-56.2-46-95.7 0-45.4 27.2-89.6 66.1-107.2 8.2-3.7 21.7-7.9 29.4-9.1 10.4-1.6 30.8-.4 41.7 2.4z"/><path d="M201 20.6c-83 11.2-157 71-186.5 150.8-22.3 60.3-18.3 134.9 10.2 192 21.5 43.1 59.6 81.6 102.1 103.4 21.1 10.9 46.3 19 71.2 22.9 16.2 2.5 53.1 2.5 68.5 0 25.9-4.2 45.2-10.5 69-22.2 14.4-7.1 39.7-23.2 41.8-26.7.8-1.2-.2-4.6-4.3-14.5-2.9-7-5.7-13.2-6.1-13.7-.5-.5-4.3 1.7-8.6 5.1C320 447.1 277.6 462 232 462c-59.8 0-115.2-26.3-154.8-73.5-32.2-38.3-48.8-88.7-46.9-142 2-53.7 22.1-99.6 60.7-138.5 28.1-28.3 63-47.2 102.9-55.7 11.9-2.6 14.1-2.8 38.6-2.8 28.3 0 39.4 1.3 59 7 27.9 8.1 58.5 26.1 80.9 47.6l10.9 10.5-14.3 6c-7.8 3.2-14.5 6.4-14.7 7-.2.7 16.5 41.3 37.1 90.4 20.7 49.1 37.6 90.1 37.6 91.1 0 3.4-7.1 24.2-11.6 33.8-2.4 5.1-6.6 13.1-9.4 17.7l-5 8.3 6.6 15.6c5.5 12.9 6.9 15.4 8 14.4 2.8-2.3 19.2-27.8 24.4-37.9l5.3-10.3 8.3 19.8c4.7 10.9 8.7 20.1 8.9 20.3.6.6 60.8-24.6 74.5-31.2 29.7-14.2 52.7-35.8 65.3-61.1 16.9-34 17.6-70.8 2.2-112-17-45.3-45.8-76.7-82.5-90-18.2-6.5-43.1-9.1-63.5-6.5-11.5 1.4-30 5.8-40.3 9.5l-7.3 2.6L402.3 91c-20.3-21.3-37.9-34.3-65.4-48.3-33.4-17.1-63.7-23.8-105.9-23.6-10.7.1-24.2.7-30 1.5zM504.5 122c9.2 2.5 22.1 8.3 29.2 13.1 6.9 4.7 18.7 16.3 24.3 23.9 15.1 20.6 26.3 49 29 74 2.3 20.7-3.1 43-14.5 60.5-8.7 13.3-27.6 29.5-44.5 38-6 3.1-47.3 20.6-47.5 20.2-1.1-1.6-87.5-208-87.3-208.3.2-.2 7.4-3.3 15.8-6.9 22.5-9.5 33.8-13 55-16.9 1.4-.2 9.3-.3 17.5-.1 11.5.2 16.9.8 23 2.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,8 @@
name: opdavies
description: A starter kit theme for Drupal 8 and Tailwind CSS.
core_version_requirement: ^8 || ^9
type: theme
base theme: stable
libraries:
- opdavies/global-scripts
- opdavies/global-styling

View file

@ -0,0 +1,21 @@
global-styling:
css:
base:
build/app.css: {}
'https://fonts.gstatic.com':
attributes:
preconnect: true
'https://fonts.googleapis.com/css2?family=Roboto+Condensed:ital,wght@0,300;0,700;1,300&display=swap': {}
global-scripts:
js:
build/app.js: {}
twitter:
js:
https://platform.twitter.com/widgets.js:
type: external
minified: true
attributes:
async: true
charset: 'utf-8'

View file

@ -0,0 +1,27 @@
<?php
/**
* @file
* Functions to support theming in the Tailwind CSS theme.
*/
use Drupal\opdavies_blog\Entity\Node\Post;
/**
* Implements hook_preprocess_HOOK().
*/
function opdavies_preprocess_page(array &$variables): void {
/** @var Post|null $node */
$node = $variables['node'] ?? NULL;
if (!$node) {
return;
}
if (!$node instanceof Post) {
return;
}
if ($node->hasTweet()) {
$variables['#attached']['library'][] = 'opdavies/twitter';
}
}

View file

@ -0,0 +1,23 @@
{
"private": true,
"scripts": {
"build:css": "/app/run yarn:build:css",
"build:js": "/app/run yarn:build:js"
},
"dependencies": {
"@tailwindcss/aspect-ratio": "^0.2.0",
"@tailwindcss/forms": "^0.2.1",
"@tailwindcss/typography": "^0.4.0",
"alpinejs": "^2.3.5",
"autoprefixer": "^10.2.5",
"elliptic": ">=6.5.3",
"esbuild": "^0.14.8",
"focus-visible": "^5.1.0",
"highlight.js": "^10.4.1",
"lodash": ">=4.17.19",
"postcss": "^8.2.1",
"postcss-easy-import": "^3.0.0",
"postcss-nested": "^5.0.5",
"tailwindcss": "^2.2.19"
}
}

View file

@ -0,0 +1,10 @@
module.exports = {
plugins: {
'postcss-easy-import': {
extensions: ['.css', '.pcss']
},
tailwindcss: {},
'postcss-nested': {},
autoprefixer: {}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -0,0 +1,61 @@
const colors = require('./tailwindcss/colours')
const defaultTheme = require('tailwindcss/defaultTheme')
const { fontFamily } = defaultTheme
module.exports = {
mode: 'jit',
darkMode: 'media',
important: true,
purge: {
content: [
'../../../../config/**/*.yml',
'tailwindcss/safelist-classes.txt',
'templates/**/*.html.twig'
],
},
theme: {
outline: {
black: '1px solid black',
white: '1px solid white'
},
extend: {
colors,
fontFamily: {
sans: [
'Roboto Condensed',
'Arial',
'Helvetica Neue',
'Helvetica',
'sans-serif',
],
mono: [
'ui-monospace',
'SFMono-Regular',
'SF Mono',
'Consolas',
'Liberation Mono',
...fontFamily.mono
]
},
spacing: {
18: '4.5rem',
'2px': '2px'
},
borderWidth: {
3: '3px'
},
width: {
96: '24rem'
}
}
},
corePlugins: {
container: false
},
plugins: [
require('./tailwindcss/plugins/focus-visible'),
require('@tailwindcss/aspect-ratio'),
require('@tailwindcss/forms'),
require('@tailwindcss/typography')
]
}

View file

@ -0,0 +1,13 @@
let { gray } = require('tailwindcss/colors')
module.exports = {
black: '#000',
blue: {
primary: '#24608A',
},
current: 'currentColor',
gray,
inherit: 'inherit',
transparent: 'transparent',
white: '#fff'
}

View file

@ -0,0 +1,9 @@
const plugin = require('tailwindcss/plugin')
module.exports = plugin(function({ addVariant, e }) {
addVariant('focus-visible', ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.${e(`focus-visible${separator}${className}`)}[data-focus-visible-added]`
})
})
})

View file

@ -0,0 +1,34 @@
bg-blue-600
bg-gray-200
block
block-opdavies-branding
border
border-gray
border-l-0
flex
flex-col
flex-shrink-0
flex-none
italic
mb-4
my-auto
p-3
pl-5
pr-6
py-3
rounded-full
rounded-l-full
rounded-r-full
sm:flex-row
sm:space-x-4
sm:space-y-0
space-x-3
space-y-3
space-y-4
text-center
text-sm
text-white
w-16
w-32
w-auto
w-full

View file

@ -0,0 +1,37 @@
{#
/**
* @file
* Theme override to display a block.
*
* Available variables:
* - plugin_id: The ID of the block implementation.
* - label: The configured label of the block if visible.
* - configuration: A list of the block's configuration values.
* - label: The configured label for the block.
* - label_display: The display settings for the label.
* - provider: The module or other provider that provided this block plugin.
* - Block plugin specific settings will also be stored here.
* - content: The content of this block.
* - attributes: array of HTML attributes populated by modules, intended to
* be added to the main container tag of this template.
* - id: A valid HTML ID and guaranteed unique.
* - title_attributes: Same as attributes, except applied to the main title
* tag that appears in the template.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
*
* @see template_preprocess_block()
*/
#}
<div{{ attributes.addClass('mt-10') }}>
{{ title_prefix }}
{% if label %}
<h2{{ title_attributes }}>{{ label }}</h2>
{% endif %}
{{ title_suffix }}
{% block content %}
{{ content }}
{% endblock %}
</div>

View file

@ -0,0 +1,37 @@
{#
/**
* @file
* Theme override to display a block.
*
* Available variables:
* - plugin_id: The ID of the block implementation.
* - label: The configured label of the block if visible.
* - configuration: A list of the block's configuration values.
* - label: The configured label for the block.
* - label_display: The display settings for the label.
* - provider: The module or other provider that provided this block plugin.
* - Block plugin specific settings will also be stored here.
* - content: The content of this block.
* - attributes: array of HTML attributes populated by modules, intended to
* be added to the main container tag of this template.
* - id: A valid HTML ID and guaranteed unique.
* - title_attributes: Same as attributes, except applied to the main title
* tag that appears in the template.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
*
* @see template_preprocess_block()
*/
#}
<div{{ attributes.addClass('mt-20') }}>
{{ title_prefix }}
{% if label %}
<h2{{ title_attributes }}>{{ label }}</h2>
{% endif %}
{{ title_suffix }}
{% block content %}
{{ content }}
{% endblock %}
</div>

View file

@ -0,0 +1,37 @@
{#
/**
* @file
* Theme override to display a block.
*
* Available variables:
* - plugin_id: The ID of the block implementation.
* - label: The configured label of the block if visible.
* - configuration: A list of the block's configuration values.
* - label: The configured label for the block.
* - label_display: The display settings for the label.
* - provider: The module or other provider that provided this block plugin.
* - Block plugin specific settings will also be stored here.
* - content: The content of this block.
* - attributes: array of HTML attributes populated by modules, intended to
* be added to the main container tag of this template.
* - id: A valid HTML ID and guaranteed unique.
* - title_attributes: Same as attributes, except applied to the main title
* tag that appears in the template.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
*
* @see template_preprocess_block()
*/
#}
<div{{ attributes.addClass('mb-6') }}>
{{ title_prefix }}
{% if label %}
<h2{{ title_attributes }}>{{ label }}</h2>
{% endif %}
{{ title_suffix }}
{% block content %}
{{ content }}
{% endblock %}
</div>

View file

@ -0,0 +1,37 @@
{#
/**
* @file
* Theme override to display a block.
*
* Available variables:
* - plugin_id: The ID of the block implementation.
* - label: The configured label of the block if visible.
* - configuration: A list of the block's configuration values.
* - label: The configured label for the block.
* - label_display: The display settings for the label.
* - provider: The module or other provider that provided this block plugin.
* - Block plugin specific settings will also be stored here.
* - content: The content of this block.
* - attributes: array of HTML attributes populated by modules, intended to
* be added to the main container tag of this template.
* - id: A valid HTML ID and guaranteed unique.
* - title_attributes: Same as attributes, except applied to the main title
* tag that appears in the template.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
* displayed after the main title tag that appears in the template.
*
* @see template_preprocess_block()
*/
#}
<div{{ attributes.addClass('mb-4') }}>
{{ title_prefix }}
{% if label %}
<h2{{ title_attributes }}>{{ label }}</h2>
{% endif %}
{{ title_suffix }}
{% block content %}
{{ content }}
{% endblock %}
</div>

Some files were not shown because too many files have changed in this diff Show more