14 Commits

Author SHA1 Message Date
3b0cdd5f65 2.2.1
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-11 06:16:45 +00:00
2bb86552e2 fix(syncedinstance): Prevent same-instance syncs and sanitize post update payloads; update tests and docs 2025-10-11 06:16:44 +00:00
00dd0c69a5 update 2025-10-10 17:02:41 +00:00
b289cb67cf update tests 2025-10-10 16:55:15 +00:00
4b2c828dd6 2.2.0
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-10 12:57:32 +00:00
11a9b23802 feat(apiclient): Add native Admin & Content API clients, JWT generator, and tag visibility features; remove external @tryghost deps and update docs 2025-10-10 12:57:31 +00:00
719bfafb93 2.1.0
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-08 09:57:59 +00:00
d493d9fd01 feat(syncedinstance): Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README 2025-10-08 09:57:59 +00:00
63e514c1da 2.0.0
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-08 09:14:46 +00:00
b3f08fb64c BREAKING CHANGE(classes.ghost): Remove Settings and Webhooks browse/read APIs, remove noisy console.error logs, and update tests/docs 2025-10-08 09:14:45 +00:00
7251e90395 1.4.1
Some checks failed
Default (tags) / security (push) Failing after 12s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-07 14:31:54 +00:00
62839e2f54 fix(tests): Remove updated_at from post and page update test payloads 2025-10-07 14:31:54 +00:00
2d9844eb61 1.4.0
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-07 14:29:36 +00:00
76c2b714b5 feat(classes.ghost): Add members, settings and webhooks support; implement Member class and add tests 2025-10-07 14:29:36 +00:00
32 changed files with 11808 additions and 680 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,60 @@
# Changelog
## 2025-10-11 - 2.2.1 - fix(syncedinstance)
Prevent same-instance syncs and sanitize post update payloads; update tests and docs
- SyncedInstance now validates and normalizes source and target base URLs (trailing slashes and case) and throws a clear error when attempting to sync an instance to itself to prevent circular syncs.
- Post.update signature changed to accept Partial<IPost>. Update logic now builds a sanitized payload and removes read-only/computed fields (uuid, comment_id, url, excerpt, reading_time, created_at, primary_author, primary_tag, etc.) before calling the Admin API to avoid conflicts.
- Added/updated integration tests (dates, syncedinstance validation) and adjusted tag tests to be resilient; README expanded with examples, usage notes, and multi-instance sync safety details.
- Improved tag sync/update to preserve slug when updating tags on targets.
## 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
- Introduce SyncedInstance class (ts/classes.syncedinstance.ts) to synchronize tags, posts and pages across Ghost instances with mapping and history support. New APIs: syncTags, syncPosts, syncPages, syncAll, getSyncStatus, clearSyncHistory, clearMappings.
- Export SyncedInstance from the package entry point (ts/index.ts).
- Add integration tests for SyncedInstance (test/test.syncedinstance.node.ts).
- Expand README (readme.md) with Quick Start, detailed Multi-Instance Synchronization docs, examples and updated API reference.
## 2025-10-08 - 2.0.0 - BREAKING CHANGE(classes.ghost)
Remove Settings and Webhooks browse/read APIs, remove noisy console.error logs, and update tests/docs
- Removed Settings API methods from Ghost: getSettings and updateSettings (breaking change).
- Removed Webhooks browsing/reading methods from Ghost: getWebhooks and getWebhookById. createWebhook, updateWebhook and deleteWebhook remain.
- Removed test/test.settings.node.ts and simplified test/test.webhook.node.ts to only exercise create/update/delete webhook flows without feature-availability guarding.
- Stripped console.error debug logging across multiple classes (Author, Ghost, Member, Page, Post, Tag) to reduce noisy runtime output.
- Updated README: removed 'Site Settings' section and clarified webhook API limitations supported by the underlying Ghost Admin SDK.
## 2025-10-07 - 1.4.1 - fix(tests)
Remove updated_at from post and page update test payloads
- Stop setting updated_at in test update payloads to avoid mutating server-managed timestamps
- Changed test/test.post.node.ts: removed updated_at assignment when updating a post
- Changed test/test.page.node.ts: removed updated_at assignment when updating a page
## 2025-10-07 - 1.4.0 - feat(classes.ghost)
Add members, settings and webhooks support; implement Member class and add tests
- Introduce IMember and Member class (ts/classes.member.ts) with CRUD (update, delete) and JSON helpers
- Add member management API to Ghost: getMembers (with minimatch filtering), getMemberById, getMemberByEmail, createMember
- Add site settings API to Ghost: getSettings and updateSettings (admin API wrappers)
- Add webhooks management to Ghost: getWebhooks, getWebhookById, createWebhook, updateWebhook, deleteWebhook
- Wire smartmatch filtering for members using @push.rocks/smartmatch
- Export Member from ts/index.ts so it's part of the public API
- Add comprehensive node tests for Ghost, Author, Member, Page, Post, Tag, Settings and Webhooks (test/*.node.ts)
- Fix Post construction usages in classes.ghost to pass the Ghost instance when creating Post objects
## 2025-10-07 - 1.3.0 - feat(core)
Add Ghost CMS API client classes and README documentation

8158
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@apiclient.xyz/ghost",
"version": "1.3.0",
"version": "2.2.1",
"private": false,
"description": "An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.",
"main": "dist_ts/index.js",
@@ -17,15 +17,13 @@
"@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^2.3.8",
"@git.zone/tstest": "^2.4.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/tapbundle": "^6.0.3",
"@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",

414
pnpm-lock.yaml generated
View File

@@ -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
@@ -28,8 +22,8 @@ importers:
specifier: ^1.3.3
version: 1.3.3
'@git.zone/tstest':
specifier: ^2.3.8
version: 2.3.8(@aws-sdk/credential-providers@3.734.0)(socks@2.8.7)(typescript@5.9.2)
specifier: ^2.4.2
version: 2.4.2(@aws-sdk/credential-providers@3.734.0)(socks@2.8.7)(typescript@5.9.2)
'@push.rocks/qenv':
specifier: ^6.1.3
version: 6.1.3
@@ -323,8 +317,8 @@ packages:
'@borewit/text-codec@0.1.1':
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
'@cloudflare/workers-types@4.20251004.0':
resolution: {integrity: sha512-FkTBHEyOBwphbW4SLQ2XLCgNntD2wz0v1Si7NwJeN0JAPW/39/w6zhsKy3rsh+203tuSfBgsoP34+Os4RaySOw==}
'@cloudflare/workers-types@4.20251008.0':
resolution: {integrity: sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA==}
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
@@ -673,8 +667,8 @@ packages:
resolution: {integrity: sha512-DDzWunkxXLtXJTxBf4EioXLwhuqdA2VzdTmOzWrw4Z4Qnms/YM67q36yajwNohAajPYyRz5DayU0ikrceFXyVw==}
hasBin: true
'@git.zone/tstest@2.3.8':
resolution: {integrity: sha512-rt7rpR2UwzHXjpqquEvWG4LfzGOGeI6lcR2YyO8pc7lqjhH+xsuaWPUQ5IwFl4Vw4VnR9ZoHBCqkjvxF8ow1wQ==}
'@git.zone/tstest@2.4.2':
resolution: {integrity: sha512-Lcxuruk/ii1xFKNbf2b1lVYtl9d8ppTpqfF7qtWlcEMdLYW4/42wZ3dcG+jQlCPikQngEYfqSVaJSLyAWzkEGQ==}
hasBin: true
'@hapi/bourne@3.0.0':
@@ -845,8 +839,8 @@ packages:
resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==}
engines: {node: '>=12'}
'@puppeteer/browsers@2.10.10':
resolution: {integrity: sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==}
'@puppeteer/browsers@2.10.11':
resolution: {integrity: sha512-kp3ORGce+oC3qUMJ+g5NH9W4Q7mMG7gV2I+alv0bCbfkZ36B2V/xKCg9uYavSgjmsElhwBneahWjJP7A6fuKLw==}
engines: {node: '>=18'}
hasBin: true
@@ -1318,6 +1312,10 @@ packages:
resolution: {integrity: sha512-XJ4z5FxvY/t0Dibms/+gLJrI5niRoY0BCmE02fwmPcRYFPI4KI876xaE79YGWIKnEslMbuQPsIEsoU/DXa0DoA==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.15.0':
resolution: {integrity: sha512-VJWncXgt+ExNn0U2+Y7UywuATtRYaodGQKFo9mDyh70q+fJGedfrqi2XuKU1BhiLeXgg6RZrW7VEKfeqFhHAJA==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.2.0':
resolution: {integrity: sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==}
engines: {node: '>=18.0.0'}
@@ -1346,6 +1344,10 @@ packages:
resolution: {integrity: sha512-BG3KSmsx9A//KyIfw+sqNmWFr1YBUr+TwpxFT7yPqAk0yyDh7oSNgzfNH7pS6OC099EGx2ltOULvumCFe8bcgw==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.3.1':
resolution: {integrity: sha512-3AvYYbB+Dv5EPLqnJIAgYw/9+WzeBiUYS8B+rU0pHq5NMQMvrZmevUROS4V2GAt0jEOn9viBzPLrZE+riTNd5Q==}
engines: {node: '>=18.0.0'}
'@smithy/hash-blob-browser@4.2.0':
resolution: {integrity: sha512-MWmrRTPqVKpN8NmxmJPTeQuhewTt8Chf+waB38LXHZoA02+BeWYVQ9ViAwHjug8m7lQb1UWuGqp3JoGDOWvvuA==}
engines: {node: '>=18.0.0'}
@@ -1382,10 +1384,18 @@ packages:
resolution: {integrity: sha512-jFVjuQeV8TkxaRlcCNg0GFVgg98tscsmIrIwRFeC74TIUyLE3jmY9xgc1WXrPQYRjQNK3aRoaIk6fhFRGOIoGw==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-endpoint@4.3.1':
resolution: {integrity: sha512-JtM4SjEgImLEJVXdsbvWHYiJ9dtuKE8bqLlvkvGi96LbejDL6qnVpVxEFUximFodoQbg0Gnkyff9EKUhFhVJFw==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-retry@4.4.0':
resolution: {integrity: sha512-yaVBR0vQnOnzex45zZ8ZrPzUnX73eUC8kVFaAAbn04+6V7lPtxn56vZEBBAhgS/eqD6Zm86o6sJs6FuQVoX5qg==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-retry@4.4.1':
resolution: {integrity: sha512-wXxS4ex8cJJteL0PPQmWYkNi9QKDWZIpsndr0wZI2EL+pSSvA/qqxXU60gBOJoIc2YgtZSWY/PE86qhKCCKP1w==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-serde@4.2.0':
resolution: {integrity: sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==}
engines: {node: '>=18.0.0'}
@@ -1434,6 +1444,10 @@ packages:
resolution: {integrity: sha512-3BDx/aCCPf+kkinYf5QQhdQ9UAGihgOVqI3QO5xQfSaIWvUE4KYLtiGRWsNe1SR7ijXC0QEPqofVp5Sb0zC8xQ==}
engines: {node: '>=18.0.0'}
'@smithy/smithy-client@4.7.1':
resolution: {integrity: sha512-WXVbiyNf/WOS/RHUoFMkJ6leEVpln5ojCjNBnzoZeMsnCg3A0BRhLK3WYc4V7PmYcYPZh9IYzzAg9XcNSzYxYQ==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.6.0':
resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==}
engines: {node: '>=18.0.0'}
@@ -1446,6 +1460,10 @@ packages:
resolution: {integrity: sha512-+erInz8WDv5KPe7xCsJCp+1WCjSbah9gWcmUXc9NqmhyPx59tf7jqFz+za1tRG1Y5KM1Cy1rWCcGypylFp4mvA==}
engines: {node: '>=18.0.0'}
'@smithy/util-base64@4.3.0':
resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==}
engines: {node: '>=18.0.0'}
'@smithy/util-body-length-browser@4.2.0':
resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==}
engines: {node: '>=18.0.0'}
@@ -1454,6 +1472,10 @@ packages:
resolution: {integrity: sha512-U8q1WsSZFjXijlD7a4wsDQOvOwV+72iHSfq1q7VD+V75xP/pdtm0WIGuaFJ3gcADDOKj2MIBn4+zisi140HEnQ==}
engines: {node: '>=18.0.0'}
'@smithy/util-body-length-node@4.2.1':
resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==}
engines: {node: '>=18.0.0'}
'@smithy/util-buffer-from@2.2.0':
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
engines: {node: '>=14.0.0'}
@@ -1470,10 +1492,18 @@ packages:
resolution: {integrity: sha512-qzHp7ZDk1Ba4LDwQVCNp90xPGqSu7kmL7y5toBpccuhi3AH7dcVBIT/pUxYcInK4jOy6FikrcTGq5wxcka8UaQ==}
engines: {node: '>=18.0.0'}
'@smithy/util-defaults-mode-browser@4.3.0':
resolution: {integrity: sha512-H4MAj8j8Yp19Mr7vVtGgi7noJjvjJbsKQJkvNnLlrIFduRFT5jq5Eri1k838YW7rN2g5FTnXpz5ktKVr1KVgPQ==}
engines: {node: '>=18.0.0'}
'@smithy/util-defaults-mode-node@4.2.0':
resolution: {integrity: sha512-FxUHS3WXgx3bTWR6yQHNHHkQHZm/XKIi/CchTnKvBulN6obWpcbzJ6lDToXn+Wp0QlVKd7uYAz2/CTw1j7m+Kg==}
engines: {node: '>=18.0.0'}
'@smithy/util-defaults-mode-node@4.2.1':
resolution: {integrity: sha512-PuDcgx7/qKEMzV1QFHJ7E4/MMeEjaA7+zS5UNcHCLPvvn59AeZQ0DSDGMpqC2xecfa/1cNGm4l8Ec/VxCuY7Ug==}
engines: {node: '>=18.0.0'}
'@smithy/util-endpoints@3.2.0':
resolution: {integrity: sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==}
engines: {node: '>=18.0.0'}
@@ -1494,6 +1524,10 @@ packages:
resolution: {integrity: sha512-vtO7ktbixEcrVzMRmpQDnw/Ehr9UWjBvSJ9fyAbadKkC4w5Cm/4lMO8cHz8Ysb8uflvQUNRcuux/oNHKPXkffg==}
engines: {node: '>=18.0.0'}
'@smithy/util-stream@4.5.0':
resolution: {integrity: sha512-0TD5M5HCGu5diEvZ/O/WquSjhJPasqv7trjoqHyWjNh/FBeBl7a0ztl9uFMOsauYtRfd8jvpzIAQhDHbx+nvZw==}
engines: {node: '>=18.0.0'}
'@smithy/util-uri-escape@4.2.0':
resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==}
engines: {node: '>=18.0.0'}
@@ -1537,12 +1571,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==}
@@ -1920,8 +1948,16 @@ packages:
bare-events@2.7.0:
resolution: {integrity: sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==}
bare-fs@4.4.5:
resolution: {integrity: sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==}
bare-events@2.8.0:
resolution: {integrity: sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.4.10:
resolution: {integrity: sha512-arqVF+xX/rJHwrONZaSPhlzleT2gXwVs9rsAe1p1mIVwWZI2A76/raio+KwwxfWMO8oV9Wo90EaUkS2QwVmy4w==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
@@ -1997,9 +2033,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 +2391,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=}
@@ -2523,8 +2553,8 @@ packages:
resolution: {integrity: sha512-cB507r5T3D55DfclY01GLkninZLfU7HXV/mhVRTnTRm5k2u+fY7Fof2dBkr80p5t7G7dlA/G5dI87QiMdPpMCQ==}
engines: {node: '>=18'}
fake-indexeddb@6.2.2:
resolution: {integrity: sha512-SGbf7fzjeHz3+12NO1dYigcYn4ivviaeULV5yY5rdGihBvvgwMds4r4UBbNIUMwkze57KTDm32rq3j1Az8mzEw==}
fake-indexeddb@6.2.3:
resolution: {integrity: sha512-idzJXFtDIHNShFZ9ssS8IdsRgAP0t9zwWvSdCKsWK2dgh2xcXA6/2Oteaxar5GJqmwzZXCrKRO6F5IEiR4yJzw==}
engines: {node: '>=18'}
fast-deep-equal@3.1.3:
@@ -3066,16 +3096,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 +3190,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=}
@@ -3797,12 +3796,12 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
puppeteer-core@24.23.0:
resolution: {integrity: sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==}
puppeteer-core@24.24.0:
resolution: {integrity: sha512-RR5AeQ6dIbSepDe9PTtfgK1fgD7TuA9qqyGxPbFCyGfvfkbR7MiqNYdE7AhbTaFIqG3hFBtWwbVKVZF8oEqj7Q==}
engines: {node: '>=18'}
puppeteer@24.23.0:
resolution: {integrity: sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==}
puppeteer@24.24.0:
resolution: {integrity: sha512-jRn6T8rSrQZXIplXICpH2zYJ2XrIFY7Ug0+TxRTuwY8ZTL7+MKDvFH0aLG7Xx3ts4twzxIKZmiYo+qg7whNpZw==}
engines: {node: '>=18'}
hasBin: true
@@ -3978,6 +3977,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
@@ -4526,7 +4530,7 @@ snapshots:
'@api.global/typedrequest': 3.1.10
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 3.0.1
'@cloudflare/workers-types': 4.20251004.0
'@cloudflare/workers-types': 4.20251008.0
'@design.estate/dees-comms': 1.0.27
'@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.1.1
@@ -4649,26 +4653,26 @@ snapshots:
'@aws-sdk/util-user-agent-browser': 3.734.0
'@aws-sdk/util-user-agent-node': 3.734.0
'@smithy/config-resolver': 4.3.0
'@smithy/core': 3.14.0
'@smithy/fetch-http-handler': 5.3.0
'@smithy/core': 3.15.0
'@smithy/fetch-http-handler': 5.3.1
'@smithy/hash-node': 4.2.0
'@smithy/invalid-dependency': 4.2.0
'@smithy/middleware-content-length': 4.2.0
'@smithy/middleware-endpoint': 4.3.0
'@smithy/middleware-retry': 4.4.0
'@smithy/middleware-endpoint': 4.3.1
'@smithy/middleware-retry': 4.4.1
'@smithy/middleware-serde': 4.2.0
'@smithy/middleware-stack': 4.2.0
'@smithy/node-config-provider': 4.3.0
'@smithy/node-http-handler': 4.3.0
'@smithy/protocol-http': 5.3.0
'@smithy/smithy-client': 4.7.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
'@smithy/url-parser': 4.2.0
'@smithy/util-base64': 4.2.0
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.0
'@smithy/util-defaults-mode-browser': 4.2.0
'@smithy/util-defaults-mode-node': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.0
'@smithy/util-defaults-mode-node': 4.2.1
'@smithy/util-endpoints': 3.2.0
'@smithy/util-middleware': 4.2.0
'@smithy/util-retry': 4.2.0
@@ -4755,26 +4759,26 @@ snapshots:
'@aws-sdk/util-user-agent-browser': 3.734.0
'@aws-sdk/util-user-agent-node': 3.734.0
'@smithy/config-resolver': 4.3.0
'@smithy/core': 3.14.0
'@smithy/fetch-http-handler': 5.3.0
'@smithy/core': 3.15.0
'@smithy/fetch-http-handler': 5.3.1
'@smithy/hash-node': 4.2.0
'@smithy/invalid-dependency': 4.2.0
'@smithy/middleware-content-length': 4.2.0
'@smithy/middleware-endpoint': 4.3.0
'@smithy/middleware-retry': 4.4.0
'@smithy/middleware-endpoint': 4.3.1
'@smithy/middleware-retry': 4.4.1
'@smithy/middleware-serde': 4.2.0
'@smithy/middleware-stack': 4.2.0
'@smithy/node-config-provider': 4.3.0
'@smithy/node-http-handler': 4.3.0
'@smithy/protocol-http': 5.3.0
'@smithy/smithy-client': 4.7.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
'@smithy/url-parser': 4.2.0
'@smithy/util-base64': 4.2.0
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.0
'@smithy/util-defaults-mode-browser': 4.2.0
'@smithy/util-defaults-mode-node': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.0
'@smithy/util-defaults-mode-node': 4.2.1
'@smithy/util-endpoints': 3.2.0
'@smithy/util-middleware': 4.2.0
'@smithy/util-retry': 4.2.0
@@ -4830,12 +4834,12 @@ snapshots:
'@aws-sdk/core@3.734.0':
dependencies:
'@aws-sdk/types': 3.734.0
'@smithy/core': 3.14.0
'@smithy/core': 3.15.0
'@smithy/node-config-provider': 4.3.0
'@smithy/property-provider': 4.2.0
'@smithy/protocol-http': 5.3.0
'@smithy/signature-v4': 5.3.0
'@smithy/smithy-client': 4.7.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
'@smithy/util-middleware': 4.2.0
fast-xml-parser: 4.4.1
@@ -4890,13 +4894,13 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.734.0
'@aws-sdk/types': 3.734.0
'@smithy/fetch-http-handler': 5.3.0
'@smithy/fetch-http-handler': 5.3.1
'@smithy/node-http-handler': 4.3.0
'@smithy/property-provider': 4.2.0
'@smithy/protocol-http': 5.3.0
'@smithy/smithy-client': 4.7.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
'@smithy/util-stream': 4.4.0
'@smithy/util-stream': 4.5.0
tslib: 2.8.1
optional: true
@@ -5069,7 +5073,7 @@ snapshots:
'@aws-sdk/credential-provider-web-identity': 3.734.0
'@aws-sdk/nested-clients': 3.734.0
'@aws-sdk/types': 3.734.0
'@smithy/core': 3.14.0
'@smithy/core': 3.15.0
'@smithy/credential-provider-imds': 4.2.0
'@smithy/property-provider': 4.2.0
'@smithy/types': 4.6.0
@@ -5189,7 +5193,7 @@ snapshots:
'@aws-sdk/core': 3.734.0
'@aws-sdk/types': 3.734.0
'@aws-sdk/util-endpoints': 3.734.0
'@smithy/core': 3.14.0
'@smithy/core': 3.15.0
'@smithy/protocol-http': 5.3.0
'@smithy/types': 4.6.0
tslib: 2.8.1
@@ -5220,26 +5224,26 @@ snapshots:
'@aws-sdk/util-user-agent-browser': 3.734.0
'@aws-sdk/util-user-agent-node': 3.734.0
'@smithy/config-resolver': 4.3.0
'@smithy/core': 3.14.0
'@smithy/fetch-http-handler': 5.3.0
'@smithy/core': 3.15.0
'@smithy/fetch-http-handler': 5.3.1
'@smithy/hash-node': 4.2.0
'@smithy/invalid-dependency': 4.2.0
'@smithy/middleware-content-length': 4.2.0
'@smithy/middleware-endpoint': 4.3.0
'@smithy/middleware-retry': 4.4.0
'@smithy/middleware-endpoint': 4.3.1
'@smithy/middleware-retry': 4.4.1
'@smithy/middleware-serde': 4.2.0
'@smithy/middleware-stack': 4.2.0
'@smithy/node-config-provider': 4.3.0
'@smithy/node-http-handler': 4.3.0
'@smithy/protocol-http': 5.3.0
'@smithy/smithy-client': 4.7.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
'@smithy/url-parser': 4.2.0
'@smithy/util-base64': 4.2.0
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.0
'@smithy/util-defaults-mode-browser': 4.2.0
'@smithy/util-defaults-mode-node': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.0
'@smithy/util-defaults-mode-node': 4.2.1
'@smithy/util-endpoints': 3.2.0
'@smithy/util-middleware': 4.2.0
'@smithy/util-retry': 4.2.0
@@ -5433,7 +5437,7 @@ snapshots:
'@borewit/text-codec@0.1.1': {}
'@cloudflare/workers-types@4.20251004.0': {}
'@cloudflare/workers-types@4.20251008.0': {}
'@colors/colors@1.6.0': {}
@@ -5727,7 +5731,7 @@ snapshots:
'@push.rocks/smartshell': 3.2.2
tsx: 4.19.2
'@git.zone/tstest@2.3.8(@aws-sdk/credential-providers@3.734.0)(socks@2.8.7)(typescript@5.9.2)':
'@git.zone/tstest@2.4.2(@aws-sdk/credential-providers@3.734.0)(socks@2.8.7)(typescript@5.9.2)':
dependencies:
'@api.global/typedserver': 3.0.79
'@git.zone/tsbundle': 2.5.1
@@ -5760,6 +5764,7 @@ snapshots:
- '@nuxt/kit'
- '@swc/helpers'
- aws-crt
- bare-abort-controller
- bare-buffer
- bufferutil
- gcp-metadata
@@ -6044,16 +6049,17 @@ snapshots:
'@pnpm/network.ca-file': 1.0.2
config-chain: 1.1.13
'@puppeteer/browsers@2.10.10':
'@puppeteer/browsers@2.10.11':
dependencies:
debug: 4.4.3
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
semver: 7.7.2
semver: 7.7.3
tar-fs: 3.1.1
yargs: 17.7.2
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
- supports-color
@@ -6171,6 +6177,7 @@ snapshots:
'@push.rocks/smartpuppeteer': 2.0.5(typescript@5.9.2)
'@push.rocks/smartunique': 3.0.9
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
@@ -6501,7 +6508,7 @@ snapshots:
'@design.estate/dees-element': 2.1.2
'@happy-dom/global-registrator': 15.11.7
'@push.rocks/smartpromise': 4.2.3
fake-indexeddb: 6.2.2
fake-indexeddb: 6.2.3
transitivePeerDependencies:
- '@nuxt/kit'
- react
@@ -6539,6 +6546,7 @@ snapshots:
pdf-lib: 1.17.1
pdf2json: 3.2.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
@@ -6559,9 +6567,10 @@ snapshots:
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartshell': 3.3.0
puppeteer: 24.23.0(typescript@5.9.2)
puppeteer: 24.24.0(typescript@5.9.2)
tree-kill: 1.2.2
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
@@ -7009,6 +7018,20 @@ snapshots:
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/core@3.15.0':
dependencies:
'@smithy/middleware-serde': 4.2.0
'@smithy/protocol-http': 5.3.0
'@smithy/types': 4.6.0
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-middleware': 4.2.0
'@smithy/util-stream': 4.5.0
'@smithy/util-utf8': 4.2.0
'@smithy/uuid': 1.1.0
tslib: 2.8.1
optional: true
'@smithy/credential-provider-imds@4.2.0':
dependencies:
'@smithy/node-config-provider': 4.3.0
@@ -7055,6 +7078,15 @@ snapshots:
'@smithy/util-base64': 4.2.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.3.1':
dependencies:
'@smithy/protocol-http': 5.3.0
'@smithy/querystring-builder': 4.2.0
'@smithy/types': 4.6.0
'@smithy/util-base64': 4.3.0
tslib: 2.8.1
optional: true
'@smithy/hash-blob-browser@4.2.0':
dependencies:
'@smithy/chunked-blob-reader': 5.2.0
@@ -7111,6 +7143,18 @@ snapshots:
'@smithy/util-middleware': 4.2.0
tslib: 2.8.1
'@smithy/middleware-endpoint@4.3.1':
dependencies:
'@smithy/core': 3.15.0
'@smithy/middleware-serde': 4.2.0
'@smithy/node-config-provider': 4.3.0
'@smithy/shared-ini-file-loader': 4.3.0
'@smithy/types': 4.6.0
'@smithy/url-parser': 4.2.0
'@smithy/util-middleware': 4.2.0
tslib: 2.8.1
optional: true
'@smithy/middleware-retry@4.4.0':
dependencies:
'@smithy/node-config-provider': 4.3.0
@@ -7123,6 +7167,19 @@ snapshots:
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/middleware-retry@4.4.1':
dependencies:
'@smithy/node-config-provider': 4.3.0
'@smithy/protocol-http': 5.3.0
'@smithy/service-error-classification': 4.2.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
'@smithy/util-middleware': 4.2.0
'@smithy/util-retry': 4.2.0
'@smithy/uuid': 1.1.0
tslib: 2.8.1
optional: true
'@smithy/middleware-serde@4.2.0':
dependencies:
'@smithy/protocol-http': 5.3.0
@@ -7200,6 +7257,17 @@ snapshots:
'@smithy/util-stream': 4.4.0
tslib: 2.8.1
'@smithy/smithy-client@4.7.1':
dependencies:
'@smithy/core': 3.15.0
'@smithy/middleware-endpoint': 4.3.1
'@smithy/middleware-stack': 4.2.0
'@smithy/protocol-http': 5.3.0
'@smithy/types': 4.6.0
'@smithy/util-stream': 4.5.0
tslib: 2.8.1
optional: true
'@smithy/types@4.6.0':
dependencies:
tslib: 2.8.1
@@ -7216,6 +7284,13 @@ snapshots:
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@smithy/util-base64@4.3.0':
dependencies:
'@smithy/util-buffer-from': 4.2.0
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
optional: true
'@smithy/util-body-length-browser@4.2.0':
dependencies:
tslib: 2.8.1
@@ -7224,6 +7299,11 @@ snapshots:
dependencies:
tslib: 2.8.1
'@smithy/util-body-length-node@4.2.1':
dependencies:
tslib: 2.8.1
optional: true
'@smithy/util-buffer-from@2.2.0':
dependencies:
'@smithy/is-array-buffer': 2.2.0
@@ -7246,6 +7326,14 @@ snapshots:
bowser: 2.12.1
tslib: 2.8.1
'@smithy/util-defaults-mode-browser@4.3.0':
dependencies:
'@smithy/property-provider': 4.2.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
tslib: 2.8.1
optional: true
'@smithy/util-defaults-mode-node@4.2.0':
dependencies:
'@smithy/config-resolver': 4.3.0
@@ -7256,6 +7344,17 @@ snapshots:
'@smithy/types': 4.6.0
tslib: 2.8.1
'@smithy/util-defaults-mode-node@4.2.1':
dependencies:
'@smithy/config-resolver': 4.3.0
'@smithy/credential-provider-imds': 4.2.0
'@smithy/node-config-provider': 4.3.0
'@smithy/property-provider': 4.2.0
'@smithy/smithy-client': 4.7.1
'@smithy/types': 4.6.0
tslib: 2.8.1
optional: true
'@smithy/util-endpoints@3.2.0':
dependencies:
'@smithy/node-config-provider': 4.3.0
@@ -7288,6 +7387,18 @@ snapshots:
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@smithy/util-stream@4.5.0':
dependencies:
'@smithy/fetch-http-handler': 5.3.1
'@smithy/node-http-handler': 4.3.0
'@smithy/types': 4.6.0
'@smithy/util-base64': 4.3.0
'@smithy/util-buffer-from': 4.2.0
'@smithy/util-hex-encoding': 4.2.0
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
optional: true
'@smithy/util-uri-escape@4.2.0':
dependencies:
tslib: 2.8.1
@@ -7337,20 +7448,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
@@ -7805,14 +7902,18 @@ snapshots:
bare-events@2.7.0: {}
bare-fs@4.4.5:
bare-events@2.8.0:
optional: true
bare-fs@4.4.10:
dependencies:
bare-events: 2.7.0
bare-events: 2.8.0
bare-path: 3.0.0
bare-stream: 2.7.0(bare-events@2.7.0)
bare-stream: 2.7.0(bare-events@2.8.0)
bare-url: 2.2.2
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
optional: true
@@ -7824,11 +7925,11 @@ snapshots:
bare-os: 3.6.2
optional: true
bare-stream@2.7.0(bare-events@2.7.0):
bare-stream@2.7.0(bare-events@2.8.0):
dependencies:
streamx: 2.23.0
optionalDependencies:
bare-events: 2.7.0
bare-events: 2.8.0
transitivePeerDependencies:
- react-native-b4a
optional: true
@@ -7892,8 +7993,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 +8315,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:
@@ -8465,7 +8560,7 @@ snapshots:
fake-indexeddb@5.0.2: {}
fake-indexeddb@6.2.2: {}
fake-indexeddb@6.2.3: {}
fast-deep-equal@3.1.3: {}
@@ -9090,30 +9185,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 +9309,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: {}
@@ -10000,9 +10057,9 @@ snapshots:
punycode@2.3.1: {}
puppeteer-core@24.23.0:
puppeteer-core@24.24.0:
dependencies:
'@puppeteer/browsers': 2.10.10
'@puppeteer/browsers': 2.10.11
chromium-bidi: 9.1.0(devtools-protocol@0.0.1508733)
debug: 4.4.3
devtools-protocol: 0.0.1508733
@@ -10010,21 +10067,23 @@ snapshots:
webdriver-bidi-protocol: 0.3.6
ws: 8.18.3
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
- supports-color
- utf-8-validate
puppeteer@24.23.0(typescript@5.9.2):
puppeteer@24.24.0(typescript@5.9.2):
dependencies:
'@puppeteer/browsers': 2.10.10
'@puppeteer/browsers': 2.10.11
chromium-bidi: 9.1.0(devtools-protocol@0.0.1508733)
cosmiconfig: 9.0.0(typescript@5.9.2)
devtools-protocol: 0.0.1508733
puppeteer-core: 24.23.0
puppeteer-core: 24.24.0
typed-query-selector: 2.12.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
@@ -10254,6 +10313,8 @@ snapshots:
semver@7.7.2: {}
semver@7.7.3: {}
send@1.2.0:
dependencies:
debug: 4.4.3
@@ -10508,9 +10569,10 @@ snapshots:
pump: 3.0.3
tar-stream: 3.1.7
optionalDependencies:
bare-fs: 4.4.5
bare-fs: 4.4.10
bare-path: 3.0.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a

1068
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should get all authors', async () => {
const authors = await testGhostInstance.getAuthors();
expect(authors).toBeArray();
console.log(`Found ${authors.length} authors`);
if (authors.length > 0) {
expect(authors[0]).toBeInstanceOf(ghost.Author);
console.log(`First author: ${authors[0].getName()} (${authors[0].getSlug()})`);
}
});
tap.test('should get authors with limit', async () => {
const authors = await testGhostInstance.getAuthors({ limit: 2 });
expect(authors).toBeArray();
expect(authors.length).toBeLessThanOrEqual(2);
});
tap.test('should filter authors with minimatch pattern', async () => {
const authors = await testGhostInstance.getAuthors();
if (authors.length > 0) {
const firstAuthorSlug = authors[0].getSlug();
const pattern = `${firstAuthorSlug.charAt(0)}*`;
const filteredAuthors = await testGhostInstance.getAuthors({ filter: pattern });
expect(filteredAuthors).toBeArray();
console.log(`Filtered authors with pattern '${pattern}': found ${filteredAuthors.length}`);
filteredAuthors.forEach((author) => {
expect(author.getSlug()).toMatch(new RegExp(`^${firstAuthorSlug.charAt(0)}`));
});
}
});
tap.test('should get author by slug', async () => {
const authors = await testGhostInstance.getAuthors({ limit: 1 });
if (authors.length > 0) {
const author = await testGhostInstance.getAuthorBySlug(authors[0].getSlug());
expect(author).toBeInstanceOf(ghost.Author);
expect(author.getSlug()).toEqual(authors[0].getSlug());
console.log(`Got author by slug: ${author.getName()}`);
}
});
tap.test('should get author by ID', async () => {
const authors = await testGhostInstance.getAuthors({ limit: 1 });
if (authors.length > 0) {
const author = await testGhostInstance.getAuthorById(authors[0].getId());
expect(author).toBeInstanceOf(ghost.Author);
expect(author.getId()).toEqual(authors[0].getId());
}
});
tap.test('should access author methods', async () => {
const authors = await testGhostInstance.getAuthors({ limit: 1 });
if (authors.length > 0) {
const author = authors[0];
expect(author.getId()).toBeTruthy();
expect(author.getName()).toBeTruthy();
expect(author.getSlug()).toBeTruthy();
const json = author.toJson();
expect(json).toBeTruthy();
expect(json.id).toEqual(author.getId());
}
});
tap.test('should update author bio', async () => {
try {
const authors = await testGhostInstance.getAuthors({ limit: 1 });
if (authors.length > 0) {
const author = authors[0];
const originalBio = author.getBio();
const updatedAuthor = await author.update({
bio: 'Updated bio for testing'
});
expect(updatedAuthor).toBeInstanceOf(ghost.Author);
expect(updatedAuthor.getBio()).toEqual('Updated bio for testing');
console.log(`Updated author bio: ${updatedAuthor.getName()}`);
await updatedAuthor.update({
bio: originalBio
});
}
} catch (error: any) {
if (error.type === 'NotImplementedError') {
console.log('Author updates not supported in this Ghost version - skipping test');
} else {
throw error;
}
}
});
export default tap.start();

206
test/test.dates.node.ts Normal file
View File

@@ -0,0 +1,206 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
let createdPost: ghost.Post;
let createdMember: ghost.Member;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should return dates as strings in posts', async () => {
const posts = await testGhostInstance.getPosts({ limit: 1 });
if (posts.length > 0) {
const post = posts[0];
expect(typeof post.postData.created_at).toEqual('string');
expect(typeof post.postData.updated_at).toEqual('string');
expect(typeof post.postData.published_at).toEqual('string');
console.log(`Post dates are strings: created_at=${post.postData.created_at}`);
}
});
tap.test('should have valid ISO 8601 date format in posts', async () => {
const posts = await testGhostInstance.getPosts({ limit: 1 });
if (posts.length > 0) {
const post = posts[0];
// Check if dates can be parsed
const createdDate = new Date(post.postData.created_at);
const updatedDate = new Date(post.postData.updated_at);
const publishedDate = new Date(post.postData.published_at);
expect(createdDate.toString()).not.toEqual('Invalid Date');
expect(updatedDate.toString()).not.toEqual('Invalid Date');
expect(publishedDate.toString()).not.toEqual('Invalid Date');
// Check if dates are valid timestamps
expect(isNaN(createdDate.getTime())).toEqual(false);
expect(isNaN(updatedDate.getTime())).toEqual(false);
expect(isNaN(publishedDate.getTime())).toEqual(false);
console.log(`Parsed dates: created=${createdDate.toISOString()}, updated=${updatedDate.toISOString()}, published=${publishedDate.toISOString()}`);
}
});
tap.test('should have ISO 8601 format with timezone offset', async () => {
const posts = await testGhostInstance.getPosts({ limit: 1 });
if (posts.length > 0) {
const post = posts[0];
// ISO 8601 with timezone: YYYY-MM-DDTHH:mm:ss.sss±HH:mm
const iso8601WithTimezonePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/;
expect(iso8601WithTimezonePattern.test(post.postData.created_at)).toEqual(true);
expect(iso8601WithTimezonePattern.test(post.postData.updated_at)).toEqual(true);
expect(iso8601WithTimezonePattern.test(post.postData.published_at)).toEqual(true);
console.log(`Dates match ISO 8601 with timezone pattern`);
}
});
tap.test('should create published post and have published_at set', async () => {
const timestamp = Date.now();
createdPost = await testGhostInstance.adminApi.posts.add({
title: `Date Test Post ${timestamp}`,
html: '<p>Testing date handling</p>',
status: 'published'
}, { source: 'html' });
createdPost = new ghost.Post(testGhostInstance, createdPost);
expect(createdPost).toBeInstanceOf(ghost.Post);
expect(createdPost.postData.status).toEqual('published');
expect(createdPost.postData.published_at).toBeTruthy();
// Published date should be a valid date
const publishedDate = new Date(createdPost.postData.published_at);
expect(publishedDate.toString()).not.toEqual('Invalid Date');
console.log(`Created published post with published_at: ${createdPost.postData.published_at}`);
});
tap.test('should preserve published_at when updating post', async () => {
if (createdPost) {
const originalPublishedAt = createdPost.postData.published_at;
const originalPublishedDate = new Date(originalPublishedAt);
await createdPost.update({
html: '<p>Updated content</p>'
});
const updatedPublishedDate = new Date(createdPost.postData.published_at);
// The published_at date should remain the same (within a second tolerance for time parsing)
expect(Math.abs(updatedPublishedDate.getTime() - originalPublishedDate.getTime())).toBeLessThan(1000);
console.log(`Published date preserved after update: original=${originalPublishedAt}, updated=${createdPost.postData.published_at}`);
}
});
tap.test('should have updated_at change when updating metadata fields', async () => {
if (createdPost) {
const originalUpdatedAt = new Date(createdPost.postData.updated_at);
const originalTitle = createdPost.postData.title;
// Wait a moment to ensure time difference
await new Promise(resolve => setTimeout(resolve, 1000));
// Update a metadata field (not just HTML) to trigger updated_at change
await createdPost.update({
title: `${originalTitle} - Modified`
});
const newUpdatedAt = new Date(createdPost.postData.updated_at);
// The updated_at should be newer when metadata fields are updated
expect(newUpdatedAt.getTime()).toBeGreaterThan(originalUpdatedAt.getTime());
console.log(`updated_at changed: ${originalUpdatedAt.toISOString()} -> ${newUpdatedAt.toISOString()}`);
}
});
tap.test('should delete test post', async () => {
if (createdPost) {
await createdPost.delete();
console.log(`Deleted test post: ${createdPost.getId()}`);
}
});
tap.test('should return dates as strings in members', async () => {
const members = await testGhostInstance.getMembers({ limit: 1 });
if (members.length > 0) {
const member = members[0];
expect(typeof member.memberData.created_at).toEqual('string');
expect(typeof member.memberData.updated_at).toEqual('string');
console.log(`Member dates are strings: created_at=${member.memberData.created_at}`);
} else {
console.log('No members to test - skipping member date test');
}
});
tap.test('should have valid date format in members', async () => {
const members = await testGhostInstance.getMembers({ limit: 1 });
if (members.length > 0) {
const member = members[0];
const createdDate = new Date(member.memberData.created_at);
const updatedDate = new Date(member.memberData.updated_at);
expect(createdDate.toString()).not.toEqual('Invalid Date');
expect(updatedDate.toString()).not.toEqual('Invalid Date');
console.log(`Member dates parsed: created=${createdDate.toISOString()}, updated=${updatedDate.toISOString()}`);
} else {
console.log('No members to test - skipping member date validation');
}
});
tap.test('should create member and verify dates', async () => {
const timestamp = Date.now();
createdMember = await testGhostInstance.createMember({
email: `datetest-${timestamp}@example.com`,
name: `Date Test User ${timestamp}`
});
expect(createdMember).toBeInstanceOf(ghost.Member);
expect(typeof createdMember.memberData.created_at).toEqual('string');
expect(typeof createdMember.memberData.updated_at).toEqual('string');
const createdDate = new Date(createdMember.memberData.created_at);
expect(createdDate.toString()).not.toEqual('Invalid Date');
console.log(`Created member with dates: created_at=${createdMember.memberData.created_at}`);
});
tap.test('should have recent created_at date for new member', async () => {
if (createdMember) {
const createdDate = new Date(createdMember.memberData.created_at);
const now = new Date();
// Should be created within the last minute
const timeDiff = now.getTime() - createdDate.getTime();
expect(timeDiff).toBeLessThan(60000); // Less than 1 minute
expect(timeDiff).toBeGreaterThanOrEqual(0); // Not in the future
console.log(`Member created ${Math.round(timeDiff / 1000)} seconds ago`);
}
});
tap.test('should delete test member', async () => {
if (createdMember) {
await createdMember.delete();
console.log(`Deleted test member: ${createdMember.getId()}`);
}
});
export default tap.start();

View File

@@ -0,0 +1,28 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
tap.test('should create a valid instance of Ghost', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
expect(testGhostInstance.options.baseUrl).toEqual('http://localhost:2368');
});
tap.test('should have adminApi configured', async () => {
expect(testGhostInstance.adminApi).toBeTruthy();
});
tap.test('should have contentApi configured', async () => {
expect(testGhostInstance.contentApi).toBeTruthy();
});
export default tap.start();
export { testGhostInstance };

View File

@@ -0,0 +1,151 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
let createdMember: ghost.Member;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should get all members', async () => {
try {
const members = await testGhostInstance.getMembers({ limit: 10 });
expect(members).toBeArray();
console.log(`Found ${members.length} members`);
if (members.length > 0) {
expect(members[0]).toBeInstanceOf(ghost.Member);
console.log(`First member: ${members[0].getEmail()}`);
}
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403) {
console.log('Members feature not available or requires permissions - skipping test');
} else {
throw error;
}
}
});
tap.test('should get members with limit', async () => {
try {
const members = await testGhostInstance.getMembers({ limit: 2 });
expect(members).toBeArray();
expect(members.length).toBeLessThanOrEqual(2);
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403) {
console.log('Members feature not available - skipping test');
} else {
throw error;
}
}
});
tap.test('should filter members with minimatch pattern', async () => {
try {
const members = await testGhostInstance.getMembers({ filter: '*@gmail.com' });
expect(members).toBeArray();
console.log(`Found ${members.length} Gmail members`);
if (members.length > 0) {
members.forEach((member) => {
expect(member.getEmail()).toContain('@gmail.com');
});
}
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403) {
console.log('Members feature not available - skipping test');
} else {
throw error;
}
}
});
tap.test('should create member', async () => {
try {
const timestamp = Date.now();
createdMember = await testGhostInstance.createMember({
email: `test${timestamp}@example.com`,
name: `Test Member ${timestamp}`
});
expect(createdMember).toBeInstanceOf(ghost.Member);
expect(createdMember.getEmail()).toEqual(`test${timestamp}@example.com`);
console.log(`Created member: ${createdMember.getId()}`);
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403) {
console.log('Members feature not available - skipping test');
} else {
throw error;
}
}
});
tap.test('should access member methods', async () => {
if (createdMember) {
expect(createdMember.getId()).toBeTruthy();
expect(createdMember.getEmail()).toBeTruthy();
expect(createdMember.getName()).toBeTruthy();
const json = createdMember.toJson();
expect(json).toBeTruthy();
expect(json.id).toEqual(createdMember.getId());
}
});
tap.test('should get member by email', async () => {
if (createdMember) {
try {
const member = await testGhostInstance.getMemberByEmail(createdMember.getEmail());
expect(member).toBeInstanceOf(ghost.Member);
expect(member.getEmail()).toEqual(createdMember.getEmail());
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403 || error.type === 'RequestNotAcceptableError') {
console.log('Member by email not supported in this Ghost version - using ID instead');
const member = await testGhostInstance.getMemberById(createdMember.getId());
expect(member).toBeInstanceOf(ghost.Member);
} else {
throw error;
}
}
}
});
tap.test('should update member', async () => {
if (createdMember) {
try {
const updatedMember = await createdMember.update({
note: 'Updated by automated tests'
});
expect(updatedMember).toBeInstanceOf(ghost.Member);
console.log(`Updated member: ${updatedMember.getId()}`);
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403) {
console.log('Members feature not available - skipping test');
} else {
throw error;
}
}
}
});
tap.test('should delete member', async () => {
if (createdMember) {
try {
await createdMember.delete();
console.log(`Deleted member: ${createdMember.getId()}`);
} catch (error: any) {
if (error.message?.includes('members') || error.statusCode === 403) {
console.log('Members feature not available - skipping test');
} else {
throw error;
}
}
}
});
export default tap.start();

106
test/test.page.node+deno.ts Normal file
View File

@@ -0,0 +1,106 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
let createdPage: ghost.Page;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should get all pages', async () => {
const pages = await testGhostInstance.getPages();
expect(pages).toBeArray();
console.log(`Found ${pages.length} pages`);
if (pages.length > 0) {
expect(pages[0]).toBeInstanceOf(ghost.Page);
console.log(`First page: ${pages[0].getTitle()} (${pages[0].getSlug()})`);
}
});
tap.test('should get pages with limit', async () => {
const pages = await testGhostInstance.getPages({ limit: 3 });
expect(pages).toBeArray();
expect(pages.length).toBeLessThanOrEqual(3);
});
tap.test('should filter pages with minimatch pattern', async () => {
const pages = await testGhostInstance.getPages();
if (pages.length > 0) {
const firstPageSlug = pages[0].getSlug();
const pattern = `${firstPageSlug.charAt(0)}*`;
const filteredPages = await testGhostInstance.getPages({ filter: pattern });
expect(filteredPages).toBeArray();
console.log(`Filtered pages with pattern '${pattern}': found ${filteredPages.length}`);
}
});
tap.test('should get page by slug', async () => {
const pages = await testGhostInstance.getPages({ limit: 1 });
if (pages.length > 0) {
const page = await testGhostInstance.getPageBySlug(pages[0].getSlug());
expect(page).toBeInstanceOf(ghost.Page);
expect(page.getSlug()).toEqual(pages[0].getSlug());
console.log(`Got page by slug: ${page.getTitle()}`);
}
});
tap.test('should get page by ID', async () => {
const pages = await testGhostInstance.getPages({ limit: 1 });
if (pages.length > 0) {
const page = await testGhostInstance.getPageById(pages[0].getId());
expect(page).toBeInstanceOf(ghost.Page);
expect(page.getId()).toEqual(pages[0].getId());
}
});
tap.test('should create page', async () => {
const timestamp = Date.now();
createdPage = await testGhostInstance.createPage({
title: `Test Page ${timestamp}`,
html: '<p>This is a test page created by automated tests.</p>',
status: 'published'
} as any);
expect(createdPage).toBeInstanceOf(ghost.Page);
expect(createdPage.getTitle()).toEqual(`Test Page ${timestamp}`);
console.log(`Created page: ${createdPage.getId()}`);
});
tap.test('should access page methods', async () => {
if (createdPage) {
expect(createdPage.getId()).toBeTruthy();
expect(createdPage.getTitle()).toBeTruthy();
expect(createdPage.getSlug()).toBeTruthy();
const json = createdPage.toJson();
expect(json).toBeTruthy();
expect(json.id).toEqual(createdPage.getId());
}
});
tap.test('should update page', async () => {
if (createdPage) {
const updatedPage = await createdPage.update({
...createdPage.pageData,
html: '<p>This page has been updated.</p>'
});
expect(updatedPage).toBeInstanceOf(ghost.Page);
console.log(`Updated page: ${updatedPage.getId()}`);
}
});
tap.test('should delete page', async () => {
if (createdPage) {
await createdPage.delete();
console.log(`Deleted page: ${createdPage.getId()}`);
}
});
export default tap.start();

144
test/test.post.node+deno.ts Normal file
View File

@@ -0,0 +1,144 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
let createdPost: ghost.Post;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should get all posts', async () => {
const posts = await testGhostInstance.getPosts();
expect(posts).toBeArray();
if (posts.length > 0) {
expect(posts[0]).toBeInstanceOf(ghost.Post);
console.log(`Found ${posts.length} posts`);
}
});
tap.test('should get posts with limit', async () => {
const posts = await testGhostInstance.getPosts({ limit: 5 });
expect(posts).toBeArray();
expect(posts.length).toBeLessThanOrEqual(5);
});
tap.test('should filter posts by tag', async () => {
const tags = await testGhostInstance.getTags({ limit: 1 });
if (tags.length > 0) {
const posts = await testGhostInstance.getPosts({ tag: tags[0].slug, limit: 5 });
expect(posts).toBeArray();
console.log(`Found ${posts.length} posts with tag '${tags[0].name}'`);
}
});
tap.test('should filter posts by author', async () => {
const authors = await testGhostInstance.getAuthors({ limit: 1 });
if (authors.length > 0) {
const posts = await testGhostInstance.getPosts({ author: authors[0].getSlug(), limit: 5 });
expect(posts).toBeArray();
console.log(`Found ${posts.length} posts by author '${authors[0].getName()}'`);
}
});
tap.test('should filter posts by featured status', async () => {
const featuredPosts = await testGhostInstance.getPosts({ featured: true, limit: 5 });
expect(featuredPosts).toBeArray();
console.log(`Found ${featuredPosts.length} featured posts`);
if (featuredPosts.length > 0) {
expect(featuredPosts[0].postData.featured).toEqual(true);
}
});
tap.test('should search posts by title', async () => {
const searchResults = await testGhostInstance.searchPosts('the', { limit: 5 });
expect(searchResults).toBeArray();
console.log(`Found ${searchResults.length} posts matching 'the'`);
});
tap.test('should get post by ID', async () => {
const posts = await testGhostInstance.getPosts({ limit: 1 });
if (posts.length > 0) {
const post = await testGhostInstance.getPostById(posts[0].getId());
expect(post).toBeInstanceOf(ghost.Post);
expect(post.getId()).toEqual(posts[0].getId());
}
});
tap.test('should get related posts', async () => {
const posts = await testGhostInstance.getPosts({ limit: 1 });
if (posts.length > 0) {
const relatedPosts = await testGhostInstance.getRelatedPosts(posts[0].getId(), 3);
expect(relatedPosts).toBeArray();
console.log(`Found ${relatedPosts.length} related posts for '${posts[0].getTitle()}'`);
}
});
tap.test('should create post from HTML', async () => {
const timestamp = Date.now();
createdPost = await testGhostInstance.createPostFromHtml({
title: `Test Post ${timestamp}`,
html: '<p>This is a test post created by automated tests.</p>',
status: 'published'
} as any);
expect(createdPost).toBeInstanceOf(ghost.Post);
expect(createdPost.getTitle()).toEqual(`Test Post ${timestamp}`);
console.log(`Created post: ${createdPost.getId()}`);
});
tap.test('should access post methods', async () => {
if (createdPost) {
expect(createdPost.getId()).toBeTruthy();
expect(createdPost.getTitle()).toBeTruthy();
const json = createdPost.toJson();
expect(json).toBeTruthy();
expect(json.id).toEqual(createdPost.getId());
}
});
tap.test('should update post', async () => {
if (createdPost) {
const updatedPost = await createdPost.update({
...createdPost.postData,
html: '<p>This post has been updated.</p>'
});
expect(updatedPost).toBeInstanceOf(ghost.Post);
console.log(`Updated post: ${updatedPost.getId()}`);
}
});
tap.test('should delete post', async () => {
if (createdPost) {
await createdPost.delete();
console.log(`Deleted post: ${createdPost.getId()}`);
}
});
tap.test('should bulk update posts', async () => {
const posts = await testGhostInstance.getPosts({ limit: 2 });
if (posts.length >= 2) {
const postIds = posts.map(p => p.getId());
const originalFeatured = posts[0].postData.featured;
const updatedPosts = await testGhostInstance.bulkUpdatePosts(postIds, {
featured: !originalFeatured
});
expect(updatedPosts).toBeArray();
expect(updatedPosts.length).toEqual(postIds.length);
await testGhostInstance.bulkUpdatePosts(postIds, {
featured: originalFeatured
});
console.log(`Bulk updated ${updatedPosts.length} posts`);
}
});
export default tap.start();

View File

@@ -0,0 +1,185 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let sourceGhost: ghost.Ghost;
let targetGhost: ghost.Ghost;
let syncedInstance: ghost.SyncedInstance;
let testTag: ghost.Tag;
let testPost: ghost.Post;
let testPage: ghost.Page;
tap.test('initialize source and target Ghost instances', async () => {
sourceGhost = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
targetGhost = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(sourceGhost).toBeInstanceOf(ghost.Ghost);
expect(targetGhost).toBeInstanceOf(ghost.Ghost);
});
tap.test('create SyncedInstance', async () => {
syncedInstance = new ghost.SyncedInstance(sourceGhost, [targetGhost]);
expect(syncedInstance).toBeInstanceOf(ghost.SyncedInstance);
expect(syncedInstance.sourceGhost).toEqual(sourceGhost);
expect(syncedInstance.targetGhosts).toBeArray();
expect(syncedInstance.targetGhosts.length).toEqual(1);
});
tap.test('create test tag on source', async () => {
const timestamp = Date.now();
testTag = await sourceGhost.createTag({
name: `Sync Test Tag ${timestamp}`,
slug: `sync-test-tag-${timestamp}`,
description: 'This is a test tag for syncing'
});
expect(testTag).toBeInstanceOf(ghost.Tag);
});
tap.test('sync tags from source to target', async () => {
const report = await syncedInstance.syncTags();
expect(report).toBeTruthy();
expect(report.contentType).toEqual('tags');
expect(report.totalItems).toBeGreaterThan(0);
expect(report.targetReports).toBeArray();
expect(report.targetReports.length).toEqual(1);
const targetReport = report.targetReports[0];
expect(targetReport.results).toBeArray();
});
tap.test('verify sync status was tracked', async () => {
const status = syncedInstance.getSyncStatus();
expect(status.totalMappings).toBeGreaterThan(0);
expect(status.recentSyncs).toBeArray();
expect(status.recentSyncs.length).toBeGreaterThan(0);
});
tap.test('create test post on source', async () => {
const timestamp = Date.now();
testPost = await sourceGhost.createPost({
title: `Sync Test Post ${timestamp}`,
slug: `sync-test-post-${timestamp}`,
html: '<p>This is a test post for syncing</p>',
status: 'published',
tags: [{ id: testTag.tagData.id }]
});
expect(testPost).toBeInstanceOf(ghost.Post);
});
tap.test('sync posts from source to target', async () => {
const report = await syncedInstance.syncPosts();
expect(report).toBeTruthy();
expect(report.contentType).toEqual('posts');
expect(report.totalItems).toBeGreaterThan(0);
expect(report.targetReports).toBeArray();
expect(report.targetReports.length).toEqual(1);
});
tap.test('verify post sync in status', async () => {
const status = syncedInstance.getSyncStatus();
expect(status.recentSyncs).toBeArray();
const postSync = status.recentSyncs.find(s => s.contentType === 'posts');
expect(postSync).toBeTruthy();
});
tap.test('create test page on source', async () => {
const timestamp = Date.now();
testPage = await sourceGhost.createPage({
title: `Sync Test Page ${timestamp}`,
slug: `sync-test-page-${timestamp}`,
html: '<p>This is a test page for syncing</p>',
status: 'published'
});
expect(testPage).toBeInstanceOf(ghost.Page);
});
tap.test('sync pages from source to target', async () => {
const report = await syncedInstance.syncPages();
expect(report).toBeTruthy();
expect(report.contentType).toEqual('pages');
expect(report.totalItems).toBeGreaterThan(0);
expect(report.targetReports).toBeArray();
expect(report.targetReports.length).toEqual(1);
});
tap.test('verify page sync in status', async () => {
const status = syncedInstance.getSyncStatus();
const pageSync = status.recentSyncs.find(s => s.contentType === 'pages');
expect(pageSync).toBeTruthy();
});
tap.test('test syncAll method', async () => {
const reports = await syncedInstance.syncAll({
types: ['tags', 'posts', 'pages'],
syncOptions: { dryRun: true }
});
expect(reports).toBeArray();
expect(reports.length).toEqual(3);
expect(reports[0].contentType).toEqual('tags');
expect(reports[1].contentType).toEqual('posts');
expect(reports[2].contentType).toEqual('pages');
});
tap.test('test clear methods', async () => {
syncedInstance.clearMappings();
syncedInstance.clearSyncHistory();
const status = syncedInstance.getSyncStatus();
expect(status.totalMappings).toEqual(0);
expect(status.recentSyncs.length).toEqual(0);
});
tap.test('update tag and re-sync', async () => {
await testTag.update({
description: 'Updated description for sync test'
});
const report = await syncedInstance.syncTags();
expect(report).toBeTruthy();
expect(report.totalItems).toBeGreaterThan(0);
});
tap.test('cleanup - delete synced content', async () => {
if (testPost) {
await testPost.delete();
const targetPosts = await targetGhost.getPosts({ filter: `slug:${testPost.postData.slug}` });
if (targetPosts.length > 0) {
await targetPosts[0].delete();
}
}
if (testPage) {
await testPage.delete();
try {
const targetPage = await targetGhost.getPageBySlug(testPage.pageData.slug);
await targetPage.delete();
} catch (error) {
}
}
if (testTag) {
await testTag.delete();
try {
const targetTag = await targetGhost.getTagBySlug(testTag.tagData.slug);
await targetTag.delete();
} catch (error) {
}
}
});
export default tap.start();

View File

@@ -0,0 +1,95 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should throw error when creating SyncedInstance with same instance', async () => {
let errorThrown = false;
let errorMessage = '';
try {
new ghost.SyncedInstance(testGhostInstance, [testGhostInstance]);
} catch (error) {
errorThrown = true;
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorThrown).toEqual(true);
expect(errorMessage).toContain('Cannot sync to the same instance');
expect(errorMessage).toContain('localhost:2368');
console.log(`Correctly prevented same-instance sync: ${errorMessage}`);
});
tap.test('should throw error when target array includes same instance', async () => {
let errorThrown = false;
let errorMessage = '';
const anotherInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
try {
new ghost.SyncedInstance(testGhostInstance, [anotherInstance]);
} catch (error) {
errorThrown = true;
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorThrown).toEqual(true);
expect(errorMessage).toContain('Cannot sync to the same instance');
console.log(`Correctly prevented sync with duplicate URL: ${errorMessage}`);
});
tap.test('should normalize URLs when comparing (trailing slash)', async () => {
let errorThrown = false;
const instanceWithTrailingSlash = new ghost.Ghost({
baseUrl: 'http://localhost:2368/',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
try {
new ghost.SyncedInstance(testGhostInstance, [instanceWithTrailingSlash]);
} catch (error) {
errorThrown = true;
}
expect(errorThrown).toEqual(true);
console.log('Correctly detected same instance despite trailing slash difference');
});
tap.test('should normalize URLs when comparing (case insensitive)', async () => {
let errorThrown = false;
const instanceWithUpperCase = new ghost.Ghost({
baseUrl: 'http://LOCALHOST:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
try {
new ghost.SyncedInstance(testGhostInstance, [instanceWithUpperCase]);
} catch (error) {
errorThrown = true;
}
expect(errorThrown).toEqual(true);
console.log('Correctly detected same instance despite case difference');
});
export default tap.start();

118
test/test.tag.node+deno.ts Normal file
View File

@@ -0,0 +1,118 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
let createdTag: ghost.Tag;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should get all tags', async () => {
const tags = await testGhostInstance.getTags();
expect(tags).toBeArray();
console.log(`Found ${tags.length} tags`);
if (tags.length > 0) {
console.log(`First tag: ${tags[0].name} (${tags[0].slug})`);
}
});
tap.test('should get tags with limit', async () => {
const tags = await testGhostInstance.getTags({ limit: 3 });
expect(tags).toBeArray();
expect(tags.length).toBeLessThanOrEqual(3);
});
tap.test('should filter tags with minimatch pattern', async () => {
const allTags = await testGhostInstance.getTags();
if (allTags.length > 0) {
const firstTagSlug = allTags[0].slug;
const pattern = `${firstTagSlug.charAt(0)}*`;
const filteredTags = await testGhostInstance.getTags({ filter: pattern });
expect(filteredTags).toBeArray();
console.log(`Filtered tags with pattern '${pattern}': found ${filteredTags.length}`);
filteredTags.forEach((tag) => {
expect(tag.slug).toMatch(new RegExp(`^${firstTagSlug.charAt(0)}`));
});
}
});
tap.test('should create tag', async () => {
const timestamp = Date.now();
createdTag = await testGhostInstance.createTag({
name: `Test Tag ${timestamp}`,
slug: `test-tag-${timestamp}`,
description: 'A test tag created by automated tests'
});
expect(createdTag).toBeInstanceOf(ghost.Tag);
expect(createdTag.getName()).toEqual(`Test Tag ${timestamp}`);
console.log(`Created tag: ${createdTag.getId()}`);
});
tap.test('should get tag by slug using created tag', async () => {
if (createdTag) {
// Note: Content API only returns tags with posts, so this test may not work
// for newly created tags without posts. Using Admin API via getTags instead.
const tags = await testGhostInstance.getTags({
filter: `slug:${createdTag.getSlug()}`,
limit: 1
});
expect(tags).toBeArray();
if (tags.length > 0) {
expect(tags[0].slug).toEqual(createdTag.getSlug());
console.log(`Found tag by slug via Admin API: ${tags[0].name}`);
}
}
});
tap.test('should verify created tag exists in getTags list', async () => {
if (createdTag) {
// Admin API getTags() should include our newly created tag
// Note: We can't filter by ID directly, so we verify the tag exists
const allTags = await testGhostInstance.getTags({ limit: 5 });
expect(allTags).toBeArray();
expect(allTags.length).toBeGreaterThan(0);
console.log(`getTags returned ${allTags.length} tags, created tag ID: ${createdTag.getId()}`);
}
});
tap.test('should access tag methods', async () => {
if (createdTag) {
expect(createdTag.getId()).toBeTruthy();
expect(createdTag.getName()).toBeTruthy();
expect(createdTag.getSlug()).toBeTruthy();
expect(createdTag.getDescription()).toBeTruthy();
const json = createdTag.toJson();
expect(json).toBeTruthy();
expect(json.id).toEqual(createdTag.getId());
}
});
tap.test('should update tag', async () => {
if (createdTag) {
const updatedTag = await createdTag.update({
description: 'Updated description for test tag'
});
expect(updatedTag).toBeInstanceOf(ghost.Tag);
expect(updatedTag.getDescription()).toEqual('Updated description for test tag');
console.log(`Updated tag: ${updatedTag.getId()}`);
}
});
tap.test('should delete tag', async () => {
if (createdTag) {
await createdTag.delete();
console.log(`Deleted tag: ${createdTag.getId()}`);
}
});
export default tap.start();

View File

@@ -1,141 +0,0 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
// make sure we can import the IPost type
import {type IPost} from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
tap.test('should create a valid instance of Ghost', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should get posts', async () => {
const posts = await testGhostInstance.getPosts();
expect(posts).toBeArray();
expect(posts[0]).toBeInstanceOf(ghost.Post);
console.log(JSON.stringify(posts[0].postData, null, 2));
posts.map((post) => {
// console.log(JSON.stringify(post.postData, null, 2));
console.log(`-> ${post.getTitle()}`);
console.log(`by ${post.getAuthor().name}`)
console.log(post.getExcerpt());
console.log(`===============`)
})
})
tap.test('should get all tags', async () => {
const tags = await testGhostInstance.getTags();
expect(tags).toBeArray();
console.log(`Found ${tags.length} tags:`);
tags.forEach((tag) => {
console.log(`-> ${tag.name} (${tag.slug})`);
});
});
tap.test('should filter tags with minimatch pattern', async () => {
const allTags = await testGhostInstance.getTags();
if (allTags.length > 0) {
const firstTagSlug = allTags[0].slug;
const pattern = `${firstTagSlug.charAt(0)}*`;
const filteredTags = await testGhostInstance.getTags({ filter: pattern });
expect(filteredTags).toBeArray();
console.log(`Filtered tags with pattern '${pattern}':`);
filteredTags.forEach((tag) => {
console.log(`-> ${tag.name} (${tag.slug})`);
expect(tag.slug).toMatch(new RegExp(`^${firstTagSlug.charAt(0)}`));
});
} else {
console.log('No tags available to test filtering');
}
});
tap.test('should get tag by slug', async () => {
const tags = await testGhostInstance.getTags({ limit: 1 });
if (tags.length > 0) {
const tag = await testGhostInstance.getTagBySlug(tags[0].slug);
expect(tag).toBeInstanceOf(ghost.Tag);
console.log(`Got tag: ${tag.getName()} (${tag.getSlug()})`);
}
});
tap.test('should get all authors', async () => {
const authors = await testGhostInstance.getAuthors();
expect(authors).toBeArray();
console.log(`Found ${authors.length} authors:`);
authors.forEach((author) => {
console.log(`-> ${author.getName()} (${author.getSlug()})`);
});
});
tap.test('should filter authors with minimatch pattern', async () => {
const authors = await testGhostInstance.getAuthors();
if (authors.length > 0) {
const firstAuthorSlug = authors[0].getSlug();
const pattern = `${firstAuthorSlug.charAt(0)}*`;
const filteredAuthors = await testGhostInstance.getAuthors({ filter: pattern });
expect(filteredAuthors).toBeArray();
console.log(`Filtered authors with pattern '${pattern}':`);
filteredAuthors.forEach((author) => {
console.log(`-> ${author.getName()} (${author.getSlug()})`);
});
}
});
tap.test('should get all pages', async () => {
const pages = await testGhostInstance.getPages();
expect(pages).toBeArray();
console.log(`Found ${pages.length} pages:`);
pages.forEach((page) => {
console.log(`-> ${page.getTitle()} (${page.getSlug()})`);
});
});
tap.test('should filter posts by tag', async () => {
const tags = await testGhostInstance.getTags({ limit: 1 });
if (tags.length > 0) {
const posts = await testGhostInstance.getPosts({ tag: tags[0].slug, limit: 5 });
expect(posts).toBeArray();
console.log(`Found ${posts.length} posts with tag '${tags[0].name}'`);
}
});
tap.test('should filter posts by featured status', async () => {
const featuredPosts = await testGhostInstance.getPosts({ featured: true, limit: 5 });
expect(featuredPosts).toBeArray();
console.log(`Found ${featuredPosts.length} featured posts`);
});
tap.test('should search posts', async () => {
const searchResults = await testGhostInstance.searchPosts('the', { limit: 5 });
expect(searchResults).toBeArray();
console.log(`Found ${searchResults.length} posts matching 'the':`);
searchResults.forEach((post) => {
console.log(`-> ${post.getTitle()}`);
});
});
tap.test('should get related posts', async () => {
const posts = await testGhostInstance.getPosts({ limit: 1 });
if (posts.length > 0) {
const relatedPosts = await testGhostInstance.getRelatedPosts(posts[0].getId(), 3);
expect(relatedPosts).toBeArray();
console.log(`Found ${relatedPosts.length} related posts for '${posts[0].getTitle()}'`);
relatedPosts.forEach((post) => {
console.log(`-> ${post.getTitle()}`);
});
}
});
tap.start()

View File

@@ -0,0 +1,41 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
import * as ghost from '../ts/index.js';
let testGhostInstance: ghost.Ghost;
let createdWebhook: any;
tap.test('initialize Ghost instance', async () => {
testGhostInstance = new ghost.Ghost({
baseUrl: 'http://localhost:2368',
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
});
expect(testGhostInstance).toBeInstanceOf(ghost.Ghost);
});
tap.test('should create webhook', async () => {
const timestamp = Date.now();
createdWebhook = await testGhostInstance.createWebhook({
event: 'post.published',
target_url: `https://example.com/webhook/${timestamp}`,
name: `Test Webhook ${timestamp}`
});
expect(createdWebhook).toBeTruthy();
expect(createdWebhook.id).toBeTruthy();
});
tap.test('should update webhook', async () => {
const updatedWebhook = await testGhostInstance.updateWebhook(createdWebhook.id, {
target_url: 'https://example.com/webhook/updated'
});
expect(updatedWebhook).toBeTruthy();
});
tap.test('should delete webhook', async () => {
await testGhostInstance.deleteWebhook(createdWebhook.id);
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@apiclient.xyz/ghost',
version: '1.3.0',
version: '2.2.1',
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 'node:fs';
import * as path from 'node: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

@@ -43,7 +43,6 @@ export class Author {
this.authorData = updatedAuthorData;
return this;
} catch (error) {
console.error('Error updating author:', error);
throw error;
}
}

View File

@@ -3,6 +3,7 @@ import { Post, type IPost, type ITag, type IAuthor } from './classes.post.js';
import { Author } from './classes.author.js';
import { Tag } from './classes.tag.js';
import { Page, type IPage } from './classes.page.js';
import { Member, type IMember } from './classes.member.js';
export interface IGhostConstructorOptions {
baseUrl: string;
@@ -21,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',
});
}
@@ -64,7 +65,6 @@ export class Ghost {
});
return postsData.map((postData: IPost) => new Post(this, postData));
} catch (error) {
console.error('Error fetching posts:', error);
throw error;
}
}
@@ -72,9 +72,8 @@ export class Ghost {
public async getPostById(id: string): Promise<Post> {
try {
const postData = await this.contentApi.posts.read({ id });
return new Post(postData, this.adminApi);
return new Post(this, postData);
} catch (error) {
console.error(`Error fetching post with id ${id}:`, error);
throw error;
}
}
@@ -82,9 +81,8 @@ export class Ghost {
public async createPost(postData: IPost): Promise<Post> {
try {
const createdPostData = await this.adminApi.posts.add(postData);
return new Post(createdPostData, this.adminApi);
return new Post(this, createdPostData);
} catch (error) {
console.error('Error creating post:', error);
throw error;
}
}
@@ -97,29 +95,55 @@ export class Ghost {
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 {
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) {
console.error('Error fetching tags:', error);
throw error;
}
}
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> {
try {
const tagData = await this.contentApi.tags.read({ id });
return new Tag(this, tagData);
} catch (error) {
console.error(`Error fetching tag with id ${id}:`, error);
throw error;
}
}
@@ -129,7 +153,6 @@ export class Ghost {
const tagData = await this.contentApi.tags.read({ slug });
return new Tag(this, tagData);
} catch (error) {
console.error(`Error fetching tag with slug ${slug}:`, error);
throw error;
}
}
@@ -139,7 +162,6 @@ export class Ghost {
const createdTagData = await this.adminApi.tags.add(tagData);
return new Tag(this, createdTagData);
} catch (error) {
console.error('Error creating tag:', error);
throw error;
}
}
@@ -158,7 +180,6 @@ export class Ghost {
return authorsData.map((author: IAuthor) => new Author(this, author));
} catch (error) {
console.error('Error fetching authors:', error);
throw error;
}
}
@@ -168,7 +189,6 @@ export class Ghost {
const authorData = await this.contentApi.authors.read({ id });
return new Author(this, authorData);
} catch (error) {
console.error(`Error fetching author with id ${id}:`, error);
throw error;
}
}
@@ -178,7 +198,6 @@ export class Ghost {
const authorData = await this.contentApi.authors.read({ slug });
return new Author(this, authorData);
} catch (error) {
console.error(`Error fetching author with slug ${slug}:`, error);
throw error;
}
}
@@ -197,7 +216,6 @@ export class Ghost {
return pagesData.map((pageData: IPage) => new Page(this, pageData));
} catch (error) {
console.error('Error fetching pages:', error);
throw error;
}
}
@@ -207,7 +225,6 @@ export class Ghost {
const pageData = await this.contentApi.pages.read({ id });
return new Page(this, pageData);
} catch (error) {
console.error(`Error fetching page with id ${id}:`, error);
throw error;
}
}
@@ -217,7 +234,6 @@ export class Ghost {
const pageData = await this.contentApi.pages.read({ slug });
return new Page(this, pageData);
} catch (error) {
console.error(`Error fetching page with slug ${slug}:`, error);
throw error;
}
}
@@ -227,7 +243,6 @@ export class Ghost {
const createdPageData = await this.adminApi.pages.add(pageData);
return new Page(this, createdPageData);
} catch (error) {
console.error('Error creating page:', error);
throw error;
}
}
@@ -242,7 +257,6 @@ export class Ghost {
});
return postsData.map((postData: IPost) => new Post(this, postData));
} catch (error) {
console.error('Error searching posts:', error);
throw error;
}
}
@@ -252,7 +266,6 @@ export class Ghost {
const result = await this.adminApi.images.upload({ file: filePath });
return result.url;
} catch (error) {
console.error('Error uploading image:', error);
throw error;
}
}
@@ -265,7 +278,6 @@ export class Ghost {
});
return await Promise.all(updatePromises);
} catch (error) {
console.error('Error bulk updating posts:', error);
throw error;
}
}
@@ -278,7 +290,6 @@ export class Ghost {
});
await Promise.all(deletePromises);
} catch (error) {
console.error('Error bulk deleting posts:', error);
throw error;
}
}
@@ -301,7 +312,82 @@ export class Ghost {
return postsData.map((postData: IPost) => new Post(this, postData));
} catch (error) {
console.error('Error fetching related posts:', error);
throw error;
}
}
public async getMembers(optionsArg?: { filter?: string; limit?: number }): Promise<Member[]> {
try {
const limit = optionsArg?.limit || 1000;
const membersData = await this.adminApi.members.browse({ limit });
if (optionsArg?.filter) {
const matcher = new plugins.smartmatch.SmartMatch(optionsArg.filter);
return membersData
.filter((member: IMember) => matcher.match(member.email))
.map((member: IMember) => new Member(this, member));
}
return membersData.map((member: IMember) => new Member(this, member));
} catch (error) {
throw error;
}
}
public async getMemberById(id: string): Promise<Member> {
try {
const memberData = await this.adminApi.members.read({ id });
return new Member(this, memberData);
} catch (error) {
throw error;
}
}
public async getMemberByEmail(email: string): Promise<Member> {
try {
const memberData = await this.adminApi.members.read({ email });
return new Member(this, memberData);
} catch (error) {
throw error;
}
}
public async createMember(memberData: Partial<IMember>): Promise<Member> {
try {
const createdMemberData = await this.adminApi.members.add(memberData);
return new Member(this, createdMemberData);
} catch (error) {
throw error;
}
}
public async createWebhook(webhookData: {
event: string;
target_url: string;
name?: string;
secret?: string;
api_version?: string;
integration_id?: string;
}): Promise<any> {
try {
return await this.adminApi.webhooks.add(webhookData);
} catch (error) {
throw error;
}
}
public async updateWebhook(id: string, webhookData: any): Promise<any> {
try {
return await this.adminApi.webhooks.edit({ ...webhookData, id });
} catch (error) {
throw error;
}
}
public async deleteWebhook(id: string): Promise<void> {
try {
await this.adminApi.webhooks.delete({ id });
} catch (error) {
throw error;
}
}

85
ts/classes.member.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { Ghost } from './classes.ghost.js';
export interface IMember {
id: string;
uuid: string;
email: string;
name?: string;
note?: string;
geolocation?: string;
enable_comment_notifications?: boolean;
subscribed?: boolean;
email_count?: number;
email_opened_count?: number;
email_open_rate?: number;
status?: string;
created_at: string;
updated_at: string;
labels?: Array<{
id: string;
name: string;
slug: string;
}>;
subscriptions?: any[];
avatar_image?: string;
comped?: boolean;
email_suppression?: {
suppressed: boolean;
info?: string;
};
}
export class Member {
public ghostInstanceRef: Ghost;
public memberData: IMember;
constructor(ghostInstanceRefArg: Ghost, memberData: IMember) {
this.ghostInstanceRef = ghostInstanceRefArg;
this.memberData = memberData;
}
public getId(): string {
return this.memberData.id;
}
public getEmail(): string {
return this.memberData.email;
}
public getName(): string | undefined {
return this.memberData.name;
}
public getStatus(): string | undefined {
return this.memberData.status;
}
public getLabels(): Array<{ id: string; name: string; slug: string }> | undefined {
return this.memberData.labels;
}
public toJson(): IMember {
return this.memberData;
}
public async update(memberData: Partial<IMember>): Promise<Member> {
try {
const updatedMemberData = await this.ghostInstanceRef.adminApi.members.edit({
...memberData,
id: this.getId()
});
this.memberData = updatedMemberData;
return this;
} catch (error) {
throw error;
}
}
public async delete(): Promise<void> {
try {
await this.ghostInstanceRef.adminApi.members.delete({ id: this.getId() });
} catch (error) {
throw error;
}
}
}

View File

@@ -49,7 +49,6 @@ export class Page {
this.pageData = updatedPageData;
return this;
} catch (error) {
console.error('Error updating page:', error);
throw error;
}
}
@@ -58,7 +57,6 @@ export class Page {
try {
await this.ghostInstanceRef.adminApi.pages.delete({ id: this.getId() });
} catch (error) {
console.error(`Error deleting page with id ${this.getId()}:`, error);
throw error;
}
}

View File

@@ -118,13 +118,37 @@ export class Post {
return this.postData;
}
public async update(postData: IPost): Promise<Post> {
public async update(postData: Partial<IPost>): Promise<Post> {
try {
const updatedPostData = await this.ghostInstanceRef.adminApi.posts.edit(postData);
// Only send fields that should be updated, not the entire post object with nested relations
const updatePayload: any = {
id: this.postData.id,
updated_at: this.postData.updated_at, // Required for conflict detection
...postData
};
// Remove read-only or computed fields that shouldn't be sent
delete updatePayload.uuid;
delete updatePayload.comment_id;
delete updatePayload.url;
delete updatePayload.excerpt;
delete updatePayload.reading_time;
delete updatePayload.created_at; // Don't send created_at in updates
delete updatePayload.primary_author;
delete updatePayload.primary_tag;
delete updatePayload.count;
delete updatePayload.email;
delete updatePayload.newsletter;
// Remove nested objects if they're not being updated
if (!postData.authors) delete updatePayload.authors;
if (!postData.tags) delete updatePayload.tags;
if (!postData.tiers) delete updatePayload.tiers;
const updatedPostData = await this.ghostInstanceRef.adminApi.posts.edit(updatePayload);
this.postData = updatedPostData;
return this;
} catch (error) {
console.error('Error updating post:', error);
throw error;
}
}
@@ -133,7 +157,6 @@ export class Post {
try {
await this.ghostInstanceRef.adminApi.posts.delete({ id: this.getId() });
} catch (error) {
console.error(`Error deleting post with id ${this.getId()}:`, error);
throw error;
}
}

View File

@@ -0,0 +1,421 @@
import * as plugins from './ghost.plugins.js';
import { Ghost } from './classes.ghost.js';
import { type IPost } from './classes.post.js';
import { type IPage } from './classes.page.js';
import { type ITag } from './classes.post.js';
export interface ISyncOptions {
incremental?: boolean;
filter?: string;
dryRun?: boolean;
}
export interface ISyncItemResult {
sourceId: string;
sourceSlug: string;
targetId?: string;
status: 'created' | 'updated' | 'skipped' | 'failed';
error?: string;
}
export interface ISyncTargetReport {
targetUrl: string;
results: ISyncItemResult[];
successCount: number;
failureCount: number;
}
export interface ISyncReport {
contentType: 'posts' | 'pages' | 'tags';
totalItems: number;
targetReports: ISyncTargetReport[];
duration: number;
timestamp: Date;
}
export interface ISyncMapping {
sourceId: string;
sourceSlug: string;
targetMappings: Array<{
targetUrl: string;
targetId: string;
lastSynced: Date;
}>;
}
export class SyncedInstance {
public sourceGhost: Ghost;
public targetGhosts: Ghost[];
private syncMappings: Map<string, ISyncMapping>;
private syncHistory: ISyncReport[];
constructor(sourceGhost: Ghost, targetGhosts: Ghost[]) {
// Validate that no target instance is the same as the source instance
const sourceUrl = sourceGhost.options.baseUrl.replace(/\/$/, '').toLowerCase();
for (const targetGhost of targetGhosts) {
const targetUrl = targetGhost.options.baseUrl.replace(/\/$/, '').toLowerCase();
if (sourceUrl === targetUrl) {
throw new Error(
`Cannot sync to the same instance. Source and target both point to: ${sourceUrl}. ` +
`This would create a circular sync and cause excessive API calls.`
);
}
}
this.sourceGhost = sourceGhost;
this.targetGhosts = targetGhosts;
this.syncMappings = new Map();
this.syncHistory = [];
}
private addMapping(contentType: string, sourceId: string, sourceSlug: string, targetUrl: string, targetId: string) {
const key = `${contentType}:${sourceId}`;
const existing = this.syncMappings.get(key);
if (existing) {
const targetMapping = existing.targetMappings.find(tm => tm.targetUrl === targetUrl);
if (targetMapping) {
targetMapping.targetId = targetId;
targetMapping.lastSynced = new Date();
} else {
existing.targetMappings.push({
targetUrl,
targetId,
lastSynced: new Date()
});
}
} else {
this.syncMappings.set(key, {
sourceId,
sourceSlug,
targetMappings: [{
targetUrl,
targetId,
lastSynced: new Date()
}]
});
}
}
private getMapping(contentType: string, sourceId: string, targetUrl: string): string | undefined {
const key = `${contentType}:${sourceId}`;
const mapping = this.syncMappings.get(key);
if (!mapping) return undefined;
const targetMapping = mapping.targetMappings.find(tm => tm.targetUrl === targetUrl);
return targetMapping?.targetId;
}
public async syncTags(optionsArg?: ISyncOptions): Promise<ISyncReport> {
const startTime = Date.now();
const report: ISyncReport = {
contentType: 'tags',
totalItems: 0,
targetReports: [],
duration: 0,
timestamp: new Date()
};
const sourceTags = await this.sourceGhost.getTags(optionsArg?.filter ? { filter: optionsArg.filter } : {});
report.totalItems = sourceTags.length;
for (const targetGhost of this.targetGhosts) {
const targetReport: ISyncTargetReport = {
targetUrl: targetGhost.options.baseUrl,
results: [],
successCount: 0,
failureCount: 0
};
for (const sourceTag of sourceTags) {
try {
let targetTag: any;
let status: 'created' | 'updated' | 'skipped' = 'created';
try {
targetTag = await targetGhost.getTagBySlug(sourceTag.slug);
if (!optionsArg?.dryRun) {
await targetTag.update({
name: sourceTag.name,
slug: sourceTag.slug,
description: sourceTag.description,
feature_image: sourceTag.feature_image,
visibility: sourceTag.visibility,
meta_title: sourceTag.meta_title,
meta_description: sourceTag.meta_description
});
}
status = 'updated';
} catch (error) {
if (!optionsArg?.dryRun) {
targetTag = await targetGhost.createTag({
name: sourceTag.name,
slug: sourceTag.slug,
description: sourceTag.description,
feature_image: sourceTag.feature_image,
visibility: sourceTag.visibility,
meta_title: sourceTag.meta_title,
meta_description: sourceTag.meta_description
});
}
}
if (!optionsArg?.dryRun && targetTag) {
this.addMapping('tags', sourceTag.id, sourceTag.slug, targetGhost.options.baseUrl, targetTag.tagData.id);
}
targetReport.results.push({
sourceId: sourceTag.id,
sourceSlug: sourceTag.slug,
targetId: targetTag?.tagData?.id,
status
});
targetReport.successCount++;
} catch (error) {
targetReport.results.push({
sourceId: sourceTag.id,
sourceSlug: sourceTag.slug,
status: 'failed',
error: error instanceof Error ? error.message : String(error)
});
targetReport.failureCount++;
}
}
report.targetReports.push(targetReport);
}
report.duration = Date.now() - startTime;
this.syncHistory.push(report);
return report;
}
public async syncPosts(optionsArg?: ISyncOptions): Promise<ISyncReport> {
const startTime = Date.now();
const report: ISyncReport = {
contentType: 'posts',
totalItems: 0,
targetReports: [],
duration: 0,
timestamp: new Date()
};
const sourcePosts = await this.sourceGhost.getPosts(optionsArg?.filter ? { filter: optionsArg.filter } : {});
report.totalItems = sourcePosts.length;
for (const targetGhost of this.targetGhosts) {
const targetReport: ISyncTargetReport = {
targetUrl: targetGhost.options.baseUrl,
results: [],
successCount: 0,
failureCount: 0
};
for (const sourcePost of sourcePosts) {
try {
const postData = sourcePost.postData;
const tagSlugs = postData.tags?.map(t => t.slug) || [];
const targetTagIds: string[] = [];
for (const tagSlug of tagSlugs) {
try {
const targetTag = await targetGhost.getTagBySlug(tagSlug);
targetTagIds.push(targetTag.tagData.id);
} catch (error) {
}
}
const syncData: any = {
title: postData.title,
slug: postData.slug,
html: postData.html,
feature_image: postData.feature_image,
featured: postData.featured,
status: postData.status,
visibility: postData.visibility,
meta_title: postData.meta_title,
meta_description: postData.meta_description,
published_at: postData.published_at,
custom_excerpt: postData.custom_excerpt,
tags: targetTagIds.length > 0 ? targetTagIds.map(id => ({ id })) : undefined
};
let targetPost: any;
let status: 'created' | 'updated' = 'created';
try {
targetPost = await targetGhost.contentApi.posts.read({ slug: postData.slug }, { formats: ['html'] });
if (!optionsArg?.dryRun) {
const updated = await targetGhost.adminApi.posts.edit({
...syncData,
id: targetPost.id,
updated_at: targetPost.updated_at
});
targetPost = updated;
}
status = 'updated';
} catch (error) {
if (!optionsArg?.dryRun) {
targetPost = await targetGhost.adminApi.posts.add(syncData);
}
}
if (!optionsArg?.dryRun && targetPost) {
this.addMapping('posts', postData.id, postData.slug, targetGhost.options.baseUrl, targetPost.id);
}
targetReport.results.push({
sourceId: postData.id,
sourceSlug: postData.slug,
targetId: targetPost?.id,
status
});
targetReport.successCount++;
} catch (error) {
targetReport.results.push({
sourceId: sourcePost.postData.id,
sourceSlug: sourcePost.postData.slug,
status: 'failed',
error: error instanceof Error ? error.message : String(error)
});
targetReport.failureCount++;
}
}
report.targetReports.push(targetReport);
}
report.duration = Date.now() - startTime;
this.syncHistory.push(report);
return report;
}
public async syncPages(optionsArg?: ISyncOptions): Promise<ISyncReport> {
const startTime = Date.now();
const report: ISyncReport = {
contentType: 'pages',
totalItems: 0,
targetReports: [],
duration: 0,
timestamp: new Date()
};
const sourcePages = await this.sourceGhost.getPages(optionsArg?.filter ? { filter: optionsArg.filter } : {});
report.totalItems = sourcePages.length;
for (const targetGhost of this.targetGhosts) {
const targetReport: ISyncTargetReport = {
targetUrl: targetGhost.options.baseUrl,
results: [],
successCount: 0,
failureCount: 0
};
for (const sourcePage of sourcePages) {
try {
const pageData = sourcePage.pageData;
const syncData: Partial<IPage> = {
title: pageData.title,
slug: pageData.slug,
html: pageData.html,
feature_image: pageData.feature_image,
featured: pageData.featured,
status: pageData.status,
visibility: pageData.visibility,
meta_title: pageData.meta_title,
meta_description: pageData.meta_description,
published_at: pageData.published_at,
custom_excerpt: pageData.custom_excerpt
};
let targetPage: any;
let status: 'created' | 'updated' = 'created';
try {
targetPage = await targetGhost.contentApi.pages.read({ slug: pageData.slug }, { formats: ['html'] });
if (!optionsArg?.dryRun) {
const updated = await targetGhost.adminApi.pages.edit({
...syncData,
id: targetPage.id,
updated_at: targetPage.updated_at
});
targetPage = updated;
}
status = 'updated';
} catch (error) {
if (!optionsArg?.dryRun) {
targetPage = await targetGhost.adminApi.pages.add(syncData);
}
}
if (!optionsArg?.dryRun && targetPage) {
this.addMapping('pages', pageData.id, pageData.slug, targetGhost.options.baseUrl, targetPage.id);
}
targetReport.results.push({
sourceId: pageData.id,
sourceSlug: pageData.slug,
targetId: targetPage?.id,
status
});
targetReport.successCount++;
} catch (error) {
targetReport.results.push({
sourceId: sourcePage.pageData.id,
sourceSlug: sourcePage.pageData.slug,
status: 'failed',
error: error instanceof Error ? error.message : String(error)
});
targetReport.failureCount++;
}
}
report.targetReports.push(targetReport);
}
report.duration = Date.now() - startTime;
this.syncHistory.push(report);
return report;
}
public async syncAll(optionsArg?: { types?: Array<'posts' | 'pages' | 'tags'>; syncOptions?: ISyncOptions }): Promise<ISyncReport[]> {
const types = optionsArg?.types || ['tags', 'posts', 'pages'];
const reports: ISyncReport[] = [];
for (const type of types) {
if (type === 'tags') {
reports.push(await this.syncTags(optionsArg?.syncOptions));
} else if (type === 'posts') {
reports.push(await this.syncPosts(optionsArg?.syncOptions));
} else if (type === 'pages') {
reports.push(await this.syncPages(optionsArg?.syncOptions));
}
}
return reports;
}
public getSyncStatus(): {
totalMappings: number;
mappings: ISyncMapping[];
recentSyncs: ISyncReport[];
} {
return {
totalMappings: this.syncMappings.size,
mappings: Array.from(this.syncMappings.values()),
recentSyncs: this.syncHistory.slice(-10)
};
}
public clearSyncHistory(): void {
this.syncHistory = [];
}
public clearMappings(): void {
this.syncMappings.clear();
}
}

View File

@@ -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;
}
@@ -39,7 +51,6 @@ export class Tag {
this.tagData = updatedTagData;
return this;
} catch (error) {
console.error('Error updating tag:', error);
throw error;
}
}
@@ -48,7 +59,6 @@ export class Tag {
try {
await this.ghostInstanceRef.adminApi.tags.delete({ id: this.getId() });
} catch (error) {
console.error(`Error deleting tag with id ${this.getId()}:`, error);
throw error;
}
}

View File

@@ -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 {

View File

@@ -2,4 +2,6 @@ export * from './classes.ghost.js';
export * from './classes.post.js';
export * from './classes.author.js';
export * from './classes.tag.js';
export * from './classes.page.js';
export * from './classes.page.js';
export * from './classes.member.js';
export * from './classes.syncedinstance.js';