Adiciona sub-módulo site_users_blog e melhora negociador de tema

Sub-módulo site_users_blog:
- Tipo de conteúdo blog_post (título, corpo, imagem, assuntos)
- Vocabulário blog_tags para categorias
- Listagem em /user/{uid}/blog via Views com filtro contextual por autor
- Padrão Pathauto: user/[node:author:uid]/blog/[node:title]
- hook_node_presave: preenche field_site_section com o autor
- hook_node_access: restringe criação às roles configuradas
- hook_preprocess_structural_pages_menu: injeta item "Blog" quando
  usuário tem posts publicados
- Plugin BlogUserHandler: resolve usuário ancestral para rotas de blog
  (post individual e listagem Views)
- Link "Post de blog" no menu "Adicionar" da conta
- Página de configuração de roles permitidas
- Update 10001: adiciona field_site_section a posts existentes

MicrositeThemeNegotiator:
- Injeta path.current para cobrir rotas sem parâmetro 'user' (ex.: Views)
- Qualquer path /user/{uid}/... recebe o tema do microsite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 07:32:10 -03:00
parent 39de6a7493
commit d72f41de97
24 changed files with 931 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.blog_post.field_blog_image
- field.field.node.blog_post.field_blog_tags
- node.type.blog_post
module:
- image
- taxonomy
targetEntityType: node
bundle: blog_post
mode: default
content:
title:
type: string_textfield
weight: -5
region: content
settings:
size: 60
placeholder: ''
third_party_settings: {}
field_blog_image:
type: image_image
weight: 1
region: content
settings:
progress_indicator: throbber
preview_image_style: thumbnail
third_party_settings: {}
body:
type: text_textarea_with_summary
weight: 2
region: content
settings:
rows: 20
summary_rows: 5
placeholder: ''
show_summary: false
third_party_settings: {}
field_blog_tags:
type: entity_reference_autocomplete_tags
weight: 3
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: {}
status:
type: boolean_checkbox
weight: 10
region: content
settings:
display_label: true
third_party_settings: {}
hidden:
created: true
field_site_section: true
promote: true
sticky: true
uid: true

View File

@@ -0,0 +1,48 @@
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.blog_post.field_blog_image
- field.field.node.blog_post.field_blog_tags
- node.type.blog_post
module:
- image
- taxonomy
- user
targetEntityType: node
bundle: blog_post
mode: default
content:
field_blog_image:
type: image
label: hidden
weight: 0
region: content
settings:
image_style: large
image_link: ''
image_loading:
attribute: lazy
third_party_settings: {}
body:
type: text_default
label: hidden
weight: 1
region: content
settings: {}
third_party_settings: {}
field_blog_tags:
type: entity_reference_label
label: above
weight: 2
region: content
settings:
link: true
third_party_settings: {}
links:
weight: 100
region: content
settings: {}
third_party_settings: {}
hidden:
field_site_section: true

View File

@@ -0,0 +1,49 @@
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.blog_post.field_blog_image
- field.field.node.blog_post.field_blog_tags
- node.type.blog_post
module:
- image
- taxonomy
- user
targetEntityType: node
bundle: blog_post
mode: teaser
content:
field_blog_image:
type: image
label: hidden
weight: 0
region: content
settings:
image_style: medium
image_link: content
image_loading:
attribute: lazy
third_party_settings: {}
body:
type: text_summary_or_trimmed
label: hidden
weight: 1
region: content
settings:
trim_length: 300
third_party_settings: {}
field_blog_tags:
type: entity_reference_label
label: hidden
weight: 2
region: content
settings:
link: true
third_party_settings: {}
links:
weight: 100
region: content
settings: {}
third_party_settings: {}
hidden:
field_site_section: true

View File

@@ -0,0 +1,34 @@
langcode: pt-br
status: true
dependencies:
config:
- field.storage.node.field_blog_image
- node.type.blog_post
entity_type: node
field_name: field_blog_image
bundle: blog_post
label: 'Imagem de destaque'
description: ''
required: false
translatable: true
default_value: {}
default_value_callback: ''
settings:
file_directory: 'blog/[date:custom:Y-m]'
file_extensions: 'png gif jpg jpeg webp'
max_filesize: ''
max_resolution: ''
min_resolution: ''
alt_field: true
alt_field_required: false
title_field: false
title_field_required: false
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
handler: default:file
handler_settings: {}
field_type: image

