feat: extract content providers from app/Plug/Content

Devto, Hashnode, Medium, Wordpress providers
with Core\Plug\Content namespace alignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 17:28:06 +00:00
parent 45db30d967
commit 97c1540add
19 changed files with 2236 additions and 0 deletions

17
composer.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "core/php-plug-content",
"description": "Content platform integrations for the Plug framework",
"type": "library",
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"core/php": "^1.0"
},
"autoload": {
"psr-4": {
"Core\\Plug\\Content\\": "src/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

84
src/Devto/Auth.php Normal file
View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Devto;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Authenticable;
use Core\Plug\Response;
/**
* Dev.to API key authentication.
*
* Uses API key (no OAuth).
*/
class Auth implements Authenticable
{
use BuildsResponse;
use UsesHttp;
private const API_URL = 'https://dev.to/api';
private string $apiKey;
public function __construct(string $apiKey = '')
{
$this->apiKey = $apiKey;
}
public static function identifier(): string
{
return 'devto';
}
public static function name(): string
{
return 'DEV Community';
}
/**
* API key generation URL.
*/
public function getAuthUrl(): string
{
return 'https://dev.to/settings/extensions';
}
/**
* Validate API key.
*
* @param array $params ['api_key' => string]
*/
public function requestAccessToken(array $params): array
{
$apiKey = $params['api_key'] ?? $this->apiKey;
if (! $apiKey) {
return ['error' => 'API key is required'];
}
// Validate by fetching user info
$response = $this->http()
->withHeaders(['api-key' => $apiKey])
->get(self::API_URL.'/users/me');
if (! $response->successful()) {
return ['error' => 'Invalid API key'];
}
$user = $response->json();
return [
'api_key' => $apiKey,
'user_id' => $user['id'],
'username' => $user['username'],
];
}
public function getAccount(): Response
{
return $this->error('Use Read::me() with API key');
}
}

46
src/Devto/Delete.php Normal file
View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Devto;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Deletable;
use Core\Plug\Response;
/**
* Dev.to article deletion (unpublishing).
*
* Note: Dev.to API doesn't support full deletion, only unpublishing.
*/
class Delete implements Deletable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://dev.to/api';
/**
* Unpublish an article (Dev.to doesn't support full deletion via API).
*/
public function delete(string $id): Response
{
// Dev.to API doesn't have a DELETE endpoint
// We unpublish instead
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->put(self::API_URL."/articles/{$id}", [
'article' => [
'published' => false,
],
]);
return $this->fromHttp($response, fn () => [
'unpublished' => true,
'note' => 'Article unpublished (Dev.to API does not support deletion)',
]);
}
}

112
src/Devto/Post.php Normal file
View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Devto;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Postable;
use Core\Plug\Response;
use Illuminate\Support\Collection;
/**
* Dev.to article publishing.
*/
class Post implements Postable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://dev.to/api';
/**
* Publish an article.
*
* @param string $text Markdown content (body_markdown)
* @param Collection $media Not used - Dev.to handles images via URLs
* @param array $params title (required), tags, canonical_url, series, published, main_image, description
*/
public function publish(string $text, Collection $media, array $params = []): Response
{
$title = $params['title'] ?? null;
if (! $title) {
return $this->error('Title is required');
}
$articleData = [
'article' => array_filter([
'title' => $title,
'body_markdown' => $text,
'published' => $params['published'] ?? false,
'tags' => isset($params['tags']) ? array_slice((array) $params['tags'], 0, 4) : null, // Max 4 tags
'series' => $params['series'] ?? null,
'canonical_url' => $params['canonical_url'] ?? null,
'main_image' => $params['main_image'] ?? ($media->first()['url'] ?? null),
'description' => $params['description'] ?? null,
'organization_id' => $params['organization_id'] ?? null,
]),
];
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->post(self::API_URL.'/articles', $articleData);
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title'],
'url' => $data['url'],
'slug' => $data['slug'],
'published' => $data['published'] ?? false,
'published_at' => $data['published_at'] ?? null,
]);
}
/**
* Update an existing article.
*/
public function update(int $articleId, array $params): Response
{
$articleData = [
'article' => array_filter([
'title' => $params['title'] ?? null,
'body_markdown' => $params['body_markdown'] ?? $params['content'] ?? null,
'published' => $params['published'] ?? null,
'tags' => isset($params['tags']) ? array_slice((array) $params['tags'], 0, 4) : null,
'series' => $params['series'] ?? null,
'canonical_url' => $params['canonical_url'] ?? null,
'main_image' => $params['main_image'] ?? null,
]),
];
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->put(self::API_URL."/articles/{$articleId}", $articleData);
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title'],
'url' => $data['url'],
'published' => $data['published'] ?? false,
]);
}
/**
* Get external URL to an article.
*/
public static function externalPostUrl(string $username, string $slug): string
{
return "https://dev.to/{$username}/{$slug}";
}
/**
* Get external URL to a profile.
*/
public static function externalAccountUrl(string $username): string
{
return "https://dev.to/{$username}";
}
}

