From d493d9fd0135ad524d8e418ab18d1157b0911bac Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 8 Oct 2025 09:57:59 +0000 Subject: [PATCH] feat(syncedinstance): Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README --- changelog.md | 8 + readme.md | 879 ++++++++++++++++++------------- test/test.syncedinstance.node.ts | 185 +++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.syncedinstance.ts | 406 ++++++++++++++ ts/index.ts | 3 +- 6 files changed, 1115 insertions(+), 368 deletions(-) create mode 100644 test/test.syncedinstance.node.ts create mode 100644 ts/classes.syncedinstance.ts diff --git a/changelog.md b/changelog.md index 7e4953a..9044efb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/readme.md b/readme.md index f397b82..4852d01 100644 --- a/readme.md +++ b/readme.md @@ -1,515 +1,662 @@ -# @apiclient.xyz/ghost -An unofficial Ghost API package +# @apiclient.xyz/ghost ๐Ÿ‘ป -## Install -To install the @apiclient.xyz/ghost package, you can use npm or yarn. Make sure you're using a package manager that supports ESM and TypeScript. +> The **unofficial** TypeScript-first Ghost CMS API client that actually makes sense + +A modern, fully-typed API client for Ghost CMS that wraps both the Content and Admin APIs into an elegant, developer-friendly interface. Built with TypeScript, designed for humans. + +## ๐Ÿš€ Why This Library? + +- **๐ŸŽฏ TypeScript Native** - Full type safety for all Ghost API operations +- **๐Ÿ”ฅ Dual API Support** - Unified interface for both Content and Admin APIs +- **โšก Modern Async/Await** - No callback hell, just clean promises +- **๐ŸŽจ Elegant API** - Intuitive methods that match your mental model +- **๐Ÿ” Smart Filtering** - Built-in minimatch support for flexible queries +- **๐Ÿ”„ Multi-Instance Sync** - Synchronize content across multiple Ghost sites +- **๐Ÿ’ช Production Ready** - Battle-tested with comprehensive error handling + +## ๐Ÿ“ฆ Installation -NPM: ```bash npm install @apiclient.xyz/ghost ``` -Yarn: +Or with pnpm: + ```bash -yarn add @apiclient.xyz/ghost +pnpm install @apiclient.xyz/ghost ``` -## Usage - -Below is a detailed guide on how to use the `@apiclient.xyz/ghost` package in your TypeScript projects. We will cover everything from initialization to advanced usage scenarios. - -### Initialization - -First, you need to import the necessary classes and initialize the Ghost instance with the required API keys. +## ๐ŸŽฏ Quick Start ```typescript import { Ghost } from '@apiclient.xyz/ghost'; -// Initialize the Ghost instance -const ghostInstance = new Ghost({ - baseUrl: 'https://your-ghost-url.com', - contentApiKey: 'your-content-api-key', - adminApiKey: 'your-admin-api-key' +const ghost = new Ghost({ + baseUrl: 'https://your-ghost-site.com', + contentApiKey: 'your_content_api_key', + adminApiKey: 'your_admin_api_key' +}); + +const posts = await ghost.getPosts(); +posts.forEach(post => console.log(post.getTitle())); +``` + +That's it. No complicated setup, no boilerplate. Just pure Ghost API goodness. + +## ๐Ÿ“š Core API + +### ๐Ÿ”‘ Authentication & Setup + +Initialize the Ghost client with your API credentials: + +```typescript +const ghost = new Ghost({ + baseUrl: 'https://your-ghost-site.com', + contentApiKey: 'your_content_api_key', // For reading public content + adminApiKey: 'your_admin_api_key' // For write operations }); ``` -### Basic Usage +## ๐Ÿ“ Posts -#### Fetching Posts - -You can fetch posts with the following method. This will give you an array of `Post` instances. +### Get All Posts ```typescript -// Fetch all posts -const posts = await ghostInstance.getPosts(); +const posts = await ghost.getPosts(); -// Print titles of all posts posts.forEach(post => { console.log(post.getTitle()); + console.log(post.getExcerpt()); + console.log(post.getFeatureImage()); }); ``` -#### Fetching a Single Post by ID - -To fetch a single post by its ID: +### Filter Posts ```typescript -const postId = 'your-post-id'; -const post = await ghostInstance.getPostById(postId); +const techPosts = await ghost.getPosts({ + tag: 'technology', + limit: 10 +}); +const featuredPosts = await ghost.getPosts({ + featured: true, + limit: 5 +}); + +const authorPosts = await ghost.getPosts({ + author: 'john-doe' +}); +``` + +### Get Single Post + +```typescript +const post = await ghost.getPostById('post-id'); console.log(post.getTitle()); console.log(post.getHtml()); +console.log(post.getAuthor()); ``` -### Post Class Methods - -The `Post` class has several methods that can be useful for different scenarios. - -#### Getting Post Data - -These methods allow you to retrieve various parts of the post data. +### Create Post ```typescript -const postId = post.getId(); -const postTitle = post.getTitle(); -const postHtml = post.getHtml(); -const postExcerpt = post.getExcerpt(); -const postFeatureImage = post.getFeatureImage(); - -console.log(`ID: ${postId}`); -console.log(`Title: ${postTitle}`); -console.log(`HTML: ${postHtml}`); -console.log(`Excerpt: ${postExcerpt}`); -console.log(`Feature Image: ${postFeatureImage}`); +const newPost = await ghost.createPost({ + title: 'My Awesome Post', + html: '