View File

@@ -0,0 +1,27 @@
langcode: pt-br
status: true
dependencies:
config:
- field.storage.node.field_blog_tags
- node.type.blog_post
- taxonomy.vocabulary.blog_tags
entity_type: node
field_name: field_blog_tags
bundle: blog_post
label: Assuntos
description: ''
required: false
translatable: false
default_value: {}
default_value_callback: ''
settings:
handler: default:taxonomy_term
handler_settings:
target_bundles:
blog_tags: blog_tags
sort:
field: name
direction: asc
auto_create: true
auto_create_bundle: blog_tags
field_type: entity_reference

View File

@@ -0,0 +1,21 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_site_section
- node.type.blog_post
module:
- dynamic_entity_reference
entity_type: node
field_name: field_site_section
bundle: blog_post
label: 'Seção do site'
description: ''
required: false
translatable: false
default_value: {}
default_value_callback: ''
settings:
handler: default
handler_settings: {}
field_type: dynamic_entity_reference

View File

@@ -0,0 +1,37 @@
langcode: en
status: true
dependencies:
module:
- image
entity_type: node
field_name: field_blog_image
type: image
settings:
uri_scheme: public
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
column_groups:
file:
label: File
columns:
- target_id
- width
- height
translatable: false
alt:
label: Alt
translatable: true
title:
label: Title
translatable: true
module: image
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:
- taxonomy
entity_type: node
field_name: field_blog_tags
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,10 @@
langcode: pt-br
status: true
dependencies: {}
name: 'Post de blog'
type: blog_post
description: 'Publicação em um blog de usuário.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: true

View File

@@ -0,0 +1 @@
allowed_roles: {}

View File

@@ -0,0 +1,8 @@
langcode: pt-br
status: true
dependencies: {}
name: 'Assuntos (blog)'
vid: blog_tags
description: 'Categorias e assuntos para posts de blog.'
hierarchy: 0
weight: 0

View File

@@ -0,0 +1,235 @@
langcode: pt-br
status: true
dependencies:
config:
- node.type.blog_post
module:
- node
- user
id: user_blog
label: 'Blog do usuário'
module: views
description: 'Lista posts do blog de um usuário, acessível em /user/{uid}/blog.'
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
id: default
display_title: Default
display_plugin: default
position: 0
display_options:
title: Blog
pager:
type: full
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
tags:
previous: ' Anterior'
next: 'Próximo '
first: '« Primeira'
last: 'Última »'
expose:
items_per_page: false
items_per_page_label: 'Itens por página'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- Todos -'
offset: false
offset_label: Deslocamento
quantity: 9
style:
type: default
options:
grouping: []
row_class: ''
default_row_class: true
row:
type: 'entity:node'
options:
relationship: none
view_mode: teaser
fields: {}
filters:
status:
id: status
table: node_field_data
field: status
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: status
plugin_id: boolean
operator: '='
value: '1'
group: 1
exposed: false
expose:
operator: ''
is_grouped: false
type:
id: type
table: node_field_data
field: type
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: type
plugin_id: bundle
operator: in
value:
blog_post: blog_post
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
operator_limit_selection: false
operator_list: {}
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: {}
group_items: {}
sorts:
created:
id: created
table: node_field_data
field: created
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: created
plugin_id: date
order: DESC
expose:
label: ''
granularity: second
arguments:
uid:
id: uid
table: node_field_data
field: uid
relationship: none
group_type: group
admin_label: Usuário
entity_type: node
entity_field: uid
plugin_id: node_uid
default_action: 'not found'
exception:
value: all
title_enable: false
title: All
title_enable: false
title: ''
default_argument_type: raw
default_argument_options:
index: 1
use_alias: false
summary_options:
base_path: ''
count: true
items_per_page: 25
override: false
summary:
sort_order: asc
number_of_records: 0
format: default_summary
specify_validation: true
validate:
type: 'entity:user'
fail: 'not found'
validate_options:
access: false
operation: view
multiple: '0'
roles: {}
break_phrase: false
not: false
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: {}
empty:
area_text_custom:
id: area_text_custom
table: views
field: area_text_custom
relationship: none
group_type: group
admin_label: ''
plugin_id: text_custom
content: 'Nenhum post publicado ainda.'
tokenize: false
empty: true
header: {}
footer: {}
relationships: {}
use_ajax: false
query:
type: views_query
options:
query_comment: ''
disable_sql_rewrite: false
distinct: false
replica: false
query_tags: []
exposed_form:
type: basic
options:
submit_button: Aplicar
reset_button: false
reset_button_label: Resetar
exposed_sorts_label: 'Ordenar por'
expose_sort_order: true
sort_asc_label: Crescente
sort_desc_label: Decrescente
use_more: false
use_more_always: true
use_more_text: mais
display_extenders: {}
rendering_language: '***LANGUAGE_language_interface***'
page_user_blog:
id: page_user_blog
display_title: 'Página do blog'
display_plugin: page
position: 1
display_options:
display_extenders: {}
path: 'user/%/blog'
menu:
type: none
title: ''
description: ''
expanded: false
parent: ''
weight: 0
context: '0'
menu_name: main

