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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<x-ink::post-card>` 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 (`<livewire:blog::search />`) 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)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
9 changes: 9 additions & 0 deletions config/ink.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
'logo' => null,
],

'schema' => [
'faq_auto' => true,
'howto_auto' => false,
],

'search' => [
'callback' => null,
],

'tables' => [
'posts' => 'blog_posts',
'categories' => 'blog_categories',
Expand Down
14 changes: 14 additions & 0 deletions docs/content/2.essentials/1.blade-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,17 @@ Sticky amber banner for draft previews. Pushes `noindex` meta tag.
```blade
<x-ink::preview-banner :post="$post" :editUrl="$editUrl" />
```

## 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 `<livewire:blog::search />` 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.
25 changes: 25 additions & 0 deletions docs/content/2.essentials/4.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions docs/content/2.essentials/7.search.md
Original file line number Diff line number Diff line change
@@ -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]
);
Comment on lines +29 to +34
},
],
```

Whatever the callback applies becomes the search strategy. Empty terms still short-circuit before the callback fires.

## Livewire component

Use `<livewire:blog::search />` 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 `<meta name="robots" content="noindex,follow">` on the search result page. Search queries should not be indexed as separate URLs.
64 changes: 64 additions & 0 deletions docs/content/2.essentials/8.schema.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions docs/content/2.essentials/9.listing-seo.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion resources/views/components/post-card.blade.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<a href="{{ $post->getUrl() }}" class="group block py-6 -mx-4 px-4 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors duration-200">
<a href="{{ $post->getUrl() }}" wire:navigate class="group block py-6 -mx-4 px-4 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors duration-200">
<div class="flex items-start justify-between gap-6">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2">
Expand Down
32 changes: 32 additions & 0 deletions resources/views/livewire/blog-search.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<div>
<label for="blog-search" class="sr-only">Search blog posts</label>
<input
type="search"
id="blog-search"
wire:model.live.debounce.400ms="query"
placeholder="Search posts…"
class="w-full rounded-md border border-zinc-200 px-4 py-2 text-sm focus:border-zinc-400 focus:outline-none"
aria-label="Search blog posts"
/>

@if ($query !== '')
<div class="mt-6">
@if ($results->isEmpty())
<p class="text-sm text-zinc-500">No posts match "{{ $query }}".</p>
@else
<ul class="space-y-4">
@foreach ($results as $post)
<li>
<a href="{{ $post->getUrl() }}" wire:navigate class="block">
<h3 class="text-base font-medium text-zinc-900">{{ $post->title }}</h3>
@if ($post->excerpt)
<p class="mt-1 text-sm text-zinc-600">{{ $post->excerpt }}</p>
@endif
</a>
</li>
@endforeach
</ul>
@endif
</div>
@endif
</div>
2 changes: 1 addition & 1 deletion resources/views/pages/category.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
@endforelse
</div>

<div class="mt-12">{{ $posts->links() }}</div>
<div class="mt-12">{{ $posts->links('ink::pagination.blog') }}</div>
</div>
@endsection
2 changes: 1 addition & 1 deletion resources/views/pages/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</div>

<div class="mt-12">
{{ $posts->links() }}
{{ $posts->links('ink::pagination.blog') }}
</div>
</div>
@endsection
2 changes: 1 addition & 1 deletion resources/views/pages/tag.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
@endforelse
</div>

<div class="mt-12">{{ $posts->links() }}</div>
<div class="mt-12">{{ $posts->links('ink::pagination.blog') }}</div>
</div>
@endsection
53 changes: 53 additions & 0 deletions resources/views/pagination/blog.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Blog pagination" class="flex items-center justify-between mt-10">
<div class="flex flex-1 items-center justify-between">
@if ($paginator->onFirstPage())
<span class="text-sm text-zinc-400" aria-hidden="true">Previous</span>
@else
<a href="{{ $paginator->previousPageUrl() }}"
rel="prev"
wire:navigate
aria-label="Go to previous page"
class="text-sm text-zinc-600 hover:text-zinc-900">Previous</a>
@endif

<ol class="flex items-center gap-2">
@foreach ($elements as $element)
@if (is_string($element))
<li class="text-sm text-zinc-400" aria-hidden="true">{{ $element }}</li>
@endif

@if (is_array($element))
@foreach ($element as $page => $url)
<li>
@if ($page == $paginator->currentPage())
<span aria-current="page"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium text-white bg-zinc-900 rounded">
{{ $page }}
</span>
@else
<a href="{{ $url }}"
wire:navigate
aria-label="Go to page {{ $page }}"
class="inline-flex items-center justify-center w-8 h-8 text-sm text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded">
{{ $page }}
</a>
@endif
</li>
@endforeach
@endif
@endforeach
</ol>

@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}"
rel="next"
wire:navigate
aria-label="Go to next page"
class="text-sm text-zinc-600 hover:text-zinc-900">Next</a>
@else
<span class="text-sm text-zinc-400" aria-hidden="true">Next</span>
@endif
</div>
</nav>
@endif
Loading