This is the content of my post.

', + feature_image: 'https://example.com/image.jpg', + tags: [{ id: 'tag-id' }], + excerpt: 'A brief summary of the post' +}); ``` -#### Updating a Post - -To update a post, you can use the `update` method. Make sure you have the necessary permissions and fields. +Or create from HTML specifically: ```typescript -const updatedPostData = { +const post = await ghost.createPostFromHtml({ + title: 'My HTML Post', + html: '

Content here

' +}); +``` + +### Update Post + +```typescript +const post = await ghost.getPostById('post-id'); +await post.update({ ...post.toJson(), title: 'Updated Title', - html: '

Updated HTML content

' -}; - -await post.update(updatedPostData); -console.log('Post updated successfully'); + html: '

Updated content

' +}); ``` -#### Deleting a Post - -To delete a post: +### Delete Post ```typescript +const post = await ghost.getPostById('post-id'); await post.delete(); -console.log('Post deleted successfully'); ``` -### Advanced Scenarios +### Search Posts -#### Creating a New Post - -You can create a new post using the `createPost` method of the `Ghost` class. +Full-text search across post titles: ```typescript -const newPostData = { - id: 'new-post-id', - title: 'New Post Title', - html: '

This is the content of the new post.

', - excerpt: 'New post excerpt', - feature_image: 'https://your-image-url.com/image.jpg' -}; - -const newPost = await ghostInstance.createPost(newPostData); - -console.log('New post created successfully'); -console.log(`ID: ${newPost.getId()}`); -console.log(`Title: ${newPost.getTitle()}`); +const results = await ghost.searchPosts('typescript tutorial', { limit: 10 }); +results.forEach(post => console.log(post.getTitle())); ``` -#### Error Handling +### Related Posts -Both the `Ghost` and `Post` classes throw errors that you can catch and handle. +Get posts with similar tags: ```typescript -try { - const posts = await ghostInstance.getPosts(); - console.log('Posts fetched successfully'); -} catch (error) { - console.error('Error fetching posts:', error); -} +const post = await ghost.getPostById('post-id'); +const related = await ghost.getRelatedPosts(post.getId(), 5); +related.forEach(p => console.log(`Related: ${p.getTitle()}`)); ``` -Similarly, for updating or deleting a post: +### Bulk Operations ```typescript -try { - await post.update({ title: 'Updated Title' }); - console.log('Post updated successfully'); -} catch (error) { - console.error('Error updating post:', error); -} - -try { - await post.delete(); - console.log('Post deleted successfully'); -} catch (error) { - console.error('Error deleting post:', error); -} -``` - -#### Fetching Posts with Filters and Options - -The `getPosts` method can accept various filters and options. - -```typescript -const filteredPosts = await ghostInstance.getPosts({ limit: 10, include: 'tags,authors' }); - -filteredPosts.forEach(post => { - console.log(post.getTitle()); -}); -``` - -#### Fetching Tags - -You can fetch all tags from your Ghost site using the `getTags` method. - -```typescript -const tags = await ghostInstance.getTags(); - -tags.forEach(tag => { - console.log(`${tag.name} (${tag.slug})`); -}); -``` - -#### Fetching Tags with Minimatch Filtering - -The `getTags` method supports filtering tags using minimatch patterns on tag slugs. - -```typescript -// Fetch all tags starting with 'tech-' -const techTags = await ghostInstance.getTags({ filter: 'tech-*' }); - -// Fetch all tags containing 'blog' -const blogTags = await ghostInstance.getTags({ filter: '*blog*' }); - -// Fetch with limit -const limitedTags = await ghostInstance.getTags({ limit: 10, filter: 'a*' }); - -limitedTags.forEach(tag => { - console.log(tag.name); -}); -``` - -#### Working with Tag Class - -Fetch individual tags and manage them with the `Tag` class. - -```typescript -// Get tag by slug -const tag = await ghostInstance.getTagBySlug('tech'); -console.log(tag.getName()); -console.log(tag.getDescription()); - -// Create a new tag -const newTag = await ghostInstance.createTag({ - name: 'JavaScript', - slug: 'javascript', - description: 'Posts about JavaScript' +await ghost.bulkUpdatePosts(['id1', 'id2', 'id3'], { + featured: true }); -// Update a tag -await newTag.update({ - description: 'All things JavaScript' -}); - -// Delete a tag -await newTag.delete(); +await ghost.bulkDeletePosts(['id4', 'id5', 'id6']); ``` -#### Fetching Authors +## ๐Ÿ“„ Pages -Manage and filter authors with minimatch patterns. +Pages work similarly to posts but are for static content: ```typescript -// Get all authors -const authors = await ghostInstance.getAuthors(); -authors.forEach(author => { - console.log(`${author.getName()} (${author.getSlug()})`); -}); +const pages = await ghost.getPages(); -// Filter authors with minimatch -const filteredAuthors = await ghostInstance.getAuthors({ filter: 'j*' }); +const aboutPage = await ghost.getPageBySlug('about'); +console.log(aboutPage.getHtml()); -// Get author by slug -const author = await ghostInstance.getAuthorBySlug('john-doe'); -console.log(author.getBio()); - -// Update author -await author.update({ - bio: 'Updated bio information' -}); -``` - -#### Working with Pages - -Ghost pages are similar to posts but for static content. - -```typescript -// Get all pages -const pages = await ghostInstance.getPages(); -pages.forEach(page => { - console.log(page.getTitle()); -}); - -// Filter pages with minimatch -const filteredPages = await ghostInstance.getPages({ filter: 'about*' }); - -// Get page by slug -const page = await ghostInstance.getPageBySlug('about'); -console.log(page.getHtml()); - -// Create a new page -const newPage = await ghostInstance.createPage({ - title: 'Contact Us', +const newPage = await ghost.createPage({ + title: 'Contact', html: '

