Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/Http/Controllers/FeedController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace App\Http\Controllers;

use App\Models\WpPost;
use App\Models\WpTerm;
use Illuminate\Http\Response;

class FeedController extends Controller
{
private const SITEMAP_POST_LIMIT = 2000;

private const FEED_POST_LIMIT = 20;

public function sitemap(): Response
{
$xml = cache()->remember('sitemap_xml', now()->addHours(6), function () {
$posts = WpPost::query()
->select(['ID', 'post_name', 'post_date'])
->limit(self::SITEMAP_POST_LIMIT)
->get();

$categories = self::terms('category');
$tags = self::terms('post_tag');

return view('sitemap', compact('posts', 'categories', 'tags'))->render();
});

return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}

/**
* Kept at the address WordPress served, so existing subscribers keep working.
*/
public function feed(): Response
{
$xml = cache()->remember('feed_xml', now()->addHour(), function () {
$posts = WpPost::query()
->with(['author'])
->limit(self::FEED_POST_LIMIT)
->get();

return view('feed', compact('posts'))->render();
});

return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}

/**
* @return \Illuminate\Support\Collection<int, \stdClass>
*/
private static function terms(string $taxonomy)
{
return WpTerm::query()
->select(['wp_terms.slug'])
->join('wp_term_taxonomy', 'wp_term_taxonomy.term_id', '=', 'wp_terms.term_id')
->where('wp_term_taxonomy.taxonomy', $taxonomy)
->where('wp_term_taxonomy.count', '>', 0)
->get();
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/PostController.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public function show($year, $month, $slug)
$relatedPosts = WpPostService::getRelatedPosts($post);

return inertia('PostContent', [
'post' => WpPostResource::make($post),
'post' => WpPostResource::make($post)->detailed(),
'relatedPosts' => WpPostResource::collection($relatedPosts),
]);
}
Expand Down
24 changes: 23 additions & 1 deletion app/Http/Resources/WpPostResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@
*/
class WpPostResource extends JsonResource
{
/**
* Whether to include the heavy, single-post-only fields.
*/
private bool $detailed = false;

/**
* Opt in to the full payload: processed content, subtitle, tags and author
* biography.
*
* This used to be inferred from $request->routeIs('post'), which meant the
* related posts rendered on that same page were also given the full
* treatment — including running EmbedProcessorService over their content —
* even though the component that renders them only reads the title,
* thumbnail, slug and date.
*/
public function detailed(): static
{
$this->detailed = true;

return $this;
}

/**
* Transform the resource into an array.
*
Expand All @@ -50,7 +72,7 @@ public function toArray(Request $request): array
],
];

if ($request->routeIs('post')) {
if ($this->detailed) {
$postData['content'] = EmbedProcessorService::processContent($this->post_content);
$postData['subtitle'] = $this->metadata->filter(fn ($meta) => $meta->meta_key === 'subtitle')?->first()?->meta_value;
$postData['date']['day'] = $parsedPostDate->format('d');
Expand Down
21 changes: 21 additions & 0 deletions app/Models/WpAttachment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

/**
* Attachment rows of wp_posts.
*
* Same table as WpPost but deliberately without WpPostScope: that scope filters
* post_type = 'post' and post_status = 'publish', which would exclude every
* attachment (post_type = 'attachment', post_status = 'inherit').
*/
class WpAttachment extends Model
{
protected $table = 'wp_posts';

protected $primaryKey = 'ID';

public $timestamps = false;
}
31 changes: 23 additions & 8 deletions app/Models/WpPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Facades\DB;

#[ScopedBy(WpPostScope::class)]
Expand All @@ -35,16 +36,30 @@ public function author(): BelongsTo
]);
}

public function getThumbnail()
/**
* Featured image, resolved through the _thumbnail_id meta row.
*
* Modelled as a relation so it can be eager loaded: wp_posts -> wp_postmeta
* (_thumbnail_id) -> wp_posts (the attachment). Reading it through
* $this->metadata() instead would issue two queries for every post.
*/
public function thumbnail(): HasOneThrough
{
if (! $postMeta = $this->metadata()->thumbnailId()->first()) {
return '';
}
return $this->hasOneThrough(
WpAttachment::class,
WpPostMeta::class,
'post_id', // wp_postmeta.post_id -> wp_posts.ID (this model)
'ID', // wp_posts.ID (attachment) -> wp_postmeta.meta_value
'ID',
'meta_value',
)
->where('wp_postmeta.meta_key', '_thumbnail_id')
->where('wp_posts.post_type', 'attachment');
}

return DB::table('wp_posts')
->where('ID', $postMeta->meta_value)
->where('post_type', 'attachment')
->value('guid') ?? '';
public function getThumbnail(): string
{
return $this->thumbnail?->guid ?? '';
}

#[Scope]
Expand Down
5 changes: 4 additions & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
Expand All @@ -19,6 +20,8 @@ public function register(): void
*/
public function boot(): void
{
//
// Turns an accidental lazy load into a loud failure while developing
// and in CI, instead of a silent extra query per record in production.
Model::preventLazyLoading(! $this->app->isProduction());
}
}
32 changes: 20 additions & 12 deletions app/Services/WpPostService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static function getHighlightedPosts(): Collection

