seo fixes
This commit is contained in:
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ class BlogController extends Controller
|
|||||||
|
|
||||||
return view('blog.index', [
|
return view('blog.index', [
|
||||||
'posts' => $posts,
|
'posts' => $posts,
|
||||||
'title' => config('app.name').' — Blog',
|
'title' => config('app.name').' — AI Tools Directory & Blog for Developers',
|
||||||
'description' => 'Latest articles from '.config('app.name').'.',
|
'description' => 'Discover and compare AI tools, plus in-depth articles on AI-assisted development, automation, and building with AI agents.',
|
||||||
'canonical' => route('blog.index'),
|
'canonical' => route('blog.index'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class ToolController extends Controller
|
|||||||
'tool' => $tool,
|
'tool' => $tool,
|
||||||
'related' => $related,
|
'related' => $related,
|
||||||
'title' => $tool->name.' — AI Tools — '.config('app.name'),
|
'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),
|
'canonical' => route('tools.show', $tool),
|
||||||
'image' => $tool->logo_url,
|
'image' => $tool->logo_url,
|
||||||
]);
|
]);
|
||||||
|
|||||||
+16
-4
@@ -79,10 +79,22 @@ class Post extends Model
|
|||||||
|
|
||||||
protected function bodyHtml(): Attribute
|
protected function bodyHtml(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(fn () => Str::markdown($this->body, [
|
return Attribute::get(function () {
|
||||||
'html_input' => 'strip',
|
$html = Str::markdown($this->body, [
|
||||||
'allow_unsafe_links' => false,
|
'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
|
protected function excerptOrSummary(): Attribute
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@@ -21,6 +22,12 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
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::composer('components.layouts.app', function ($view) {
|
||||||
$view->with('footerPages', Page::published()->inFooter()->get());
|
$view->with('footerPages', Page::published()->inFooter()->get());
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -3,3 +3,5 @@ Allow: /
|
|||||||
Disallow: /admin
|
Disallow: /admin
|
||||||
|
|
||||||
Sitemap: /sitemap.xml
|
Sitemap: /sitemap.xml
|
||||||
|
|
||||||
|
# AI agents: see /llms.txt for how to use this site's API.
|
||||||
|
|||||||
@@ -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>`.
|
||||||
@@ -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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'BreadcrumbList',
|
'@type' => 'BreadcrumbList',
|
||||||
'itemListElement' => [
|
'itemListElement' => [
|
||||||
['@type' => 'ListItem', 'position' => 1, 'name' => 'Blog', 'item' => route('blog.index')],
|
['@type' => 'ListItem', 'position' => 1, 'name' => 'Blog', 'item' => route('blog.index')],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'Blog',
|
'@type' => 'Blog',
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'url' => $canonical,
|
'url' => $canonical,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'BlogPosting',
|
'@type' => 'BlogPosting',
|
||||||
'headline' => $post->title,
|
'headline' => $post->title,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'BreadcrumbList',
|
'@type' => 'BreadcrumbList',
|
||||||
'itemListElement' => [
|
'itemListElement' => [
|
||||||
['@type' => 'ListItem', 'position' => 1, 'name' => 'Blog', 'item' => route('blog.index')],
|
['@type' => 'ListItem', 'position' => 1, 'name' => 'Blog', 'item' => route('blog.index')],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
$seoTitle = $title ?? config('app.name');
|
$seoTitle = $title ?? config('app.name');
|
||||||
$seoDescription = $description ?? 'A blog powered by ' . config('app.name') . '.';
|
$seoDescription = $description ?? 'A blog powered by ' . config('app.name') . '.';
|
||||||
$seoCanonical = $canonical ?? url()->current();
|
$seoCanonical = $canonical ?? url()->current();
|
||||||
$seoImage = $image ?? null;
|
$seoImage = $image ?? asset('images/og-default.png');
|
||||||
$seoType = $type ?? 'website';
|
$seoType = $type ?? 'website';
|
||||||
@endphp
|
@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">
|
<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>© {{ now()->year }} {{ config('app.name') }}</p>
|
<p>© {{ now()->year }} {{ config('app.name') }}</p>
|
||||||
|
|
||||||
@if (($footerPages ?? collect())->isNotEmpty())
|
<nav class="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
<nav class="flex flex-wrap gap-x-6 gap-y-2">
|
@foreach ($footerPages ?? [] as $footerPage)
|
||||||
@foreach ($footerPages as $footerPage)
|
<a href="{{ route('pages.show', $footerPage) }}" class="hover:text-neutral-900 hover:underline underline-offset-4">
|
||||||
<a href="{{ route('pages.show', $footerPage) }}" class="hover:text-neutral-900 hover:underline underline-offset-4">
|
{{ $footerPage->title }}
|
||||||
{{ $footerPage->title }}
|
</a>
|
||||||
</a>
|
@endforeach
|
||||||
@endforeach
|
<a href="{{ route('api.docs') }}" class="hover:text-neutral-900 hover:underline underline-offset-4">API</a>
|
||||||
</nav>
|
</nav>
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'WebPage',
|
'@type' => 'WebPage',
|
||||||
'name' => $page->title,
|
'name' => $page->title,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'BreadcrumbList',
|
'@type' => 'BreadcrumbList',
|
||||||
'itemListElement' => [
|
'itemListElement' => [
|
||||||
['@type' => 'ListItem', 'position' => 1, 'name' => 'AI Tools', 'item' => route('tools.index')],
|
['@type' => 'ListItem', 'position' => 1, 'name' => 'AI Tools', 'item' => route('tools.index')],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'CollectionPage',
|
'@type' => 'CollectionPage',
|
||||||
'name' => 'AI Tools Directory',
|
'name' => 'AI Tools Directory',
|
||||||
'url' => $canonical,
|
'url' => $canonical,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@push('jsonld')
|
@push('jsonld')
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{!! json_encode([
|
{!! json_encode([
|
||||||
'@context' => 'https://schema.org',
|
'@@context' => 'https://schema.org',
|
||||||
'@type' => 'SoftwareApplication',
|
'@type' => 'SoftwareApplication',
|
||||||
'name' => $tool->name,
|
'name' => $tool->name,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\ApiDocsController;
|
||||||
use App\Http\Controllers\BlogController;
|
use App\Http\Controllers\BlogController;
|
||||||
use App\Http\Controllers\PageController;
|
use App\Http\Controllers\PageController;
|
||||||
use App\Http\Controllers\SitemapController;
|
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('/', [BlogController::class, 'index'])->name('blog.index');
|
||||||
Route::get('/blog/feed.xml', [BlogController::class, 'feed'])->name('blog.feed');
|
Route::get('/blog/feed.xml', [BlogController::class, 'feed'])->name('blog.feed');
|
||||||
Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap');
|
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('/category/{category:slug}', [BlogController::class, 'category'])->name('blog.category');
|
||||||
Route::get('/tag/{tag:slug}', [BlogController::class, 'tag'])->name('blog.tag');
|
Route::get('/tag/{tag:slug}', [BlogController::class, 'tag'])->name('blog.tag');
|
||||||
Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');
|
Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');
|
||||||
|
|||||||
Reference in New Issue
Block a user