Contact information...

' }); -// Update a page await newPage.update({ - html: '

Updated contact information...

' + html: '

Updated content

' }); -// Delete a page await newPage.delete(); ``` -#### Enhanced Post Filtering - -The `getPosts` method now supports multiple filtering options. +Filter pages with minimatch patterns: ```typescript -// Filter by tag -const techPosts = await ghostInstance.getPosts({ tag: 'tech', limit: 10 }); - -// Filter by author -const authorPosts = await ghostInstance.getPosts({ author: 'john-doe' }); - -// Get only featured posts -const featuredPosts = await ghostInstance.getPosts({ featured: true }); - -// Combine multiple filters -const filteredPosts = await ghostInstance.getPosts({ - tag: 'javascript', - featured: true, - limit: 5 +const filteredPages = await ghost.getPages({ + filter: 'about*', + limit: 10 }); ``` -#### Searching Posts +## ๐Ÿท๏ธ Tags -Full-text search across post titles and excerpts. +### Get Tags ```typescript -// Search for posts containing a keyword -const searchResults = await ghostInstance.searchPosts('ghost api', { limit: 10 }); +const tags = await ghost.getTags(); +tags.forEach(tag => console.log(`${tag.name} (${tag.slug})`)); +``` -searchResults.forEach(post => { - console.log(post.getTitle()); - console.log(post.getExcerpt()); +### Filter Tags with Minimatch + +```typescript +const techTags = await ghost.getTags({ filter: 'tech-*' }); +const blogTags = await ghost.getTags({ filter: '*blog*' }); +``` + +### Get Single Tag + +```typescript +const tag = await ghost.getTagBySlug('javascript'); +console.log(tag.getName()); +console.log(tag.getDescription()); +``` + +### Create, Update, Delete Tags + +```typescript +const newTag = await ghost.createTag({ + name: 'TypeScript', + slug: 'typescript', + description: 'All about TypeScript' +}); + +await newTag.update({ + description: 'Everything TypeScript related' +}); + +await newTag.delete(); +``` + +## ๐Ÿ‘ค Authors + +### Get Authors + +```typescript +const authors = await ghost.getAuthors(); +authors.forEach(author => { + console.log(`${author.getName()} (${author.getSlug()})`); }); ``` -#### Finding Related Posts - -Get posts with similar tags. +### Filter Authors ```typescript -const post = await ghostInstance.getPostById('some-post-id'); - -// Get related posts based on tags -const relatedPosts = await ghostInstance.getRelatedPosts(post.getId(), 5); - -relatedPosts.forEach(relatedPost => { - console.log(`Related: ${relatedPost.getTitle()}`); +const filteredAuthors = await ghost.getAuthors({ + filter: 'j*', + limit: 10 }); ``` -#### Image Upload - -Upload images to your Ghost site. +### Get Single Author ```typescript -// Upload an image file -const imageUrl = await ghostInstance.uploadImage('/path/to/image.jpg'); +const author = await ghost.getAuthorBySlug('john-doe'); +console.log(author.getBio()); +console.log(author.getProfileImage()); +``` -// Use the uploaded image URL in a post -await ghostInstance.createPost({ +### Update Author + +```typescript +await author.update({ + bio: 'Updated bio information', + website: 'https://johndoe.com' +}); +``` + +## ๐Ÿ‘ฅ Members + +Manage your Ghost site members (requires Ghost membership features): + +### Get Members + +```typescript +const members = await ghost.getMembers({ limit: 100 }); +members.forEach(member => { + console.log(`${member.getName()} - ${member.getEmail()}`); + console.log(`Status: ${member.getStatus()}`); +}); +``` + +### Filter Members + +```typescript +const gmailMembers = await ghost.getMembers({ + filter: '*@gmail.com' +}); +``` + +### Get Single Member + +```typescript +const member = await ghost.getMemberByEmail('user@example.com'); +console.log(member.getStatus()); +console.log(member.getLabels()); +``` + +### Create Member + +```typescript +const newMember = await ghost.createMember({ + email: 'newuser@example.com', + name: 'New User', + note: 'VIP member' +}); +``` + +### Update and Delete Members + +```typescript +await member.update({ + name: 'Updated Name', + note: 'Premium member' +}); + +await member.delete(); +``` + +## ๐Ÿช Webhooks + +Manage webhooks for Ghost events: + +```typescript +const webhook = await ghost.createWebhook({ + event: 'post.published', + target_url: 'https://example.com/webhook', + name: 'Post Published Webhook' +}); + +await ghost.updateWebhook(webhook.id, { + target_url: 'https://example.com/new-webhook' +}); + +await ghost.deleteWebhook(webhook.id); +``` + +**Note:** The Ghost Admin API only supports creating, updating, and deleting webhooks. Browsing and reading individual webhooks are not supported by the underlying SDK. + +## ๐Ÿ–ผ๏ธ Image Upload + +Upload images to your Ghost site: + +```typescript +const imageUrl = await ghost.uploadImage('/path/to/image.jpg'); + +await ghost.createPost({ title: 'Post with Image', html: '