163
src/Devto/Read.php Normal file
View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Devto;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Readable;
use Core\Plug\Response;
/**
* Dev.to article and profile reading.
*/
class Read implements Readable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://dev.to/api';
/**
* Get an article by ID.
*/
public function get(string $id): Response
{
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->get(self::API_URL."/articles/{$id}");
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title'],
'description' => $data['description'],
'body_markdown' => $data['body_markdown'] ?? null,
'body_html' => $data['body_html'] ?? null,
'url' => $data['url'],
'slug' => $data['slug'],
'tags' => $data['tags'] ?? [],
'published' => $data['published'] ?? false,
'published_at' => $data['published_at'] ?? null,
'reading_time' => $data['reading_time_minutes'] ?? null,
'reactions_count' => $data['public_reactions_count'] ?? 0,
'comments_count' => $data['comments_count'] ?? 0,
]);
}
/**
* Get the authenticated user's profile.
*/
public function me(): Response
{
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->get(self::API_URL.'/users/me');
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'username' => $data['username'],
'name' => $data['name'],
'image' => $data['profile_image'],
'bio' => $data['summary'] ?? null,
'location' => $data['location'] ?? null,
'website_url' => $data['website_url'] ?? null,
'twitter_username' => $data['twitter_username'] ?? null,
'github_username' => $data['github_username'] ?? null,
'joined_at' => $data['joined_at'] ?? null,
]);
}
/**
* List user's articles.
*/
public function list(array $params = []): Response
{
$queryParams = [
'page' => $params['page'] ?? 1,
'per_page' => $params['per_page'] ?? 30,
];
// Can filter by username, tag, state, etc.
if (isset($params['username'])) {
$queryParams['username'] = $params['username'];
}
if (isset($params['tag'])) {
$queryParams['tag'] = $params['tag'];
}
if (isset($params['state'])) {
$queryParams['state'] = $params['state']; // fresh, rising, all
}
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->get(self::API_URL.'/articles', $queryParams);
return $this->fromHttp($response, fn ($data) => [
'articles' => array_map(fn ($article) => [
'id' => $article['id'],
'title' => $article['title'],
'description' => $article['description'],
'url' => $article['url'],
'tags' => $article['tag_list'] ?? [],
'published_at' => $article['published_at'] ?? null,
'reading_time' => $article['reading_time_minutes'] ?? null,
'reactions_count' => $article['public_reactions_count'] ?? 0,
'comments_count' => $article['comments_count'] ?? 0,
], $data),
]);
}
/**
* List user's published articles.
*/
public function myArticles(array $params = []): Response
{
$queryParams = [
'page' => $params['page'] ?? 1,
'per_page' => $params['per_page'] ?? 30,
];
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->get(self::API_URL.'/articles/me', $queryParams);
return $this->fromHttp($response, fn ($data) => [
'articles' => array_map(fn ($article) => [
'id' => $article['id'],
'title' => $article['title'],
'url' => $article['url'],
'published' => $article['published'] ?? false,
'published_at' => $article['published_at'] ?? null,
'page_views_count' => $article['page_views_count'] ?? 0,
], $data),
]);
}
/**
* List user's unpublished (draft) articles.
*/
public function myDrafts(array $params = []): Response
{
$queryParams = [
'page' => $params['page'] ?? 1,
'per_page' => $params['per_page'] ?? 30,
];
$response = $this->http()
->withHeaders(['api-key' => $this->accessToken()])
->get(self::API_URL.'/articles/me/unpublished', $queryParams);
return $this->fromHttp($response, fn ($data) => [
'articles' => array_map(fn ($article) => [
'id' => $article['id'],
'title' => $article['title'],
'body_markdown' => $article['body_markdown'] ?? null,
], $data),
]);
}
}

91
src/Hashnode/Auth.php Normal file
View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Hashnode;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Authenticable;
use Core\Plug\Response;
/**
* Hashnode Personal Access Token authentication.
*
* Uses PAT (no OAuth).
*/
class Auth implements Authenticable
{
use BuildsResponse;
use UsesHttp;
private const API_URL = 'https://gql.hashnode.com';
private string $accessToken;
public function __construct(string $accessToken = '')
{
$this->accessToken = $accessToken;
}
public static function identifier(): string
{
return 'hashnode';
}
public static function name(): string
{
return 'Hashnode';
}
/**
* PAT generation URL.
*/
public function getAuthUrl(): string
{
return 'https://hashnode.com/settings/developer';
}
/**
* Validate access token.
*
* @param array $params ['access_token' => string]
*/
public function requestAccessToken(array $params): array
{
$token = $params['access_token'] ?? $this->accessToken;
if (! $token) {
return ['error' => 'Access token is required'];
}
// Validate by fetching user info
$response = $this->http()
->withHeaders(['Authorization' => $token])
->post(self::API_URL, [
'query' => 'query { me { id username name } }',
]);
if (! $response->successful()) {
return ['error' => 'Invalid access token'];
}
$data = $response->json('data.me');
if (! $data) {
return ['error' => 'Invalid access token'];
}
return [
'access_token' => $token,
'user_id' => $data['id'],
'username' => $data['username'],
'name' => $data['name'],
];
}
public function getAccount(): Response
{
return $this->error('Use Read::me() with access token');
}
}