View File

@@ -0,0 +1,20 @@
langcode: pt-br
status: true
dependencies:
module:
- node
- pathauto
- user
id: blog_post
label: 'Blog post'
type: 'canonical_entities:node'
pattern: 'user/[node:author:uid]/blog/[node:title]'
selection_criteria:
bundle:
id: 'entity_bundle:node'
negate: false
bundles:
blog_post: blog_post
selection_logic: and
weight: 0
relationships: {}

View File

@@ -0,0 +1,9 @@
site_users_blog.settings:
type: config_object
label: 'Configurações do blog de usuário'
mapping:
allowed_roles:
type: sequence
label: 'Roles com permissão para criar posts'
sequence:
type: boolean

View File

@@ -0,0 +1,12 @@
name: 'Site Users — Blog'
type: module
description: 'Blog por usuário integrado ao microsite pessoal.'
package: 'Site Users'
core_version_requirement: ^11
dependencies:
- drupal:node
- drupal:taxonomy
- drupal:views
- drupal:user
- site_users:site_users
- structural_pages:structural_pages

View File

@@ -0,0 +1,79 @@
<?php
/**
* @file
* Instalação do módulo site_users_blog.
*/
/**
* Implements hook_install().
*/
function site_users_blog_install(): void {
// Adiciona o campo body ao tipo blog_post.
$type = \Drupal::entityTypeManager()
->getStorage('node_type')
->load('blog_post');
if ($type) {
node_add_body_field($type, 'Conteúdo');
}
}
/**
* Adiciona field_site_section ao blog_post e preenche posts existentes.
*/
function site_users_blog_update_10001(): string {
$entity_type_manager = \Drupal::entityTypeManager();
// Cria a instância do campo se ainda não existir.
if (!\Drupal\field\Entity\FieldConfig::loadByName('node', 'blog_post', 'field_site_section')) {
\Drupal\field\Entity\FieldConfig::create([
'field_name' => 'field_site_section',
'entity_type' => 'node',
'bundle' => 'blog_post',
'label' => 'Seção do site',
'required' => FALSE,
'translatable' => FALSE,
])->save();
// Oculta o campo no formulário padrão.
$form_display = $entity_type_manager
->getStorage('entity_form_display')
->load('node.blog_post.default');
if ($form_display) {
$form_display->removeComponent('field_site_section')->save();
}
// Oculta o campo nas exibições padrão e teaser.
foreach (['default', 'teaser'] as $mode) {
$view_display = $entity_type_manager
->getStorage('entity_view_display')
->load('node.blog_post.' . $mode);
if ($view_display) {
$view_display->removeComponent('field_site_section')->save();
}
}
}
// Preenche posts existentes que ainda não têm field_site_section.
$nids = $entity_type_manager->getStorage('node')->getQuery()
->condition('type', 'blog_post')
->notExists('field_site_section')
->accessCheck(FALSE)
->execute();
$count = 0;
foreach ($nids as $nid) {
$node = $entity_type_manager->getStorage('node')->load($nid);
if ($node) {
$node->set('field_site_section', [
'target_type' => 'user',
'target_id' => $node->getOwnerId(),
]);
$node->save();
$count++;
}
}
return "field_site_section adicionado ao blog_post; $count posts existentes atualizados.";
}