Content here

', feature_image: imageUrl }); ``` -#### Bulk Operations +## ๐Ÿ”„ Multi-Instance Synchronization -Perform operations on multiple posts at once. +The `SyncedInstance` class enables you to synchronize content across multiple Ghost instances - perfect for staging environments, multi-region deployments, or content distribution. + +### Setup ```typescript -// Bulk update posts -const postIds = ['id1', 'id2', 'id3']; -await ghostInstance.bulkUpdatePosts(postIds, { - featured: true +import { Ghost, SyncedInstance } from '@apiclient.xyz/ghost'; + +const sourceGhost = new Ghost({ + baseUrl: 'https://source.ghost.com', + contentApiKey: 'source_content_key', + adminApiKey: 'source_admin_key' }); -// Bulk delete posts -await ghostInstance.bulkDeletePosts(['id4', 'id5']); +const targetGhost1 = new Ghost({ + baseUrl: 'https://target1.ghost.com', + contentApiKey: 'target1_content_key', + adminApiKey: 'target1_admin_key' +}); + +const targetGhost2 = new Ghost({ + baseUrl: 'https://target2.ghost.com', + contentApiKey: 'target2_content_key', + adminApiKey: 'target2_admin_key' +}); + +const synced = new SyncedInstance(sourceGhost, [targetGhost1, targetGhost2]); ``` -#### Members Management - -Manage your Ghost site members (requires Ghost membership features enabled). +### Sync Content ```typescript -// Get all members -const members = await ghostInstance.getMembers({ limit: 100 }); -members.forEach(member => { - console.log(`${member.getName()} - ${member.getEmail()}`); -}); +const tagReport = await synced.syncTags(); +console.log(`Synced ${tagReport.totalItems} tags`); +console.log(`Duration: ${tagReport.duration}ms`); -// Filter members with minimatch -const filteredMembers = await ghostInstance.getMembers({ - filter: '*@gmail.com' -}); +const postReport = await synced.syncPosts(); +console.log(`Success: ${postReport.targetReports[0].successCount}`); +console.log(`Failed: ${postReport.targetReports[0].failureCount}`); -// Get member by email -const member = await ghostInstance.getMemberByEmail('user@example.com'); -console.log(member.getStatus()); - -// Create a new member -const newMember = await ghostInstance.createMember({ - email: 'newuser@example.com', - name: 'New User' -}); - -// Update a member -await newMember.update({ - name: 'Updated Name', - note: 'VIP member' -}); - -// Delete a member -await newMember.delete(); +const pageReport = await synced.syncPages(); ``` -#### Webhooks Management - -Manage webhooks for Ghost events. Note: The Ghost Admin API only supports creating, updating, and deleting webhooks (browsing and reading individual webhooks are not supported by the underlying SDK). +### Sync Options ```typescript -// Create a webhook -const newWebhook = await ghostInstance.createWebhook({ - event: 'post.published', - target_url: 'https://example.com/webhook', - name: 'Post Published Webhook' +const report = await synced.syncPosts({ + filter: 'featured-*', + dryRun: true, + incremental: true }); -// Update a webhook -await ghostInstance.updateWebhook(newWebhook.id, { - target_url: 'https://example.com/new-webhook' +report.targetReports.forEach(targetReport => { + console.log(`Target: ${targetReport.targetUrl}`); + targetReport.results.forEach(result => { + console.log(` ${result.sourceSlug}: ${result.status}`); + }); }); - -// Delete a webhook -await ghostInstance.deleteWebhook(newWebhook.id); ``` -### Example Projects +### Sync Everything -To give you a comprehensive understanding, let's look at a couple of example projects. +```typescript +const reports = await synced.syncAll({ + types: ['tags', 'posts', 'pages'], + syncOptions: { + dryRun: false + } +}); -#### Example 1: A Basic Blog +reports.forEach(report => { + console.log(`${report.contentType}: ${report.totalItems} items`); +}); +``` -In this scenario, we will create a simple script to fetch all posts and display their titles. +### Sync Status & History + +```typescript +const status = synced.getSyncStatus(); +console.log(`Total mappings: ${status.totalMappings}`); +console.log(`Recent syncs: ${status.recentSyncs.length}`); + +status.mappings.forEach(mapping => { + console.log(`Source: ${mapping.sourceSlug}`); + mapping.targetMappings.forEach(tm => { + console.log(` -> ${tm.targetUrl} (${tm.targetId})`); + }); +}); + +synced.clearSyncHistory(); +synced.clearMappings(); +``` + +## ๐ŸŽจ Complete Example + +Here's a comprehensive example showing various operations: ```typescript import { Ghost } from '@apiclient.xyz/ghost'; -(async () => { - const ghostInstance = new Ghost({ - baseUrl: 'https://your-ghost-url.com', - contentApiKey: 'your-content-api-key', - adminApiKey: 'your-admin-api-key' - }); - - try { - const posts = await ghostInstance.getPosts(); - posts.forEach(post => console.log(post.getTitle())); - } catch (error) { - console.error('Error fetching posts:', error); - } -})(); -``` - -#### Example 2: Post Management Tool - -In this example, let's create a tool that can fetch, create, update, and delete posts. - -```typescript -import { Ghost, Post, IPostOptions } from '@apiclient.xyz/ghost'; - -const ghostInstance = new Ghost({ - baseUrl: 'https://your-ghost-url.com', - contentApiKey: 'your-content-api-key', - adminApiKey: 'your-admin-api-key' +const ghost = new Ghost({ + baseUrl: 'https://your-ghost-site.com', + contentApiKey: 'your_content_key', + adminApiKey: 'your_admin_key' }); -(async () => { - // Fetch posts - const posts = await ghostInstance.getPosts(); - console.log('Fetched posts:'); - posts.forEach(post => console.log(post.getTitle())); +async function main() { + const imageUrl = await ghost.uploadImage('./banner.jpg'); + + const tag = await ghost.createTag({ + name: 'Tutorial', + slug: 'tutorial', + description: 'Step-by-step guides' + }); - // Create a new post - const newPostData: IPostOptions = { - id: 'new-post-id', - title: 'New Post Title', - html: '