56
src/Hashnode/Delete.php Normal file
View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Hashnode;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Deletable;
use Core\Plug\Response;
/**
* Hashnode post deletion.
*/
class Delete implements Deletable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://gql.hashnode.com';
/**
* Delete a post.
*/
public function delete(string $id): Response
{
$query = <<<'GRAPHQL'
mutation RemovePost($input: RemovePostInput!) {
removePost(input: $input) {
post {
id
}
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, [
'query' => $query,
'variables' => [
'input' => ['id' => $id],
],
]);
return $this->fromHttp($response, function ($data) {
if (isset($data['errors'])) {
return ['error' => $data['errors'][0]['message'] ?? 'Deletion failed'];
}
return ['deleted' => true];
});
}
}

198
src/Hashnode/Post.php Normal file
View file

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Hashnode;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Postable;
use Core\Plug\Response;
use Illuminate\Support\Collection;
/**
* Hashnode article publishing via GraphQL API.
*/
class Post implements Postable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://gql.hashnode.com';
private ?string $publicationId = null;
/**
* Set publication ID for posting.
*/
public function forPublication(string $publicationId): self
{
$this->publicationId = $publicationId;
return $this;
}
/**
* Publish an article.
*
* @param string $text Markdown content
* @param Collection $media Cover image (first item used)
* @param array $params title (required), subtitle, slug, tags, canonicalUrl, publishedAt
*/
public function publish(string $text, Collection $media, array $params = []): Response
{
$title = $params['title'] ?? null;
$publicationId = $params['publication_id'] ?? $this->publicationId;
if (! $title) {
return $this->error('Title is required');
}
if (! $publicationId) {
return $this->error('Publication ID is required');
}
$input = [
'title' => $title,
'contentMarkdown' => $text,
'publicationId' => $publicationId,
];
// Optional fields
if (isset($params['subtitle'])) {
$input['subtitle'] = $params['subtitle'];
}
if (isset($params['slug'])) {
$input['slug'] = $params['slug'];
}
if (isset($params['canonicalUrl'])) {
$input['originalArticleURL'] = $params['canonicalUrl'];
}
if ($media->isNotEmpty()) {
$input['coverImageOptions'] = [
'coverImageURL' => $media->first()['url'] ?? null,
];
}
if (isset($params['tags'])) {
$input['tags'] = array_map(fn ($tag) => [
'slug' => is_array($tag) ? $tag['slug'] : $tag,
'name' => is_array($tag) ? ($tag['name'] ?? $tag['slug']) : $tag,
], (array) $params['tags']);
}
if (isset($params['publishedAt'])) {
$input['publishedAt'] = $params['publishedAt'];
}
// Determine if draft or published
$mutation = isset($params['draft']) && $params['draft']
? 'createDraft'
: 'publishPost';
$query = <<<'GRAPHQL'
mutation PublishPost($input: PublishPostInput!) {
publishPost(input: $input) {
post {
id
title
slug
url
publication {
id
}
}
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, [
'query' => $query,
'variables' => ['input' => $input],
]);
return $this->fromHttp($response, function ($data) {
$post = $data['data']['publishPost']['post'] ?? null;
if (! $post) {
$errors = $data['errors'] ?? [];
return ['error' => $errors[0]['message'] ?? 'Failed to publish'];
}
return [
'id' => $post['id'],
'title' => $post['title'],
'slug' => $post['slug'],
'url' => $post['url'],
];
});
}
/**
* Update an existing post.
*/
public function update(string $postId, array $params): Response
{
$input = array_filter([
'id' => $postId,
'title' => $params['title'] ?? null,
'contentMarkdown' => $params['content'] ?? $params['body'] ?? null,
'subtitle' => $params['subtitle'] ?? null,
'slug' => $params['slug'] ?? null,
]);
$query = <<<'GRAPHQL'
mutation UpdatePost($input: UpdatePostInput!) {
updatePost(input: $input) {
post {
id
title
slug
url
}
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, [
'query' => $query,
'variables' => ['input' => $input],
]);
return $this->fromHttp($response, function ($data) {
$post = $data['data']['updatePost']['post'] ?? null;
return $post ? [
'id' => $post['id'],
'title' => $post['title'],
'url' => $post['url'],
] : ['error' => 'Failed to update'];
});
}
/**
* Get external URL to an article.
*/
public static function externalPostUrl(string $publicationHost, string $slug): string
{
return "https://{$publicationHost}/{$slug}";
}
/**
* Get external URL to a profile.
*/
public static function externalAccountUrl(string $username): string
{
return "https://hashnode.com/@{$username}";
}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Hashnode;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Listable;
use Core\Plug\Response;
/**
* Hashnode publications listing.
*/
class Publications implements Listable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://gql.hashnode.com';
/**
* List user's publications.
*/
public function listEntities(): Response
{
$query = <<<'GRAPHQL'
query {
me {
publications(first: 50) {
edges {
node {
id
title
displayTitle
url
about {
markdown
}
favicon
isTeam
postsCount
}
}
}
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, ['query' => $query]);
return $this->fromHttp($response, function ($data) {
$publications = $data['data']['me']['publications']['edges'] ?? [];
return [
'publications' => array_map(fn ($edge) => [
'id' => $edge['node']['id'],
'title' => $edge['node']['title'],
'display_title' => $edge['node']['displayTitle'] ?? $edge['node']['title'],
'url' => $edge['node']['url'],
'about' => $edge['node']['about']['markdown'] ?? null,
'favicon' => $edge['node']['favicon'] ?? null,
'is_team' => $edge['node']['isTeam'] ?? false,
'posts_count' => $edge['node']['postsCount'] ?? 0,
], $publications),
];
});
}
/**
* Get publication by ID.
*/
public function get(string $publicationId): Response
{
$query = <<<'GRAPHQL'
query GetPublication($id: ObjectId!) {
publication(id: $id) {
id
title
displayTitle
url
about {
markdown
}
favicon
isTeam
postsCount
author {
id
username
name
}
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, [
'query' => $query,
'variables' => ['id' => $publicationId],
]);
return $this->fromHttp($response, function ($data) {
$pub = $data['data']['publication'] ?? null;
if (! $pub) {
return ['error' => 'Publication not found'];
}
return [
'id' => $pub['id'],
'title' => $pub['title'],
'display_title' => $pub['displayTitle'] ?? $pub['title'],
'url' => $pub['url'],
'about' => $pub['about']['markdown'] ?? null,
'favicon' => $pub['favicon'] ?? null,
'is_team' => $pub['isTeam'] ?? false,
'posts_count' => $pub['postsCount'] ?? 0,
'author' => $pub['author'] ?? null,
];
});
}
}

217
src/Hashnode/Read.php Normal file
View file

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Hashnode;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Readable;
use Core\Plug\Response;
/**
* Hashnode post and profile reading via GraphQL API.
*/
class Read implements Readable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://gql.hashnode.com';
/**
* Get a post by ID.
*/
public function get(string $id): Response
{
$query = <<<'GRAPHQL'
query GetPost($id: ID!) {
post(id: $id) {
id
title
slug
url
content {
markdown
html
}
brief
coverImage {
url
}
tags {
name
slug
}
publishedAt
views
reactionCount
responseCount
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, [
'query' => $query,
'variables' => ['id' => $id],
]);
return $this->fromHttp($response, function ($data) {
$post = $data['data']['post'] ?? null;
if (! $post) {
return ['error' => 'Post not found'];
}
return [
'id' => $post['id'],
'title' => $post['title'],
'slug' => $post['slug'],
'url' => $post['url'],
'content_markdown' => $post['content']['markdown'] ?? null,
'content_html' => $post['content']['html'] ?? null,
'brief' => $post['brief'] ?? null,
'cover_image' => $post['coverImage']['url'] ?? null,
'tags' => $post['tags'] ?? [],
'published_at' => $post['publishedAt'] ?? null,
'views' => $post['views'] ?? 0,
'reactions' => $post['reactionCount'] ?? 0,
'responses' => $post['responseCount'] ?? 0,
];
});
}
/**
* Get the authenticated user's profile.
*/
public function me(): Response
{
$query = <<<'GRAPHQL'
query {
me {
id
username
name
bio {
markdown
}
profilePicture
publications(first: 10) {
edges {
node {
id
title
url
}
}
}
followersCount
followingsCount
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, ['query' => $query]);
return $this->fromHttp($response, function ($data) {
$user = $data['data']['me'] ?? null;
if (! $user) {
return ['error' => 'Not authenticated'];
}
return [
'id' => $user['id'],
'username' => $user['username'],
'name' => $user['name'],
'image' => $user['profilePicture'] ?? null,
'bio' => $user['bio']['markdown'] ?? null,
'followers_count' => $user['followersCount'] ?? 0,
'following_count' => $user['followingsCount'] ?? 0,
'publications' => array_map(fn ($edge) => [
'id' => $edge['node']['id'],
'title' => $edge['node']['title'],
'url' => $edge['node']['url'],
], $user['publications']['edges'] ?? []),
];
});
}
/**
* List posts from a publication.
*/
public function list(array $params = []): Response
{
$publicationId = $params['publication_id'] ?? null;
if (! $publicationId) {
return $this->error('publication_id is required');
}
$query = <<<'GRAPHQL'
query GetPublicationPosts($publicationId: ObjectId!, $first: Int!, $after: String) {
publication(id: $publicationId) {
posts(first: $first, after: $after) {
edges {
node {
id
title
slug
url
brief
publishedAt
views
reactionCount
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
GRAPHQL;
$response = $this->http()
->withHeaders(['Authorization' => $this->accessToken()])
->post(self::API_URL, [
'query' => $query,
'variables' => [
'publicationId' => $publicationId,
'first' => $params['limit'] ?? 20,
'after' => $params['cursor'] ?? null,
],
]);
return $this->fromHttp($response, function ($data) {
$posts = $data['data']['publication']['posts'] ?? null;
if (! $posts) {
return ['error' => 'Publication not found'];
}
return [
'posts' => array_map(fn ($edge) => [
'id' => $edge['node']['id'],
'title' => $edge['node']['title'],
'slug' => $edge['node']['slug'],
'url' => $edge['node']['url'],
'brief' => $edge['node']['brief'],
'published_at' => $edge['node']['publishedAt'],
'views' => $edge['node']['views'] ?? 0,
'reactions' => $edge['node']['reactionCount'] ?? 0,
], $posts['edges'] ?? []),
'has_next_page' => $posts['pageInfo']['hasNextPage'] ?? false,
'cursor' => $posts['pageInfo']['endCursor'] ?? null,
];
});
}
}

123
src/Medium/Auth.php Normal file
View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Medium;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Authenticable;
use Core\Plug\Response;
/**
* Medium OAuth 2.0 authentication.
*/
class Auth implements Authenticable
{
use BuildsResponse;
use UsesHttp;
private const AUTH_URL = 'https://medium.com/m/oauth/authorize';
private const TOKEN_URL = 'https://api.medium.com/v1/tokens';
private string $clientId;
private string $clientSecret;
private string $redirectUri;
public function __construct(
string $clientId = '',
string $clientSecret = '',
string $redirectUri = ''
) {
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->redirectUri = $redirectUri;
}
public static function identifier(): string
{
return 'medium';
}
public static function name(): string
{
return 'Medium';
}
public function getAuthUrl(): string
{
$state = bin2hex(random_bytes(16));
$params = http_build_query([
'client_id' => $this->clientId,
'scope' => 'basicProfile,publishPost,listPublications',
'state' => $state,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
]);
return self::AUTH_URL.'?'.$params;
}
public function requestAccessToken(array $params): array
{
$code = $params['code'] ?? null;
if (! $code) {
return ['error' => 'Authorization code is required'];
}
$response = $this->http()->post(self::TOKEN_URL, [
'code' => $code,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'authorization_code',
'redirect_uri' => $this->redirectUri,
]);
if (! $response->successful()) {
return [
'error' => $response->json('error') ?? 'Token exchange failed',
];
}
$data = $response->json();
return [
'access_token' => $data['access_token'],
'token_type' => $data['token_type'] ?? 'Bearer',
'refresh_token' => $data['refresh_token'] ?? null,
'scope' => $data['scope'] ?? [],
'expires_at' => $data['expires_at'] ?? null,
];
}
/**
* Refresh access token.
*/
public function refreshToken(string $refreshToken): array
{
$response = $this->http()->post(self::TOKEN_URL, [
'refresh_token' => $refreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'refresh_token',
]);
if (! $response->successful()) {
return [
'error' => $response->json('error') ?? 'Refresh failed',
];
}
return $response->json();
}
public function getAccount(): Response
{
return $this->error('Use Read::me() with access token');
}
}

126
src/Medium/Post.php Normal file
View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Medium;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Postable;
use Core\Plug\Response;
use Illuminate\Support\Collection;
/**
* Medium article publishing.
*/
class Post implements Postable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://api.medium.com/v1';
private ?string $authorId = null;
private ?string $publicationId = null;
/**
* Set the author ID for posting.
*/
public function forAuthor(string $authorId): self
{
$this->authorId = $authorId;
return $this;
}
/**
* Set publication ID for posting to a publication.
*/
public function forPublication(string $publicationId): self
{
$this->publicationId = $publicationId;
return $this;
}
/**
* Publish an article.
*
* @param string $text The HTML or Markdown content
* @param Collection $media Not used - Medium handles media within content
* @param array $params title (required), contentFormat (html/markdown), tags, canonicalUrl, publishStatus
*/
public function publish(string $text, Collection $media, array $params = []): Response
{
$title = $params['title'] ?? null;
if (! $title) {
return $this->error('Title is required');
}
$postData = [
'title' => $title,
'contentFormat' => $params['contentFormat'] ?? 'html', // html or markdown
'content' => $text,
'publishStatus' => $params['publishStatus'] ?? 'public', // public, draft, unlisted
];
// Optional parameters
if (isset($params['tags'])) {
$postData['tags'] = array_slice((array) $params['tags'], 0, 5); // Max 5 tags
}
if (isset($params['canonicalUrl'])) {
$postData['canonicalUrl'] = $params['canonicalUrl'];
}
if (isset($params['license'])) {
$postData['license'] = $params['license']; // all-rights-reserved, cc-40-by, etc.
}
if (isset($params['notifyFollowers'])) {
$postData['notifyFollowers'] = $params['notifyFollowers'];
}
// Determine endpoint
$endpoint = $this->publicationId
? self::API_URL."/publications/{$this->publicationId}/posts"
: self::API_URL."/users/{$this->authorId}/posts";
if (! $this->publicationId && ! $this->authorId) {
return $this->error('Author ID or Publication ID required');
}
$response = $this->http()
->withToken($this->accessToken())
->post($endpoint, $postData);
return $this->fromHttp($response, fn ($data) => [
'id' => $data['data']['id'],
'title' => $data['data']['title'],
'url' => $data['data']['url'],
'publishStatus' => $data['data']['publishStatus'],
'publishedAt' => $data['data']['publishedAt'] ?? null,
'authorId' => $data['data']['authorId'],
]);
}
/**
* Get external URL to an article.
*/
public static function externalPostUrl(string $url): string
{
return $url;
}
/**
* Get external URL to a profile.
*/
public static function externalAccountUrl(string $username): string
{
return "https://medium.com/@{$username}";
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Medium;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Listable;
use Core\Plug\Response;
/**
* Medium publications listing.
*/
class Publications implements Listable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://api.medium.com/v1';
private ?string $userId = null;
/**
* Set user ID for listing publications.
*/
public function forUser(string $userId): self
{
$this->userId = $userId;
return $this;
}
/**
* List publications the user can write to.
*/
public function listEntities(): Response
{
if (! $this->userId) {
// Get user ID first
$meResponse = $this->http()
->withToken($this->accessToken())
->get(self::API_URL.'/me');
if (! $meResponse->successful()) {
return $this->fromHttp($meResponse);
}
$this->userId = $meResponse->json('data.id');
}
$response = $this->http()
->withToken($this->accessToken())
->get(self::API_URL."/users/{$this->userId}/publications");
return $this->fromHttp($response, fn ($data) => [
'publications' => array_map(fn ($pub) => [
'id' => $pub['id'],
'name' => $pub['name'],
'description' => $pub['description'] ?? null,
'url' => $pub['url'],
'imageUrl' => $pub['imageUrl'] ?? null,
], $data['data'] ?? []),
]);
}
/**
* Get contributors to a publication.
*/
public function contributors(string $publicationId): Response
{
$response = $this->http()
->withToken($this->accessToken())
->get(self::API_URL."/publications/{$publicationId}/contributors");
return $this->fromHttp($response, fn ($data) => [
'contributors' => array_map(fn ($contributor) => [
'userId' => $contributor['userId'],
'publicationId' => $contributor['publicationId'],
'role' => $contributor['role'],
], $data['data'] ?? []),
]);
}
}

59
src/Medium/Read.php Normal file
View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Medium;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Readable;
use Core\Plug\Response;
/**
* Medium profile and publication reading.
*/
class Read implements Readable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://api.medium.com/v1';
/**
* Get a post by ID.
*
* Note: Medium API doesn't provide a direct post retrieval endpoint.
*/
public function get(string $id): Response
{
return $this->error('Medium API does not support retrieving individual posts');
}
/**
* Get the authenticated user's profile.
*/
public function me(): Response
{
$response = $this->http()
->withToken($this->accessToken())
->get(self::API_URL.'/me');
return $this->fromHttp($response, fn ($data) => [
'id' => $data['data']['id'],
'username' => $data['data']['username'],
'name' => $data['data']['name'],
'image' => $data['data']['imageUrl'] ?? null,
'url' => $data['data']['url'],
]);
}
/**
* List not supported for Medium (no feed API).
*/
public function list(array $params = []): Response
{
return $this->error('Medium API does not support listing posts');
}
}

126
src/Wordpress/Auth.php Normal file
View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Wordpress;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Authenticable;
use Core\Plug\Response;
/**
* WordPress REST API authentication.
*
* Supports application passwords (self-hosted) and OAuth 2.0 (WordPress.com).
*/
class Auth implements Authenticable
{
use BuildsResponse;
use UsesHttp;
private string $siteUrl;
private ?string $username = null;
private ?string $applicationPassword = null;
public function __construct(string $siteUrl = '', ?string $username = null, ?string $applicationPassword = null)
{
$this->siteUrl = rtrim($siteUrl, '/');
$this->username = $username;
$this->applicationPassword = $applicationPassword;
}
public static function identifier(): string
{
return 'wordpress';
}
public static function name(): string
{
return 'WordPress';
}
/**
* Set site URL.
*/
public function forSite(string $siteUrl): self
{
$this->siteUrl = rtrim($siteUrl, '/');
return $this;
}
/**
* Application passwords setup URL.
*/
public function getAuthUrl(): string
{
return $this->siteUrl.'/wp-admin/profile.php#application-passwords';
}
/**
* Validate credentials.
*
* @param array $params ['site_url' => string, 'username' => string, 'application_password' => string]
*/
public function requestAccessToken(array $params): array
{
$siteUrl = rtrim($params['site_url'] ?? $this->siteUrl, '/');
$username = $params['username'] ?? $this->username;
$password = $params['application_password'] ?? $this->applicationPassword;
if (! $siteUrl) {
return ['error' => 'Site URL is required'];
}
if (! $username || ! $password) {
return ['error' => 'Username and application password are required'];
}
// Validate by fetching user info
$response = $this->http()
->withBasicAuth($username, $password)
->get($siteUrl.'/wp-json/wp/v2/users/me');
if (! $response->successful()) {
return ['error' => 'Invalid credentials or site configuration'];
}
$user = $response->json();
return [
'site_url' => $siteUrl,
'username' => $username,
'application_password' => $password,
'user_id' => $user['id'],
'user_slug' => $user['slug'],
'user_name' => $user['name'],
];
}
public function getAccount(): Response
{
return $this->error('Use Read::me() with credentials');
}
/**
* Check if site has REST API enabled.
*/
public function checkSite(string $siteUrl): Response
{
$siteUrl = rtrim($siteUrl, '/');
$response = $this->http()->get($siteUrl.'/wp-json');
return $this->fromHttp($response, fn ($data) => [
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'url' => $data['url'] ?? $siteUrl,
'home' => $data['home'] ?? null,
'namespaces' => $data['namespaces'] ?? [],
'has_wp_v2' => in_array('wp/v2', $data['namespaces'] ?? []),
]);
}
}

94
src/Wordpress/Delete.php Normal file
View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Wordpress;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Deletable;
use Core\Plug\Response;
/**
* WordPress post deletion.
*/
class Delete implements Deletable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private string $siteUrl = '';
private ?string $username = null;
private ?string $applicationPassword = null;
/**
* Set site URL.
*/
public function forSite(string $siteUrl): self
{
$this->siteUrl = rtrim($siteUrl, '/');
return $this;
}
/**
* Set basic auth credentials.
*/
public function withCredentials(string $username, string $password): self
{
$this->username = $username;
$this->applicationPassword = $password;
return $this;
}
/**
* Delete a post.
*
* @param string $id Post ID
*/
public function delete(string $id): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
// force=true permanently deletes, otherwise moves to trash
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->delete($this->siteUrl."/wp-json/wp/v2/posts/{$id}", [
'force' => false, // Move to trash
]);
return $this->fromHttp($response, fn ($data) => [
'deleted' => true,
'id' => $data['id'],
'status' => $data['status'], // Should be 'trash'
]);
}
/**
* Permanently delete a post.
*/
public function forceDelete(int $postId): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->delete($this->siteUrl."/wp-json/wp/v2/posts/{$postId}", [
'force' => true,
]);
return $this->fromHttp($response, fn ($data) => [
'deleted' => $data['deleted'] ?? true,
'previous' => $data['previous'] ?? null,
]);
}
}

127
src/Wordpress/Media.php Normal file
View file

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Wordpress;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\MediaUploadable;
use Core\Plug\Response;
/**
* WordPress media upload.
*/
class Media implements MediaUploadable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private string $siteUrl = '';
private ?string $username = null;
private ?string $applicationPassword = null;
/**
* Set site URL.
*/
public function forSite(string $siteUrl): self
{
$this->siteUrl = rtrim($siteUrl, '/');
return $this;
}
/**
* Set basic auth credentials.
*/
public function withCredentials(string $username, string $password): self
{
$this->username = $username;
$this->applicationPassword = $password;
return $this;
}
public function upload(array $item): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$path = $item['path'] ?? null;
if (! $path || ! file_exists($path)) {
return $this->error('File not found');
}
$filename = $item['name'] ?? basename($path);
$mimeType = mime_content_type($path);
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->withHeaders([
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
'Content-Type' => $mimeType,
])
->withBody(file_get_contents($path), $mimeType)
->post($this->siteUrl.'/wp-json/wp/v2/media');
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title']['rendered'] ?? $filename,
'url' => $data['source_url'] ?? $data['guid']['rendered'],
'link' => $data['link'],
'mime_type' => $data['mime_type'],
'media_details' => $data['media_details'] ?? null,
]);
}
/**
* Update media item metadata.
*/
public function update(int $mediaId, array $params): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->post($this->siteUrl."/wp-json/wp/v2/media/{$mediaId}", array_filter([
'title' => $params['title'] ?? null,
'caption' => $params['caption'] ?? null,
'alt_text' => $params['alt_text'] ?? $params['alt'] ?? null,
'description' => $params['description'] ?? null,
]));
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title']['rendered'] ?? null,
'url' => $data['source_url'] ?? null,
]);
}
/**
* Delete a media item.
*/
public function delete(int $mediaId, bool $force = false): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->delete($this->siteUrl."/wp-json/wp/v2/media/{$mediaId}", [
'force' => $force,
]);
return $this->fromHttp($response, fn ($data) => [
'deleted' => $data['deleted'] ?? true,
]);
}
}

