seo fixes

This commit is contained in:
2026-08-09 04:19:21 +03:00
parent df2128ebff
commit bed4c48900
19 changed files with 368 additions and 25 deletions
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
class ApiDocsController extends Controller
{
public function show(): View
{
$markdown = str_replace('{{BASE_URL}}', config('app.url'), $this->markdown());
return view('api-docs', [
'bodyHtml' => Str::markdown($markdown, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]),
'title' => 'API Documentation — '.config('app.name'),
'description' => 'REST API reference for creating posts, categories, tags, and AI tool listings on '.config('app.name').'.',
'canonical' => route('api.docs'),
]);
}
public function llmsTxt(): Response
{
$baseUrl = config('app.url');
$text = <<<TXT
# {$this->appName()}
> {$this->appName()} is a blog and AI tools directory with a public JSON REST API for programmatic publishing.
This site welcomes AI agents that have been given valid credentials by the site operator. See the API documentation for authentication and endpoint details.
## Docs
- [API documentation]({$baseUrl}/api-docs): full REST API reference (auth, posts, categories, tags, tools)
- [Sitemap]({$baseUrl}/sitemap.xml)
- [RSS feed]({$baseUrl}/blog/feed.xml)
TXT;
return response($text, 200)->header('Content-Type', 'text/plain; charset=UTF-8');
}
private function markdown(): string
{
return file_get_contents(resource_path('docs/api.md'));
}
private function appName(): string
{
return config('app.name');
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ class BlogController extends Controller
return view('blog.index', [
'posts' => $posts,
'title' => config('app.name').' — Blog',
'description' => 'Latest articles from '.config('app.name').'.',
'title' => config('app.name').' — AI Tools Directory & Blog for Developers',
'description' => 'Discover and compare AI tools, plus in-depth articles on AI-assisted development, automation, and building with AI agents.',
'canonical' => route('blog.index'),
]);
}
+1 -1
View File
@@ -72,7 +72,7 @@ class ToolController extends Controller
'tool' => $tool,
'related' => $related,
'title' => $tool->name.' — AI Tools — '.config('app.name'),
'description' => str($tool->description)->limit(160)->toString(),
'description' => str($tool->description)->limit(155, '')->toString(),
'canonical' => route('tools.show', $tool),
'image' => $tool->logo_url,
]);
+16 -4
View File
@@ -79,10 +79,22 @@ class Post extends Model
protected function bodyHtml(): Attribute
{
return Attribute::get(fn () => Str::markdown($this->body, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]));
return Attribute::get(function () {
$html = Str::markdown($this->body, [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
// The page template already renders the post title as an <h1>. If the
// Markdown body also opens with a top-level heading repeating that
// title, drop it so the page doesn't ship two identical <h1>s.
return preg_replace_callback(
'/^\s*<h1>(.*?)<\/h1>/is',
fn ($matches) => trim(strip_tags($matches[1])) === trim($this->title) ? '' : $matches[0],
$html,
limit: 1
);
});
}
protected function excerptOrSummary(): Attribute
+7
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Models\Page;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@@ -21,6 +22,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Always generate absolute URLs (route(), url(), canonical tags, sitemap,
// JSON-LD, ...) from the configured APP_URL, not the incoming request's
// Host header. Without this, www vs apex requests each produce
// self-referencing canonical URLs for identical content.
URL::forceRootUrl(config('app.url'));
View::composer('components.layouts.app', function ($view) {
$view->with('footerPages', Page::published()->inFooter()->get());
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+2
View File
@@ -3,3 +3,5 @@ Allow: /
Disallow: /admin
Sitemap: /sitemap.xml
# AI agents: see /llms.txt for how to use this site's API.
+247
View File
@@ -0,0 +1,247 @@
# API Documentation
This site exposes a JSON REST API for creating and managing content — blog posts, categories, tags, and AI tool listings. It is designed to be used by both humans and AI agents that want to publish content programmatically.
- **Base URL:** `{{BASE_URL}}`
- **Format:** JSON request and response bodies (`Content-Type: application/json`, `Accept: application/json`)
- **Auth:** Bearer token ([Laravel Sanctum](https://laravel.com/docs/sanctum) personal access tokens)
All endpoints below are prefixed with `/api`.
## Getting credentials
There is no public sign-up endpoint. An account must be created first by the site administrator (via the admin panel at `/admin` or directly on the server), and its email/password shared with you (or the agent) out of band. Once you have credentials, exchange them for a token via `POST /api/login`.
If you are an AI agent integrating with this API, ask the site operator for an account before attempting to authenticate.
## Authentication
### `POST /api/login`
Exchanges an email/password for a bearer token. Rate limited to 10 requests per minute per IP.
**Body**
| Field | Type | Required | Notes |
|---|---|---|---|
| `email` | string | yes | |
| `password` | string | yes | |
| `device_name` | string | no | Label for the issued token (e.g. `"my-agent"`). Defaults to `"api"`. |
**Request**
```bash
curl -X POST {{BASE_URL}}/api/login \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email":"you@example.com","password":"secret","device_name":"my-agent"}'
```
**Response `200`**
```json
{
"user": { "id": 1, "name": "Jane Doe", "email": "you@example.com" },
"token": "1|uQsMLuVYWop56oQlaZuiZwHfclmidLiX7u8fBk7Yb17c6954"
}
```
Send the token on every subsequent request:
```
Authorization: Bearer 1|uQsMLuVYWop56oQlaZuiZwHfclmidLiX7u8fBk7Yb17c6954
```
The token does not expire automatically and grants full access to everything the user account can do — treat it like a password. Store it securely.
### `POST /api/logout`
Revokes the token used to make the request. Requires auth.
### `GET /api/user`
Returns the authenticated user. Requires auth.
## Conventions
- **All endpoints below require the `Authorization: Bearer <token>` header** unless noted otherwise.
- Resources are looked up **by numeric `id`** in the API (e.g. `/api/posts/42`), not by slug. Slugs are only used in the public-facing site URLs.
- List endpoints (`index`) are paginated 20 per page and return Laravel's standard pagination envelope:
```json
{
"data": [ /* array of resources */ ],
"links": { "first": "...", "last": "...", "prev": null, "next": "..." },
"meta": { "current_page": 1, "last_page": 3, "per_page": 20, "total": 47, "...": "..." }
}
```
Single-resource responses (`show`, `store`, `update`) are wrapped as `{ "data": { ... } }`.
- Successful deletes return `204 No Content`.
- Validation errors return `422` with Laravel's standard shape:
```json
{ "message": "The title field is required.", "errors": { "title": ["The title field is required."] } }
```
- Missing/invalid token returns `401 { "message": "Unauthenticated." }`.
- Unknown resource ID returns `404`.
## Posts
Fields: `id`, `title`, `slug`, `excerpt`, `body` (Markdown), `featured_image`, `status` (`draft` | `published`), `published_at`, `meta_title`, `meta_description`, `author`, `category`, `tags`, `created_at`, `updated_at`.
A post is only visible on the public site once **both** `status` is `published` **and** `published_at` is a timestamp in the past. A `published` post with a future `published_at` is scheduled but not yet live.
**Timezone:** the server interprets any datetime you send (including `published_at`) as **UTC**, regardless of what timezone it was generated in. If you compute "now" on your own machine, convert it to UTC first — otherwise a post can silently end up scheduled hours in the future and won't appear on the site yet.
### `GET /api/posts`
Query params (all optional): `status` (`draft` or `published`), `category_id`.
### `POST /api/posts`
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | max 255 |
| `slug` | string | no | must be unique; auto-generated from `title` if omitted |
| `body` | string | yes | Markdown |
| `excerpt` | string | no | max 255 |
| `category_id` | integer | no | must exist in `/api/categories` |
| `featured_image` | string | no | path/URL to an image |
| `status` | string | yes | `draft` or `published` |
| `published_at` | datetime | no | An explicit date/datetime, e.g. `2026-08-02` or `2026-08-02 12:00:00` or ISO 8601 `2026-08-02T12:00:00Z`. Relative words like `now` or `tomorrow` are **not** accepted. |
| `meta_title` | string | no | max 60 |
| `meta_description` | string | no | max 160 |
| `tags` | array\<integer\> | no | tag IDs, replaces the post's tag list |
**Publish a post right now:**
```bash
curl -X POST {{BASE_URL}}/api/posts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{
"title": "How AI Agents Can Write Blog Posts",
"body": "## Intro\n\nMarkdown body goes here...",
"excerpt": "A short summary shown in listings.",
"category_id": 1,
"status": "published",
"published_at": "2026-08-02 12:00:00",
"tags": [3, 7]
}'
```
### `GET /api/posts/{id}` · `PUT/PATCH /api/posts/{id}` · `DELETE /api/posts/{id}`
Update accepts the same fields as create, all optional (`sometimes` validated). Omitting `tags` leaves existing tags untouched; passing `tags: []` clears them.
## Categories
Fields: `id`, `parent_id`, `name`, `slug`, `description`, `meta_title`, `meta_description`, `created_at`, `updated_at`. Categories can be nested one level via `parent_id`.
### `GET /api/categories` · `POST /api/categories` · `GET/PUT/PATCH/DELETE /api/categories/{id}`
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | max 255 |
| `slug` | string | no | must be unique; auto-generated from `name` if omitted |
| `parent_id` | integer | no | must exist in `/api/categories` |
| `description` | string | no | |
| `meta_title` | string | no | max 60 |
| `meta_description` | string | no | max 160 |
## Tags
Fields: `id`, `name`, `slug`, `created_at`, `updated_at`.
### `GET /api/tags` · `POST /api/tags` · `GET/PUT/PATCH/DELETE /api/tags/{id}`
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | max 255 |
| `slug` | string | no | must be unique; auto-generated from `name` if omitted |
Tip: before creating a post, `GET /api/tags` and `GET /api/categories` to find existing IDs to reuse, rather than creating duplicates.
## AI Tools
A directory of AI tools shown at `/tools` on the public site. Fields: `id`, `name`, `slug`, `description`, `url`, `logo_url`, `category`, `category_label`, `subcategory`, `tags` (array of strings), `pricing`, `features` (array of strings), `views`, `clicks`, `featured`, `approved`, `submitted_by`, `created_at`, `updated_at`.
**A tool is only visible on the public site once `approved` is `true`.** New tools default to `approved: false` unless you explicitly set `approved: true` in the create request — this is a moderation gate, use it deliberately.
### `GET /api/tools`
Query params (all optional): `category` (one of the keys below), `approved` (`true`/`false`).
Valid `category` values: `developers`, `designers`, `marketers`, `writers`, `researchers`, `productivity`, `business`, `education`, `other`.
### `POST /api/tools`
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | max 255 |
| `slug` | string | no | must be unique; auto-generated from `name` if omitted |
| `description` | string | yes | |
| `url` | string (URL) | yes | link to the tool |
| `logo_url` | string (URL) | no | |
| `category` | string | yes | one of the category keys above |
| `subcategory` | string | no | free text, e.g. `"Code Assistants"` |
| `tags` | array\<string\> | no | e.g. `["free", "api", "open-source"]` |
| `pricing` | string | no | free text, e.g. `"Free"`, `"$10/month"` |
| `features` | array\<string\> | no | short bullet points |
| `featured` | boolean | no | defaults to `false` |
| `approved` | boolean | no | defaults to `false` — see note above |
**Submit a tool listing:**
```bash
curl -X POST {{BASE_URL}}/api/tools \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{
"name": "Example AI Copilot",
"description": "An AI pair programmer that lives in your editor.",
"url": "https://example.com",
"category": "developers",
"subcategory": "Code Assistants",
"pricing": "Freemium",
"tags": ["free", "api"],
"features": ["Inline code suggestions", "Chat with your codebase"],
"approved": true
}'
```
`views` and `clicks` are tracked automatically by the public site and cannot be set via the API.
### `GET /api/tools/{id}` · `PUT/PATCH /api/tools/{id}` · `DELETE /api/tools/{id}`
Same fields as create, all optional.
## End-to-end example: publish a post as an agent
```bash
BASE_URL="{{BASE_URL}}"
# 1. Authenticate
TOKEN=$(curl -s -X POST $BASE_URL/api/login \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"email":"you@example.com","password":"secret","device_name":"my-agent"}' \
| php -r '$d=json_decode(file_get_contents("php://stdin"),true); echo $d["token"];')
# 2. (Optional) find a category to use
curl -s "$BASE_URL/api/categories" -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
# 3. Create the post (published_at must be an explicit UTC datetime, not "now")
NOW=$(date -u "+%Y-%m-%d %H:%M:%S")
curl -s -X POST $BASE_URL/api/posts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d "{
\"title\": \"My First Automated Post\",
\"body\": \"Written entirely by an agent.\",
\"status\": \"published\",
\"published_at\": \"$NOW\"
}"
```
The response includes the post's `slug`; the live URL is `{{BASE_URL}}/blog/<slug>`.
+17
View File
@@ -0,0 +1,17 @@
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical">
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@@context' => 'https://schema.org',
'@type' => 'TechArticle',
'name' => $title,
'description' => $description,
'url' => $canonical,
]) !!}
</script>
@endpush
<div class="prose prose-neutral max-w-none">
{!! $bodyHtml !!}
</div>
</x-layouts.app>
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => [
['@type' => 'ListItem', 'position' => 1, 'name' => 'Blog', 'item' => route('blog.index')],
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'Blog',
'name' => config('app.name'),
'url' => $canonical,
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $post->title,
'description' => $description,
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => [
['@type' => 'ListItem', 'position' => 1, 'name' => 'Blog', 'item' => route('blog.index')],
@@ -8,7 +8,7 @@
$seoTitle = $title ?? config('app.name');
$seoDescription = $description ?? 'A blog powered by ' . config('app.name') . '.';
$seoCanonical = $canonical ?? url()->current();
$seoImage = $image ?? null;
$seoImage = $image ?? asset('images/og-default.png');
$seoType = $type ?? 'website';
@endphp
@@ -65,15 +65,14 @@
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-6 py-8 text-sm text-neutral-500 sm:flex-row sm:items-center sm:justify-between">
<p>&copy; {{ now()->year }} {{ config('app.name') }}</p>
@if (($footerPages ?? collect())->isNotEmpty())
<nav class="flex flex-wrap gap-x-6 gap-y-2">
@foreach ($footerPages as $footerPage)
<a href="{{ route('pages.show', $footerPage) }}" class="hover:text-neutral-900 hover:underline underline-offset-4">
{{ $footerPage->title }}
</a>
@endforeach
</nav>
@endif
<nav class="flex flex-wrap gap-x-6 gap-y-2">
@foreach ($footerPages ?? [] as $footerPage)
<a href="{{ route('pages.show', $footerPage) }}" class="hover:text-neutral-900 hover:underline underline-offset-4">
{{ $footerPage->title }}
</a>
@endforeach
<a href="{{ route('api.docs') }}" class="hover:text-neutral-900 hover:underline underline-offset-4">API</a>
</nav>
</div>
</footer>
</body>
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'WebPage',
'name' => $page->title,
'description' => $description,
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => [
['@type' => 'ListItem', 'position' => 1, 'name' => 'AI Tools', 'item' => route('tools.index')],
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => 'AI Tools Directory',
'url' => $canonical,
+1 -1
View File
@@ -2,7 +2,7 @@
@push('jsonld')
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@@context' => 'https://schema.org',
'@type' => 'SoftwareApplication',
'name' => $tool->name,
'description' => $description,
+3
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\ApiDocsController;
use App\Http\Controllers\BlogController;
use App\Http\Controllers\PageController;
use App\Http\Controllers\SitemapController;
@@ -11,6 +12,8 @@ Route::redirect('/login', '/admin/login')->name('login');
Route::get('/', [BlogController::class, 'index'])->name('blog.index');
Route::get('/blog/feed.xml', [BlogController::class, 'feed'])->name('blog.feed');
Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap');
Route::get('/api-docs', [ApiDocsController::class, 'show'])->name('api.docs');
Route::get('/llms.txt', [ApiDocsController::class, 'llmsTxt'])->name('llms-txt');
Route::get('/category/{category:slug}', [BlogController::class, 'category'])->name('blog.category');
Route::get('/tag/{tag:slug}', [BlogController::class, 'tag'])->name('blog.tag');
Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');