feat(apiclient): Add native Admin & Content API clients, JWT generator, and tag visibility features; remove external @tryghost deps and update docs

This commit is contained in:
2025-10-10 12:57:31 +00:00
parent 719bfafb93
commit 11a9b23802
14 changed files with 875 additions and 125 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

67
.serena/project.yml Normal file
View File

@@ -0,0 +1,67 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "ghost"

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## 2025-10-10 - 2.2.0 - feat(apiclient)
Add native Admin & Content API clients, JWT generator, and tag visibility features; remove external @tryghost deps and update docs
- Add native GhostAdminAPI and GhostContentAPI implementations (ts/apiclient/*) using global fetch with support for browse/read/add/edit/delete, webhooks, and image upload.
- Add generateToken JWT implementation compatible with Ghost Admin API (ts/apiclient/ghost.jwt.ts) with Web Crypto and Node fallbacks.
- Switch Ghost class to use built-in apiclient implementations and bump default API version to v6.0.
- Use Admin API to fetch ALL tags (including zero-count) and add visibility filtering and minimatch filtering in getTags; add convenience methods getPublicTags and getInternalTags.
- Add Tag methods getVisibility(), isInternal(), and isPublic() to expose visibility information.
- Replace imports of @tryghost/* with local apiclient implementations in ts/ghost.plugins.ts and remove @tryghost dependencies from package.json.
- Update README to document tag visibility, new tag-related APIs/examples, and other notes about using Admin API for tags.
## 2025-10-08 - 2.1.0 - feat(syncedinstance) ## 2025-10-08 - 2.1.0 - feat(syncedinstance)
Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README

View File

@@ -23,9 +23,7 @@
"@types/node": "^22.12.0" "@types/node": "^22.12.0"
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartmatch": "^2.0.0", "@push.rocks/smartmatch": "^2.0.0"
"@tryghost/admin-api": "^1.14.0",
"@tryghost/content-api": "^1.12.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

110
pnpm-lock.yaml generated
View File

@@ -11,12 +11,6 @@ importers:
'@push.rocks/smartmatch': '@push.rocks/smartmatch':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@tryghost/admin-api':
specifier: ^1.14.0
version: 1.14.0
'@tryghost/content-api':
specifier: ^1.12.0
version: 1.12.0
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^2.6.8 specifier: ^2.6.8
@@ -1537,12 +1531,6 @@ packages:
'@tootallnate/quickjs-emscripten@0.23.0': '@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
'@tryghost/admin-api@1.14.0':
resolution: {integrity: sha512-FKAs4uHYhgqAXqRHdGNvODq60vK0H9FK2f26ib50dKsoSwB/vTspXxjcmqlPLyZfeQpOnaRlFK+ykpriYCOuTg==}
'@tryghost/content-api@1.12.0':
resolution: {integrity: sha512-rU9yrBHlVIohOLSpC9PeH9evMeNCk4OKGvI0VEwNwuwWlsQIpBn3o79DzleEN0tbMynlObYAG0IJ/EGkIumtJA==}
'@tsclass/tsclass@3.0.48': '@tsclass/tsclass@3.0.48':
resolution: {integrity: sha512-hC65UvDlp9qvsl6OcIZXz0JNiWZ0gyzsTzbXpg215sGxopgbkOLCr6E0s4qCTnweYm95gt2AdY95uP7M7kExaQ==} resolution: {integrity: sha512-hC65UvDlp9qvsl6OcIZXz0JNiWZ0gyzsTzbXpg215sGxopgbkOLCr6E0s4qCTnweYm95gt2AdY95uP7M7kExaQ==}
@@ -1997,9 +1985,6 @@ packages:
buffer-crc32@0.2.13: buffer-crc32@0.2.13:
resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
buffer-json@2.0.0: buffer-json@2.0.0:
resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==} resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==}
@@ -2358,9 +2343,6 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
@@ -3066,16 +3048,6 @@ packages:
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
keygrip@1.1.0: keygrip@1.1.0:
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3170,36 +3142,15 @@ packages:
lodash.clonedeep@4.5.0: lodash.clonedeep@4.5.0:
resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=} resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=}
lodash.includes@4.3.0:
resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=}
lodash.isarguments@3.1.0: lodash.isarguments@3.1.0:
resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=} resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=}
lodash.isarray@3.0.4: lodash.isarray@3.0.4:
resolution: {integrity: sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=} resolution: {integrity: sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=}
lodash.isboolean@3.0.3:
resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=}
lodash.isinteger@4.0.4:
resolution: {integrity: sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=}
lodash.isnumber@3.0.3:
resolution: {integrity: sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=}
lodash.isstring@4.0.1:
resolution: {integrity: sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=}
lodash.keys@3.1.2: lodash.keys@3.1.2:
resolution: {integrity: sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=} resolution: {integrity: sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=}
lodash.once@4.1.1:
resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=}
lodash.restparam@3.6.1: lodash.restparam@3.6.1:
resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=} resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=}
@@ -6768,19 +6719,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@aws-sdk/credential-providers' - '@aws-sdk/credential-providers'
- '@mongodb-js/zstd' - '@mongodb-js/zstd'
- '@nuxt/kit'
- aws-crt - aws-crt
- bufferutil - bufferutil
- gcp-metadata - gcp-metadata
- kerberos - kerberos
- mongodb-client-encryption - mongodb-client-encryption
- react
- react-native-b4a - react-native-b4a
- snappy - snappy
- socks - socks
- supports-color - supports-color
- utf-8-validate - utf-8-validate
- vue
'@push.rocks/taskbuffer@3.4.0': '@push.rocks/taskbuffer@3.4.0':
dependencies: dependencies:
@@ -7337,20 +7285,6 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {}
'@tryghost/admin-api@1.14.0':
dependencies:
axios: 1.12.2(debug@4.4.3)
form-data: 4.0.4
jsonwebtoken: 9.0.2
transitivePeerDependencies:
- debug
'@tryghost/content-api@1.12.0':
dependencies:
axios: 1.12.2(debug@4.4.3)
transitivePeerDependencies:
- debug
'@tsclass/tsclass@3.0.48': '@tsclass/tsclass@3.0.48':
dependencies: dependencies:
type-fest: 2.19.0 type-fest: 2.19.0
@@ -7892,8 +7826,6 @@ snapshots:
buffer-crc32@0.2.13: {} buffer-crc32@0.2.13: {}
buffer-equal-constant-time@1.0.1: {}
buffer-json@2.0.0: {} buffer-json@2.0.0: {}
buffer@6.0.3: buffer@6.0.3:
@@ -8216,10 +8148,6 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
elliptic@6.6.1: elliptic@6.6.1:
@@ -9090,30 +9018,6 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.2
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
keygrip@1.1.0: keygrip@1.1.0:
dependencies: dependencies:
tsscmp: 1.0.6 tsscmp: 1.0.6
@@ -9238,30 +9142,16 @@ snapshots:
lodash.clonedeep@4.5.0: {} lodash.clonedeep@4.5.0: {}
lodash.includes@4.3.0: {}
lodash.isarguments@3.1.0: {} lodash.isarguments@3.1.0: {}
lodash.isarray@3.0.4: {} lodash.isarray@3.0.4: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.keys@3.1.2: lodash.keys@3.1.2:
dependencies: dependencies:
lodash._getnative: 3.9.1 lodash._getnative: 3.9.1
lodash.isarguments: 3.1.0 lodash.isarguments: 3.1.0
lodash.isarray: 3.0.4 lodash.isarray: 3.0.4
lodash.once@4.1.1: {}
lodash.restparam@3.6.1: {} lodash.restparam@3.6.1: {}
lodash@4.17.21: {} lodash@4.17.21: {}

View File

@@ -9,8 +9,10 @@ A modern, fully-typed API client for Ghost CMS that wraps both the Content and A
- **🎯 TypeScript Native** - Full type safety for all Ghost API operations - **🎯 TypeScript Native** - Full type safety for all Ghost API operations
- **🔥 Dual API Support** - Unified interface for both Content and Admin APIs - **🔥 Dual API Support** - Unified interface for both Content and Admin APIs
- **⚡ Modern Async/Await** - No callback hell, just clean promises - **⚡ Modern Async/Await** - No callback hell, just clean promises
- **🌐 Universal Compatibility** - Native fetch implementation works in Node.js, Deno, Bun, and browsers
- **🎨 Elegant API** - Intuitive methods that match your mental model - **🎨 Elegant API** - Intuitive methods that match your mental model
- **🔍 Smart Filtering** - Built-in minimatch support for flexible queries - **🔍 Smart Filtering** - Built-in minimatch support for flexible queries
- **🏷️ Complete Tag Support** - Fetch ALL tags (including zero-count), filter by visibility (internal/external)
- **🔄 Multi-Instance Sync** - Synchronize content across multiple Ghost sites - **🔄 Multi-Instance Sync** - Synchronize content across multiple Ghost sites
- **💪 Production Ready** - Battle-tested with comprehensive error handling - **💪 Production Ready** - Battle-tested with comprehensive error handling
@@ -199,18 +201,46 @@ const filteredPages = await ghost.getPages({
## 🏷️ Tags ## 🏷️ Tags
### Get Tags ### Get All Tags
```typescript ```typescript
// Get ALL tags (including those with zero posts)
const tags = await ghost.getTags(); const tags = await ghost.getTags();
tags.forEach(tag => console.log(`${tag.name} (${tag.slug})`)); tags.forEach(tag => console.log(`${tag.name} (${tag.slug})`));
``` ```
**Note**: Uses Admin API to fetch ALL tags, including tags with zero posts. Previous versions using Content API would omit tags with no associated content.
### Filter by Visibility
Ghost supports two tag types:
- **Public tags**: Standard tags visible to readers
- **Internal tags**: Tags prefixed with `#` for internal organization (not visible publicly)
```typescript
// Get only public tags
const publicTags = await ghost.getPublicTags();
// Get only internal tags (e.g., #feature, #urgent)
const internalTags = await ghost.getInternalTags();
// Get all tags with explicit visibility filter
const publicTags = await ghost.getTags({ visibility: 'public' });
const internalTags = await ghost.getTags({ visibility: 'internal' });
const allTags = await ghost.getTags({ visibility: 'all' }); // default
```
### Filter Tags with Minimatch ### Filter Tags with Minimatch
```typescript ```typescript
const techTags = await ghost.getTags({ filter: 'tech-*' }); const techTags = await ghost.getTags({ filter: 'tech-*' });
const blogTags = await ghost.getTags({ filter: '*blog*' }); const blogTags = await ghost.getTags({ filter: '*blog*' });
// Combine visibility and pattern filtering
const internalNews = await ghost.getTags({
filter: 'news-*',
visibility: 'internal'
});
``` ```
### Get Single Tag ### Get Single Tag
@@ -219,21 +249,38 @@ const blogTags = await ghost.getTags({ filter: '*blog*' });
const tag = await ghost.getTagBySlug('javascript'); const tag = await ghost.getTagBySlug('javascript');
console.log(tag.getName()); console.log(tag.getName());
console.log(tag.getDescription()); console.log(tag.getDescription());
console.log(tag.getVisibility()); // 'public' or 'internal'
// Check visibility
if (tag.isInternal()) {
console.log('This is an internal tag');
}
``` ```
### Create, Update, Delete Tags ### Create, Update, Delete Tags
```typescript ```typescript
// Create a public tag
const newTag = await ghost.createTag({ const newTag = await ghost.createTag({
name: 'TypeScript', name: 'TypeScript',
slug: 'typescript', slug: 'typescript',
description: 'All about TypeScript' description: 'All about TypeScript',
visibility: 'public'
}); });
// Create an internal tag (note the # prefix)
const internalTag = await ghost.createTag({
name: '#feature',
slug: 'hash-feature',
visibility: 'internal'
});
// Update tag
await newTag.update({ await newTag.update({
description: 'Everything TypeScript related' description: 'Everything TypeScript related'
}); });
// Delete tag (now works reliably!)
await newTag.delete(); await newTag.delete();
``` ```
@@ -531,7 +578,9 @@ try {
| `getPageById(id)` | Get page by ID | `Promise<Page>` | | `getPageById(id)` | Get page by ID | `Promise<Page>` |
| `getPageBySlug(slug)` | Get page by slug | `Promise<Page>` | | `getPageBySlug(slug)` | Get page by slug | `Promise<Page>` |
| `createPage(data)` | Create a new page | `Promise<Page>` | | `createPage(data)` | Create a new page | `Promise<Page>` |
| `getTags(options?)` | Get all tags | `Promise<ITag[]>` | | `getTags(options?)` | Get all tags (including zero-count) | `Promise<ITag[]>` |
| `getPublicTags(options?)` | Get only public tags | `Promise<ITag[]>` |
| `getInternalTags(options?)` | Get only internal tags | `Promise<ITag[]>` |
| `getTagById(id)` | Get tag by ID | `Promise<Tag>` | | `getTagById(id)` | Get tag by ID | `Promise<Tag>` |
| `getTagBySlug(slug)` | Get tag by slug | `Promise<Tag>` | | `getTagBySlug(slug)` | Get tag by slug | `Promise<Tag>` |
| `createTag(data)` | Create a new tag | `Promise<Tag>` | | `createTag(data)` | Create a new tag | `Promise<Tag>` |
@@ -583,6 +632,9 @@ try {
| `getName()` | Get tag name | `string` | | `getName()` | Get tag name | `string` |
| `getSlug()` | Get tag slug | `string` | | `getSlug()` | Get tag slug | `string` |
| `getDescription()` | Get tag description | `string \| undefined` | | `getDescription()` | Get tag description | `string \| undefined` |
| `getVisibility()` | Get tag visibility | `string` |
| `isInternal()` | Check if tag is internal | `boolean` |
| `isPublic()` | Check if tag is public | `boolean` |
| `toJson()` | Get raw tag data | `ITag` | | `toJson()` | Get raw tag data | `ITag` |
| `update(data)` | Update the tag | `Promise<Tag>` | | `update(data)` | Update the tag | `Promise<Tag>` |
| `delete()` | Delete the tag | `Promise<void>` | | `delete()` | Delete the tag | `Promise<void>` |

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@apiclient.xyz/ghost', name: '@apiclient.xyz/ghost',
version: '2.1.0', version: '2.2.0',
description: 'An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.' description: 'An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.'
} }

View File

@@ -0,0 +1,318 @@
/**
* Ghost Admin API Client
* Full CRUD operations for Ghost content using native fetch
*/
import { generateToken } from './ghost.jwt.js';
import type { THttpMethod, IBrowseOptions, IGhostAPIResponse, IGhostErrorResponse } from './ghost.types.js';
import * as fs from 'fs';
import * as path from 'path';
export interface IGhostAdminAPIOptions {
url: string;
key: string;
version?: string;
ghostPath?: string;
}
export class GhostAdminAPI {
private url: string;
private key: string;
private version: string;
private ghostPath: string;
private acceptVersionHeader: string;
constructor(options: IGhostAdminAPIOptions) {
this.url = options.url.replace(/\/$/, ''); // Remove trailing slash
this.key = options.key;
this.version = options.version || 'v3';
this.ghostPath = options.ghostPath || 'ghost';
if (!this.url) {
throw new Error('GhostAdminAPI: url is required');
}
if (!this.key) {
throw new Error('GhostAdminAPI: key is required');
}
// Set accept version header
if (this.version.match(/^v\d+$/)) {
this.acceptVersionHeader = `${this.version}.0`;
} else if (this.version.match(/^v\d+\.\d+$/)) {
this.acceptVersionHeader = this.version;
} else {
this.acceptVersionHeader = 'v6.0';
}
}
/**
* Build the API prefix based on version
*/
private getAPIPrefix(): string {
// v5+ doesn't need version prefix
if (this.version === 'v5' || this.version === 'v6' || this.version.match(/^v[5-9]\.\d+/)) {
return `/admin/`;
}
// v2-v4 and canary need version prefix
return `/${this.version}/admin/`;
}
/**
* Build full API URL
*/
private buildUrl(resource: string, identifier?: string, params?: Record<string, any>): string {
const apiPrefix = this.getAPIPrefix();
let endpoint = `${this.url}/${this.ghostPath}/api${apiPrefix}${resource}/`;
if (identifier) {
endpoint += `${identifier}/`;
}
// Build query string if params exist
if (params && Object.keys(params).length > 0) {
const queryString = Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null)
.map(key => {
const value = Array.isArray(params[key])
? params[key].join(',')
: params[key];
return `${key}=${encodeURIComponent(value)}`;
})
.join('&');
if (queryString) {
endpoint += `?${queryString}`;
}
}
return endpoint;
}
/**
* Get authorization token
*/
private async getAuthToken(): Promise<string> {
const audience = this.getAPIPrefix();
return await generateToken(this.key, audience);
}
/**
* Make API request
*/
private async makeRequest<T>(
resource: string,
method: THttpMethod = 'GET',
identifier?: string,
body?: any,
queryParams?: Record<string, any>
): Promise<T | T[] | void> {
const url = this.buildUrl(resource, identifier, queryParams);
const token = await this.getAuthToken();
const headers: Record<string, string> = {
'Authorization': `Ghost ${token}`,
'Accept-Version': this.acceptVersionHeader,
'Accept': 'application/json',
'User-Agent': 'GhostAdminAPI/2.0'
};
const requestOptions: RequestInit = {
method,
headers
};
// Add body for POST/PUT
if (body && (method === 'POST' || method === 'PUT')) {
headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
try {
const response = await fetch(url, requestOptions);
if (!response.ok) {
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
errors: [{ type: 'UnknownError', message: response.statusText }]
}));
const error = errorData.errors?.[0];
const err = new Error(error?.message || response.statusText);
Object.assign(err, {
name: error?.type || 'GhostAdminAPIError',
statusCode: response.status,
...error
});
throw err;
}
// DELETE returns empty response
if (method === 'DELETE') {
return;
}
const data: IGhostAPIResponse<T> = await response.json();
// Extract the resource data
const resourceData = data[resource];
if (!resourceData) {
return data as any; // For some special endpoints that don't follow standard format
}
// If it's an array and has meta, attach meta to the array
if (Array.isArray(resourceData) && data.meta) {
return Object.assign(resourceData, { meta: data.meta });
}
// If it's an array with single item and no meta, return the item
if (Array.isArray(resourceData) && resourceData.length === 1 && !data.meta) {
return resourceData[0];
}
return resourceData as T | T[];
} catch (error) {
throw error;
}
}
/**
* Create resource API methods factory
*/
private createResourceAPI(resourceType: string) {
return {
browse: (options?: IBrowseOptions) => {
return this.makeRequest(resourceType, 'GET', undefined, undefined, options);
},
read: (data: { id?: string; slug?: string; email?: string }, queryParams?: Record<string, any>) => {
if (data.slug) {
return this.makeRequest(resourceType, 'GET', `slug/${data.slug}`, undefined, queryParams);
}
if (data.email) {
return this.makeRequest(resourceType, 'GET', `email/${data.email}`, undefined, queryParams);
}
if (data.id) {
return this.makeRequest(resourceType, 'GET', data.id, undefined, queryParams);
}
throw new Error('Must provide id, slug, or email');
},
add: (data: any, queryParams?: Record<string, any>) => {
if (!data || !Object.keys(data).length) {
return Promise.reject(new Error('Missing data'));
}
const body: any = {};
body[resourceType] = [data];
return this.makeRequest(resourceType, 'POST', undefined, body, queryParams);
},
edit: (data: any, queryParams?: Record<string, any>) => {
if (!data) {
return Promise.reject(new Error('Missing data'));
}
if (!data.id) {
return Promise.reject(new Error('Must include data.id'));
}
const id = data.id;
const updateData = { ...data };
delete updateData.id;
const body: any = {};
body[resourceType] = [updateData];
return this.makeRequest(resourceType, 'PUT', id, body, queryParams);
},
delete: (data: { id?: string; email?: string }, queryParams?: Record<string, any>) => {
if (!data) {
return Promise.reject(new Error('Missing data'));
}
if (!data.id && !data.email) {
return Promise.reject(new Error('Must include either data.id or data.email'));
}
const identifier = data.email ? `email/${data.email}` : data.id;
return this.makeRequest(resourceType, 'DELETE', identifier, undefined, queryParams);
}
};
}
// Resource APIs
public posts = this.createResourceAPI('posts');
public pages = this.createResourceAPI('pages');
public tags = this.createResourceAPI('tags');
public members = this.createResourceAPI('members');
public users = this.createResourceAPI('users');
// Webhooks (limited operations)
public webhooks = {
add: (data: any, queryParams?: Record<string, any>) => {
const body: any = { webhooks: [data] };
return this.makeRequest('webhooks', 'POST', undefined, body, queryParams);
},
edit: (data: any, queryParams?: Record<string, any>) => {
if (!data.id) {
return Promise.reject(new Error('Must include data.id'));
}
const id = data.id;
const updateData = { ...data };
delete updateData.id;
const body: any = { webhooks: [updateData] };
return this.makeRequest('webhooks', 'PUT', id, body, queryParams);
},
delete: (data: { id: string }, queryParams?: Record<string, any>) => {
if (!data.id) {
return Promise.reject(new Error('Must include data.id'));
}
return this.makeRequest('webhooks', 'DELETE', data.id, undefined, queryParams);
}
};
// Image upload
public images = {
upload: async (data: { file: string }) => {
if (!data || !data.file) {
throw new Error('Must provide file path');
}
const url = this.buildUrl('images', 'upload');
const token = await this.getAuthToken();
// Read file
const fileBuffer = fs.readFileSync(data.file);
const fileName = path.basename(data.file);
// Create FormData
const formData = new FormData();
// Convert Buffer to ArrayBuffer for Blob
const arrayBuffer = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength) as ArrayBuffer;
const blob = new Blob([arrayBuffer], { type: 'image/*' });
formData.append('file', blob, fileName);
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Ghost ${token}`,
'Accept-Version': this.acceptVersionHeader,
'User-Agent': 'GhostAdminAPI/2.0'
},
body: formData
});
if (!response.ok) {
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
errors: [{ type: 'UnknownError', message: response.statusText }]
}));
const error = errorData.errors?.[0];
const err = new Error(error?.message || response.statusText);
throw err;
}
const result = await response.json();
return result.images?.[0] || result;
}
};
}

View File

@@ -0,0 +1,191 @@
/**
* Ghost Content API Client
* Read-only API for published content using native fetch
*/
import type { IBrowseOptions, IReadOptions, IGhostAPIResponse, IGhostErrorResponse } from './ghost.types.js';
export interface IGhostContentAPIOptions {
url: string;
key: string;
version?: string;
ghostPath?: string;
}
export class GhostContentAPI {
private url: string;
private key: string;
private version: string;
private ghostPath: string;
constructor(options: IGhostContentAPIOptions) {
this.url = options.url.replace(/\/$/, ''); // Remove trailing slash
this.key = options.key;
this.version = options.version || 'v3';
this.ghostPath = options.ghostPath || 'ghost';
if (!this.url) {
throw new Error('GhostContentAPI: url is required');
}
if (!this.key) {
throw new Error('GhostContentAPI: key is required');
}
}
/**
* Build the API prefix based on version
*/
private getAPIPrefix(): string {
// v5+ doesn't need version prefix
if (this.version === 'v5' || this.version === 'v6' || this.version.match(/^v[5-9]\.\d+/)) {
return `/content/`;
}
// v2-v4 and canary need version prefix
return `/${this.version}/content/`;
}
/**
* Build full API URL
*/
private buildUrl(resource: string, identifier?: string, params?: Record<string, any>): string {
const apiPrefix = this.getAPIPrefix();
let endpoint = `${this.url}/${this.ghostPath}/api${apiPrefix}${resource}/`;
if (identifier) {
endpoint += `${identifier}/`;
}
// Add key to params
const queryParams = {
key: this.key,
...params
};
// Build query string
const queryString = Object.keys(queryParams)
.filter(key => queryParams[key] !== undefined && queryParams[key] !== null)
.map(key => {
const value = Array.isArray(queryParams[key])
? queryParams[key].join(',')
: queryParams[key];
return `${key}=${encodeURIComponent(value)}`;
})
.join('&');
return queryString ? `${endpoint}?${queryString}` : endpoint;
}
/**
* Make API request
*/
private async makeRequest<T>(
resource: string,
identifier?: string,
params?: Record<string, any>
): Promise<T | T[]> {
const url = this.buildUrl(resource, identifier, params);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Accept-Version': this.version.match(/^\d+\.\d+$/) ? `v${this.version}` : this.version,
'User-Agent': 'GhostContentAPI/2.0'
}
});
if (!response.ok) {
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
errors: [{ type: 'UnknownError', message: response.statusText }]
}));
const error = errorData.errors?.[0];
const err = new Error(error?.message || response.statusText);
Object.assign(err, {
name: error?.type || 'GhostContentAPIError',
statusCode: response.status,
...error
});
throw err;
}
const data: IGhostAPIResponse<T> = await response.json();
// Extract the resource data
const resourceData = data[resource];
if (!resourceData) {
throw new Error(`Response missing ${resource} property`);
}
// If it's an array and has meta, attach meta to the array
if (Array.isArray(resourceData) && data.meta) {
return Object.assign(resourceData, { meta: data.meta });
}
// If it's an array with single item and no meta, return the item
if (Array.isArray(resourceData) && resourceData.length === 1 && !data.meta) {
return resourceData[0];
}
return resourceData as T | T[];
} catch (error) {
throw error;
}
}
/**
* Create resource API methods
*/
public posts = {
browse: (options?: IBrowseOptions) => this.makeRequest('posts', undefined, options),
read: (options: IReadOptions) => {
if (options.slug) {
return this.makeRequest('posts', `slug/${options.slug}`, options);
}
if (options.id) {
return this.makeRequest('posts', options.id, options);
}
throw new Error('Must provide id or slug');
}
};
public pages = {
browse: (options?: IBrowseOptions) => this.makeRequest('pages', undefined, options),
read: (options: IReadOptions) => {
if (options.slug) {
return this.makeRequest('pages', `slug/${options.slug}`, options);
}
if (options.id) {
return this.makeRequest('pages', options.id, options);
}
throw new Error('Must provide id or slug');
}
};
public tags = {
browse: (options?: IBrowseOptions) => this.makeRequest('tags', undefined, options),
read: (options: IReadOptions) => {
if (options.slug) {
return this.makeRequest('tags', `slug/${options.slug}`, options);
}
if (options.id) {
return this.makeRequest('tags', options.id, options);
}
throw new Error('Must provide id or slug');
}
};
public authors = {
browse: (options?: IBrowseOptions) => this.makeRequest('authors', undefined, options),
read: (options: IReadOptions) => {
if (options.slug) {
return this.makeRequest('authors', `slug/${options.slug}`, options);
}
if (options.id) {
return this.makeRequest('authors', options.id, options);
}
throw new Error('Must provide id or slug');
}
};
}

116
ts/apiclient/ghost.jwt.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* JWT token generator for Ghost Admin API
* Implements HS256 signing compatible with Ghost's authentication
*/
/**
* Base64 URL encode (without padding)
*/
function base64UrlEncode(data: Uint8Array): string {
const base64 = typeof Buffer !== 'undefined'
? Buffer.from(data).toString('base64')
: btoa(String.fromCharCode(...data));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Generate JWT token for Ghost Admin API
* @param key - Admin API key in format {id}:{secret}
* @param audience - Token audience (API prefix like '/admin/')
* @returns JWT token string
*/
export async function generateToken(key: string, audience: string): Promise<string> {
// Parse the admin key
const [keyId, secret] = key.split(':');
if (!keyId || !secret) {
throw new Error('Invalid admin API key format. Expected {id}:{secret}');
}
if (keyId.length !== 24 || secret.length !== 64) {
throw new Error('Invalid admin API key format. Expected 24 hex chars for id and 64 for secret');
}
// Create JWT header
const header = {
alg: 'HS256',
typ: 'JWT',
kid: keyId
};
// Create JWT payload
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now,
exp: now + 300, // 5 minutes
aud: audience
};
// Encode header and payload
const headerEncoded = base64UrlEncode(
new TextEncoder().encode(JSON.stringify(header))
);
const payloadEncoded = base64UrlEncode(
new TextEncoder().encode(JSON.stringify(payload))
);
// Create signature data
const signatureData = `${headerEncoded}.${payloadEncoded}`;
// Convert secret from hex to bytes
const secretBytes = hexToBytes(secret);
// Import key for HMAC
let cryptoKey: CryptoKey;
// Try to use Web Crypto API (works in Node 15+ and all modern browsers)
try {
const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
// Convert to proper BufferSource type
const secretBuffer = secretBytes.buffer.slice(secretBytes.byteOffset, secretBytes.byteOffset + secretBytes.byteLength) as ArrayBuffer;
cryptoKey = await crypto.subtle.importKey(
'raw',
secretBuffer,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// Sign the data
const signature = await crypto.subtle.sign(
'HMAC',
cryptoKey,
new TextEncoder().encode(signatureData)
);
// Encode signature
const signatureEncoded = base64UrlEncode(new Uint8Array(signature));
// Return complete JWT
return `${signatureData}.${signatureEncoded}`;
} catch (error) {
// Fallback for older Node versions using crypto module directly
const crypto = await import('crypto');
const hmac = crypto.createHmac('sha256', secretBytes);
hmac.update(signatureData);
const signature = hmac.digest();
const signatureEncoded = base64UrlEncode(signature);
return `${signatureData}.${signatureEncoded}`;
}
}

View File

@@ -0,0 +1,66 @@
/**
* Shared types for Ghost API clients
*/
export interface IGhostAPIResponse<T> {
[key: string]: T | T[] | IGhostMeta;
meta?: IGhostMeta;
}
export interface IGhostMeta {
pagination?: {
page: number;
limit: number;
pages: number;
total: number;
next: number | null;
prev: number | null;
};
}
export interface IGhostError {
type: string;
message: string;
context?: string;
property?: string;
help?: string;
code?: string;
id?: string;
ghostErrorCode?: string;
}
export interface IGhostErrorResponse {
errors: IGhostError[];
}
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface IRequestOptions {
method?: THttpMethod;
headers?: Record<string, string>;
body?: string;
signal?: AbortSignal;
}
/**
* Query parameters for browse operations
*/
export interface IBrowseOptions {
limit?: number;
page?: number;
filter?: string;
include?: string;
fields?: string;
order?: string;
}
/**
* Options for read operations (by ID, slug, or email)
*/
export interface IReadOptions {
id?: string;
slug?: string;
email?: string;
include?: string;
fields?: string;
}

View File

@@ -22,13 +22,13 @@ export class Ghost {
this.adminApi = new plugins.GhostAdminAPI({ this.adminApi = new plugins.GhostAdminAPI({
url: this.options.baseUrl, url: this.options.baseUrl,
key: this.options.adminApiKey, key: this.options.adminApiKey,
version: 'v3', version: 'v6.0',
}); });
this.contentApi = new plugins.GhostContentAPI({ this.contentApi = new plugins.GhostContentAPI({
url: this.options.baseUrl, url: this.options.baseUrl,
key: this.options.contentApiKey, key: this.options.contentApiKey,
version: 'v3', version: 'v6.0',
}); });
} }
@@ -95,11 +95,31 @@ export class Ghost {
return new Post(this, postData); return new Post(this, postData);
} }
public async getTags(optionsArg?: { filter?: string; limit?: number }): Promise<ITag[]> { public async getTags(optionsArg?: {
filter?: string;
limit?: number;
visibility?: 'public' | 'internal' | 'all';
include?: string;
}): Promise<ITag[]> {
try { try {
const limit = optionsArg?.limit || 1000; const limit = optionsArg?.limit || 1000;
const tagsData = await this.contentApi.tags.browse({ limit }); const visibility = optionsArg?.visibility || 'all';
// Use Admin API to get ALL tags including those with zero posts
const browseOptions: any = { limit };
// Add visibility filter if not 'all'
if (visibility !== 'all') {
browseOptions.filter = `visibility:${visibility}`;
}
if (optionsArg?.include) {
browseOptions.include = optionsArg.include;
}
const tagsData = await this.adminApi.tags.browse(browseOptions);
// Apply minimatch filter if provided
if (optionsArg?.filter) { if (optionsArg?.filter) {
const matcher = new plugins.smartmatch.SmartMatch(optionsArg.filter); const matcher = new plugins.smartmatch.SmartMatch(optionsArg.filter);
return tagsData.filter((tag: ITag) => matcher.match(tag.slug)); return tagsData.filter((tag: ITag) => matcher.match(tag.slug));
@@ -111,6 +131,14 @@ export class Ghost {
} }
} }
public async getPublicTags(optionsArg?: { filter?: string; limit?: number }): Promise<ITag[]> {
return this.getTags({ ...optionsArg, visibility: 'public' });
}
public async getInternalTags(optionsArg?: { filter?: string; limit?: number }): Promise<ITag[]> {
return this.getTags({ ...optionsArg, visibility: 'internal' });
}
public async getTagById(id: string): Promise<Tag> { public async getTagById(id: string): Promise<Tag> {
try { try {
const tagData = await this.contentApi.tags.read({ id }); const tagData = await this.contentApi.tags.read({ id });

View File

@@ -26,6 +26,18 @@ export class Tag {
return this.tagData.description; return this.tagData.description;
} }
public getVisibility(): string {
return this.tagData.visibility;
}
public isInternal(): boolean {
return this.tagData.visibility === 'internal';
}
public isPublic(): boolean {
return this.tagData.visibility === 'public';
}
public toJson(): ITag { public toJson(): ITag {
return this.tagData; return this.tagData;
} }

View File

@@ -1,5 +1,5 @@
import GhostContentAPI from '@tryghost/content-api'; import { GhostContentAPI } from './apiclient/ghost.contentapi.js';
import GhostAdminAPI from '@tryghost/admin-api'; import { GhostAdminAPI } from './apiclient/ghost.adminapi.js';
import * as smartmatch from '@push.rocks/smartmatch'; import * as smartmatch from '@push.rocks/smartmatch';
export { export {