174
src/Wordpress/Post.php Normal file
View file

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Wordpress;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Postable;
use Core\Plug\Response;
use Illuminate\Support\Collection;
/**
* WordPress post publishing via REST API.
*/
class Post implements Postable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private string $siteUrl = '';
private ?string $username = null;
private ?string $applicationPassword = null;
/**
* Set site URL.
*/
public function forSite(string $siteUrl): self
{
$this->siteUrl = rtrim($siteUrl, '/');
return $this;
}
/**
* Set basic auth credentials.
*/
public function withCredentials(string $username, string $password): self
{
$this->username = $username;
$this->applicationPassword = $password;
return $this;
}
/**
* Publish a post.
*
* @param string $text HTML content
* @param Collection $media Featured image (first item used)
* @param array $params title, status, excerpt, categories, tags, format, featured_media, slug
*/
public function publish(string $text, Collection $media, array $params = []): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$title = $params['title'] ?? null;
if (! $title) {
return $this->error('Title is required');
}
$postData = [
'title' => $title,
'content' => $text,
'status' => $params['status'] ?? 'publish', // publish, draft, pending, private
];
// Optional parameters
if (isset($params['excerpt'])) {
$postData['excerpt'] = $params['excerpt'];
}
if (isset($params['slug'])) {
$postData['slug'] = $params['slug'];
}
if (isset($params['categories'])) {
$postData['categories'] = (array) $params['categories'];
}
if (isset($params['tags'])) {
$postData['tags'] = (array) $params['tags'];
}
if (isset($params['format'])) {
$postData['format'] = $params['format']; // standard, aside, gallery, link, etc.
}
if (isset($params['featured_media'])) {
$postData['featured_media'] = $params['featured_media'];
}
// Handle media upload for featured image
if ($media->isNotEmpty() && ! isset($params['featured_media'])) {
$mediaUploader = (new Media)
->forSite($this->siteUrl)
->withCredentials($this->username, $this->applicationPassword);
$uploadResult = $mediaUploader->upload($media->first());
if ($uploadResult->isOk()) {
$postData['featured_media'] = $uploadResult->get('id');
}
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->post($this->siteUrl.'/wp-json/wp/v2/posts', $postData);
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title']['rendered'] ?? $title,
'slug' => $data['slug'],
'url' => $data['link'],
'status' => $data['status'],
'guid' => $data['guid']['rendered'] ?? null,
]);
}
/**
* Update an existing post.
*/
public function update(int $postId, array $params): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$postData = array_filter([
'title' => $params['title'] ?? null,
'content' => $params['content'] ?? null,
'excerpt' => $params['excerpt'] ?? null,
'status' => $params['status'] ?? null,
'slug' => $params['slug'] ?? null,
'categories' => $params['categories'] ?? null,
'tags' => $params['tags'] ?? null,
'featured_media' => $params['featured_media'] ?? null,
]);
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->post($this->siteUrl."/wp-json/wp/v2/posts/{$postId}", $postData);
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title']['rendered'] ?? null,
'url' => $data['link'],
'status' => $data['status'],
]);
}
/**
* Get external URL to a post.
*/
public static function externalPostUrl(string $siteUrl, string $slug): string
{
return rtrim($siteUrl, '/').'/'.$slug;
}
/**
* Get external URL to site.
*/
public static function externalAccountUrl(string $siteUrl): string
{
return $siteUrl;
}
}

