diff --git a/app/Http/Controllers/ApiDocsController.php b/app/Http/Controllers/ApiDocsController.php new file mode 100644 index 0000000..68bc676 --- /dev/null +++ b/app/Http/Controllers/ApiDocsController.php @@ -0,0 +1,56 @@ +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 = <<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'); + } +} diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index c3db739..76e6f61 100644 --- a/app/Http/Controllers/BlogController.php +++ b/app/Http/Controllers/BlogController.php @@ -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'), ]); } diff --git a/app/Http/Controllers/ToolController.php b/app/Http/Controllers/ToolController.php index 6571447..b087cd0 100644 --- a/app/Http/Controllers/ToolController.php +++ b/app/Http/Controllers/ToolController.php @@ -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, ]); diff --git a/app/Models/Post.php b/app/Models/Post.php index f52a9f1..06caba8 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -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

. If the + // Markdown body also opens with a top-level heading repeating that + // title, drop it so the page doesn't ship two identical

s. + return preg_replace_callback( + '/^\s*

(.*?)<\/h1>/is', + fn ($matches) => trim(strip_tags($matches[1])) === trim($this->title) ? '' : $matches[0], + $html, + limit: 1 + ); + }); } protected function excerptOrSummary(): Attribute diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ce44a55..43992ad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); }); diff --git a/public/images/og-default.png b/public/images/og-default.png new file mode 100644 index 0000000..3cb7c6a Binary files /dev/null and b/public/images/og-default.png differ diff --git a/public/robots.txt b/public/robots.txt index 7cae0be..ee9e225 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -3,3 +3,5 @@ Allow: / Disallow: /admin Sitemap: /sitemap.xml + +# AI agents: see /llms.txt for how to use this site's API. diff --git a/resources/docs/api.md b/resources/docs/api.md new file mode 100644 index 0000000..eb3ae96 --- /dev/null +++ b/resources/docs/api.md @@ -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 ` 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\ | 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\ | no | e.g. `["free", "api", "open-source"]` | +| `pricing` | string | no | free text, e.g. `"Free"`, `"$10/month"` | +| `features` | array\ | 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/`. diff --git a/resources/views/api-docs.blade.php b/resources/views/api-docs.blade.php new file mode 100644 index 0000000..4460449 --- /dev/null +++ b/resources/views/api-docs.blade.php @@ -0,0 +1,17 @@ + + @push('jsonld') + + @endpush + +
+ {!! $bodyHtml !!} +
+
diff --git a/resources/views/blog/category.blade.php b/resources/views/blog/category.blade.php index 97a8b76..837aaaa 100644 --- a/resources/views/blog/category.blade.php +++ b/resources/views/blog/category.blade.php @@ -2,7 +2,7 @@ @push('jsonld')