From e77715fce45b1b164cbe1c0f9395628f83723668 Mon Sep 17 00:00:00 2001
From: Manuk
Date: Thu, 14 May 2026 01:16:54 +0400
Subject: [PATCH] feat(v2.1.0): listing SEO, schema completion, search,
pagination
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports the v1.5.0 work to the Relaticle\Ink namespace after the v2.0.0 rename
landed in main. No behavior changes vs the original v1.5.0 PR — pure namespace
adaptation.
Adds:
- BlogListingSeo helper for per-page canonicals on listing routes
- Post::search scope with config-driven callback hook
- FAQPage + HowTo + Blog + CollectionPage JSON-LD auto-emission
- Numbered, aria-labeled pagination view
- BlogSearch Livewire component
- wire:navigate on post-card
Fixes:
- Tag URLs in BlogSitemapGenerator
- Per-page canonicals on /blog?page=N
- seo()->for(\$post) called on show/preview
See CHANGELOG.md and PR description for full details.
---
CHANGELOG.md | 19 +++
README.md | 4 +-
config/ink.php | 9 ++
.../2.essentials/1.blade-components.md | 14 ++
docs/content/2.essentials/4.configuration.md | 25 ++++
docs/content/2.essentials/7.search.md | 55 +++++++
docs/content/2.essentials/8.schema.md | 64 +++++++++
docs/content/2.essentials/9.listing-seo.md | 48 +++++++
.../views/components/post-card.blade.php | 2 +-
.../views/livewire/blog-search.blade.php | 32 +++++
resources/views/pages/category.blade.php | 2 +-
resources/views/pages/index.blade.php | 2 +-
resources/views/pages/tag.blade.php | 2 +-
resources/views/pagination/blog.blade.php | 53 +++++++
src/BlogSitemapGenerator.php | 14 ++
src/Http/Controllers/BlogController.php | 39 ++++-
src/InkServiceProvider.php | 136 ++++++++++++++++++
src/Livewire/BlogSearch.php | 34 +++++
src/Models/Post.php | 42 ++++++
src/Support/BlogListingSeo.php | 56 ++++++++
src/Support/SchemaExtractor.php | 123 ++++++++++++++++
tests/Feature/BlogFaqSchemaTest.php | 93 ++++++++++++
tests/Feature/BlogHowToSchemaTest.php | 81 +++++++++++
tests/Feature/BlogListingSchemaTest.php | 63 ++++++++
tests/Feature/BlogListingSeoTest.php | 111 ++++++++++++++
tests/Feature/BlogPaginationViewTest.php | 36 +++++
tests/Feature/BlogPostSearchTest.php | 50 +++++++
tests/Feature/BlogSitemapTagsTest.php | 49 +++++++
tests/Feature/Livewire/BlogSearchTest.php | 40 ++++++
tests/Fixtures/views/layouts/empty.blade.php | 2 +-
30 files changed, 1290 insertions(+), 10 deletions(-)
create mode 100644 docs/content/2.essentials/7.search.md
create mode 100644 docs/content/2.essentials/8.schema.md
create mode 100644 docs/content/2.essentials/9.listing-seo.md
create mode 100644 resources/views/livewire/blog-search.blade.php
create mode 100644 resources/views/pagination/blog.blade.php
create mode 100644 src/Livewire/BlogSearch.php
create mode 100644 src/Support/BlogListingSeo.php
create mode 100644 src/Support/SchemaExtractor.php
create mode 100644 tests/Feature/BlogFaqSchemaTest.php
create mode 100644 tests/Feature/BlogHowToSchemaTest.php
create mode 100644 tests/Feature/BlogListingSchemaTest.php
create mode 100644 tests/Feature/BlogListingSeoTest.php
create mode 100644 tests/Feature/BlogPaginationViewTest.php
create mode 100644 tests/Feature/BlogPostSearchTest.php
create mode 100644 tests/Feature/BlogSitemapTagsTest.php
create mode 100644 tests/Feature/Livewire/BlogSearchTest.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f03a880..bcd36b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file.
+## [2.1.0] - Unreleased
+
+### Added
+- `Relaticle\Ink\Support\BlogListingSeo` helper for building per-page `SEOData` for listings. Headless consumers can call `BlogListingSeo::forIndex/forCategory/forTag` from their own controllers.
+- Auto-emit `FAQPage` JSON-LD on post pages when content contains an `## FAQ` H2 followed by `### Question?` / answer-paragraph pairs. Controlled by `schema.faq_auto` config (default `true`).
+- Auto-emit `HowTo` JSON-LD on post pages when content contains a `## Steps` H2 followed by `### Step Name` / paragraph pairs. Opt-in via `schema.howto_auto` config (default `false`).
+- `Relaticle\Ink\Support\SchemaExtractor` helper for FAQ + HowTo HTML parsing.
+- Auto-emit `Blog` and `CollectionPage` JSON-LD on listing routes (`blog.index` emits both; `blog.category` and `blog.tag` emit `CollectionPage`).
+- Numbered, aria-labeled pagination view at `ink::pagination.blog`. Listing pages (index/category/tag) use it by default. Publish via `php artisan vendor:publish --tag=ink-views` to customize.
+- `wire:navigate` on the `` post-link and pagination links for SPA-feel navigation in Livewire 4 hosts. No-op when Livewire is not present.
+- `Post::search($term)` query scope. Defaults to a portable LIKE search across title/excerpt/content. Override via `search.callback` config for Postgres FTS, MySQL FULLTEXT, Scout, etc.
+- The shipped `/blog` route now honors `?q=` for search. The existing `BlogListingSeo::forIndex(searchQuery:)` call ensures `noindex,follow` is set on search result URLs.
+- `BlogSearch` Livewire component (``) with URL-synced `?q=` query, 400ms debounce, and empty state. Theme-agnostic — publish the view at `resources/views/vendor/ink/livewire/blog-search.blade.php` to restyle.
+
+### Fixed
+- `BlogSitemapGenerator` now includes `/blog/tag/{slug}` URLs when the tags feature is enabled and the tag has published posts.
+- Listing routes (`/blog`, `/blog/category/{slug}`, `/blog/tag/{slug}`) now emit per-page canonical URLs and page-aware titles. Previously every paginated page canonicalized to page 1, causing Google to treat pages 2+ as duplicate content.
+- Shipped `show` and `preview` routes now call `seo()->for($post)` so post-attached SEO (BlogPosting + BreadcrumbList + FAQPage JSON-LD) actually renders in public-routes mode. Previously the schema only worked for consumers who overrode the controller.
+
## [2.0.0] - Unreleased
### Changed (BREAKING)
diff --git a/README.md b/README.md
index b364152..ea6bbf4 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,9 @@ Filament-native content publishing for blog, docs, and AI-citable articles. Ship
## Features
- **Filament Admin** — PostResource and CategoryResource with markdown editor, draft/published/scheduled UX, SEO fields, featured images, and bulk publish/unpublish/schedule actions
-- **SEO Components** — Meta tags, Open Graph, Twitter Cards, JSON-LD structured data, RSS feed, canonical URLs
+- **SEO Components** — Meta tags, Open Graph, Twitter Cards, RSS feed, per-page canonicals on paginated listings
+- **JSON-LD Schema** — `BlogPosting` + `BreadcrumbList` on post pages, `FAQPage` and `HowTo` auto-detected from content (opt-in), `Blog` + `CollectionPage` on listings
+- **Search** — Portable `Post::search()` scope (LIKE by default, override for FTS / Scout), drop-in `BlogSearch` Livewire component with `?q=` URL sync
- **13 MCP Tools** — Full CRUD for posts and categories via Model Context Protocol, with markdown sanitization (HTML stripped, unsafe links blocked)
- **Publishable UI Components** — Post card, header, body, related posts, category badge, preview banner — all with dark mode
- **Two install modes**
diff --git a/config/ink.php b/config/ink.php
index 94c2e90..4f1af95 100644
--- a/config/ink.php
+++ b/config/ink.php
@@ -32,6 +32,15 @@
'logo' => null,
],
+ 'schema' => [
+ 'faq_auto' => true,
+ 'howto_auto' => false,
+ ],
+
+ 'search' => [
+ 'callback' => null,
+ ],
+
'tables' => [
'posts' => 'blog_posts',
'categories' => 'blog_categories',
diff --git a/docs/content/2.essentials/1.blade-components.md b/docs/content/2.essentials/1.blade-components.md
index 468d745..d8f675d 100644
--- a/docs/content/2.essentials/1.blade-components.md
+++ b/docs/content/2.essentials/1.blade-components.md
@@ -100,3 +100,17 @@ Sticky amber banner for draft previews. Pushes `noindex` meta tag.
```blade
```
+
+## Pagination
+
+The package ships a numbered, aria-labeled pagination view used by the index/category/tag pages. To use it in your own views:
+
+```blade
+{{ $posts->links('ink::pagination.blog') }}
+```
+
+The view emits `aria-label="Blog pagination"` on the nav, `aria-current="page"` on the active page, and `wire:navigate` on every link.
+
+## Search (Livewire)
+
+Drop `` anywhere. It reads `?q=` from the URL, debounces 400ms, and renders up to 20 matching posts. Empty queries show no results until the user types.
diff --git a/docs/content/2.essentials/4.configuration.md b/docs/content/2.essentials/4.configuration.md
index 9bb531b..d7f398c 100644
--- a/docs/content/2.essentials/4.configuration.md
+++ b/docs/content/2.essentials/4.configuration.md
@@ -82,6 +82,31 @@ return [
'media_library' => false,
],
+ /*
+ |--------------------------------------------------------------------------
+ | Schema auto-emission
+ |--------------------------------------------------------------------------
+ | Detect FAQ and HowTo sections in post content and emit JSON-LD schema
+ | automatically. FAQ detection looks for an `## FAQ` H2 followed by H3
+ | question / paragraph answer pairs. HowTo detection looks for a `## Steps`
+ | H2 followed by H3 step headings.
+ */
+ 'schema' => [
+ 'faq_auto' => true,
+ 'howto_auto' => false,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Search
+ |--------------------------------------------------------------------------
+ | Defaults to a portable LIKE search across title/excerpt/content.
+ | Override `callback` to use Postgres FTS, MySQL FULLTEXT, Scout, etc.
+ */
+ 'search' => [
+ 'callback' => null,
+ ],
+
/*
|--------------------------------------------------------------------------
| RSS feed metadata
diff --git a/docs/content/2.essentials/7.search.md b/docs/content/2.essentials/7.search.md
new file mode 100644
index 0000000..968d14a
--- /dev/null
+++ b/docs/content/2.essentials/7.search.md
@@ -0,0 +1,55 @@
+---
+title: Search
+description: Portable search across blog posts with a config-driven backend hook.
+navigation:
+ icon: i-lucide-search
+---
+
+The package ships a `Post::search($term)` query scope and a `BlogSearch` Livewire component.
+
+## Default behavior
+
+`Post::search($term)` does a `LIKE` search across `title`, `excerpt`, and `content`. Works on every database driver out of the box.
+
+```php
+$posts = Post::query()
+ ->published()
+ ->search($request->query('q', ''))
+ ->latest('published_at')
+ ->paginate(12);
+```
+
+Empty / whitespace-only terms are no-ops — the scope leaves the query unchanged. LIKE wildcards in the term are escaped.
+
+## Override the backend
+
+Set `search.callback` in `config/ink.php` to use Postgres FTS, MySQL FULLTEXT, Scout, Meilisearch, or anything else:
+
+```php
+'search' => [
+ 'callback' => function (\Illuminate\Database\Eloquent\Builder $query, string $term): void {
+ $query->whereRaw(
+ "to_tsvector('english', title || ' ' || excerpt || ' ' || content) @@ plainto_tsquery('english', ?)",
+ [$term]
+ );
+ },
+],
+```
+
+Whatever the callback applies becomes the search strategy. Empty terms still short-circuit before the callback fires.
+
+## Livewire component
+
+Use `` anywhere. It reads `?q=` from the URL, debounces 400ms, and renders up to 20 matches with an empty state.
+
+To restyle, publish the view:
+
+```bash
+php artisan vendor:publish --tag=ink-views
+```
+
+Then edit `resources/views/vendor/ink/livewire/blog-search.blade.php`.
+
+## SEO
+
+When the shipped `/blog` route handles `?q=`, the package emits `` on the search result page. Search queries should not be indexed as separate URLs.
diff --git a/docs/content/2.essentials/8.schema.md b/docs/content/2.essentials/8.schema.md
new file mode 100644
index 0000000..edc3de5
--- /dev/null
+++ b/docs/content/2.essentials/8.schema.md
@@ -0,0 +1,64 @@
+---
+title: Schema
+description: Automatic JSON-LD schema emission for posts and listings.
+navigation:
+ icon: i-lucide-code-2
+---
+
+The package emits JSON-LD schema automatically for blog content. Each schema type can be toggled in `config/ink.php`.
+
+## What's emitted by default
+
+| Schema | Where | Toggle |
+|---|---|---|
+| `BlogPosting` | Post pages | Always on (via `Post::getDynamicSEOData()`) |
+| `BreadcrumbList` | Post pages | Always on |
+| `FAQPage` | Post pages with `## FAQ` section | `schema.faq_auto` (default `true`) |
+| `HowTo` | Post pages with `## Steps` section | `schema.howto_auto` (default `false`) |
+| `Blog` | `/blog` index | Always when public-routes mode is on |
+| `CollectionPage` | `/blog`, `/blog/category/{slug}`, `/blog/tag/{slug}` | Always when public-routes mode is on |
+
+## FAQPage conventions
+
+To trigger FAQPage emission, include a `## FAQ` heading followed by `### Question?` and a paragraph answer:
+
+```markdown
+## FAQ
+
+### Does the package auto-emit schema?
+Yes — FAQ schema is on by default.
+
+### Can I turn it off?
+Set `schema.faq_auto` to `false`.
+```
+
+## HowTo conventions
+
+To trigger HowTo emission, opt in via `schema.howto_auto = true` and write a `## Steps` section with `### Step Name` headings:
+
+```markdown
+## Steps
+
+### Install the package
+Run `composer require relaticle/ink`.
+
+### Publish the config
+Run `php artisan vendor:publish --tag=ink-config`.
+```
+
+Each `### heading` becomes a `HowToStep` with auto-incremented `position`.
+
+## Disabling auto-detection
+
+```php
+'schema' => [
+ 'faq_auto' => false,
+ 'howto_auto' => false,
+],
+```
+
+The `BlogPosting` + `BreadcrumbList` schemas remain on regardless — they're considered baseline for any blog.
+
+## Validate your schema
+
+After publishing, paste a post URL into [Google Rich Results Test](https://search.google.com/test/rich-results) or the [Schema.org Validator](https://validator.schema.org/) to confirm zero errors.
diff --git a/docs/content/2.essentials/9.listing-seo.md b/docs/content/2.essentials/9.listing-seo.md
new file mode 100644
index 0000000..3b78e51
--- /dev/null
+++ b/docs/content/2.essentials/9.listing-seo.md
@@ -0,0 +1,48 @@
+---
+title: Listing SEO
+description: Per-page canonicals, titles, and metadata for listing routes.
+navigation:
+ icon: i-lucide-list
+---
+
+The package's listing routes (`/blog`, `/blog/category/{slug}`, `/blog/tag/{slug}`) emit per-page canonical URLs, titles, and metadata via `BlogListingSeo`.
+
+## Behavior
+
+| Route | Page 1 canonical | Page N canonical |
+|---|---|---|
+| `/blog` | `/blog` | `/blog?page=N` |
+| `/blog?q=term` | `/blog` | `noindex,follow` |
+| `/blog/category/guides` | `/blog/category/guides` | `/blog/category/guides?page=N` |
+| `/blog/tag/filament` | `/blog/tag/filament` | `/blog/tag/filament?page=N` |
+
+Titles include `— Page N` from page 2 onward.
+
+## Headless consumers
+
+If you write your own controllers in headless mode, call the helper directly:
+
+```php
+use Relaticle\Ink\Support\BlogListingSeo;
+
+public function index(Request $request)
+{
+ $page = (int) $request->query('page', 1);
+ seo()->for(BlogListingSeo::forIndex(page: $page, searchQuery: $request->query('q')));
+
+ return view('blog.index', [...]);
+}
+```
+
+Available factories:
+- `BlogListingSeo::forIndex(int $page = 1, ?string $searchQuery = null): SEOData`
+- `BlogListingSeo::forCategory(Category $category, int $page = 1): SEOData`
+- `BlogListingSeo::forTag(Tag $tag, int $page = 1): SEOData`
+
+## Pagination
+
+The package ships a numbered, aria-labeled pagination view at `ink::pagination.blog`. Listing pages use it by default. Publish to customize:
+
+```bash
+php artisan vendor:publish --tag=ink-views
+```
diff --git a/resources/views/components/post-card.blade.php b/resources/views/components/post-card.blade.php
index 31631fa..f470d12 100644
--- a/resources/views/components/post-card.blade.php
+++ b/resources/views/components/post-card.blade.php
@@ -1,4 +1,4 @@
-
+
diff --git a/resources/views/livewire/blog-search.blade.php b/resources/views/livewire/blog-search.blade.php
new file mode 100644
index 0000000..cfde4a8
--- /dev/null
+++ b/resources/views/livewire/blog-search.blade.php
@@ -0,0 +1,32 @@
+
+
+
+
+ @if ($query !== '')
+
+ @if ($results->isEmpty())
+
No posts match "{{ $query }}".
+ @else
+
+ @endif
+
+ @endif
+
diff --git a/resources/views/pages/category.blade.php b/resources/views/pages/category.blade.php
index 34b76ca..d2c51cf 100644
--- a/resources/views/pages/category.blade.php
+++ b/resources/views/pages/category.blade.php
@@ -12,6 +12,6 @@
@endforelse
-
{{ $posts->links() }}
+
{{ $posts->links('ink::pagination.blog') }}
@endsection
diff --git a/resources/views/pages/index.blade.php b/resources/views/pages/index.blade.php
index 497b203..3b66c7b 100644
--- a/resources/views/pages/index.blade.php
+++ b/resources/views/pages/index.blade.php
@@ -13,7 +13,7 @@
- {{ $posts->links() }}
+ {{ $posts->links('ink::pagination.blog') }}
@endsection
diff --git a/resources/views/pages/tag.blade.php b/resources/views/pages/tag.blade.php
index f032d6a..9b8b16e 100644
--- a/resources/views/pages/tag.blade.php
+++ b/resources/views/pages/tag.blade.php
@@ -12,6 +12,6 @@
@endforelse
- {{ $posts->links() }}
+ {{ $posts->links('ink::pagination.blog') }}
@endsection
diff --git a/resources/views/pagination/blog.blade.php b/resources/views/pagination/blog.blade.php
new file mode 100644
index 0000000..39cd3b4
--- /dev/null
+++ b/resources/views/pagination/blog.blade.php
@@ -0,0 +1,53 @@
+@if ($paginator->hasPages())
+
+@endif
diff --git a/src/BlogSitemapGenerator.php b/src/BlogSitemapGenerator.php
index 74c2a35..c3b5ddf 100644
--- a/src/BlogSitemapGenerator.php
+++ b/src/BlogSitemapGenerator.php
@@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Route;
use Relaticle\Ink\Models\Category;
use Relaticle\Ink\Models\Post;
+use Relaticle\Ink\Models\Tag;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
@@ -53,6 +54,19 @@ public static function addToSitemap(Sitemap $sitemap): Sitemap
});
}
+ if (Route::has('blog.tag') && config('ink.features.tags', false)) {
+ Tag::query()
+ ->select(['id', 'slug'])
+ ->whereHas('posts', fn (Builder $query) => $query->published())
+ ->each(function (Tag $tag) use ($sitemap): void {
+ $sitemap->add(
+ Url::create(route('blog.tag', $tag->slug))
+ ->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
+ ->setPriority(0.5)
+ );
+ });
+ }
+
return $sitemap;
}
}
diff --git a/src/Http/Controllers/BlogController.php b/src/Http/Controllers/BlogController.php
index 34b7cec..4113106 100644
--- a/src/Http/Controllers/BlogController.php
+++ b/src/Http/Controllers/BlogController.php
@@ -11,6 +11,7 @@
use Relaticle\Ink\Models\Category;
use Relaticle\Ink\Models\Post;
use Relaticle\Ink\Models\Tag;
+use Relaticle\Ink\Support\BlogListingSeo;
class BlogController extends Controller
{
@@ -18,11 +19,25 @@ public function index(Request $request): View
{
$perPage = (int) config('ink.per_page', 12);
- $posts = Post::query()
+ $searchQuery = trim((string) $request->query('q', ''));
+
+ $query = Post::query()
->with(['category', 'author', 'seo'])
- ->published()
+ ->published();
+
+ if ($searchQuery !== '') {
+ $query->search($searchQuery);
+ }
+
+ $posts = $query
->latest('published_at')
- ->paginate($perPage);
+ ->paginate($perPage)
+ ->withQueryString();
+
+ seo()->for(BlogListingSeo::forIndex(
+ page: (int) $request->query('page', 1),
+ searchQuery: $request->query('q'),
+ ));
return view('ink::pages.index', [
'posts' => $posts,
@@ -39,6 +54,8 @@ public function show(string $slug): View
$relatedPosts = $post->relatedPosts(limit: 3)->get();
+ seo()->for($post);
+
return view('ink::pages.show', [
'post' => $post,
'relatedPosts' => $relatedPosts,
@@ -57,6 +74,11 @@ public function category(string $slug): View
->latest('published_at')
->paginate($perPage);
+ seo()->for(BlogListingSeo::forCategory(
+ category: $category,
+ page: (int) request()->query('page', 1),
+ ));
+
return view('ink::pages.category', [
'category' => $category,
'posts' => $posts,
@@ -65,8 +87,12 @@ public function category(string $slug): View
public function preview(Post $post): View
{
+ $post->loadMissing(['category', 'author', 'seo']);
+
+ seo()->for($post);
+
return view('ink::pages.preview', [
- 'post' => $post->loadMissing(['category', 'author', 'seo']),
+ 'post' => $post,
]);
}
@@ -84,6 +110,11 @@ public function tag(string $slug): View
->latest('published_at')
->paginate($perPage);
+ seo()->for(BlogListingSeo::forTag(
+ tag: $tag,
+ page: (int) request()->query('page', 1),
+ ));
+
return view('ink::pages.tag', [
'tag' => $tag,
'posts' => $posts,
diff --git a/src/InkServiceProvider.php b/src/InkServiceProvider.php
index fe0d980..3e1f38c 100644
--- a/src/InkServiceProvider.php
+++ b/src/InkServiceProvider.php
@@ -5,8 +5,17 @@
namespace Relaticle\Ink;
use Illuminate\Support\Facades\Blade;
+use Livewire\Livewire;
+use RalphJSmit\Laravel\SEO\Facades\SEOManager;
+use RalphJSmit\Laravel\SEO\Schema\CustomSchema;
+use RalphJSmit\Laravel\SEO\TagCollection;
+use RalphJSmit\Laravel\SEO\TagManager;
+use Relaticle\Ink\Livewire\BlogSearch;
+use Relaticle\Ink\Models\Post;
+use Relaticle\Ink\Support\SchemaExtractor;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
+use Throwable;
class InkServiceProvider extends PackageServiceProvider
{
@@ -23,6 +32,13 @@ public function configurePackage(Package $package): void
->hasViews(static::$viewNamespace);
}
+ public function packageRegistered(): void
+ {
+ // TagManager must be a singleton so seo()->for() called in the
+ // BlogController persists to the {!! seo() !!} call in the layout.
+ $this->app->singleton(TagManager::class);
+ }
+
public function packageBooted(): void
{
Blade::componentNamespace('Relaticle\\Ink\\Components', 'ink');
@@ -30,5 +46,125 @@ public function packageBooted(): void
if (config('ink.features.public_routes')) {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
}
+
+ $this->registerHowToSchemaTransformer();
+ $this->registerListingSchemaTransformer();
+
+ if (class_exists(Livewire::class)) {
+ Livewire::component('blog::search', BlogSearch::class);
+
+ Livewire::resolveMissingComponent(function (string $name): ?string {
+ if ($name === 'blog::search') {
+ return BlogSearch::class;
+ }
+
+ return null;
+ });
+ }
+ }
+
+ private function registerHowToSchemaTransformer(): void
+ {
+ SEOManager::tagTransformer(function (TagCollection $tags): TagCollection {
+ if (! config('ink.schema.howto_auto', false)) {
+ return $tags;
+ }
+
+ try {
+ $route = request()->route();
+ $param = $route?->parameter('post');
+
+ $post = $param instanceof Post
+ ? $param
+ : (is_string($param)
+ ? Post::query()->published()->where('slug', $param)->first()
+ : null);
+
+ if (! $post instanceof Post && ($slug = $route?->parameter('slug')) !== null) {
+ $post = Post::query()->published()->where('slug', $slug)->first();
+ }
+
+ if (! $post instanceof Post) {
+ return $tags;
+ }
+
+ $steps = SchemaExtractor::extractHowToSteps($post->renderedContent());
+
+ if ($steps === []) {
+ return $tags;
+ }
+
+ $tags->push(new CustomSchema([
+ '@context' => 'https://schema.org',
+ '@type' => 'HowTo',
+ 'name' => $post->title,
+ 'step' => $steps,
+ ]));
+
+ return $tags;
+ } catch (Throwable) {
+ return $tags;
+ }
+ });
+ }
+
+ private function registerListingSchemaTransformer(): void
+ {
+ SEOManager::tagTransformer(function (TagCollection $tags): TagCollection {
+ try {
+ $route = request()->route();
+ $routeName = $route?->getName();
+
+ $isIndex = $routeName === 'blog.index';
+ $isCategory = $routeName === 'blog.category';
+ $isTag = $routeName === 'blog.tag';
+
+ if (! ($isIndex || $isCategory || $isTag)) {
+ return $tags;
+ }
+
+ $publisher = config('ink.publisher');
+ $publisherEntity = $publisher && ! empty($publisher['name']) ? [
+ '@type' => 'Organization',
+ 'name' => $publisher['name'],
+ 'url' => $publisher['url'] ?? null,
+ ] : null;
+
+ $collectionName = match (true) {
+ $isCategory => (string) ($route?->parameter('slug') ?? 'Category'),
+ $isTag => (string) ($route?->parameter('slug') ?? 'Tag'),
+ default => 'Blog',
+ };
+
+ $collectionPage = array_filter([
+ '@context' => 'https://schema.org',
+ '@type' => 'CollectionPage',
+ 'url' => url()->current(),
+ 'name' => $collectionName,
+ 'isPartOf' => $publisherEntity ? [
+ '@type' => 'WebSite',
+ 'name' => $publisher['name'],
+ 'url' => $publisher['url'] ?? null,
+ ] : null,
+ ], fn ($value) => $value !== null);
+
+ $tags->push(new CustomSchema($collectionPage));
+
+ if ($isIndex) {
+ $blog = array_filter([
+ '@context' => 'https://schema.org',
+ '@type' => 'Blog',
+ 'url' => route('blog.index'),
+ 'name' => $publisher && ! empty($publisher['name']) ? "{$publisher['name']} Blog" : 'Blog',
+ 'publisher' => $publisherEntity,
+ ], fn ($value) => $value !== null);
+ $tags->push(new CustomSchema($blog));
+ }
+
+ return $tags;
+ } catch (Throwable) {
+ return $tags;
+ }
+ });
}
}
diff --git a/src/Livewire/BlogSearch.php b/src/Livewire/BlogSearch.php
new file mode 100644
index 0000000..cec8447
--- /dev/null
+++ b/src/Livewire/BlogSearch.php
@@ -0,0 +1,34 @@
+query === ''
+ ? new Collection
+ : Post::query()
+ ->with(['category', 'author'])
+ ->published()
+ ->search($this->query)
+ ->latest('published_at')
+ ->limit(20)
+ ->get();
+
+ return view('ink::livewire.blog-search', [
+ 'results' => $results,
+ ]);
+ }
+}
diff --git a/src/Models/Post.php b/src/Models/Post.php
index 2098863..1e53c98 100644
--- a/src/Models/Post.php
+++ b/src/Models/Post.php
@@ -18,11 +18,13 @@
use Illuminate\Support\Str;
use RalphJSmit\Laravel\SEO\Schema\ArticleSchema;
use RalphJSmit\Laravel\SEO\Schema\BreadcrumbListSchema;
+use RalphJSmit\Laravel\SEO\Schema\FaqPageSchema;
use RalphJSmit\Laravel\SEO\SchemaCollection;
use RalphJSmit\Laravel\SEO\Support\HasSEO;
use RalphJSmit\Laravel\SEO\Support\SEOData;
use Relaticle\Ink\Database\Factories\PostFactory;
use Relaticle\Ink\Enums\PostStatus;
+use Relaticle\Ink\Support\SchemaExtractor;
use Spatie\LaravelMarkdown\MarkdownRenderer;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
@@ -134,6 +136,32 @@ protected function published(Builder $query): void
});
}
+ #[Scope]
+ protected function search(Builder $query, string $term): void
+ {
+ $term = trim($term);
+
+ if ($term === '') {
+ return;
+ }
+
+ $callback = config('ink.search.callback');
+
+ if (is_callable($callback)) {
+ $callback($query, $term);
+
+ return;
+ }
+
+ $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $term).'%';
+
+ $query->where(function (Builder $q) use ($like): void {
+ $q->where('title', 'like', $like)
+ ->orWhere('excerpt', 'like', $like)
+ ->orWhere('content', 'like', $like);
+ });
+ }
+
public function toHtml(): string
{
return Cache::rememberForever(
@@ -243,6 +271,20 @@ public function getDynamicSEOData(): SEOData
return $breadcrumbs->prependBreadcrumbs($crumbs);
});
+ if (config('ink.schema.faq_auto', true)) {
+ $faqEntities = SchemaExtractor::extractFaqEntities($this->renderedContent());
+
+ if ($faqEntities !== []) {
+ $schema = $schema->addFaqPage(function (FaqPageSchema $faq) use ($faqEntities): FaqPageSchema {
+ foreach ($faqEntities as $entity) {
+ $faq->addQuestion($entity['name'], $entity['acceptedAnswer']['text']);
+ }
+
+ return $faq;
+ });
+ }
+ }
+
return new SEOData(
title: $this->title,
description: $this->excerpt,
diff --git a/src/Support/BlogListingSeo.php b/src/Support/BlogListingSeo.php
new file mode 100644
index 0000000..7d75cfe
--- /dev/null
+++ b/src/Support/BlogListingSeo.php
@@ -0,0 +1,56 @@
+ 1 ? "Blog — Page {$page}" : 'Blog';
+ $description = 'Latest posts from the blog.';
+ $url = $page > 1 ? "{$base}?page={$page}" : $base;
+ $robots = $searchQuery !== null && $searchQuery !== '' ? 'noindex,follow' : null;
+
+ return new SEOData(
+ title: $title,
+ description: $description,
+ url: $url,
+ robots: $robots,
+ );
+ }
+
+ public static function forCategory(Category $category, int $page = 1): SEOData
+ {
+ $base = route('blog.category', $category->slug);
+ $title = $page > 1 ? "{$category->name} — Page {$page}" : $category->name;
+ $description = "Posts in {$category->name}.";
+ $url = $page > 1 ? "{$base}?page={$page}" : $base;
+
+ return new SEOData(
+ title: $title,
+ description: $description,
+ url: $url,
+ );
+ }
+
+ public static function forTag(Tag $tag, int $page = 1): SEOData
+ {
+ $base = route('blog.tag', $tag->slug);
+ $title = $page > 1 ? "{$tag->name} — Page {$page}" : $tag->name;
+ $description = "Posts tagged {$tag->name}.";
+ $url = $page > 1 ? "{$base}?page={$page}" : $base;
+
+ return new SEOData(
+ title: $title,
+ description: $description,
+ url: $url,
+ );
+ }
+}
diff --git a/src/Support/SchemaExtractor.php b/src/Support/SchemaExtractor.php
new file mode 100644
index 0000000..cff268a
--- /dev/null
+++ b/src/Support/SchemaExtractor.php
@@ -0,0 +1,123 @@
+answer
` pairs.
+ *
+ * @return list
+ */
+ public static function extractFaqEntities(string $html): array
+ {
+ $section = self::findSectionByHeading($html, 'FAQ');
+
+ if ($section === null) {
+ return [];
+ }
+
+ preg_match_all(
+ '/]*>(.+?)<\/h3>\s*
]*>(.+?)<\/p>/is',
+ $section,
+ $pairs,
+ PREG_SET_ORDER,
+ );
+
+ $entities = [];
+
+ foreach ($pairs as $pair) {
+ $name = self::cleanText($pair[1]);
+ $text = self::cleanText($pair[2]);
+
+ if ($name === '' || $text === '') {
+ continue;
+ }
+
+ $entities[] = [
+ '@type' => 'Question',
+ 'name' => $name,
+ 'acceptedAnswer' => [
+ '@type' => 'Answer',
+ 'text' => $text,
+ ],
+ ];
+ }
+
+ return $entities;
+ }
+
+ /**
+ * Extract HowTo step entities from rendered HTML containing a `## Steps`
+ * H2 followed by H3 step headings, each followed by one or more paragraphs.
+ *
+ * @return list
+ */
+ public static function extractHowToSteps(string $html): array
+ {
+ $section = self::findSectionByHeading($html, 'Steps');
+
+ if ($section === null) {
+ return [];
+ }
+
+ preg_match_all(
+ '/]*>(.+?)<\/h3>\s*
]*>(.+?)<\/p>/is',
+ $section,
+ $pairs,
+ PREG_SET_ORDER,
+ );
+
+ $steps = [];
+
+ foreach ($pairs as $index => $pair) {
+ $name = self::cleanText($pair[1]);
+ $text = self::cleanText($pair[2]);
+
+ if ($name === '' || $text === '') {
+ continue;
+ }
+
+ $steps[] = [
+ '@type' => 'HowToStep',
+ 'position' => $index + 1,
+ 'name' => $name,
+ 'text' => $text,
+ ];
+ }
+
+ return $steps;
+ }
+
+ private static function findSectionByHeading(string $html, string $heading): ?string
+ {
+ if (! preg_match_all(
+ '/
]*>(?.*?)<\/h2>(?.*?)(?=]+class="[^"]*heading-permalink[^"]*"[^>]*>.*?<\/a>/is', '', $html) ?? $html;
+
+ return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5));
+ }
+}
diff --git a/tests/Feature/BlogFaqSchemaTest.php b/tests/Feature/BlogFaqSchemaTest.php
new file mode 100644
index 0000000..b6d4bac
--- /dev/null
+++ b/tests/Feature/BlogFaqSchemaTest.php
@@ -0,0 +1,93 @@
+set('ink.features.public_routes', true);
+ config()->set('ink.layout', 'tests::layouts.empty');
+
+ $this->app->register(InkServiceProvider::class, force: true);
+ $this->app->getProvider(InkServiceProvider::class)->packageBooted();
+ Route::getRoutes()->refreshNameLookups();
+
+ // The shipped package controller does not call seo()->for() in show().
+ // Consumers override the controller to do so (see Post::getDynamicSEOData
+ // for the article + FAQ schema wiring). We simulate that here by binding
+ // a fresh TagManager and pointing it at the post we want to render.
+ $this->app->forgetInstance(TagManager::class);
+});
+
+function renderSeoForPost(Post $post): string
+{
+ seo()->for($post);
+
+ return (string) seo();
+}
+
+it('emits FAQPage JSON-LD when content has a ## FAQ section', function () {
+ $content = <<<'MD'
+ Intro paragraph.
+
+ ## FAQ
+
+ ### Does the package support FAQ schema?
+ Yes, automatically detected from headings.
+
+ ### Is it opt-in?
+ Yes, via config.
+ MD;
+
+ $post = Post::factory()->published()->create(['content' => $content]);
+
+ $output = renderSeoForPost($post);
+
+ expect($output)->toContain('FAQPage');
+ expect($output)->toContain('Question');
+ expect($output)->toContain('Does the package support FAQ schema?');
+});
+
+it('omits FAQPage when the schema.faq_auto config is disabled', function () {
+ config()->set('ink.schema.faq_auto', false);
+
+ $post = Post::factory()->published()->create([
+ 'content' => "## FAQ\n\n### Q?\n\nA.",
+ ]);
+
+ $output = renderSeoForPost($post);
+
+ expect($output)->not->toContain('FAQPage');
+});
+
+it('omits FAQPage when no FAQ section is present', function () {
+ $post = Post::factory()->published()->create([
+ 'content' => 'Just a post with no FAQ heading.',
+ ]);
+
+ $output = renderSeoForPost($post);
+
+ expect($output)->not->toContain('FAQPage');
+});
+
+it('extracts FAQ entities directly from rendered HTML', function () {
+ $html = 'FAQ
Q1?
A1
Q2?
A2
';
+
+ $entities = SchemaExtractor::extractFaqEntities($html);
+
+ expect($entities)->toHaveCount(2);
+ expect($entities[0]['name'])->toBe('Q1?');
+ expect($entities[0]['acceptedAnswer']['text'])->toBe('A1');
+});
+
+it('returns empty array when HTML has no FAQ section', function () {
+ $entities = SchemaExtractor::extractFaqEntities(
+ 'Not FAQ
Text
'
+ );
+
+ expect($entities)->toBe([]);
+});
diff --git a/tests/Feature/BlogHowToSchemaTest.php b/tests/Feature/BlogHowToSchemaTest.php
new file mode 100644
index 0000000..cc332fb
--- /dev/null
+++ b/tests/Feature/BlogHowToSchemaTest.php
@@ -0,0 +1,81 @@
+set('ink.features.public_routes', true);
+ config()->set('ink.layout', 'tests::layouts.empty');
+ config()->set('ink.schema.howto_auto', true);
+
+ $this->app->register(InkServiceProvider::class, force: true);
+ $this->app->getProvider(InkServiceProvider::class)->packageBooted();
+ Route::getRoutes()->refreshNameLookups();
+
+ $this->app->forgetInstance(TagManager::class);
+});
+
+it('emits HowTo JSON-LD when content has a ## Steps section and howto_auto is on', function () {
+ $content = <<<'MD'
+ Setup overview.
+
+ ## Steps
+
+ ### Install the package
+ Run `composer require relaticle/ink`.
+
+ ### Publish the config
+ Run `php artisan vendor:publish --tag=ink-config`.
+ MD;
+
+ $post = Post::factory()->published()->create(['content' => $content]);
+
+ $response = $this->get(route('blog.show', $post->slug));
+
+ $response->assertOk();
+ $response->assertSee('HowTo', escape: false);
+ $response->assertSee('HowToStep', escape: false);
+ $response->assertSee('"position":1', escape: false);
+ $response->assertSee('"position":2', escape: false);
+ $response->assertSee('Install the package', escape: false);
+});
+
+it('omits HowTo when howto_auto is off (default)', function () {
+ config()->set('ink.schema.howto_auto', false);
+
+ $post = Post::factory()->published()->create([
+ 'content' => "## Steps\n\n### A\n\nDo a.\n\n### B\n\nDo b.",
+ ]);
+
+ $response = $this->get(route('blog.show', $post->slug));
+
+ $response->assertOk();
+ $response->assertDontSee('"@type":"HowTo"', escape: false);
+});
+
+it('omits HowTo when no Steps section is present', function () {
+ $post = Post::factory()->published()->create([
+ 'content' => 'Just a post with no Steps heading.',
+ ]);
+
+ $response = $this->get(route('blog.show', $post->slug));
+
+ $response->assertOk();
+ $response->assertDontSee('"@type":"HowTo"', escape: false);
+});
+
+it('extracts HowTo steps directly from rendered HTML', function () {
+ $html = 'Steps
First
Do this.
Second
Do that.
';
+ $steps = SchemaExtractor::extractHowToSteps($html);
+
+ expect($steps)->toHaveCount(2);
+ expect($steps[0]['position'])->toBe(1);
+ expect($steps[0]['name'])->toBe('First');
+ expect($steps[0]['text'])->toBe('Do this.');
+ expect($steps[1]['position'])->toBe(2);
+});
diff --git a/tests/Feature/BlogListingSchemaTest.php b/tests/Feature/BlogListingSchemaTest.php
new file mode 100644
index 0000000..49365a1
--- /dev/null
+++ b/tests/Feature/BlogListingSchemaTest.php
@@ -0,0 +1,63 @@
+set('ink.features.public_routes', true);
+ config()->set('ink.features.tags', true);
+ config()->set('ink.layout', 'tests::layouts.empty');
+
+ $this->app->register(InkServiceProvider::class, force: true);
+ $this->app->getProvider(InkServiceProvider::class)->packageBooted();
+ Route::getRoutes()->refreshNameLookups();
+
+ $this->app->forgetInstance(TagManager::class);
+});
+
+it('emits Blog + CollectionPage JSON-LD on /blog', function () {
+ Post::factory(2)->published()->create();
+
+ $response = $this->get(route('blog.index'));
+
+ $response->assertOk();
+ $response->assertSee('"@type":"Blog"', escape: false);
+ $response->assertSee('"@type":"CollectionPage"', escape: false);
+});
+
+it('emits CollectionPage JSON-LD on /blog/category/{slug}', function () {
+ $category = Category::create(['name' => 'Guides', 'slug' => 'guides']);
+ Post::factory(2)->published()->for($category)->create();
+
+ $response = $this->get(route('blog.category', ['slug' => 'guides']));
+
+ $response->assertOk();
+ $response->assertSee('"@type":"CollectionPage"', escape: false);
+});
+
+it('emits CollectionPage JSON-LD on /blog/tag/{slug}', function () {
+ $tag = Tag::factory()->create(['name' => 'Filament']);
+ $post = Post::factory()->published()->create();
+ $post->tags()->attach($tag);
+
+ $response = $this->get(route('blog.tag', ['slug' => $tag->slug]));
+
+ $response->assertOk();
+ $response->assertSee('"@type":"CollectionPage"', escape: false);
+});
+
+it('does not emit listing schema on post detail pages', function () {
+ $post = Post::factory()->published()->create();
+
+ $response = $this->get(route('blog.show', $post->slug));
+
+ $response->assertOk();
+ $response->assertDontSee('"@type":"CollectionPage"', escape: false);
+ $response->assertDontSee('"@type":"Blog"', escape: false);
+});
diff --git a/tests/Feature/BlogListingSeoTest.php b/tests/Feature/BlogListingSeoTest.php
new file mode 100644
index 0000000..32b3f28
--- /dev/null
+++ b/tests/Feature/BlogListingSeoTest.php
@@ -0,0 +1,111 @@
+set('ink.features.public_routes', true);
+ config()->set('ink.features.tags', true);
+ config()->set('ink.per_page', 2);
+ config()->set('ink.layout', 'tests::layouts.empty');
+
+ $this->app->register(InkServiceProvider::class, force: true);
+ $this->app->getProvider(InkServiceProvider::class)->packageBooted();
+ Route::getRoutes()->refreshNameLookups();
+});
+
+it('sets a self-canonical on the blog index page 1', function () {
+ Post::factory(3)->published()->create();
+
+ $response = $this->get('/blog');
+
+ $response->assertOk();
+ $response->assertSee('published()->create();
+
+ $response = $this->get('/blog?page=2');
+
+ $response->assertOk();
+ $response->assertSee('assertSee('Page 2', escape: false);
+});
+
+it('sets a category-aware canonical on /blog/category/{slug}', function () {
+ $category = Category::factory()->create(['name' => 'Guides', 'slug' => 'guides']);
+ Post::factory(3)->published()->create(['category_id' => $category->id]);
+
+ $response = $this->get('/blog/category/guides');
+
+ $response->assertOk();
+ $response->assertSee('create(['name' => 'Filament']);
+ $post = Post::factory()->published()->create();
+ $post->tags()->attach($tag);
+
+ $response = $this->get('/blog/tag/'.$tag->slug);
+
+ $response->assertOk();
+ $response->assertSee('title)->toContain('Page 3');
+ expect((string) $data->url)->toBe(url('/blog?page=3'));
+});
+
+it('marks search result pages as noindex via headless helper', function () {
+ $data = BlogListingSeo::forIndex(searchQuery: 'laravel');
+
+ expect($data->robots)->toBe('noindex,follow');
+});
+
+it('builds category SEOData with page-aware url', function () {
+ $category = Category::factory()->create(['name' => 'News', 'slug' => 'news']);
+
+ $data = BlogListingSeo::forCategory($category, page: 2);
+
+ expect($data->title)->toContain('News');
+ expect($data->title)->toContain('Page 2');
+ expect((string) $data->url)->toBe(url('/blog/category/news?page=2'));
+});
+
+it('builds tag SEOData with page-aware url', function () {
+ $tag = Tag::factory()->create(['name' => 'Laravel']);
+
+ $data = BlogListingSeo::forTag($tag, page: 2);
+
+ expect($data->title)->toContain($tag->name);
+ expect($data->title)->toContain('Page 2');
+ expect((string) $data->url)->toBe(url('/blog/tag/'.$tag->slug.'?page=2'));
+});
+
+it('emits noindex on /blog?q=searchterm', function () {
+ Post::factory()->published()->create(['title' => 'Webhooks post']);
+ $response = $this->get('/blog?q=webhooks');
+ $response->assertOk();
+ $response->assertSee('noindex', escape: false);
+});
+
+it('filters results by q parameter', function () {
+ Post::factory()->published()->create(['title' => 'Webhooks tutorial']);
+ Post::factory()->published()->create(['title' => 'Forms guide']);
+
+ $response = $this->get('/blog?q=webhooks');
+ $response->assertOk();
+ $response->assertSee('Webhooks tutorial');
+ $response->assertDontSee('Forms guide');
+});
diff --git a/tests/Feature/BlogPaginationViewTest.php b/tests/Feature/BlogPaginationViewTest.php
new file mode 100644
index 0000000..d281c24
--- /dev/null
+++ b/tests/Feature/BlogPaginationViewTest.php
@@ -0,0 +1,36 @@
+set('ink.features.public_routes', true);
+ config()->set('ink.per_page', 2);
+ config()->set('ink.layout', 'tests::layouts.empty');
+
+ $this->app->register(InkServiceProvider::class, force: true);
+ $this->app->getProvider(InkServiceProvider::class)->packageBooted();
+ Route::getRoutes()->refreshNameLookups();
+});
+
+it('renders numbered pagination with aria-label on the blog index', function () {
+ Post::factory(5)->published()->create();
+
+ $response = $this->get('/blog');
+
+ $response->assertOk();
+ $response->assertSee('aria-label="Blog pagination"', escape: false);
+ $response->assertSee('aria-label="Go to page 2"', escape: false);
+});
+
+it('marks the current page with aria-current', function () {
+ Post::factory(5)->published()->create();
+
+ $response = $this->get('/blog?page=2');
+
+ $response->assertOk();
+ $response->assertSee('aria-current="page"', escape: false);
+});
diff --git a/tests/Feature/BlogPostSearchTest.php b/tests/Feature/BlogPostSearchTest.php
new file mode 100644
index 0000000..e606bf5
--- /dev/null
+++ b/tests/Feature/BlogPostSearchTest.php
@@ -0,0 +1,50 @@
+published()->create(['title' => 'Webhooks in Laravel', 'content' => '']);
+ Post::factory()->published()->create(['title' => 'Forms in Filament', 'content' => '']);
+
+ $results = Post::query()->published()->search('webhooks')->get();
+
+ expect($results)->toHaveCount(1);
+ expect($results->first()->title)->toBe('Webhooks in Laravel');
+});
+
+it('matches by excerpt and content via the default LIKE strategy', function () {
+ Post::factory()->published()->create([
+ 'title' => 'Unrelated',
+ 'excerpt' => 'A post about queues',
+ 'content' => '...',
+ ]);
+ Post::factory()->published()->create([
+ 'title' => 'Unrelated 2',
+ 'excerpt' => '',
+ 'content' => 'Discussion of webhooks here.',
+ ]);
+
+ expect(Post::query()->published()->search('queues')->count())->toBe(1);
+ expect(Post::query()->published()->search('webhooks')->count())->toBe(1);
+});
+
+it('returns the unfiltered query when term is empty or whitespace', function () {
+ Post::factory(3)->published()->create();
+
+ expect(Post::query()->published()->search('')->count())->toBe(3);
+ expect(Post::query()->published()->search(' ')->count())->toBe(3);
+});
+
+it('delegates to the configured callback when search.callback is set', function () {
+ Post::factory()->published()->create(['title' => 'matches']);
+ Post::factory()->published()->create(['title' => 'no']);
+
+ config()->set('ink.search.callback', function (Builder $query, string $term) {
+ $query->where('title', $term);
+ });
+
+ expect(Post::query()->published()->search('matches')->count())->toBe(1);
+});
diff --git a/tests/Feature/BlogSitemapTagsTest.php b/tests/Feature/BlogSitemapTagsTest.php
new file mode 100644
index 0000000..00a9667
--- /dev/null
+++ b/tests/Feature/BlogSitemapTagsTest.php
@@ -0,0 +1,49 @@
+set('ink.features.public_routes', true);
+ config()->set('ink.features.tags', true);
+ config()->set('ink.layout', 'tests::layouts.empty');
+
+ $this->app->register(InkServiceProvider::class, force: true);
+ $this->app->getProvider(InkServiceProvider::class)->packageBooted();
+ Route::getRoutes()->refreshNameLookups();
+});
+
+test('adds tag URLs only when blog.tag route exists and the tag has published posts', function () {
+ $usedTag = Tag::factory()->create(['name' => 'Used Tag']);
+ $emptyTag = Tag::factory()->create(['name' => 'Empty Tag']);
+
+ $post = Post::factory()->published()->create();
+ $post->tags()->attach($usedTag);
+
+ $sitemap = BlogSitemapGenerator::addToSitemap(Sitemap::create());
+
+ $urls = collect($sitemap->getTags())->map(fn ($tag) => $tag->url)->all();
+
+ expect($urls)
+ ->toContain(route('blog.tag', $usedTag->slug))
+ ->not->toContain(route('blog.tag', $emptyTag->slug));
+});
+
+test('omits all tag URLs when tags feature is off', function () {
+ config()->set('ink.features.tags', false);
+
+ $tag = Tag::factory()->create(['name' => 'Used Tag']);
+ $post = Post::factory()->published()->create();
+ $post->tags()->attach($tag);
+
+ $sitemap = BlogSitemapGenerator::addToSitemap(Sitemap::create());
+ $urls = collect($sitemap->getTags())->map(fn ($tag) => $tag->url)->all();
+
+ expect($urls)->not->toContain(route('blog.tag', $tag->slug));
+});
diff --git a/tests/Feature/Livewire/BlogSearchTest.php b/tests/Feature/Livewire/BlogSearchTest.php
new file mode 100644
index 0000000..8852707
--- /dev/null
+++ b/tests/Feature/Livewire/BlogSearchTest.php
@@ -0,0 +1,40 @@
+published()->create(['title' => 'Webhooks integration']);
+ Post::factory()->published()->create(['title' => 'Forms tutorial']);
+
+ Livewire::test(BlogSearch::class, ['query' => 'webhooks'])
+ ->assertSee('Webhooks integration')
+ ->assertDontSee('Forms tutorial');
+});
+
+it('shows empty state when no matches', function () {
+ Post::factory()->published()->create(['title' => 'Webhooks']);
+
+ Livewire::test(BlogSearch::class, ['query' => 'nope'])
+ ->assertSee('No posts match');
+});
+
+it('starts with empty query and shows no results until typed', function () {
+ Post::factory()->published()->create(['title' => 'Webhooks']);
+
+ Livewire::test(BlogSearch::class)
+ ->assertSet('query', '')
+ ->assertDontSee('Webhooks');
+});
+
+it('updates results when query is set', function () {
+ Post::factory()->published()->create(['title' => 'Webhooks integration']);
+
+ Livewire::test(BlogSearch::class)
+ ->set('query', 'webhooks')
+ ->assertSet('query', 'webhooks')
+ ->assertSee('Webhooks integration');
+});
diff --git a/tests/Fixtures/views/layouts/empty.blade.php b/tests/Fixtures/views/layouts/empty.blade.php
index 948316a..8c8b010 100644
--- a/tests/Fixtures/views/layouts/empty.blade.php
+++ b/tests/Fixtures/views/layouts/empty.blade.php
@@ -1,4 +1,4 @@
-{{ $title ?? 'Blog' }}
+{{ $title ?? 'Blog' }}{!! seo() !!}
@yield('content')