This is the content of the new post.

', - }; + const post = await ghost.createPost({ + title: 'Getting Started with Ghost', + html: '

Welcome

This is an introduction...

', + feature_image: imageUrl, + tags: [{ id: tag.getId() }], + featured: true + }); - const newPost = await ghostInstance.createPost(newPostData); - console.log('New post created:', newPost.getTitle()); + console.log(`Created post: ${post.getTitle()}`); + + const related = await ghost.getRelatedPosts(post.getId(), 5); + console.log(`Found ${related.length} related posts`); - // Update the new post - const updatedPost = await newPost.update({ title: 'Updated Post Title' }); - console.log('Post updated:', updatedPost.getTitle()); + const searchResults = await ghost.searchPosts('getting started', { limit: 10 }); + console.log(`Search found ${searchResults.length} posts`); +} - // Delete the new post - await updatedPost.delete(); - console.log('Post deleted'); -})(); +main().catch(console.error); ``` -### Unit Tests +## ๐Ÿ”’ Error Handling -This package includes unit tests written using the `@push.rocks/tapbundle` and `@push.rocks/qenv` libraries. Here is how you can run them. +All methods throw errors that you can catch and handle: -1. Install the development dependencies: +```typescript +try { + const post = await ghost.getPostById('invalid-id'); +} catch (error) { + console.error('Failed to fetch post:', error); +} + +try { + await post.update({ title: 'New Title' }); +} catch (error) { + console.error('Failed to update post:', error); +} +``` + +## ๐Ÿ“– API Reference + +### Ghost Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getPosts(options?)` | Get all posts with optional filtering | `Promise` | +| `getPostById(id)` | Get a single post by ID | `Promise` | +| `createPost(data)` | Create a new post | `Promise` | +| `createPostFromHtml(data)` | Create post from HTML | `Promise` | +| `searchPosts(query, options?)` | Search posts by title | `Promise` | +| `getRelatedPosts(postId, limit)` | Get related posts | `Promise` | +| `bulkUpdatePosts(ids, updates)` | Update multiple posts | `Promise` | +| `bulkDeletePosts(ids)` | Delete multiple posts | `Promise` | +| `getPages(options?)` | Get all pages | `Promise` | +| `getPageById(id)` | Get page by ID | `Promise` | +| `getPageBySlug(slug)` | Get page by slug | `Promise` | +| `createPage(data)` | Create a new page | `Promise` | +| `getTags(options?)` | Get all tags | `Promise` | +| `getTagById(id)` | Get tag by ID | `Promise` | +| `getTagBySlug(slug)` | Get tag by slug | `Promise` | +| `createTag(data)` | Create a new tag | `Promise` | +| `getAuthors(options?)` | Get all authors | `Promise` | +| `getAuthorById(id)` | Get author by ID | `Promise` | +| `getAuthorBySlug(slug)` | Get author by slug | `Promise` | +| `getMembers(options?)` | Get all members | `Promise` | +| `getMemberById(id)` | Get member by ID | `Promise` | +| `getMemberByEmail(email)` | Get member by email | `Promise` | +| `createMember(data)` | Create a new member | `Promise` | +| `createWebhook(data)` | Create a webhook | `Promise` | +| `updateWebhook(id, data)` | Update a webhook | `Promise` | +| `deleteWebhook(id)` | Delete a webhook | `Promise` | +| `uploadImage(filePath)` | Upload an image | `Promise` | + +### Post Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getId()` | Get post ID | `string` | +| `getTitle()` | Get post title | `string` | +| `getHtml()` | Get post HTML content | `string` | +| `getExcerpt()` | Get post excerpt | `string` | +| `getFeatureImage()` | Get feature image URL | `string \| undefined` | +| `getAuthor()` | Get primary author | `IAuthor` | +| `toJson()` | Get raw post data | `IPost` | +| `update(data)` | Update the post | `Promise` | +| `delete()` | Delete the post | `Promise` | + +### Page Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getId()` | Get page ID | `string` | +| `getTitle()` | Get page title | `string` | +| `getHtml()` | Get page HTML content | `string` | +| `getSlug()` | Get page slug | `string` | +| `getFeatureImage()` | Get feature image URL | `string \| undefined` | +| `getAuthor()` | Get primary author | `IAuthor` | +| `toJson()` | Get raw page data | `IPage` | +| `update(data)` | Update the page | `Promise` | +| `delete()` | Delete the page | `Promise` | + +### Tag Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getId()` | Get tag ID | `string` | +| `getName()` | Get tag name | `string` | +| `getSlug()` | Get tag slug | `string` | +| `getDescription()` | Get tag description | `string \| undefined` | +| `toJson()` | Get raw tag data | `ITag` | +| `update(data)` | Update the tag | `Promise` | +| `delete()` | Delete the tag | `Promise` | + +### Author Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getId()` | Get author ID | `string` | +| `getName()` | Get author name | `string` | +| `getSlug()` | Get author slug | `string` | +| `getProfileImage()` | Get profile image URL | `string \| undefined` | +| `getBio()` | Get author bio | `string \| undefined` | +| `toJson()` | Get raw author data | `IAuthor` | +| `update(data)` | Update the author | `Promise` | + +### Member Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `getId()` | Get member ID | `string` | +| `getEmail()` | Get member email | `string` | +| `getName()` | Get member name | `string \| undefined` | +| `getStatus()` | Get member status | `string \| undefined` | +| `getLabels()` | Get member labels | `Array \| undefined` | +| `toJson()` | Get raw member data | `IMember` | +| `update(data)` | Update the member | `Promise` | +| `delete()` | Delete the member | `Promise` | + +### SyncedInstance Class + +| Method | Description | Returns | +|--------|-------------|---------| +| `syncPosts(options?)` | Sync posts to targets | `Promise` | +| `syncPages(options?)` | Sync pages to targets | `Promise` | +| `syncTags(options?)` | Sync tags to targets | `Promise` | +| `syncAll(options?)` | Sync all content types | `Promise` | +| `getSyncStatus()` | Get sync status & mappings | `Object` | +| `clearSyncHistory()` | Clear sync history | `void` | +| `clearMappings()` | Clear ID mappings | `void` | + +## ๐Ÿงช Testing ```bash -npm install +pnpm test ``` -2. Run the tests: +## ๐Ÿ“ TypeScript Support -```bash -npm test +This library is written in TypeScript and provides full type definitions out of the box. No `@types/*` package needed. + +```typescript +import type { IPost, ITag, IAuthor, IMember } from '@apiclient.xyz/ghost'; ``` -### Conclusion +## ๐Ÿค Contributing -The `@apiclient.xyz/ghost` package provides a comprehensive and type-safe way to interact with the Ghost CMS API using TypeScript. The features provided by the `Ghost` and `Post` classes allow for a wide range of interactions, from basic CRUD operations to advanced filtering and error handling. +This is an open-source project. Issues and pull requests are welcome! -For more information, please refer to the [documentation](https://apiclient.xyz.gitlab.io/ghost/). -undefined \ No newline at end of file +Repository: [https://code.foss.global/apiclient.xyz/ghost](https://code.foss.global/apiclient.xyz/ghost) + +## License and Legal Information + +This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. + +### Trademarks + +This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH. + +### Company Information + +Task Venture Capital GmbH +Registered at District court Bremen HRB 35230 HB, Germany + +For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. + +By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. diff --git a/test/test.syncedinstance.node.ts b/test/test.syncedinstance.node.ts new file mode 100644 index 0000000..3985f28 --- /dev/null +++ b/test/test.syncedinstance.node.ts @@ -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: '

This is a test post for syncing

', + 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: '

This is a test page for syncing

', + 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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c4dfa7a..c3d40d2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@apiclient.xyz/ghost', - version: '2.0.0', + version: '2.1.0', description: 'An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.' } diff --git a/ts/classes.syncedinstance.ts b/ts/classes.syncedinstance.ts new file mode 100644 index 0000000..2e9f8aa --- /dev/null +++ b/ts/classes.syncedinstance.ts @@ -0,0 +1,406 @@ +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; + private syncHistory: ISyncReport[]; + + constructor(sourceGhost: Ghost, targetGhosts: Ghost[]) { + 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 { + 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, + 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 { + 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 { + 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 = { + 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 { + 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(); + } +} diff --git a/ts/index.ts b/ts/index.ts index e656fda..6c1bca1 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -3,4 +3,5 @@ export * from './classes.post.js'; export * from './classes.author.js'; export * from './classes.tag.js'; export * from './classes.page.js'; -export * from './classes.member.js'; \ No newline at end of file +export * from './classes.member.js'; +export * from './classes.syncedinstance.js'; \ No newline at end of file