View File

@@ -0,0 +1,15 @@
site_users_blog.add_blog_post:
title: 'Post de blog'
route_name: node.add
route_parameters:
node_type: blog_post
menu_name: account
parent: site_users.add_content
weight: 10
site_users_blog.settings:
title: 'Blog de usuário'
description: 'Configura roles com permissão para publicar posts de blog.'
route_name: site_users_blog.settings
parent: site_users_microsite.settings
weight: 10

View File

@@ -0,0 +1,102 @@
<?php
/**
* @file
* Blog pessoal por usuário.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
use Drupal\user\UserInterface;
/**
* Implements hook_node_presave().
*
* Preenche field_site_section com o autor do post para que o bloco de
* navegação do structural_pages encontre o usuário ancestral corretamente.
*/
function site_users_blog_node_presave(NodeInterface $node): void {
if ($node->bundle() !== 'blog_post') {
return;
}
if (!$node->hasField('field_site_section') || !$node->get('field_site_section')->isEmpty()) {
return;
}
$node->set('field_site_section', [
'target_type' => 'user',
'target_id' => $node->getOwnerId(),
]);
}
/**
* Implements hook_node_access().
*
* Restringe a criação de blog_post às roles configuradas pelo administrador.
* Se nenhuma role estiver configurada, qualquer usuário autenticado pode criar.
*/
function site_users_blog_node_access(NodeInterface $node, string $op, AccountInterface $account): AccessResultInterface {
if ($node->bundle() !== 'blog_post' || $op !== 'create') {
return AccessResult::neutral();
}
$allowed = \Drupal::config('site_users_blog.settings')->get('allowed_roles') ?? [];
$allowed = array_filter($allowed);
$result = AccessResult::neutral()
->addCacheTags(['config:site_users_blog.settings'])
->cachePerUser();
if (empty($allowed)) {
return $result;
}
foreach (array_keys($allowed) as $role) {
if ($account->hasRole($role)) {
return $result;
}
}
return AccessResult::forbidden()
->addCacheTags(['config:site_users_blog.settings'])
->cachePerUser();
}
/**
* Implements hook_preprocess_structural_pages_menu().
*
* Adiciona o item "Blog" ao menu do microsite quando o usuário tem pelo menos
* um post publicado.
*/
function site_users_blog_preprocess_structural_pages_menu(array &$variables): void {
$user = site_users_get_microsite_user();
if ($user === NULL) {
return;
}
$nids = \Drupal::entityTypeManager()->getStorage('node')
->getQuery()
->condition('uid', $user->id())
->condition('type', 'blog_post')
->condition('status', 1)
->accessCheck(FALSE)
->range(0, 1)
->execute();
if (empty($nids)) {
return;
}
$url = \Drupal\Core\Url::fromRoute('view.user_blog.page_user_blog', [
'arg_0' => $user->id(),
])->toString();
$variables['tree'][] = [
'id' => 0,
'title' => t('Blog'),
'url' => $url,
'is_redirect' => FALSE,
'children' => [],
];
}

View File

@@ -0,0 +1,3 @@
administer site_users_blog settings:
title: 'Administrar configurações do blog de usuário'
restrict access: true

View File

@@ -0,0 +1,7 @@
site_users_blog.settings:
path: '/admin/config/local-modules/site-users/blog'
defaults:
_form: '\Drupal\site_users_blog\Form\BlogSettingsForm'
_title: 'Blog de usuário'
requirements:
_permission: 'administer site_users_blog settings'

View File