return cache()->remember($cacheKey, $cacheTTL, function () {
return WpPost::query()
->with(['metadata'])
->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug('destaques')
->limit(self::DEFAULT_LIMIT_HIGHLIGHT_POSTS)
->get();
Expand All @@ -34,10 +34,11 @@ public static function getHomePosts(int $page, ?string $searchTerm = null): Leng

return cache()->remember($cacheKey, $cacheTTL, function () use ($searchTerm) {
return WpPost::query()
->with(['metadata'])
->with(['terms', 'author', 'thumbnail'])
->whereNotCategorySlug('destaques')
->useSearchTerm($searchTerm)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS)
->withQueryString();
});
}

Expand All @@ -48,9 +49,10 @@ public static function getPostsByCategorySlug(int $page, string $slug): LengthAw

return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug($slug)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS)
->withQueryString();
});
}

Expand All @@ -61,9 +63,10 @@ public static function getPostsByAuthorNicename(int $page, string $nicename): Le

return cache()->remember($cacheKey, $cacheTTL, function () use ($nicename) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereAuthorNicename($nicename)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS)
->withQueryString();
});
}

Expand All @@ -74,10 +77,11 @@ public static function getPostsByYearMonth(int $page, string $year, string $mont

return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereYear('post_date', $year)
->whereMonth('post_date', $month)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS)
->withQueryString();
});
}

Expand All @@ -88,9 +92,10 @@ public static function getPostsByTagSlug(int $page, string $slug): LengthAwarePa

return cache()->remember($cacheKey, $cacheTTL, function () use ($slug) {
return WpPost::query()
->with(['metadata', 'author'])
->with(['terms', 'author', 'thumbnail'])
->whereTagSlug($slug)
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS);
->paginate(self::DEFAULT_PER_PAGE_ON_LISTS)
->withQueryString();
});
}

Expand All @@ -101,6 +106,9 @@ public static function getPostByYearMonthSlug($year, $month, $slug): WpPost

return cache()->remember($cacheKey, $cacheTTL, function () use ($year, $month, $slug) {
return WpPost::query()
// The single post view also renders the subtitle meta and the
// author biography, so it needs more than the listings do.
->with(['terms', 'thumbnail', 'metadata', 'author.metadata'])
->whereYear('post_date', $year)
->whereMonth('post_date', $month)
->where('post_name', $slug)
Expand All @@ -124,7 +132,7 @@ public static function getRelatedPosts(WpPost $post, int $limit = 3): Collection
}

return WpPost::query()
->with(['metadata'])
->with(['terms', 'author', 'thumbnail'])
->whereCategorySlug($categorySlug)
->where('ID', '!=', $post->ID)
->limit($limit)
Expand Down
3 changes: 2 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
Expand Down
2 changes: 2 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
User-agent: *
Disallow:

Sitemap: https://sourcevortex.com.br/sitemap.xml
19 changes: 18 additions & 1 deletion resources/js/components/PostCardListPagination.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,22 @@ const props = defineProps<{

const currentPage = ref(props.backendCurrentPage);

/**
* Real URL for a page number.
*
* Rendered into href so crawlers can follow pagination, and so the current
* query string survives — navigating with only { page } used to drop ?s= and
* silently turn page 2 of a search into the plain home listing.
*/
const pageUrl = (page: number) => {
const url = new URL(window.location.href);
url.searchParams.set('page', String(page));

return url.pathname + url.search;
};

watch(currentPage, (newPage) => {
router.get(window.location.pathname, { page: newPage }, { preserveState: true, preserveScroll: true, replace: true });
router.get(pageUrl(newPage), {}, { preserveState: true, preserveScroll: true, replace: true });
});
</script>

Expand All @@ -32,8 +46,11 @@ watch(currentPage, (newPage) => {
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
as="a"
:href="pageUrl(item.value)"
class="cursor-pointer border-0 bg-transparent! text-2xl text-[#e6c619] shadow-none"
:class="{ 'font-bold text-black dark:text-white': item.value === page }"
@click.prevent="currentPage = item.value"
>
{{ item.value }}
</PaginationItem>
Expand Down
4 changes: 3 additions & 1 deletion resources/views/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
})();
</script>

<title inertia>{{ config('app.name', 'Laravel') }}</title>
{{-- The per-page <title> lives in the seo partial, which already
computes it for every component. Keeping it there means it is
correct without depending on SSR being available. --}}

<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.webp" type="image/webp">
Expand Down
24 changes: 24 additions & 0 deletions resources/views/feed.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php echo '<?xml version="1.0" encoding="UTF-8"?>'."\n"; ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ config('app.name') }}</title>
<link>{{ url('/') }}</link>
<description>{{ config('app.name') }}</description>
<language>pt-BR</language>
<atom:link href="{{ url('/feed') }}" rel="self" type="application/rss+xml"/>
@foreach($posts as $post)
@php($date = \Carbon\Carbon::parse($post->post_date))
@php($link = url(sprintf('/%s/%s/%s', $date->format('Y'), $date->format('m'), $post->post_name)))
<item>
<title>{{ strip_tags($post->post_title) }}</title>
<link>{{ $link }}</link>
<guid isPermaLink="true">{{ $link }}</guid>
<pubDate>{{ $date->toRfc2822String() }}</pubDate>
@if($post->author)
<dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">{{ $post->author->display_name }}</dc:creator>
@endif
<description>{{ \Illuminate\Support\Str::limit(trim(strip_tags($post->post_excerpt ?: $post->post_content)), 300) }}</description>
</item>
@endforeach
</channel>
</rss>
Loading