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 @@ - +
-
{{ $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')