From 11a9b23802fa247de7fe267acc7b8a9a0a42628a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 10 Oct 2025 12:57:31 +0000 Subject: [PATCH] feat(apiclient): Add native Admin & Content API clients, JWT generator, and tag visibility features; remove external @tryghost deps and update docs --- .serena/.gitignore | 1 + .serena/project.yml | 67 +++++++ changelog.md | 11 ++ package.json | 4 +- pnpm-lock.yaml | 110 ----------- readme.md | 58 +++++- ts/00_commitinfo_data.ts | 2 +- ts/apiclient/ghost.adminapi.ts | 318 +++++++++++++++++++++++++++++++ ts/apiclient/ghost.contentapi.ts | 191 +++++++++++++++++++ ts/apiclient/ghost.jwt.ts | 116 +++++++++++ ts/apiclient/ghost.types.ts | 66 +++++++ ts/classes.ghost.ts | 40 +++- ts/classes.tag.ts | 12 ++ ts/ghost.plugins.ts | 4 +- 14 files changed, 875 insertions(+), 125 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 ts/apiclient/ghost.adminapi.ts create mode 100644 ts/apiclient/ghost.contentapi.ts create mode 100644 ts/apiclient/ghost.jwt.ts create mode 100644 ts/apiclient/ghost.types.ts diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..e1a85f7 --- /dev/null +++ b/.serena/project.yml @@ -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" diff --git a/changelog.md b/changelog.md index 9044efb..ad17dd7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # 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) Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README diff --git a/package.json b/package.json index 8ae9991..3ac5050 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,7 @@ "@types/node": "^22.12.0" }, "dependencies": { - "@push.rocks/smartmatch": "^2.0.0", - "@tryghost/admin-api": "^1.14.0", - "@tryghost/content-api": "^1.12.0" + "@push.rocks/smartmatch": "^2.0.0" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dfed86..ddb1ae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,6 @@ importers: '@push.rocks/smartmatch': specifier: ^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: '@git.zone/tsbuild': specifier: ^2.6.8 @@ -1537,12 +1531,6 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': 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': resolution: {integrity: sha512-hC65UvDlp9qvsl6OcIZXz0JNiWZ0gyzsTzbXpg215sGxopgbkOLCr6E0s4qCTnweYm95gt2AdY95uP7M7kExaQ==} @@ -1997,9 +1985,6 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=} - buffer-json@2.0.0: resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==} @@ -2358,9 +2343,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - ee-first@1.1.1: resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} @@ -3066,16 +3048,6 @@ packages: jsonfile@6.2.0: 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: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -3170,36 +3142,15 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=} - lodash.includes@4.3.0: - resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=} - lodash.isarguments@3.1.0: resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=} lodash.isarray@3.0.4: 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: resolution: {integrity: sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=} - lodash.once@4.1.1: - resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=} - lodash.restparam@3.6.1: resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=} @@ -6768,19 +6719,16 @@ snapshots: transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' - - '@nuxt/kit' - aws-crt - bufferutil - gcp-metadata - kerberos - mongodb-client-encryption - - react - react-native-b4a - snappy - socks - supports-color - utf-8-validate - - vue '@push.rocks/taskbuffer@3.4.0': dependencies: @@ -7337,20 +7285,6 @@ snapshots: '@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': dependencies: type-fest: 2.19.0 @@ -7892,8 +7826,6 @@ snapshots: buffer-crc32@0.2.13: {} - buffer-equal-constant-time@1.0.1: {} - buffer-json@2.0.0: {} buffer@6.0.3: @@ -8216,10 +8148,6 @@ snapshots: eastasianwidth@0.2.0: {} - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - ee-first@1.1.1: {} elliptic@6.6.1: @@ -9090,30 +9018,6 @@ snapshots: optionalDependencies: 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: dependencies: tsscmp: 1.0.6 @@ -9238,30 +9142,16 @@ snapshots: lodash.clonedeep@4.5.0: {} - lodash.includes@4.3.0: {} - lodash.isarguments@3.1.0: {} 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: dependencies: lodash._getnative: 3.9.1 lodash.isarguments: 3.1.0 lodash.isarray: 3.0.4 - lodash.once@4.1.1: {} - lodash.restparam@3.6.1: {} lodash@4.17.21: {} diff --git a/readme.md b/readme.md index 4852d01..0d23818 100644 --- a/readme.md +++ b/readme.md @@ -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 - **🔥 Dual API Support** - Unified interface for both Content and Admin APIs - **⚡ 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 - **🔍 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 - **💪 Production Ready** - Battle-tested with comprehensive error handling @@ -199,18 +201,46 @@ const filteredPages = await ghost.getPages({ ## 🏷️ Tags -### Get Tags +### Get All Tags ```typescript +// Get ALL tags (including those with zero posts) const tags = await ghost.getTags(); 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 ```typescript const techTags = await ghost.getTags({ filter: 'tech-*' }); const blogTags = await ghost.getTags({ filter: '*blog*' }); + +// Combine visibility and pattern filtering +const internalNews = await ghost.getTags({ + filter: 'news-*', + visibility: 'internal' +}); ``` ### Get Single Tag @@ -219,21 +249,38 @@ const blogTags = await ghost.getTags({ filter: '*blog*' }); const tag = await ghost.getTagBySlug('javascript'); console.log(tag.getName()); 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 ```typescript +// Create a public tag const newTag = await ghost.createTag({ name: '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({ description: 'Everything TypeScript related' }); +// Delete tag (now works reliably!) await newTag.delete(); ``` @@ -531,7 +578,9 @@ try { | `getPageById(id)` | Get page by ID | `Promise` | | `getPageBySlug(slug)` | Get page by slug | `Promise` | | `createPage(data)` | Create a new page | `Promise` | -| `getTags(options?)` | Get all tags | `Promise` | +| `getTags(options?)` | Get all tags (including zero-count) | `Promise` | +| `getPublicTags(options?)` | Get only public tags | `Promise` | +| `getInternalTags(options?)` | Get only internal tags | `Promise` | | `getTagById(id)` | Get tag by ID | `Promise` | | `getTagBySlug(slug)` | Get tag by slug | `Promise` | | `createTag(data)` | Create a new tag | `Promise` | @@ -583,6 +632,9 @@ try { | `getName()` | Get tag name | `string` | | `getSlug()` | Get tag slug | `string` | | `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` | | `update(data)` | Update the tag | `Promise` | | `delete()` | Delete the tag | `Promise` | diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c3d40d2..2a75dc0 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts/apiclient/ghost.adminapi.ts b/ts/apiclient/ghost.adminapi.ts new file mode 100644 index 0000000..399796e --- /dev/null +++ b/ts/apiclient/ghost.adminapi.ts @@ -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 { + 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 { + const audience = this.getAPIPrefix(); + return await generateToken(this.key, audience); + } + + /** + * Make API request + */ + private async makeRequest( + resource: string, + method: THttpMethod = 'GET', + identifier?: string, + body?: any, + queryParams?: Record + ): Promise { + const url = this.buildUrl(resource, identifier, queryParams); + const token = await this.getAuthToken(); + + const headers: Record = { + '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 = 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + const body: any = { webhooks: [data] }; + return this.makeRequest('webhooks', 'POST', undefined, body, queryParams); + }, + + edit: (data: any, queryParams?: Record) => { + 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) => { + 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; + } + }; +} diff --git a/ts/apiclient/ghost.contentapi.ts b/ts/apiclient/ghost.contentapi.ts new file mode 100644 index 0000000..267f39a --- /dev/null +++ b/ts/apiclient/ghost.contentapi.ts @@ -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 { + 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( + resource: string, + identifier?: string, + params?: Record + ): Promise { + 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 = 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'); + } + }; +} diff --git a/ts/apiclient/ghost.jwt.ts b/ts/apiclient/ghost.jwt.ts new file mode 100644 index 0000000..01c6a4a --- /dev/null +++ b/ts/apiclient/ghost.jwt.ts @@ -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 { + // 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}`; + } +} diff --git a/ts/apiclient/ghost.types.ts b/ts/apiclient/ghost.types.ts new file mode 100644 index 0000000..e446db3 --- /dev/null +++ b/ts/apiclient/ghost.types.ts @@ -0,0 +1,66 @@ +/** + * Shared types for Ghost API clients + */ + +export interface IGhostAPIResponse { + [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; + 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; +} diff --git a/ts/classes.ghost.ts b/ts/classes.ghost.ts index b7785df..4f16410 100644 --- a/ts/classes.ghost.ts +++ b/ts/classes.ghost.ts @@ -22,13 +22,13 @@ export class Ghost { this.adminApi = new plugins.GhostAdminAPI({ url: this.options.baseUrl, key: this.options.adminApiKey, - version: 'v3', + version: 'v6.0', }); this.contentApi = new plugins.GhostContentAPI({ url: this.options.baseUrl, key: this.options.contentApiKey, - version: 'v3', + version: 'v6.0', }); } @@ -95,22 +95,50 @@ export class Ghost { return new Post(this, postData); } - public async getTags(optionsArg?: { filter?: string; limit?: number }): Promise { + public async getTags(optionsArg?: { + filter?: string; + limit?: number; + visibility?: 'public' | 'internal' | 'all'; + include?: string; + }): Promise { try { 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) { const matcher = new plugins.smartmatch.SmartMatch(optionsArg.filter); return tagsData.filter((tag: ITag) => matcher.match(tag.slug)); } - + return tagsData; } catch (error) { throw error; } } + public async getPublicTags(optionsArg?: { filter?: string; limit?: number }): Promise { + return this.getTags({ ...optionsArg, visibility: 'public' }); + } + + public async getInternalTags(optionsArg?: { filter?: string; limit?: number }): Promise { + return this.getTags({ ...optionsArg, visibility: 'internal' }); + } + public async getTagById(id: string): Promise { try { const tagData = await this.contentApi.tags.read({ id }); diff --git a/ts/classes.tag.ts b/ts/classes.tag.ts index 41d94ef..014b29f 100644 --- a/ts/classes.tag.ts +++ b/ts/classes.tag.ts @@ -26,6 +26,18 @@ export class Tag { 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 { return this.tagData; } diff --git a/ts/ghost.plugins.ts b/ts/ghost.plugins.ts index 8298692..17ba380 100644 --- a/ts/ghost.plugins.ts +++ b/ts/ghost.plugins.ts @@ -1,5 +1,5 @@ -import GhostContentAPI from '@tryghost/content-api'; -import GhostAdminAPI from '@tryghost/admin-api'; +import { GhostContentAPI } from './apiclient/ghost.contentapi.js'; +import { GhostAdminAPI } from './apiclient/ghost.adminapi.js'; import * as smartmatch from '@push.rocks/smartmatch'; export {