@@ -0,0 +1,63 @@
<?php
namespace Drupal\site_users_blog\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\user\RoleInterface;
/**
* Formulário de configuração do blog de usuário.
*/
class BlogSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames(): array {
return ['site_users_blog.settings'];
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'site_users_blog_settings_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('site_users_blog.settings');
$allowed = $config->get('allowed_roles') ?? [];
$roles = user_roles(TRUE);
unset($roles[RoleInterface::AUTHENTICATED_ID]);
$options = array_map(fn($role) => $role->label(), $roles);
$form['allowed_roles'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Roles que podem criar posts de blog'),
'#description' => $this->t('Nenhuma seleção significa que qualquer usuário autenticado pode criar posts.'),
'#options' => $options,
'#default_value' => array_keys(array_filter($allowed)),
];
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$selected = array_filter($form_state->getValue('allowed_roles'));
$this->config('site_users_blog.settings')
->set('allowed_roles', $selected)
->save();
parent::submitForm($form, $form_state);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\site_users_blog\Plugin\ParentEntityHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\node\NodeInterface;
use Drupal\structural_pages\Attribute\ParentEntityHandler;
use Drupal\structural_pages\ParentEntityHandler\ParentEntityHandlerBase;
use Drupal\user\UserInterface;
/**
* Determina o usuário pai a partir de rotas de blog (post ou listagem).
*
* Cobre:
* - entity.node.canonical de blog_post → retorna o autor
* - view.user_blog.page_user_blog → retorna o usuário cujo UID é arg_0
*/
#[ParentEntityHandler(
id: 'blog_user',
label: new TranslatableMarkup('Blog do usuário'),
entity_type_id: 'user',
clears_site_section: FALSE,
sort_field: 'name',
weight: 20,
)]
class BlogUserHandler extends ParentEntityHandlerBase {
/**
* {@inheritdoc}
*/
public function getEntityFromRoute(RouteMatchInterface $route_match): ?EntityInterface {
$route_name = $route_match->getRouteName() ?? '';
if ($route_name === 'entity.node.canonical') {
$node = $route_match->getParameter('node');
if ($node instanceof NodeInterface && $node->bundle() === 'blog_post') {
return $node->getOwner();
}
return NULL;
}
if ($route_name === 'view.user_blog.page_user_blog') {
$uid = $route_match->getParameter('arg_0');
if (is_numeric($uid)) {
return $this->entityTypeManager->getStorage('user')->load($uid);
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function handlesEntity(EntityInterface $entity): bool {
return $entity instanceof UserInterface;
}
}

View File

@@ -8,7 +8,7 @@ services:
site_users_microsite.theme_negotiator:
class: Drupal\site_users_microsite\Theme\MicrositeThemeNegotiator
arguments: ['@path_alias.manager']
arguments: ['@path_alias.manager', '@path.current']
tags:
- { name: theme_negotiator, priority: 100 }

View File

@@ -2,6 +2,7 @@
namespace Drupal\site_users_microsite\Theme;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Theme\ThemeNegotiatorInterface;
use Drupal\path_alias\AliasManagerInterface;
@@ -14,6 +15,7 @@ class MicrositeThemeNegotiator implements ThemeNegotiatorInterface {
public function __construct(
private AliasManagerInterface $aliasManager,
private CurrentPathStack $currentPath,
) {}
/**
@@ -55,7 +57,7 @@ class MicrositeThemeNegotiator implements ThemeNegotiatorInterface {
}
}
// Nós cujo alias começa com /user/{uid}/ (ex.: structural_pages).
// Nós cujo alias começa com /user/{uid}/ (ex.: structural_pages, blog).
if ($route_name === 'entity.node.canonical') {
$node = $route_match->getParameter('node');
if ($node) {
@@ -67,6 +69,12 @@ class MicrositeThemeNegotiator implements ThemeNegotiatorInterface {
}
}
// Qualquer rota cujo path atual (já processado) seja /user/{uid}/...
// Cobre Views e outras rotas que não expõem parâmetro 'user' na rota.
if (preg_match('#^/user/\d+/#', $this->currentPath->getPath())) {
return TRUE;
}
return FALSE;
}