209
src/Wordpress/Read.php Normal file
View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Content\Wordpress;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Readable;
use Core\Plug\Response;
/**
* WordPress post and profile reading.
*/
class Read implements Readable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private string $siteUrl = '';
private ?string $username = null;
private ?string $applicationPassword = null;
/**
* Set site URL.
*/
public function forSite(string $siteUrl): self
{
$this->siteUrl = rtrim($siteUrl, '/');
return $this;
}
/**
* Set basic auth credentials.
*/
public function withCredentials(string $username, string $password): self
{
$this->username = $username;
$this->applicationPassword = $password;
return $this;
}
/**
* Get a post by ID.
*/
public function get(string $id): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->get($this->siteUrl."/wp-json/wp/v2/posts/{$id}");
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'title' => $data['title']['rendered'] ?? '',
'content' => $data['content']['rendered'] ?? '',
'excerpt' => $data['excerpt']['rendered'] ?? '',
'slug' => $data['slug'],
'url' => $data['link'],
'status' => $data['status'],
'date' => $data['date'],
'modified' => $data['modified'],
'author' => $data['author'],
'categories' => $data['categories'] ?? [],
'tags' => $data['tags'] ?? [],
'featured_media' => $data['featured_media'] ?? null,
]);
}
/**
* Get the authenticated user's profile.
*/
public function me(): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->get($this->siteUrl.'/wp-json/wp/v2/users/me');
return $this->fromHttp($response, fn ($data) => [
'id' => $data['id'],
'username' => $data['slug'],
'name' => $data['name'],
'image' => $data['avatar_urls']['96'] ?? $data['avatar_urls']['48'] ?? null,
'description' => $data['description'] ?? null,
'url' => $data['url'] ?? null,
'link' => $data['link'] ?? null,
'roles' => $data['roles'] ?? [],
]);
}
/**
* List posts.
*/
public function list(array $params = []): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$queryParams = [
'per_page' => $params['per_page'] ?? 10,
'page' => $params['page'] ?? 1,
'status' => $params['status'] ?? 'publish',
'orderby' => $params['orderby'] ?? 'date',
'order' => $params['order'] ?? 'desc',
];
if (isset($params['author'])) {
$queryParams['author'] = $params['author'];
}
if (isset($params['categories'])) {
$queryParams['categories'] = is_array($params['categories'])
? implode(',', $params['categories'])
: $params['categories'];
}
if (isset($params['tags'])) {
$queryParams['tags'] = is_array($params['tags'])
? implode(',', $params['tags'])
: $params['tags'];
}
if (isset($params['search'])) {
$queryParams['search'] = $params['search'];
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->get($this->siteUrl.'/wp-json/wp/v2/posts', $queryParams);
return $this->fromHttp($response, fn ($data) => [
'posts' => array_map(fn ($post) => [
'id' => $post['id'],
'title' => $post['title']['rendered'] ?? '',
'excerpt' => strip_tags($post['excerpt']['rendered'] ?? ''),
'slug' => $post['slug'],
'url' => $post['link'],
'status' => $post['status'],
'date' => $post['date'],
], $data),
'total' => (int) ($response->header('X-WP-Total') ?? count($data)),
'total_pages' => (int) ($response->header('X-WP-TotalPages') ?? 1),
]);
}
/**
* Get categories.
*/
public function categories(array $params = []): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->get($this->siteUrl.'/wp-json/wp/v2/categories', [
'per_page' => $params['per_page'] ?? 100,
]);
return $this->fromHttp($response, fn ($data) => [
'categories' => array_map(fn ($cat) => [
'id' => $cat['id'],
'name' => $cat['name'],
'slug' => $cat['slug'],
'count' => $cat['count'],
], $data),
]);
}
/**
* Get tags.
*/
public function tags(array $params = []): Response
{
if (! $this->siteUrl) {
return $this->error('Site URL is required');
}
$response = $this->http()
->withBasicAuth($this->username, $this->applicationPassword)
->get($this->siteUrl.'/wp-json/wp/v2/tags', [
'per_page' => $params['per_page'] ?? 100,
]);
return $this->fromHttp($response, fn ($data) => [
'tags' => array_map(fn ($tag) => [
'id' => $tag['id'],
'name' => $tag['name'],
'slug' => $tag['slug'],
'count' => $tag['count'],
], $data),
]);
}
}