diff --git a/ghost/collections/.eslintrc.js b/ghost/collections/.eslintrc.js deleted file mode 100644 index cb690be63fa..00000000000 --- a/ghost/collections/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/ts' - ] -}; diff --git a/ghost/collections/README.md b/ghost/collections/README.md deleted file mode 100644 index 61dfb4e3131..00000000000 --- a/ghost/collections/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Collections - -Manages everything to do with Collections - - -## Usage - - -## Develop - -This is a monorepo package. - -Follow the instructions for the top-level repo. -1. `git clone` this repo & `cd` into it as usual -2. Run `yarn` to install top-level dependencies. - - - -## Test - -- `yarn lint` run just eslint -- `yarn test` run lint and tests - diff --git a/ghost/collections/package.json b/ghost/collections/package.json deleted file mode 100644 index 565daec0fd2..00000000000 --- a/ghost/collections/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@tryghost/collections", - "version": "0.0.0", - "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/collections", - "author": "Ghost Foundation", - "private": true, - "main": "build/index.js", - "types": "build/index.d.ts", - "scripts": { - "build": "yarn build:ts", - "build:ts": "tsc", - "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot -r ts-node/register './test/**/*.test.ts'", - "test": "yarn test:types && yarn test:unit", - "test:types": "tsc --noEmit", - "lint:code": "eslint src/ --ext .ts --cache", - "lint": "yarn lint:code && yarn lint:test", - "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" - }, - "files": [ - "build" - ], - "devDependencies": { - "@tryghost/domain-events": "0.0.0", - "c8": "8.0.1", - "mocha": "10.2.0", - "sinon": "15.2.0" - }, - "dependencies": { - "@tryghost/debug": "0.1.32", - "@tryghost/errors": "1.3.5", - "@tryghost/in-memory-repository": "0.0.0", - "@tryghost/logging": "2.4.18", - "@tryghost/nql": "0.12.7", - "@tryghost/nql-filter-expansions": "0.0.0", - "@tryghost/post-events": "0.0.0", - "@tryghost/tpl": "0.1.32", - "bson-objectid": "2.0.4", - "lodash": "4.17.21" - }, - "c8": { - "exclude": [ - "src/CollectionPost.ts", - "src/CollectionRepository.ts", - "src/UniqueChecker.ts", - "src/**/*.d.ts", - "test/**/*.ts" - ] - } -} diff --git a/ghost/collections/src/Collection.ts b/ghost/collections/src/Collection.ts deleted file mode 100644 index 5c7e0409120..00000000000 --- a/ghost/collections/src/Collection.ts +++ /dev/null @@ -1,277 +0,0 @@ -import {UniqueChecker} from './UniqueChecker'; -import {ValidationError} from '@tryghost/errors'; -import tpl from '@tryghost/tpl'; -import nql from '@tryghost/nql'; -import {posts as postExpansions} from '@tryghost/nql-filter-expansions'; -import {CollectionPost} from './CollectionPost'; -import {CollectionPostAdded} from './events/CollectionPostAdded'; -import {CollectionPostRemoved} from './events/CollectionPostRemoved'; - -import ObjectID from 'bson-objectid'; - -type CollectionEvent = CollectionPostAdded | CollectionPostRemoved; - -const messages = { - invalidIDProvided: 'Invalid ID provided for Collection', - invalidDateProvided: 'Invalid date provided for {fieldName}', - invalidFilterProvided: { - message: 'Invalid filter provided for automatic Collection', - context: 'Automatic type of collection should always have a filter value' - }, - noTitleProvided: 'Title must be provided', - slugMustBeUnique: 'Slug must be unique' -}; - -function validateFilter(filter: string | null, type: 'manual' | 'automatic', isAllowedEmpty = false) { - const allowedProperties = ['featured', 'published_at', 'tag', 'tags']; - if (type === 'manual') { - if (filter !== null) { - throw new ValidationError({ - message: tpl(messages.invalidFilterProvided.message), - context: tpl(messages.invalidFilterProvided.context) - }); - } - return; - } - - // type === 'automatic' now - if (filter === null) { - throw new ValidationError({ - message: tpl(messages.invalidFilterProvided.message), - context: tpl(messages.invalidFilterProvided.context) - }); - } - - if (filter.trim() === '' && !isAllowedEmpty) { - throw new ValidationError({ - message: tpl(messages.invalidFilterProvided.message), - context: tpl(messages.invalidFilterProvided.context) - }); - } - - try { - const parsedFilter = nql(filter); - const keysUsed: string[] = []; - nql.utils.mapQuery(parsedFilter.toJSON(), function (value: unknown, key: string) { - keysUsed.push(key); - }); - if (keysUsed.some(key => !allowedProperties.includes(key))) { - throw new ValidationError({ - message: tpl(messages.invalidFilterProvided.message) - }); - } - } catch (err) { - throw new ValidationError({ - message: tpl(messages.invalidFilterProvided.message) - }); - } -} - -export class Collection { - events: CollectionEvent[]; - id: string; - title: string; - private _slug: string; - get slug() { - return this._slug; - } - - async setSlug(slug: string, uniqueChecker: UniqueChecker) { - if (slug === this.slug) { - return; - } - if (await uniqueChecker.isUniqueSlug(slug)) { - this._slug = slug; - } else { - throw new ValidationError({ - message: tpl(messages.slugMustBeUnique) - }); - } - } - description: string; - type: 'manual' | 'automatic'; - _filter: string | null; - get filter() { - return this._filter; - } - set filter(value) { - if (this.slug === 'latest' || this.slug === 'featured') { - return; - } - validateFilter(value, this.type); - this._filter = value; - } - featureImage: string | null; - createdAt: Date; - updatedAt: Date; - get deletable() { - return this.slug !== 'latest' && this.slug !== 'featured'; - } - private _deleted: boolean = false; - - private _posts: string[]; - get posts() { - return this._posts; - } - - public get deleted() { - return this._deleted; - } - - public set deleted(value: boolean) { - if (this.deletable) { - this._deleted = value; - } - } - - postMatchesFilter(post: CollectionPost) { - const filterNql = nql(this.filter, { - expansions: postExpansions - }); - return filterNql.queryJSON(post); - } - - /** - * @param post {{id: string}} - The post to add to the collection - * @param index {number} - The index to insert the post at, use negative numbers to count from the end. - */ - addPost(post: CollectionPost, index: number = -0) { - if (this.slug === 'latest') { - return false; - } - if (this.type === 'automatic') { - const matchesFilter = this.postMatchesFilter(post); - - if (!matchesFilter) { - return false; - } - } - - if (this.posts.includes(post.id)) { - this._posts = this.posts.filter(id => id !== post.id); - } else { - this.events.push(CollectionPostAdded.create({ - post_id: post.id, - collection_id: this.id - })); - } - - if (index < 0 || Object.is(index, -0)) { - index = this.posts.length + index; - } - - this.posts.splice(index, 0, post.id); - return true; - } - - removePost(id: string) { - if (this.posts.includes(id)) { - this._posts = this.posts.filter(postId => postId !== id); - this.events.push(CollectionPostRemoved.create({ - post_id: id, - collection_id: this.id - })); - } - } - - includesPost(id: string) { - return this.posts.includes(id); - } - - removeAllPosts() { - for (const id of this._posts) { - this.events.push(CollectionPostRemoved.create({ - post_id: id, - collection_id: this.id - })); - } - this._posts = []; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private constructor(data: any) { - this.id = data.id; - this.title = data.title; - this._slug = data.slug; - this.description = data.description; - this.type = data.type; - this._filter = data.filter; - this.featureImage = data.featureImage; - this.createdAt = data.createdAt; - this.updatedAt = data.updatedAt; - this.deleted = data.deleted; - this._posts = data.posts; - this.events = []; - } - - toJSON() { - return { - id: this.id, - title: this.title, - slug: this.slug, - description: this.description, - type: this.type, - filter: this.filter, - featureImage: this.featureImage, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - posts: this.posts - }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static validateDateField(date: any, fieldName: string): Date { - if (!date) { - return new Date(); - } - - if (date instanceof Date) { - return date; - } - - throw new ValidationError({ - message: tpl(messages.invalidDateProvided, {fieldName}) - }); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static async create(data: any): Promise { - let id; - - if (!data.id) { - id = new ObjectID(); - } else if (typeof data.id === 'string') { - id = ObjectID.createFromHexString(data.id); - } else if (data.id instanceof ObjectID) { - id = data.id; - } else { - throw new ValidationError({ - message: tpl(messages.invalidIDProvided) - }); - } - - const type = data.type === 'automatic' ? 'automatic' : 'manual'; - const filter = typeof data.filter === 'string' ? data.filter : null; - validateFilter(filter, type, data.slug === 'latest'); - - if (!data.title) { - throw new ValidationError({ - message: tpl(messages.noTitleProvided) - }); - } - - return new Collection({ - id: id.toHexString(), - title: data.title, - slug: data.slug, - description: data.description || null, - type: type, - filter: filter, - featureImage: data.feature_image || null, - createdAt: Collection.validateDateField(data.created_at, 'created_at'), - updatedAt: Collection.validateDateField(data.updated_at, 'updated_at'), - deleted: data.deleted || false, - posts: data.slug !== 'latest' ? (data.posts || []) : [] - }); - } -} diff --git a/ghost/collections/src/CollectionPost.ts b/ghost/collections/src/CollectionPost.ts deleted file mode 100644 index 87579888d1b..00000000000 --- a/ghost/collections/src/CollectionPost.ts +++ /dev/null @@ -1,7 +0,0 @@ -// eslint-disable-next-line ghost/filenames/match-regex -export type CollectionPost = { - id: string; - featured: boolean; - published_at: Date; - tags: Array<{slug: string}>; -}; diff --git a/ghost/collections/src/CollectionRepository.ts b/ghost/collections/src/CollectionRepository.ts deleted file mode 100644 index 57d508f2be7..00000000000 --- a/ghost/collections/src/CollectionRepository.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import {Collection} from './Collection'; -import {Knex} from 'knex'; - -export interface CollectionRepository { - createTransaction(fn: (transaction: Knex.Transaction) => Promise): Promise - save(collection: Collection, options?: {transaction: Knex.Transaction}): Promise - getById(id: string, options?: {transaction: Knex.Transaction}): Promise - getBySlug(slug: string, options?: {transaction: Knex.Transaction}): Promise - getAll(options?: any): Promise -} diff --git a/ghost/collections/src/CollectionsRepositoryInMemory.ts b/ghost/collections/src/CollectionsRepositoryInMemory.ts deleted file mode 100644 index 1a0b74e0837..00000000000 --- a/ghost/collections/src/CollectionsRepositoryInMemory.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {InMemoryRepository} from '@tryghost/in-memory-repository'; -import {Collection} from './Collection'; - -export class CollectionsRepositoryInMemory extends InMemoryRepository { - protected toPrimitive(entity: Collection): object { - return entity.toJSON(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createTransaction(cb: (transaction: any) => Promise): Promise { - return cb(null); - } - - async getBySlug(slug: string): Promise { - return this.store.find(item => item.slug === slug) || null; - } -} diff --git a/ghost/collections/src/CollectionsService.ts b/ghost/collections/src/CollectionsService.ts deleted file mode 100644 index 597ad6f6654..00000000000 --- a/ghost/collections/src/CollectionsService.ts +++ /dev/null @@ -1,657 +0,0 @@ -import logging from '@tryghost/logging'; -import tpl from '@tryghost/tpl'; -import isEqual from 'lodash/isEqual'; -import {Knex} from 'knex'; -import { - PostsBulkUnpublishedEvent, - PostsBulkFeaturedEvent, - PostsBulkUnfeaturedEvent, - PostsBulkAddTagsEvent -} from '@tryghost/post-events'; -import debugModule from '@tryghost/debug'; -import {Collection} from './Collection'; -import {CollectionRepository} from './CollectionRepository'; -import {CollectionPost} from './CollectionPost'; -import {MethodNotAllowedError} from '@tryghost/errors'; -import {PostAddedEvent} from './events/PostAddedEvent'; -import {PostEditedEvent} from './events/PostEditedEvent'; -import {RepositoryUniqueChecker} from './RepositoryUniqueChecker'; -import {TagDeletedEvent} from './events/TagDeletedEvent'; - -const debug = debugModule('collections'); - -const messages = { - cannotDeleteBuiltInCollectionError: { - message: 'Cannot delete builtin collection', - context: 'The collection {id} is a builtin collection and cannot be deleted' - }, - collectionNotFound: { - message: 'Collection not found', - context: 'Collection with id: {id} does not exist' - } -}; - -interface SlugService { - generate(desired: string, options: {transaction: Knex.Transaction}): Promise; -} - -type CollectionsServiceDeps = { - collectionsRepository: CollectionRepository; - postsRepository: PostsRepository; - slugService: SlugService; - DomainEvents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subscribe: (event: any, handler: (e: any) => void) => void; - }; -}; - -type CollectionPostDTO = { - id: string; - sort_order: number; -}; - -type CollectionPostListItemDTO = { - id: string; - url: string; - slug: string; - title: string; - featured: boolean; - featured_image?: string; - created_at: Date; - updated_at: Date; - published_at: Date, - tags: Array<{slug: string}>; -} - -type ManualCollection = { - title: string; - type: 'manual'; - slug?: string; - description?: string; - feature_image?: string; - filter?: null; - deletable?: boolean; -}; - -type AutomaticCollection = { - title: string; - type: 'automatic'; - filter: string; - slug?: string; - description?: string; - feature_image?: string; - deletable?: boolean; -}; - -type CollectionInputDTO = ManualCollection | AutomaticCollection; - -type CollectionDTO = { - id: string; - title: string | null; - slug: string; - description: string | null; - feature_image: string | null; - type: 'manual' | 'automatic'; - filter: string | null; - created_at: Date; - updated_at: Date | null; - posts: CollectionPostDTO[]; -}; - -type QueryOptions = { - filter?: string; - include?: string; - page?: number; - limit?: number; - transaction?: Knex.Transaction; -} - -interface PostsRepository { - getAll(options: QueryOptions): Promise; - getAllIds(options?: {transaction: Knex.Transaction}): Promise; -} - -export class CollectionsService { - private collectionsRepository: CollectionRepository; - private postsRepository: PostsRepository; - private DomainEvents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subscribe: (event: any, handler: (e: any) => void) => void; - }; - private uniqueChecker: RepositoryUniqueChecker; - private slugService: SlugService; - - constructor(deps: CollectionsServiceDeps) { - this.collectionsRepository = deps.collectionsRepository; - this.postsRepository = deps.postsRepository; - this.DomainEvents = deps.DomainEvents; - this.uniqueChecker = new RepositoryUniqueChecker(this.collectionsRepository); - this.slugService = deps.slugService; - } - - private async toDTO(collection: Collection, options?: {transaction: Knex.Transaction}): Promise { - const dto = { - id: collection.id, - title: collection.title, - slug: collection.slug, - description: collection.description || null, - feature_image: collection.featureImage || null, - type: collection.type, - filter: collection.filter, - created_at: collection.createdAt, - updated_at: collection.updatedAt, - posts: collection.posts.map((postId, index) => ({ - id: postId, - sort_order: index - })) - }; - if (collection.slug === 'latest') { - const allPostIds = await this.postsRepository.getAllIds(options); - dto.posts = allPostIds.map((id, index) => ({ - id, - sort_order: index - })); - } - return dto; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private fromDTO(data: any): any { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mappedDTO: {[index: string]:any} = { - title: data.title, - slug: data.slug, - description: data.description, - featureImage: data.feature_image, - filter: data.filter - }; - - // delete out keys that contain undefined values - for (const key of Object.keys(mappedDTO)) { - if (mappedDTO[key] === undefined) { - delete mappedDTO[key]; - } - } - - return mappedDTO; - } - - /** - * @description Subscribes to Domain events to update collections when posts are added, updated or deleted - */ - subscribeToEvents() { - // @NOTE: event handling should be moved to the client - Ghost app - // Leaving commented out handlers here to move them all together - - // this.DomainEvents.subscribe(PostDeletedEvent, async (event: PostDeletedEvent) => { - // logging.info(`PostDeletedEvent received, removing post ${event.id} from all collections`); - // try { - // await this.removePostFromAllCollections(event.id); - // /* c8 ignore next 3 */ - // } catch (err) { - // logging.error({err, message: 'Error handling PostDeletedEvent'}); - // } - // }); - - this.DomainEvents.subscribe(PostAddedEvent, async (event: PostAddedEvent) => { - logging.info(`PostAddedEvent received, adding post ${event.data.id} to matching collections`); - try { - await this.addPostToMatchingCollections(event.data); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling PostAddedEvent'}); - } - }); - - this.DomainEvents.subscribe(PostEditedEvent, async (event: PostEditedEvent) => { - if (this.hasPostEditRelevantChanges(event.data) === false) { - return; - } - - logging.info(`PostEditedEvent received, updating post ${event.data.id} in matching collections`); - try { - await this.updatePostInMatchingCollections(event.data); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling PostEditedEvent'}); - } - }); - - // this.DomainEvents.subscribe(PostsBulkDestroyedEvent, async (event: PostsBulkDestroyedEvent) => { - // logging.info(`BulkDestroyEvent received, removing posts ${event.data} from all collections`); - // try { - // await this.removePostsFromAllCollections(event.data); - // /* c8 ignore next 3 */ - // } catch (err) { - // logging.error({err, message: 'Error handling PostsBulkDestroyedEvent'}); - // } - // }); - - this.DomainEvents.subscribe(PostsBulkUnpublishedEvent, async (event: PostsBulkUnpublishedEvent) => { - logging.info(`PostsBulkUnpublishedEvent received, updating collection posts ${event.data}`); - try { - await this.updateUnpublishedPosts(event.data); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling PostsBulkUnpublishedEvent'}); - } - }); - - this.DomainEvents.subscribe(PostsBulkFeaturedEvent, async (event: PostsBulkFeaturedEvent) => { - logging.info(`PostsBulkFeaturedEvent received, updating collection posts ${event.data}`); - try { - await this.updateFeaturedPosts(event.data); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling PostsBulkFeaturedEvent'}); - } - }); - - this.DomainEvents.subscribe(PostsBulkUnfeaturedEvent, async (event: PostsBulkUnfeaturedEvent) => { - logging.info(`PostsBulkUnfeaturedEvent received, updating collection posts ${event.data}`); - try { - await this.updateFeaturedPosts(event.data); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling PostsBulkUnfeaturedEvent'}); - } - }); - - this.DomainEvents.subscribe(TagDeletedEvent, async (event: TagDeletedEvent) => { - logging.info(`TagDeletedEvent received for ${event.data.id}, updating all collections`); - try { - await this.updateAllAutomaticCollections(); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling TagDeletedEvent'}); - } - }); - - this.DomainEvents.subscribe(PostsBulkAddTagsEvent, async (event: PostsBulkAddTagsEvent) => { - logging.info(`PostsBulkAddTagsEvent received for ${event.data}, updating all collections`); - try { - await this.updateAllAutomaticCollections(); - /* c8 ignore next 3 */ - } catch (err) { - logging.error({err, message: 'Error handling PostsBulkAddTagsEvent'}); - } - }); - } - - private hasPostEditRelevantChanges(postEditEvent: PostEditedEvent['data']): boolean { - const current = { - id: postEditEvent.current.id, - featured: postEditEvent.current.featured, - published_at: postEditEvent.current.published_at, - tags: postEditEvent.current.tags - }; - const previous = { - id: postEditEvent.previous.id, - featured: postEditEvent.previous.featured, - published_at: postEditEvent.previous.published_at, - tags: postEditEvent.previous.tags - }; - - return !isEqual(current, previous); - } - - async updateAllAutomaticCollections(): Promise { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collections = await this.collectionsRepository.getAll({ - transaction - }); - - for (const collection of collections) { - if (collection.type === 'automatic' && collection.filter) { - collection.removeAllPosts(); - - const posts = await this.postsRepository.getAll({ - filter: collection.filter, - transaction - }); - - for (const post of posts) { - collection.addPost(post); - } - - await this.collectionsRepository.save(collection, {transaction}); - } - } - }); - } - - async createCollection(data: CollectionInputDTO): Promise { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const slug = await this.slugService.generate(data.slug || data.title, {transaction}); - const collection = await Collection.create({ - title: data.title, - slug: slug, - description: data.description, - type: data.type, - filter: data.filter, - featureImage: data.feature_image, - deletable: data.deletable - }); - - if (collection.type === 'automatic' && collection.filter) { - const posts = await this.postsRepository.getAll({ - filter: collection.filter, - transaction: transaction - }); - - for (const post of posts) { - await collection.addPost(post); - } - } - - await this.collectionsRepository.save(collection, {transaction}); - - return this.toDTO(collection); - }); - } - - async addPostToCollection(collectionId: string, post: CollectionPostListItemDTO): Promise { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collection = await this.collectionsRepository.getById(collectionId, {transaction}); - - if (!collection) { - return null; - } - - await collection.addPost(post); - - await this.collectionsRepository.save(collection, {transaction}); - - return this.toDTO(collection); - }); - } - - async removePostFromAllCollections(postId: string) { - return await this.collectionsRepository.createTransaction(async (transaction) => { - // @NOTE: can be optimized by having a "getByPostId" method on the collections repository - const collections = await this.collectionsRepository.getAll({transaction}); - - for (const collection of collections) { - if (collection.includesPost(postId)) { - collection.removePost(postId); - await this.collectionsRepository.save(collection, {transaction}); - } - } - }); - } - - async removePostsFromAllCollections(postIds: string[]) { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collections = await this.collectionsRepository.getAll({transaction}); - - for (const collection of collections) { - for (const postId of postIds) { - if (collection.includesPost(postId)) { - collection.removePost(postId); - } - } - await this.collectionsRepository.save(collection, {transaction}); - } - }); - } - - private async addPostToMatchingCollections(post: CollectionPost) { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collections = await this.collectionsRepository.getAll({ - filter: 'type:automatic', - transaction: transaction - }); - - for (const collection of collections) { - const added = await collection.addPost(post); - - if (added) { - await this.collectionsRepository.save(collection, {transaction}); - } - } - }); - } - - async updatePostInMatchingCollections(postEdit: PostEditedEvent['data']) { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collections = await this.collectionsRepository.getAll({ - filter: 'type:automatic+slug:-latest', - transaction - }); - - let collectionsChangeLog = ''; - for (const collection of collections) { - if (collection.includesPost(postEdit.id) && !collection.postMatchesFilter(postEdit.current)) { - collection.removePost(postEdit.id); - await this.collectionsRepository.save(collection, {transaction}); - - collectionsChangeLog += `Post ${postEdit.id} was updated and removed from collection ${collection.slug} with filter ${collection.filter} \n`; - } else if (!collection.includesPost(postEdit.id) && collection.postMatchesFilter(postEdit.current)) { - const added = await collection.addPost(postEdit.current); - - if (added) { - await this.collectionsRepository.save(collection, {transaction}); - } - - collectionsChangeLog += `Post ${postEdit.id} was updated and added to collection ${collection.slug} with filter ${collection.filter}\n`; - } else { - debug(`Post ${postEdit.id} was updated but did not update any collections`); - } - } - - if (collectionsChangeLog.length > 0) { - logging.info(collectionsChangeLog); - } - }); - } - - async updateUnpublishedPosts(postIds: string[]) { - return await this.collectionsRepository.createTransaction(async (transaction) => { - let collections = await this.collectionsRepository.getAll({ - filter: 'type:automatic+slug:-latest+slug:-featured', - transaction - }); - - // only process collections that have a filter that includes published_at - collections = collections.filter(collection => collection.filter?.includes('published_at')); - - if (!collections.length) { - return; - } - - await this.updatePostsInCollections(postIds, collections, transaction); - }); - } - - async updateFeaturedPosts(postIds: string[]) { - return await this.collectionsRepository.createTransaction(async (transaction) => { - let collections = await this.collectionsRepository.getAll({ - filter: 'type:automatic+slug:-latest', - transaction - }); - - // only process collections that have a filter that includes featured - collections = collections.filter(collection => collection.filter?.includes('featured')); - - if (!collections.length) { - return; - } - - await this.updatePostsInCollections(postIds, collections, transaction); - }); - } - - async updatePostsInCollections(postIds: string[], collections: Collection[], transaction: Knex.Transaction) { - const posts = await this.postsRepository.getAll({ - filter: `id:[${postIds.join(',')}]`, - transaction: transaction - }); - - let collectionsChangeLog = ''; - for (const collection of collections) { - let addedPostsCount = 0; - let removedPostsCount = 0; - - for (const post of posts) { - if (collection.includesPost(post.id) && !collection.postMatchesFilter(post)) { - collection.removePost(post.id); - removedPostsCount += 1; - debug(`Post ${post.id} was updated and removed from collection ${collection.id} with filter ${collection.filter}`); - } else if (!collection.includesPost(post.id) && collection.postMatchesFilter(post)) { - await collection.addPost(post); - addedPostsCount += 1; - debug(`Post ${post.id} was unpublished and added to collection ${collection.id} with filter ${collection.filter}`); - } - } - - collectionsChangeLog += `Collection ${collection.slug} was updated with total ${posts.length} posts, added: ${addedPostsCount}, removed: ${removedPostsCount} \n`; - await this.collectionsRepository.save(collection, {transaction}); - } - - if (collectionsChangeLog.length > 0) { - logging.info(collectionsChangeLog); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async edit(data: any): Promise { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collection = await this.collectionsRepository.getById(data.id, {transaction}); - - if (!collection) { - return null; - } - - const collectionData = this.fromDTO(data); - - if (collectionData.title) { - collection.title = collectionData.title; - } - - if (data.slug !== undefined) { - await collection.setSlug(data.slug, this.uniqueChecker); - } - - if (data.description !== undefined) { - collection.description = data.description; - } - - if (data.filter !== undefined) { - collection.filter = data.filter; - } - - if (data.feature_image !== undefined) { - collection.featureImage = data.feature_image; - } - - if (collection.type === 'manual' && data.posts) { - for (const post of data.posts) { - await collection.addPost(post); - } - } - - if (collection.type === 'automatic' && data.filter) { - const posts = await this.postsRepository.getAll({ - filter: data.filter, - transaction - }); - - collection.removeAllPosts(); - - for (const post of posts) { - await collection.addPost(post); - } - } - - await this.collectionsRepository.save(collection, {transaction}); - - return this.toDTO(collection); - }); - } - - async getById(id: string, options?: {transaction: Knex.Transaction}): Promise { - const collection = await this.collectionsRepository.getById(id, options); - if (!collection) { - return null; - } - return this.toDTO(collection, options); - } - - async getBySlug(slug: string, options?: {transaction: Knex.Transaction}): Promise { - const collection = await this.collectionsRepository.getBySlug(slug, options); - if (!collection) { - return null; - } - return this.toDTO(collection, options); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async getAll(options?: QueryOptions): Promise<{data: CollectionDTO[], meta: any}> { - const collections = await this.collectionsRepository.getAll(options); - - const collectionsDTOs: CollectionDTO[] = await Promise.all( - collections.map(collection => this.toDTO(collection)) - ); - - return { - data: collectionsDTOs, - meta: { - pagination: { - page: 1, - pages: 1, - limit: collections.length, - total: collections.length, - prev: null, - next: null - } - } - }; - } - async getCollectionsForPost(postId: string): Promise { - const collections = await this.collectionsRepository.getAll({ - filter: `posts:'${postId}',slug:latest` - }); - - return Promise.all(collections.sort((a, b) => { - // NOTE: sorting is here to keep DB engine ordering consistent - return a.slug.localeCompare(b.slug); - }).map(collection => this.toDTO(collection))); - } - - async destroy(id: string): Promise { - const collection = await this.collectionsRepository.getById(id); - - if (collection) { - if (collection.deletable === false) { - throw new MethodNotAllowedError({ - message: tpl(messages.cannotDeleteBuiltInCollectionError.message), - context: tpl(messages.cannotDeleteBuiltInCollectionError.context, { - id: collection.id - }) - }); - } - - collection.deleted = true; - await this.collectionsRepository.save(collection); - } - - return collection; - } - - async removePostFromCollection(id: string, postId: string): Promise { - return await this.collectionsRepository.createTransaction(async (transaction) => { - const collection = await this.collectionsRepository.getById(id, {transaction}); - - if (!collection) { - return null; - } - - if (collection) { - collection.removePost(postId); - await this.collectionsRepository.save(collection, {transaction}); - } - - return this.toDTO(collection); - }); - } -} diff --git a/ghost/collections/src/RepositoryUniqueChecker.ts b/ghost/collections/src/RepositoryUniqueChecker.ts deleted file mode 100644 index ec682a5aeba..00000000000 --- a/ghost/collections/src/RepositoryUniqueChecker.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {CollectionRepository} from './CollectionRepository'; -import {UniqueChecker} from './UniqueChecker'; - -export class RepositoryUniqueChecker implements UniqueChecker { - constructor( - private repository: CollectionRepository - ) {} - - async isUniqueSlug(slug: string): Promise { - const entity = await this.repository.getBySlug(slug); - return entity === null; - } -} diff --git a/ghost/collections/src/UniqueChecker.ts b/ghost/collections/src/UniqueChecker.ts deleted file mode 100644 index 68336357e2d..00000000000 --- a/ghost/collections/src/UniqueChecker.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface UniqueChecker { - isUniqueSlug(slug: string): Promise -} diff --git a/ghost/collections/src/events/CollectionPostAdded.ts b/ghost/collections/src/events/CollectionPostAdded.ts deleted file mode 100644 index 00cb8d5dec4..00000000000 --- a/ghost/collections/src/events/CollectionPostAdded.ts +++ /dev/null @@ -1,19 +0,0 @@ -type CollectionPostAddedData = { - collection_id: string; - post_id: string; -}; - -export class CollectionPostAdded { - data: CollectionPostAddedData; - timestamp: Date; - type = 'CollectionPostAdded' as const; - - constructor(data: CollectionPostAddedData, timestamp: Date) { - this.data = data; - this.timestamp = timestamp; - } - - static create(data: CollectionPostAddedData, timestamp = new Date()) { - return new CollectionPostAdded(data, timestamp); - } -} diff --git a/ghost/collections/src/events/CollectionPostRemoved.ts b/ghost/collections/src/events/CollectionPostRemoved.ts deleted file mode 100644 index 305f6b8eece..00000000000 --- a/ghost/collections/src/events/CollectionPostRemoved.ts +++ /dev/null @@ -1,19 +0,0 @@ -type CollectionPostRemovedData = { - collection_id: string; - post_id: string; -}; - -export class CollectionPostRemoved { - data: CollectionPostRemovedData; - timestamp: Date; - type = 'CollectionPostRemoved' as const; - - constructor(data: CollectionPostRemovedData, timestamp: Date) { - this.data = data; - this.timestamp = timestamp; - } - - static create(data: CollectionPostRemovedData, timestamp = new Date()) { - return new CollectionPostRemoved(data, timestamp); - } -} diff --git a/ghost/collections/src/events/PostAddedEvent.ts b/ghost/collections/src/events/PostAddedEvent.ts deleted file mode 100644 index d7146619665..00000000000 --- a/ghost/collections/src/events/PostAddedEvent.ts +++ /dev/null @@ -1,23 +0,0 @@ -type PostData = { - id: string; - featured: boolean; - published_at: Date; - tags: Array<{slug: string}>; -}; - -export class PostAddedEvent { - id: string; - data: PostData; - timestamp: Date; - - constructor(data: PostData, timestamp: Date) { - this.id = data.id; - this.data = data; - this.timestamp = timestamp; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static create(data: any, timestamp = new Date()) { - return new PostAddedEvent(data, timestamp); - } -} diff --git a/ghost/collections/src/events/PostEditedEvent.ts b/ghost/collections/src/events/PostEditedEvent.ts deleted file mode 100644 index 1f5c7c37dac..00000000000 --- a/ghost/collections/src/events/PostEditedEvent.ts +++ /dev/null @@ -1,37 +0,0 @@ -type PostEditData = { - id: string; - current: { - id: string; - title: string; - status: string; - featured: boolean; - published_at: Date; - tags: Array<{slug: string}>; - }, - previous: { - id: string; - title: string; - status: string; - featured: boolean; - published_at: Date; - tags: Array<{slug: string}>; - } -}; - -export class PostEditedEvent { - id: string; - data: PostEditData; - timestamp: Date; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(data: any, timestamp: Date) { - this.id = data.id; - this.data = data; - this.timestamp = timestamp; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static create(data: any, timestamp = new Date()) { - return new PostEditedEvent(data, timestamp); - } -} diff --git a/ghost/collections/src/events/TagDeletedEvent.ts b/ghost/collections/src/events/TagDeletedEvent.ts deleted file mode 100644 index a0161e3f6f2..00000000000 --- a/ghost/collections/src/events/TagDeletedEvent.ts +++ /dev/null @@ -1,16 +0,0 @@ -export class TagDeletedEvent { - id: string; - data: {slug: string, id: string}; - timestamp: Date; - - constructor(data: {slug: string, id: string}, timestamp: Date) { - this.id = data.id; - this.data = data; - this.timestamp = timestamp; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static create(data: any, timestamp = new Date()) { - return new TagDeletedEvent(data, timestamp); - } -} diff --git a/ghost/collections/src/index.ts b/ghost/collections/src/index.ts deleted file mode 100644 index af417d2388d..00000000000 --- a/ghost/collections/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './CollectionsService'; -export * from './CollectionsRepositoryInMemory'; -export * from './Collection'; -export * from './events/PostAddedEvent'; -export * from './events/PostEditedEvent'; -export * from './events/TagDeletedEvent'; -export * from './events/CollectionPostAdded'; -export * from './events/CollectionPostRemoved'; diff --git a/ghost/collections/src/libraries.d.ts b/ghost/collections/src/libraries.d.ts deleted file mode 100644 index 8f59a1209bf..00000000000 --- a/ghost/collections/src/libraries.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '@tryghost/debug'; -declare module '@tryghost/domain-events' -declare module '@tryghost/errors'; -declare module '@tryghost/logging' -declare module '@tryghost/nql' -declare module '@tryghost/tpl'; diff --git a/ghost/collections/test/.eslintrc.js b/ghost/collections/test/.eslintrc.js deleted file mode 100644 index 6fe6dc1504a..00000000000 --- a/ghost/collections/test/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/collections/test/Collection.test.ts b/ghost/collections/test/Collection.test.ts deleted file mode 100644 index 7c8206afb34..00000000000 --- a/ghost/collections/test/Collection.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import assert from 'assert/strict'; -import ObjectID from 'bson-objectid'; -import {Collection} from '../src/index'; - -const uniqueChecker = { - async isUniqueSlug() { - return true; - } -}; - -describe('Collection', function () { - it('Create Collection entity', async function () { - const collection = await Collection.create({ - title: 'Test Collection' - }); - - assert.ok(collection instanceof Collection); - assert.ok(collection.id, 'generated id should be set'); - assert.ok(ObjectID.isValid(collection.id), 'generated id should be valid ObjectID'); - - assert.equal(collection.title, 'Test Collection'); - assert.ok(collection.createdAt instanceof Date); - assert.ok(collection.updatedAt instanceof Date); - assert.ok((collection.deleted === false), 'deleted should be false'); - }); - - it('Cannot create a collection without a title', async function () { - assert.rejects(async () => { - await Collection.create({}); - }); - }); - - it('Can serialize Collection to JSON', async function () { - const collection = await Collection.create({ - title: 'Serialize me', - posts: [{ - id: 'post-1' - }, { - id: 'post-2' - }] - }); - - const json = collection.toJSON(); - - assert.ok(json); - assert.equal(json.id, collection.id); - assert.equal(json.title, 'Serialize me'); - assert.ok(collection.createdAt instanceof Date); - assert.ok(collection.updatedAt instanceof Date); - assert.equal(Object.keys(json).length, 10, 'should only have 9 keys + 1 posts relation'); - assert.deepEqual(Object.keys(json), [ - 'id', - 'title', - 'slug', - 'description', - 'type', - 'filter', - 'featureImage', - 'createdAt', - 'updatedAt', - 'posts' - ]); - - assert.equal(json.posts.length, 2, 'should have 2 posts'); - const serializedPost = json.posts[0]; - assert.equal(Object.keys(serializedPost).length, 1, 'should only have 1 key'); - assert.deepEqual(Object.keys(serializedPost), [ - 'id' - ]); - }); - - it('Can create a Collection with predefined ID', async function () { - const id = new ObjectID(); - const savedCollection = await Collection.create({ - id: id.toHexString(), - title: 'Blah' - }); - - assert.equal(savedCollection.id, id.toHexString(), 'Collection should have same id'); - }); - - it('Can create a Collection with predefined ObjectID instance', async function () { - const id = new ObjectID(); - const savedCollection = await Collection.create({ - id: id, - title: 'Bleh' - }); - - assert.equal(savedCollection.id, id.toHexString(), 'Collection should have same id'); - }); - - it('Can create a Collection with predefined created_at and updated_at values', async function () { - const createdAt = new Date(); - const updatedAt = new Date(); - const savedCollection = await Collection.create({ - created_at: createdAt, - updated_at: updatedAt, - title: 'Bluh' - }); - - assert.equal(savedCollection.createdAt, createdAt, 'Collection should have same created_at'); - assert.equal(savedCollection.updatedAt, updatedAt, 'Collection should have same updated_at'); - }); - - it('Throws an error when trying to create a Collection with an invalid ID', async function () { - await assert.rejects(async () => { - await Collection.create({ - id: 12345 - }); - }, (err: any) => { - assert.equal(err.message, 'Invalid ID provided for Collection', 'Error message should match'); - return true; - }); - }); - - it('Throws an error when trying to create a Collection with invalid created_at date', async function () { - await assert.rejects(async () => { - await Collection.create({ - created_at: 'invalid date', - title: 'Blih' - }); - }, (err: any) => { - assert.equal(err.message, 'Invalid date provided for created_at', 'Error message should match'); - return true; - }); - }); - - it('Throws an error when trying to create an automatic Collection without a filter', async function () { - await assert.rejects(async () => { - await Collection.create({ - type: 'automatic', - filter: null - }); - }, (err: any) => { - assert.equal(err.message, 'Invalid filter provided for automatic Collection', 'Error message should match'); - assert.equal(err.context, 'Automatic type of collection should always have a filter value', 'Error message should match'); - return true; - }); - }); - - describe('setSlug', function () { - it('Does not bother checking uniqueness if slug is unchanged', async function () { - const collection = await Collection.create({ - slug: 'test-collection', - title: 'Testing edits', - type: 'automatic', - filter: 'featured:true' - }); - - await collection.setSlug('test-collection', { - isUniqueSlug: () => { - throw new Error('Should not have checked uniqueness'); - } - }); - }); - - it('Throws an error if slug is not unique', async function () { - const collection = await Collection.create({ - slug: 'test-collection', - title: 'Testing edits', - type: 'automatic', - filter: 'featured:true' - }); - - assert.rejects(async () => { - await collection.setSlug('not-unique', { - async isUniqueSlug() { - return false; - } - }); - }); - }); - }); - - it('Can edit Collection values', async function () { - const collection = await Collection.create({ - slug: 'test-collection', - title: 'Testing edits', - type: 'automatic', - filter: 'featured:true' - }); - - assert.equal(collection.title, 'Testing edits'); - - collection.title = 'Edited title'; - await collection.setSlug('edited-slug', uniqueChecker); - - assert.equal(collection.title, 'Edited title'); - assert.equal(collection.slug, 'edited-slug'); - }); - - it('Throws when the collection filter is malformed', async function () { - const collection = await Collection.create({ - title: 'Testing edits', - type: 'automatic', - filter: 'featured:true' - }); - - assert.throws(() => { - collection.filter = 'my name is, my name is, my name is, wicka wicka slim shady'; - }, (err: any) => { - assert.equal(err.message, 'Invalid filter provided for automatic Collection', 'Error message should match'); - return true; - }); - }); - - it('Throws when the collection filter is invalid', async function () { - assert.rejects(async () => { - await Collection.create({ - title: 'Testing creating collections with invalid filter', - type: 'automatic', - filter: 'unknown:egg' - }); - }); - const collection = await Collection.create({ - title: 'Testing edits', - type: 'automatic', - filter: 'featured:true' - }); - - assert.throws(() => { - collection.filter = 'unknown:true'; - }, (err: any) => { - assert.equal(err.message, 'Invalid filter provided for automatic Collection', 'Error message should match'); - return true; - }); - }); - - it('Throws when the collection filter is empty', async function () { - const collection = await Collection.create({ - title: 'Testing edits', - type: 'automatic', - filter: 'featured:true' - }); - - assert.throws(() => { - collection.filter = null; - }, (err: any) => { - assert.equal(err.message, 'Invalid filter provided for automatic Collection', 'Error message should match'); - assert.equal(err.context, 'Automatic type of collection should always have a filter value', 'Error message should match'); - return true; - }); - }); - - it('Does not throw when collection filter is empty for automatic "latest" collection', async function (){ - const collection = await Collection.create({ - title: 'Latest', - slug: 'latest', - type: 'automatic', - filter: '' - }); - - collection.filter = ''; - }); - - it('throws when trying to set an empty filter on an automatic collection', async function () { - assert.rejects(async () => { - await Collection.create({ - title: 'Testing Creating Automatic With Empty Filter', - slug: 'testing-creating-automatic-with-empty-filter', - type: 'automatic', - filter: '' - }); - }); - - const collection = await Collection.create({ - title: 'Testing Editing Automatic With Empty Filter', - slug: 'testing-editing-automatic-with-empty-filter', - type: 'automatic', - filter: 'featured:true' - }); - - assert.throws(() => { - collection.filter = ''; - }, (err: any) => { - assert.equal(err.message, 'Invalid filter provided for automatic Collection', 'Error message should match'); - assert.equal(err.context, 'Automatic type of collection should always have a filter value', 'Error message should match'); - return true; - }); - }); - - it('throws when trying to set filter on a manual collection', async function () { - const collection = await Collection.create({ - title: 'Testing Manual Filter', - slug: 'testing-manual-filter', - type: 'manual', - filter: null - }); - - assert.throws(() => { - collection.filter = 'awesome:true'; - }, (err: any) => { - assert.equal(err.message, 'Invalid filter provided for automatic Collection', 'Error message should match'); - assert.equal(err.context, 'Automatic type of collection should always have a filter value', 'Error message should match'); - return true; - }); - }); - - it('Can add posts to different positions', async function () { - const collection = await Collection.create({ - title: 'Testing adding posts', - type: 'manual' - }); - - assert(collection.posts.length === 0); - - const posts = [{ - id: '0', - featured: false, - published_at: new Date(), - tags: [] - }, { - id: '1', - featured: false, - published_at: new Date(), - tags: [] - }, { - id: '2', - featured: false, - published_at: new Date(), - tags: [] - }, { - id: '3', - featured: false, - published_at: new Date(), - tags: [] - }]; - - collection.addPost(posts[0]); - collection.addPost(posts[1]); - collection.addPost(posts[2], 1); - collection.addPost(posts[3], 0); - - assert(collection.posts.length as number === 4); - assert(collection.posts[0] === '3'); - - collection.addPost(posts[3], -1); - assert(collection.posts.length as number === 4); - assert(collection.posts[collection.posts.length - 2] === '3'); - }); - - it('Does not add a post to the latest collection', async function () { - const collection = await Collection.create({ - title: 'Testing adding to latest', - slug: 'latest', - type: 'automatic', - filter: '' - }); - - assert.equal(collection.posts.length, 0, 'Collection should have no posts'); - - const added = await collection.addPost({ - id: '0', - featured: false, - published_at: new Date(), - tags: [] - }); - - assert.equal(added, false); - assert.equal(collection.posts.length, 0, 'The non-featured post should not have been added'); - }); - - it('Adds a post to an automatic collection when it matches the filter', async function () { - const collection = await Collection.create({ - title: 'Testing adding posts', - type: 'automatic', - filter: 'featured:true' - }); - - assert.equal(collection.posts.length, 0, 'Collection should have no posts'); - - const added = await collection.addPost({ - id: '0', - featured: false, - published_at: new Date(), - tags: [] - }); - - assert.equal(added, false); - assert.equal(collection.posts.length, 0, 'The non-featured post should not have been added'); - - const featuredAdded = await collection.addPost({ - id: '1', - featured: true, - published_at: new Date(), - tags: [] - }); - - assert.equal(featuredAdded, true); - assert.equal(collection.posts.length, 1, 'The featured post should have been added'); - }); - - it('Removes a post by id', async function () { - const collection = await Collection.create({ - title: 'Testing adding posts' - }); - - assert.equal(collection.posts.length, 0); - - collection.addPost({ - id: '0', - featured: false, - published_at: new Date(), - tags: [] - - }); - - assert.equal(collection.posts.length, 1); - - collection.removePost('0'); - - assert.equal(collection.posts.length, 0); - }); - - it('Cannot set "latest" collection to deleted', async function () { - const collection = await Collection.create({ - title: 'Testing adding posts', - slug: 'latest' - }); - - assert.equal(collection.deleted, false); - - collection.deleted = true; - - assert.equal(collection.deleted, false); - }); - - it('Cannot set featured collection to deleted', async function () { - const collection = await Collection.create({ - title: 'Testing adding posts', - slug: 'featured' - }); - - assert.equal(collection.deleted, false); - - collection.deleted = true; - - assert.equal(collection.deleted, false); - }); - - it('Can set other collection to deleted', async function () { - const collection = await Collection.create({ - title: 'Testing adding posts', - slug: 'non-internal-slug' - }); - - assert.equal(collection.deleted, false); - - collection.deleted = true; - - assert.equal(collection.deleted, true); - }); - - describe('postMatchesFilter', function () { - it('Can match a post with a filter', async function () { - const collection = await Collection.create({ - title: 'Testing filtering posts', - type: 'automatic', - filter: 'featured:true' - }); - - const featuredPost = { - id: '0', - featured: true, - published_at: new Date(), - tags: [] - }; - - const nonFeaturedPost = { - id: '1', - featured: false, - published_at: new Date(), - tags: [] - }; - - assert.ok(collection.postMatchesFilter(featuredPost), 'Post should match the filter'); - assert.ok(!collection.postMatchesFilter(nonFeaturedPost), 'Post should not match the filter'); - }); - - it('Can match a post with a tag filter', async function () { - const collection = await Collection.create({ - title: 'Testing filtering posts', - type: 'automatic', - filter: 'tag:avocado' - }); - - const avocadoPost = { - id: '0', - featured: false, - tags: [{ - slug: 'avocado' - }], - published_at: new Date() - }; - const nonAvocadoPost = { - id: '1', - featured: false, - tags: [{ - slug: 'not-avocado' - }], - published_at: new Date() - }; - - assert.ok(collection.postMatchesFilter(avocadoPost), 'Post should match the filter'); - assert.ok(!collection.postMatchesFilter(nonAvocadoPost), 'Post should not match the filter'); - }); - - it('Can match a post with a tags filter', async function () { - const collection = await Collection.create({ - title: 'Testing filtering posts', - type: 'automatic', - filter: 'tags:avocado' - }); - - const avocadoPost = { - id: '0', - featured: false, - tags: [{ - slug: 'avocado' - }], - published_at: new Date() - }; - const nonAvocadoPost = { - id: '1', - featured: false, - tags: [{ - slug: 'not-avocado' - }], - published_at: new Date() - }; - - assert.ok(collection.postMatchesFilter(avocadoPost), 'Post should match the filter'); - assert.ok(!collection.postMatchesFilter(nonAvocadoPost), 'Post should not match the filter'); - }); - }); -}); diff --git a/ghost/collections/test/RepositoryUniqueChecker.test.ts b/ghost/collections/test/RepositoryUniqueChecker.test.ts deleted file mode 100644 index 7e46a51633c..00000000000 --- a/ghost/collections/test/RepositoryUniqueChecker.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import assert from 'assert/strict'; -import {CollectionsRepositoryInMemory} from '../src/CollectionsRepositoryInMemory'; -import {Collection} from '../src/Collection'; -import {RepositoryUniqueChecker} from '../src/RepositoryUniqueChecker'; - -describe('RepositoryUniqueChecker', function () { - let uniqueChecker: RepositoryUniqueChecker; - - beforeEach(async function () { - const repository = new CollectionsRepositoryInMemory(); - const collection = await Collection.create({ - title: 'Test', - slug: 'not-unique' - }); - repository.save(collection); - uniqueChecker = new RepositoryUniqueChecker(repository); - }); - - it('should return true if slug is unique', async function () { - const actual = await uniqueChecker.isUniqueSlug('unique'); - const expected = true; - - assert.equal(actual, expected, 'The slug "unique" should be unique'); - }); - - it('should return false if slug is not unique', async function () { - const actual = await uniqueChecker.isUniqueSlug('not-unique'); - const expected = false; - - assert.equal(actual, expected, 'The slug "not-unique" should not be unique'); - }); -}); diff --git a/ghost/collections/test/TagDeletedEvent.test.ts b/ghost/collections/test/TagDeletedEvent.test.ts deleted file mode 100644 index 34403173905..00000000000 --- a/ghost/collections/test/TagDeletedEvent.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import assert from 'assert/strict'; -import {TagDeletedEvent} from '../src'; - -describe('TagDeletedEvent', function () { - it('should create a TagDeletedEvent', function () { - const event = TagDeletedEvent.create({id: '1', slug: 'tag-1'}); - - const actual = event instanceof TagDeletedEvent; - const expected = true; - - assert.equal(actual, expected, 'TagDeletedEvent.create() did not return an instance of TagDeletedEvent'); - }); -}); diff --git a/ghost/collections/test/collections.test.ts b/ghost/collections/test/collections.test.ts deleted file mode 100644 index f4660580874..00000000000 --- a/ghost/collections/test/collections.test.ts +++ /dev/null @@ -1,689 +0,0 @@ -import assert from 'assert/strict'; -import sinon from 'sinon'; -import DomainEvents from '@tryghost/domain-events'; -import { - CollectionsService, - CollectionsRepositoryInMemory, - PostAddedEvent, - PostEditedEvent, - TagDeletedEvent -} from '../src/index'; -import { - PostsBulkUnpublishedEvent, - PostsBulkFeaturedEvent, - PostsBulkUnfeaturedEvent, - PostsBulkAddTagsEvent -} from '@tryghost/post-events'; -import {PostsRepositoryInMemory} from './fixtures/PostsRepositoryInMemory'; -import {posts as postFixtures} from './fixtures/posts'; -import {CollectionPost} from '../src/CollectionPost'; - -const initPostsRepository = async (posts: any): Promise => { - const postsRepository = new PostsRepositoryInMemory(); - - for (const post of posts) { - const collectionPost = { - id: post.id, - title: post.title, - slug: post.slug, - featured: post.featured, - published_at: post.published_at?.toISOString(), - tags: post.tags, - deleted: false - }; - - await postsRepository.save(collectionPost as CollectionPost & {deleted: false}); - } - - return postsRepository; -}; - -describe('CollectionsService', function () { - let collectionsService: CollectionsService; - let postsRepository: PostsRepositoryInMemory; - - beforeEach(async function () { - const collectionsRepository = new CollectionsRepositoryInMemory(); - postsRepository = await initPostsRepository(postFixtures); - - collectionsService = new CollectionsService({ - collectionsRepository, - postsRepository, - DomainEvents, - slugService: { - async generate(input) { - return input.replace(/\s+/g, '-').toLowerCase(); - } - } - }); - }); - - it('Instantiates a CollectionsService', function () { - assert.ok(collectionsService, 'CollectionsService should initialize'); - }); - - it('Can do CRUD operations on a collection', async function () { - const savedCollection = await collectionsService.createCollection({ - title: 'testing collections', - description: 'testing collections description', - type: 'manual', - filter: null - }); - - const createdCollection = await collectionsService.getById(savedCollection.id); - - assert.ok(createdCollection, 'Collection should be saved'); - assert.ok(createdCollection.id, 'Collection should have an id'); - assert.equal(createdCollection.title, 'testing collections', 'Collection title should match'); - - const allCollections = await collectionsService.getAll(); - assert.equal(allCollections.data.length, 1, 'There should be one collection'); - - await collectionsService.destroy(createdCollection.id); - const deletedCollection = await collectionsService.getById(savedCollection.id); - - assert.equal(deletedCollection, null, 'Collection should be deleted'); - }); - - it('Can retrieve a collection by slug', async function () { - const savedCollection = await collectionsService.createCollection({ - title: 'slug test', - slug: 'get-me-by-slug', - type: 'manual', - filter: null - }); - - const retrievedCollection = await collectionsService.getBySlug('get-me-by-slug'); - assert.ok(retrievedCollection, 'Collection should be saved'); - assert.ok(retrievedCollection.slug, 'Collection should have a slug'); - assert.equal(savedCollection.title, 'slug test', 'Collection title should match'); - - const nonExistingCollection = await collectionsService.getBySlug('i-do-not-exist'); - assert.equal(nonExistingCollection, null, 'Collection should not exist'); - }); - - it('Throws when built in collection is attempted to be deleted', async function () { - const collection = await collectionsService.createCollection({ - title: 'Featured Posts', - slug: 'featured', - description: 'Collection of featured posts', - type: 'automatic', - deletable: false, - filter: 'featured:true' - }); - - await assert.rejects(async () => { - await collectionsService.destroy(collection.id); - }, (err: any) => { - assert.equal(err.message, 'Cannot delete builtin collection', 'Error message should match'); - assert.equal(err.context, `The collection ${collection.id} is a builtin collection and cannot be deleted`, 'Error context should match'); - return true; - }); - }); - - describe('getCollectionsForPost', function () { - it('Can get collections for a post', async function () { - const collection = await collectionsService.createCollection({ - title: 'testing collections', - slug: 'testing-collections', - type: 'manual' - }); - - const collection2 = await collectionsService.createCollection({ - title: 'testing collections 1', - slug: '1-testing-collections', - type: 'manual' - }); - - await collectionsService.addPostToCollection(collection.id, postFixtures[0]); - await collectionsService.addPostToCollection(collection2.id, postFixtures[0]); - - const collections = await collectionsService.getCollectionsForPost(postFixtures[0].id); - - assert.equal(collections.length, 2, 'There should be one collection'); - assert.equal(collections[0].id, collection2.id, 'Collections should be sorted by slug'); - assert.equal(collections[1].id, collection.id, 'Collections should be sorted by slug'); - }); - }); - - describe('addPostToCollection', function () { - it('Can add a Post to a Collection', async function () { - const collection = await collectionsService.createCollection({ - title: 'testing collections', - description: 'testing collections description', - type: 'manual' - }); - - const editedCollection = await collectionsService.addPostToCollection(collection.id, postFixtures[0]); - - assert.equal(editedCollection?.posts.length, 1, 'Collection should have one post'); - assert.equal(editedCollection?.posts[0].id, postFixtures[0].id, 'Collection should have the correct post'); - }); - - it('Does not error when trying to add a post to a collection that does not exist', async function () { - const editedCollection = await collectionsService.addPostToCollection('fake id', postFixtures[0]); - assert(editedCollection === null); - }); - }); - - describe('latest collection', function () { - it('Includes all posts when fetched directly', async function () { - await collectionsService.createCollection({ - title: 'Latest', - slug: 'latest', - type: 'automatic', - filter: '' - }); - const collection = await collectionsService.getBySlug('latest'); - assert(collection?.posts.length === 4); - }); - }); - - describe('edit', function () { - it('Can edit existing collection', async function () { - const savedCollection = await collectionsService.createCollection({ - title: 'testing collections', - description: 'testing collections description', - type: 'manual' - }); - - const editedCollection = await collectionsService.edit({ - id: savedCollection.id, - title: 'Edited title', - description: 'Edited description', - feature_image: '/assets/images/edited.jpg', - slug: 'changed' - }); - - assert.equal(editedCollection?.title, 'Edited title', 'Collection title should be edited'); - assert.equal(editedCollection?.slug, 'changed', 'Collection slug should be edited'); - assert.equal(editedCollection?.description, 'Edited description', 'Collection description should be edited'); - assert.equal(editedCollection?.feature_image, '/assets/images/edited.jpg', 'Collection feature_image should be edited'); - assert.equal(editedCollection?.type, 'manual', 'Collection type should not be edited'); - }); - - it('Resolves to null when editing unexistend collection', async function () { - const editedCollection = await collectionsService.edit({ - id: '12345' - }); - - assert.equal(editedCollection, null, 'Collection should be null'); - }); - - it('Adds a Post to a Collection', async function () { - const collection = await collectionsService.createCollection({ - title: 'testing collections', - description: 'testing collections description', - type: 'manual' - }); - - const editedCollection = await collectionsService.edit({ - id: collection.id, - posts: [{ - id: postFixtures[0].id - }] - }); - - assert.equal(editedCollection?.posts.length, 1, 'Collection should have one post'); - assert.equal(editedCollection?.posts[0].id, postFixtures[0].id, 'Collection should have the correct post'); - assert.equal(editedCollection?.posts[0].sort_order, 0, 'Collection should have the correct post sort order'); - }); - - it('Removes a Post from a Collection', async function () { - const collection = await collectionsService.createCollection({ - title: 'testing collections', - description: 'testing collections description', - type: 'manual' - }); - - let editedCollection = await collectionsService.edit({ - id: collection.id, - posts: [{ - id: postFixtures[0].id - }, { - id: postFixtures[1].id - }] - }); - - assert.equal(editedCollection?.posts.length, 2, 'Collection should have two posts'); - - editedCollection = await collectionsService.removePostFromCollection(collection.id, postFixtures[0].id); - - assert.equal(editedCollection?.posts.length, 1, 'Collection should have one posts'); - }); - - it('Returns null when removing post from non existing collection', async function () { - const collection = await collectionsService.removePostFromCollection('i-do-not-exist', postFixtures[0].id); - - assert.equal(collection, null, 'Collection should be null'); - }); - }); - - describe('Automatic Collections', function () { - it('Can create an automatic collection', async function () { - const collection = await collectionsService.createCollection({ - title: 'I am automatic', - description: 'testing automatic collection', - type: 'automatic', - filter: 'featured:true' - }); - - assert.equal(collection.type, 'automatic', 'Collection should be automatic'); - assert.equal(collection.filter, 'featured:true', 'Collection should have the correct filter'); - - assert.equal(collection.posts.length, 2, 'Collection should have two posts'); - }); - - it('Updates the automatic collection posts when the filter is changed', async function () { - let collection = await collectionsService.createCollection({ - title: 'I am automatic', - description: 'testing automatic collection', - type: 'automatic', - filter: 'featured:true' - }); - - assert.equal(collection?.type, 'automatic', 'Collection should be automatic'); - assert.equal(collection?.posts.length, 2, 'Collection should have two featured post'); - assert.equal(collection?.posts[0].id, 'post-3-featured', 'Collection should have the correct post'); - assert.equal(collection?.posts[1].id, 'post-4-featured', 'Collection should have the correct post'); - - let updatedCollection = await collectionsService.edit({ - id: collection.id, - filter: 'featured:true+published_at:>2023-05-20' - }); - - assert.equal(updatedCollection?.posts.length, 1, 'Collection should have one post'); - assert.equal(updatedCollection?.posts[0].id, 'post-3-featured', 'Collection should have the correct post'); - }); - - describe('updateCollections', function () { - let automaticFeaturedCollection: any; - let automaticNonFeaturedCollection: any; - let manualCollection: any; - - beforeEach(async function () { - automaticFeaturedCollection = await collectionsService.createCollection({ - title: 'Featured Collection', - description: 'testing automatic collection', - type: 'automatic', - filter: 'featured:true' - }); - - automaticNonFeaturedCollection = await collectionsService.createCollection({ - title: 'Non-Featured Collection', - description: 'testing automatic collection', - type: 'automatic', - filter: 'featured:false' - }); - - manualCollection = await collectionsService.createCollection({ - title: 'Manual Collection', - description: 'testing manual collection', - type: 'manual' - }); - - await collectionsService.addPostToCollection(manualCollection.id, postFixtures[0]); - await collectionsService.addPostToCollection(manualCollection.id, postFixtures[1]); - }); - - afterEach(async function () { - await collectionsService.destroy(automaticFeaturedCollection.id); - await collectionsService.destroy(automaticNonFeaturedCollection.id); - await collectionsService.destroy(manualCollection.id); - }); - - it('Updates all automatic collections when a tag is deleted', async function () { - const collectionsRepository = new CollectionsRepositoryInMemory(); - postsRepository = await initPostsRepository([ - { - id: 'post-1', - url: 'http://localhost:2368/post-1/', - title: 'Post 1', - slug: 'post-1', - featured: false, - tags: [{slug: 'to-be-deleted'}, {slug: 'other-tag'}], - created_at: new Date('2023-03-15T07:19:07.447Z'), - updated_at: new Date('2023-03-15T07:19:07.447Z'), - published_at: new Date('2023-03-15T07:19:07.447Z') - }, { - id: 'post-2', - url: 'http://localhost:2368/post-2/', - title: 'Post 2', - slug: 'post-2', - featured: false, - tags: [{slug: 'to-be-deleted'}, {slug: 'other-tag'}], - created_at: new Date('2023-04-05T07:20:07.447Z'), - updated_at: new Date('2023-04-05T07:20:07.447Z'), - published_at: new Date('2023-04-05T07:20:07.447Z') - } - ]); - - collectionsService = new CollectionsService({ - collectionsRepository, - postsRepository, - DomainEvents, - slugService: { - async generate(input) { - return input.replace(/\s+/g, '-').toLowerCase(); - } - } - }); - - const automaticCollectionWithTag = await collectionsService.createCollection({ - title: 'Automatic Collection with Tag', - description: 'testing automatic collection with tag', - type: 'automatic', - filter: 'tags:to-be-deleted' - }); - - const automaticCollectionWithoutTag = await collectionsService.createCollection({ - title: 'Automatic Collection without Tag', - description: 'testing automatic collection without tag', - type: 'automatic', - filter: 'tags:other-tag' - }); - - assert.equal((await collectionsService.getById(automaticCollectionWithTag.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(automaticCollectionWithoutTag.id))?.posts.length, 2); - - collectionsService.subscribeToEvents(); - const tagDeletedEvent = TagDeletedEvent.create({ - id: 'to-be-deleted' - }); - - const posts = await postsRepository.getAll(); - - for (const post of posts) { - post.tags = post.tags.filter(tag => tag.slug !== 'to-be-deleted'); - await postsRepository.save(post); - } - - DomainEvents.dispatch(tagDeletedEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticCollectionWithTag.id))?.posts.length, 0); - assert.equal((await collectionsService.getById(automaticCollectionWithoutTag.id))?.posts.length, 2); - }); - - it('Updates all collections when post tags are added in bulk', async function () { - const collectionsRepository = new CollectionsRepositoryInMemory(); - postsRepository = await initPostsRepository([ - { - id: 'post-1', - url: 'http://localhost:2368/post-1/', - title: 'Post 1', - slug: 'post-1', - featured: false, - tags: [{slug: 'existing-tag'}], - created_at: new Date('2023-03-15T07:19:07.447Z'), - updated_at: new Date('2023-03-15T07:19:07.447Z'), - published_at: new Date('2023-03-15T07:19:07.447Z') - }, { - id: 'post-2', - url: 'http://localhost:2368/post-2/', - title: 'Post 2', - slug: 'post-2', - featured: false, - tags: [], - created_at: new Date('2023-04-05T07:20:07.447Z'), - updated_at: new Date('2023-04-05T07:20:07.447Z'), - published_at: new Date('2023-04-05T07:20:07.447Z') - } - ]); - - collectionsService = new CollectionsService({ - collectionsRepository, - postsRepository, - DomainEvents, - slugService: { - async generate(input) { - return input.replace(/\s+/g, '-').toLowerCase(); - } - } - }); - - const automaticCollectionWithExistingTag = await collectionsService.createCollection({ - title: 'Automatic Collection with Tag', - description: 'testing automatic collection with tag', - type: 'automatic', - filter: 'tags:existing-tag' - }); - - const automaticCollectionWithBulkAddedTag = await collectionsService.createCollection({ - title: 'Automatic Collection without Tag', - description: 'testing automatic collection without tag', - type: 'automatic', - filter: 'tags:to-be-created' - }); - - assert.equal((await collectionsService.getById(automaticCollectionWithExistingTag.id))?.posts.length, 1); - assert.equal((await collectionsService.getById(automaticCollectionWithBulkAddedTag.id))?.posts.length, 0); - - collectionsService.subscribeToEvents(); - - const posts = await postsRepository.getAll(); - - for (const post of posts) { - post.tags.push({slug: 'to-be-created'}); - await postsRepository.save(post); - } - - const postsBulkAddTagsEvent = PostsBulkAddTagsEvent.create([ - 'post-1', - 'post-2' - ]); - - DomainEvents.dispatch(postsBulkAddTagsEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticCollectionWithExistingTag.id))?.posts.length, 1); - assert.equal((await collectionsService.getById(automaticCollectionWithBulkAddedTag.id))?.posts.length, 2); - }); - - it('Updates all collections when post is deleted', async function () { - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - - await collectionsService.removePostFromAllCollections(postFixtures[0].id); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 1); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 1); - }); - - it('Updates all collections when posts are deleted in bulk', async function () { - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - - const deletedPostIds = [ - postFixtures[0].id, - postFixtures[1].id - ]; - await collectionsService.removePostsFromAllCollections(deletedPostIds); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 0); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 0); - }); - - it('Updates collections with publish filter when PostsBulkUnpublishedEvent event is produced', async function () { - const publishedPostsCollection = await collectionsService.createCollection({ - title: 'Published Posts', - slug: 'published-posts', - type: 'automatic', - filter: 'published_at:>=2023-05-00T00:00:00.000Z' - }); - - assert.equal((await collectionsService.getById(publishedPostsCollection.id))?.posts.length, 2, 'Only two post fixtures are published on the 5th month of 2023'); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - - collectionsService.subscribeToEvents(); - - await postsRepository.save(Object.assign(postFixtures[2], { - published_at: null - })); - const postsBulkUnpublishedEvent = PostsBulkUnpublishedEvent.create([ - postFixtures[2].id - ]); - - DomainEvents.dispatch(postsBulkUnpublishedEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(publishedPostsCollection.id))?.posts.length, 1, 'Only one post left as published on the 5th month of 2023'); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts.length, 2, 'There should be no change to the featured filter collection'); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2, 'There should be no change to the non-featured filter collection'); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2, 'There should be no change to the manual collection'); - }); - - it('Updates collections with publish filter when PostsBulkFeaturedEvent/PostsBulkUnfeaturedEvent events are produced', async function () { - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - - collectionsService.subscribeToEvents(); - - const featuredPost = await postsRepository.getById(postFixtures[0].id); - if (featuredPost) { - featuredPost.featured = true; - } - - await postsRepository.save(featuredPost as CollectionPost & {deleted: false}); - - const postsBulkFeaturedEvent = PostsBulkFeaturedEvent.create([ - postFixtures[0].id - ]); - - DomainEvents.dispatch(postsBulkFeaturedEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts.length, 3, 'There should be one extra post in the featured filter collection'); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 1, 'There should be one less posts in the non-featured filter collection'); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2, 'There should be no change to the manual collection'); - - const unFeaturedPost2 = await postsRepository.getById(postFixtures[2].id); - if (unFeaturedPost2) { - unFeaturedPost2.featured = false; - } - await postsRepository.save(unFeaturedPost2 as CollectionPost & {deleted: false}); - - const unFeaturedPost3 = await postsRepository.getById(postFixtures[3].id); - if (unFeaturedPost3) { - unFeaturedPost3.featured = false; - } - await postsRepository.save(unFeaturedPost3 as CollectionPost & {deleted: false}); - - const postsBulkUnfeaturedEvent = PostsBulkUnfeaturedEvent.create([ - postFixtures[2].id, - postFixtures[3].id - ]); - - DomainEvents.dispatch(postsBulkUnfeaturedEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts.length, 1, 'There should be two less posts in the featured filter collection'); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 3, 'There should be two extra posts in the non-featured filter collection'); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2, 'There should be no change to the manual collection'); - }); - - it('Updates only index collection when a non-featured post is added', async function () { - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - - collectionsService.subscribeToEvents(); - const postAddedEvent = PostAddedEvent.create({ - id: 'non-featured-post', - featured: false - }); - - DomainEvents.dispatch(postAddedEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 3); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - }); - - it('Moves post from featured to non featured collection when the featured attribute is changed', async function () { - collectionsService.subscribeToEvents(); - const newFeaturedPost: CollectionPost & {deleted: false} = { - id: 'post-featured', - featured: false, - published_at: new Date('2023-03-16T07:19:07.447Z'), - tags: [], - deleted: false - }; - await postsRepository.save(newFeaturedPost); - const updateCollectionEvent = PostEditedEvent.create({ - id: newFeaturedPost.id, - current: { - id: newFeaturedPost.id, - featured: false - }, - previous: { - id: newFeaturedPost.id, - featured: true - } - }); - - DomainEvents.dispatch(updateCollectionEvent); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 3); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - - // change featured back to true - const updateCollectionEventBackToFeatured = PostEditedEvent.create({ - id: newFeaturedPost.id, - current: { - id: newFeaturedPost.id, - featured: true - }, - previous: { - id: newFeaturedPost.id, - featured: false - } - }); - - DomainEvents.dispatch(updateCollectionEventBackToFeatured); - await DomainEvents.allSettled(); - - assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 3); - assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); - assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); - }); - - it('Does nothing when the PostEditedEvent contains no relevant changes', async function () { - collectionsService.subscribeToEvents(); - const updatePostInMatchingCollectionsSpy = sinon.spy(collectionsService, 'updatePostInMatchingCollections'); - const postEditEvent = PostEditedEvent.create({ - id: 'something', - current: { - id: 'unique-post-id', - status: 'scheduled', - featured: true, - tags: ['they', 'do', 'not', 'change'] - }, - previous: { - id: 'unique-post-id', - status: 'published', - featured: true, - tags: ['they', 'do', 'not', 'change'] - } - }); - - DomainEvents.dispatch(postEditEvent); - await DomainEvents.allSettled(); - - assert.equal(updatePostInMatchingCollectionsSpy.callCount, 0, 'updatePostInMatchingCollections method should not have been called'); - }); - }); - }); -}); diff --git a/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts b/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts deleted file mode 100644 index 06936390a55..00000000000 --- a/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {InMemoryRepository} from '@tryghost/in-memory-repository'; -import {CollectionPost} from '../../src/CollectionPost'; - -export class PostsRepositoryInMemory extends InMemoryRepository { - protected toPrimitive(entity: CollectionPost): object { - return { - id: entity.id, - featured: entity.featured, - published_at: entity.published_at, - tags: entity.tags.map(tag => tag.slug) - }; - } - - async getAllIds() { - const posts = await this.getAll(); - return posts.map(post => post.id); - } -} diff --git a/ghost/collections/test/fixtures/posts.ts b/ghost/collections/test/fixtures/posts.ts deleted file mode 100644 index 1a0563f5e5f..00000000000 --- a/ghost/collections/test/fixtures/posts.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const posts = [{ - id: 'post-1', - url: 'http://localhost:2368/post-1/', - title: 'Post 1', - slug: 'post-1', - tags: [], - featured: false, - created_at: new Date('2023-03-15T07:19:07.447Z'), - updated_at: new Date('2023-03-15T07:19:07.447Z'), - published_at: new Date('2023-03-15T07:19:07.447Z') -}, { - id: 'post-2', - url: 'http://localhost:2368/post-2/', - title: 'Post 2', - slug: 'post-2', - tags: [], - featured: false, - created_at: new Date('2023-04-05T07:20:07.447Z'), - updated_at: new Date('2023-04-05T07:20:07.447Z'), - published_at: new Date('2023-04-05T07:20:07.447Z') -}, { - id: 'post-3-featured', - url: 'http://localhost:2368/featured-post-3/', - title: 'Featured Post 3', - slug: 'featured-post-3', - tags: [], - featured: true, - created_at: new Date('2023-05-25T07:21:07.447Z'), - updated_at: new Date('2023-05-25T07:21:07.447Z'), - published_at: new Date('2023-05-25T07:21:07.447Z') -}, { - id: 'post-4-featured', - url: 'http://localhost:2368/featured-post-4/', - title: 'Featured Post 4', - slug: 'featured-post-4', - tags: [], - featured: true, - created_at: new Date('2023-05-15T07:21:07.447Z'), - updated_at: new Date('2023-05-15T07:21:07.447Z'), - published_at: new Date('2023-05-15T07:21:07.447Z') -}]; diff --git a/ghost/collections/tsconfig.json b/ghost/collections/tsconfig.json deleted file mode 100644 index 7f7ed386648..00000000000 --- a/ghost/collections/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": [ - "src/**/*" - ], - "compilerOptions": { - "outDir": "build" - } -} diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index e0c18800feb..e5ce1b21b00 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -336,8 +336,6 @@ async function initServices() { const postsPublic = require('./server/services/posts-public'); const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); - const collections = require('./server/services/collections'); - const modelToDomainEventInterceptor = require('./server/services/model-to-domain-event-interceptor'); const mailEvents = require('./server/services/mail-events'); const donationService = require('./server/services/donations'); const recommendationsService = require('./server/services/recommendations'); @@ -383,8 +381,6 @@ async function initServices() { linkTracking.init(), emailSuppressionList.init(), slackNotifications.init(), - collections.init(), - modelToDomainEventInterceptor.init(), mediaInliner.init(), mailEvents.init(), donationService.init(), diff --git a/ghost/core/core/server/api/endpoints/collections-public.js b/ghost/core/core/server/api/endpoints/collections-public.js deleted file mode 100644 index b04d7a8c11c..00000000000 --- a/ghost/core/core/server/api/endpoints/collections-public.js +++ /dev/null @@ -1,56 +0,0 @@ -const errors = require('@tryghost/errors'); -const tpl = require('@tryghost/tpl'); -const collectionsService = require('../../services/collections'); - -const messages = { - collectionNotFound: 'Collection not found.' -}; - -/** @type {import('@tryghost/api-framework').Controller} */ -const controller = { - docName: 'collections', - - readBySlug: { - headers: { - cacheInvalidate: false - }, - data: [ - 'slug' - ], - permissions: true, - async query(frame) { - const model = await collectionsService.api.getBySlug(frame.data.slug); - - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.collectionNotFound) - }); - } - - return model; - } - }, - - readById: { - headers: { - cacheInvalidate: false - }, - data: [ - 'id' - ], - permissions: true, - async query(frame) { - const model = await collectionsService.api.getById(frame.data.id); - - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.collectionNotFound) - }); - } - - return model; - } - } -}; - -module.exports = controller; diff --git a/ghost/core/core/server/api/endpoints/collections.js b/ghost/core/core/server/api/endpoints/collections.js deleted file mode 100644 index 5c1ea7c5b1d..00000000000 --- a/ghost/core/core/server/api/endpoints/collections.js +++ /dev/null @@ -1,141 +0,0 @@ -const errors = require('@tryghost/errors'); -const tpl = require('@tryghost/tpl'); -const collectionsService = require('../../services/collections'); - -const messages = { - collectionNotFound: 'Collection not found.' -}; - -/** @type {import('@tryghost/api-framework').Controller} */ -const controller = { - docName: 'collections', - - browse: { - headers: { - cacheInvalidate: false - }, - options: [ - 'limit', - 'order', - 'page', - 'filter', - 'include' - ], - validation: { - options: { - include: { - values: ['count.posts'] - } - } - }, - permissions: true, - query(frame) { - return collectionsService.api.getAll({ - filter: frame.options.filter, - limit: frame.options.limit, - page: frame.options.page - }); - } - }, - - read: { - headers: { - cacheInvalidate: false - }, - options: [ - 'include' - ], - data: [ - 'id', - 'slug' - ], - validation: { - options: { - include: { - values: ['count.posts'] - } - } - }, - permissions: true, - async query(frame) { - let model; - if (frame.data.id) { - model = await collectionsService.api.getById(frame.data.id); - } else { - model = await collectionsService.api.getBySlug(frame.data.slug); - } - - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.collectionNotFound) - }); - } - - return model; - } - }, - - add: { - statusCode: 201, - headers: { - cacheInvalidate: true - }, - permissions: true, - async query(frame) { - return await collectionsService.api.createCollection(frame.data.collections[0]); - } - }, - - edit: { - headers: { - cacheInvalidate: true - }, - options: [ - 'id' - ], - validation: { - options: { - id: { - required: true - } - } - }, - permissions: true, - async query(frame) { - const model = await collectionsService.api.edit(Object.assign(frame.data.collections[0], { - id: frame.options.id - })); - - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.collectionNotFound) - }); - } - - return model; - } - }, - - destroy: { - statusCode: 204, - headers: { - cacheInvalidate: true - }, - options: [ - 'id' - ], - validation: { - options: { - id: { - required: true - } - } - }, - permissions: true, - async query(frame) { - return await collectionsService.api.destroy(frame.options.id); - } - } -}; - -module.exports = controller; diff --git a/ghost/core/core/server/api/endpoints/index.js b/ghost/core/core/server/api/endpoints/index.js index ad106d96bf4..5001841dd85 100644 --- a/ghost/core/core/server/api/endpoints/index.js +++ b/ghost/core/core/server/api/endpoints/index.js @@ -12,10 +12,6 @@ module.exports = { return apiFramework.pipeline(require('./authentication'), localUtils); }, - get collections() { - return apiFramework.pipeline(require('./collections'), localUtils); - }, - get db() { return apiFramework.pipeline(require('./db'), localUtils); }, @@ -229,10 +225,6 @@ module.exports = { return apiFramework.pipeline(require('./pages-public'), localUtils, 'content'); }, - get collectionsPublic() { - return apiFramework.pipeline(require('./collections-public'), localUtils); - }, - get tagsPublic() { return apiFramework.pipeline(require('./tags-public'), localUtils, 'content'); }, diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/collection-posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/collection-posts.js deleted file mode 100644 index ed1b101eaa9..00000000000 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/collection-posts.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * - * @param {import('@tryghost/collections').CollectionPostListItemDTO[]} collectionPosts[] - * - * @returns {SerializedCollectionPost} - */ -const mapper = (collectionPost) => { - return collectionPost; -}; - -/** - * @typedef {Object} SerializedCollectionPost - * @prop {string} id - * @prop {string} title - * @prop {string} slug - * @prop {string} feature_image - */ - -module.exports = mapper; diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/collections.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/collections.js deleted file mode 100644 index 7d80638308b..00000000000 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/collections.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * - * @param {import('@tryghost/collections').Collection | object} collection - * - * @returns {SerializedCollection} - */ -const mapper = (collection, frame) => { - let json; - if (collection.toJSON) { - json = collection.toJSON(); - } else { - json = collection; - } - - const serialized = { - id: json.id, - title: json.title || null, - slug: json.slug, - description: json.description || null, - type: json.type, - filter: json.filter, - feature_image: json.feature_image || json.featureImage || null, - created_at: (json.created_at || json.createdAt).toISOString().replace(/\d{3}Z$/, '000Z'), - updated_at: (json.updated_at || json.updatedAt).toISOString().replace(/\d{3}Z$/, '000Z') - }; - - if (frame?.options?.withRelated?.includes('count.posts')) { - serialized.count = { - posts: json.posts.length - }; - } - - return serialized; -}; - -module.exports = mapper; - -/** - * @typedef {Object} SerializedCollection - * @prop {string} id - * @prop {string} title - * @prop {string} slug - * @prop {string} description - * @prop {string} type - * @prop {string} filter - * @prop {string} feature_image - * @prop {string} created_at - * @prop {string} updated_at - */ diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js index 8ccf99fd223..98902c8fce1 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js @@ -3,7 +3,6 @@ module.exports = { activityFeedEvents: require('./activity-feed-events'), authors: require('./authors'), comments: require('./comments'), - collections: require('./collections'), emails: require('./emails'), emailBatches: require('./email-batches'), emailFailures: require('./email-failures'), diff --git a/ghost/core/core/server/services/collections/BookshelfCollectionsRepository.js b/ghost/core/core/server/services/collections/BookshelfCollectionsRepository.js deleted file mode 100644 index 452d4e350a9..00000000000 --- a/ghost/core/core/server/services/collections/BookshelfCollectionsRepository.js +++ /dev/null @@ -1,249 +0,0 @@ -const logger = require('@tryghost/logging'); -const Collection = require('@tryghost/collections').Collection; -const sentry = require('../../../shared/sentry'); -const {default: ObjectID} = require('bson-objectid'); -/** - * @typedef {import('@tryghost/collections/src/CollectionRepository')} CollectionRepository - */ - -/** - * @implements {CollectionRepository} - */ -module.exports = class BookshelfCollectionsRepository { - #model; - #relationModel; - /** @type {import('@tryghost/domain-events')} */ - #DomainEvents; - constructor(model, relationModel, DomainEvents) { - this.#model = model; - this.#relationModel = relationModel; - this.#DomainEvents = DomainEvents; - } - - async createTransaction(cb) { - return this.#model.transaction(cb); - } - - /** - * @param {string} id - * @returns {Promise} - */ - async getById(id, options = {}) { - const model = await this.#model.findOne({id}, { - require: false, - transacting: options.transaction - }); - if (!model) { - return null; - } - - model.collectionPostIds = await this.#fetchCollectionPostIds(model.id, options); - - return this.#modelToCollection(model); - } - - /** - * @param {string} slug - * @returns {Promise} - */ - async getBySlug(slug, options = {}) { - const model = await this.#model.findOne({slug}, { - require: false, - transacting: options.transaction - }); - - if (!model) { - return null; - } - - model.collectionPostIds = await this.#fetchCollectionPostIds(model.id, options); - - return this.#modelToCollection(model); - } - - /** - * NOTE: we are only fetching post_id column here to save memory on - * instances with a large amount of posts - * - * The method could be further optimized to fetch posts for - * multiple collections at a time. - * - * @param {string} collectionId collection to fetch post ids for - * @param {Object} options bookshelf options - * - * @returns {Promise>} - */ - async #fetchCollectionPostIds(collectionId, options = {}) { - const toSelect = options.columns || ['post_id']; - - const query = this.#relationModel.query(); - if (options.transaction) { - query.transacting(options.transaction); - } - - return await query - .select(toSelect) - .where('collection_id', collectionId); - } - - /** - * @param {object} [options] - * @param {string} [options.filter] - * @param {string} [options.order] - * @param {string[]} [options.withRelated] - * @param {import('knex').Transaction} [options.transaction] - */ - async getAll(options = {}) { - const models = await this.#model.findAll({ - ...options, - transacting: options.transaction - }); - - for (const model of models) { - // NOTE: Ideally collection posts would be fetching as a part of findAll query. - // Because bookshelf introduced a massive processing and memory overhead - // we are fetching collection post ids separately using raw knex query - model.collectionPostIds = await this.#fetchCollectionPostIds(model.id, options); - } - - return (await Promise.all(models.map(model => this.#modelToCollection(model)))).filter(entity => !!entity); - } - - async #modelToCollection(model) { - const json = model.toJSON(); - let filter = json.filter; - - if (json.type === 'automatic' && typeof filter !== 'string') { - filter = ''; - } - - try { - // NOTE: collectionPosts are not a part of serialized model - // and are fetched separately to save memory - const posts = model.collectionPostIds; - - return await Collection.create({ - id: json.id, - slug: json.slug, - title: json.title, - description: json.description, - filter: filter, - type: json.type, - featureImage: json.feature_image, - posts: posts.map(collectionPost => collectionPost.post_id), - createdAt: json.created_at, - updatedAt: json.updated_at - }); - } catch (err) { - logger.error(err); - sentry.captureException(err); - return null; - } - } - - /** - * @param {Collection} collection - * @param {object} [options] - * @param {import('knex').Transaction} [options.transaction] - * @returns {Promise} - */ - async save(collection, options = {}) { - if (!options.transaction) { - return this.createTransaction((transaction) => { - return this.save(collection, { - ...options, - transaction - }); - }); - } - - if (collection.deleted) { - await this.#relationModel.query().delete().where('collection_id', collection.id).transacting(options.transaction); - await this.#model.query().delete().where('id', collection.id).transacting(options.transaction); - return; - } - - const data = { - id: collection.id, - slug: collection.slug, - title: collection.title, - description: collection.description, - filter: collection.filter, - type: collection.type, - feature_image: collection.featureImage || null, - created_at: collection.createdAt, - updated_at: collection.updatedAt - }; - - const existing = await this.#model.findOne( - {id: data.id}, - { - require: false, - transacting: options.transaction - } - ); - - if (!existing) { - await this.#model.add(data, { - transacting: options.transaction - }); - const collectionPostsRelations = collection.posts.map((postId, index) => { - return { - id: (new ObjectID).toHexString(), - sort_order: collection.type === 'manual' ? index : 0, - collection_id: collection.id, - post_id: postId - }; - }); - if (collectionPostsRelations.length > 0) { - await this.#relationModel.query().insert(collectionPostsRelations).transacting(options.transaction); - } - } else { - await this.#model.edit(data, { - id: data.id, - transacting: options.transaction - }); - - if (collection.type === 'manual') { - const collectionPostsRelations = collection.posts.map((postId, index) => { - return { - id: (new ObjectID).toHexString(), - sort_order: index, - collection_id: collection.id, - post_id: postId - }; - }); - await this.#relationModel.query().delete().where('collection_id', collection.id).transacting(options.transaction); - if (collectionPostsRelations.length > 0) { - await this.#relationModel.query().insert(collectionPostsRelations).transacting(options.transaction); - } - } else { - const collectionPostsToDelete = collection.events.filter(event => event.type === 'CollectionPostRemoved').map((event) => { - return event.data.post_id; - }); - - const collectionPostsToInsert = collection.events.filter(event => event.type === 'CollectionPostAdded').map((event) => { - return { - id: (new ObjectID).toHexString(), - sort_order: 0, - collection_id: collection.id, - post_id: event.data.post_id - }; - }); - - if (collectionPostsToDelete.length > 0) { - await this.#relationModel.query().delete().where('collection_id', collection.id).whereIn('post_id', collectionPostsToDelete).transacting(options.transaction); - } - if (collectionPostsToInsert.length > 0) { - await this.#relationModel.query().insert(collectionPostsToInsert).transacting(options.transaction); - } - } - - options.transaction.executionPromise.then(() => { - for (const event of collection.events) { - this.#DomainEvents.dispatch(event); - } - }); - } - } -}; diff --git a/ghost/core/core/server/services/collections/PostsRepository.js b/ghost/core/core/server/services/collections/PostsRepository.js deleted file mode 100644 index 6f7e9382716..00000000000 --- a/ghost/core/core/server/services/collections/PostsRepository.js +++ /dev/null @@ -1,48 +0,0 @@ -class PostsRepository { - constructor({models, moment}) { - this.models = models; - this.moment = moment; - } - - /** - * @param {Object} options - * @returns Promise - */ - async getAllIds({transaction} = {}) { - const query = this.models.Post.query().select('id').where('type', 'post'); - const rows = transaction ? await query.transacting(transaction) : await query; - - return rows.map(row => row.id); - } - async getAll({filter, transaction}) { - const {data: models} = await this.models.Post.findPage({ - filter: `(${filter})+type:post`, - transacting: transaction, - limit: 'all', - status: 'all', - withRelated: ['tags'] - }); - - const json = models.map(m => m.toJSON()); - - return json.map((postJson) => { - return { - id: postJson.id, - featured: postJson.featured, - published_at: this.moment(postJson.published_at).toISOString(true), - tags: postJson.tags.map(tag => ({ - slug: tag.slug - })) - }; - }); - } -} - -module.exports = PostsRepository; - -module.exports.getInstance = () => { - const moment = require('moment-timezone'); - const models = require('../../models'); - - return new PostsRepository({models, moment}); -}; diff --git a/ghost/core/core/server/services/collections/index.js b/ghost/core/core/server/services/collections/index.js deleted file mode 100644 index 102ef66d4fe..00000000000 --- a/ghost/core/core/server/services/collections/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('./service'); diff --git a/ghost/core/core/server/services/collections/service.js b/ghost/core/core/server/services/collections/service.js deleted file mode 100644 index 20641a78d88..00000000000 --- a/ghost/core/core/server/services/collections/service.js +++ /dev/null @@ -1,53 +0,0 @@ -const { - CollectionsService -} = require('@tryghost/collections'); -const BookshelfCollectionsRepository = require('./BookshelfCollectionsRepository'); - -let inited = false; -class CollectionsServiceWrapper { - /** @type {CollectionsService} */ - api; - - constructor() { - const DomainEvents = require('@tryghost/domain-events'); - const postsRepository = require('./PostsRepository').getInstance(); - const models = require('../../models'); - const collectionsRepositoryInMemory = new BookshelfCollectionsRepository(models.Collection, models.CollectionPost, DomainEvents); - - const collectionsService = new CollectionsService({ - collectionsRepository: collectionsRepositoryInMemory, - postsRepository: postsRepository, - DomainEvents: DomainEvents, - slugService: { - async generate(input, options) { - return models.Collection.generateSlug(models.Collection, input, { - transacting: options.transaction - }); - } - } - }); - - this.api = collectionsService; - } - - async init() { - const config = require('../../../shared/config'); - const labs = require('../../../shared/labs'); - - // CASE: emergency kill switch in case we need to disable collections outside of labs - if (config.get('hostSettings:collections:enabled') === false) { - return; - } - - if (labs.isSet('collections')) { - if (inited) { - return; - } - - inited = true; - this.api.subscribeToEvents(); - } - } -} - -module.exports = new CollectionsServiceWrapper(); diff --git a/ghost/core/core/server/services/model-to-domain-event-interceptor/index.js b/ghost/core/core/server/services/model-to-domain-event-interceptor/index.js deleted file mode 100644 index e40077ecedc..00000000000 --- a/ghost/core/core/server/services/model-to-domain-event-interceptor/index.js +++ /dev/null @@ -1,18 +0,0 @@ -let inited = false; - -module.exports.init = async () => { - if (inited) { - return; - } - inited = true; - - const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents'); - const {ModelToDomainEventInterceptor} = require('@tryghost/model-to-domain-event-interceptor'); - const events = require('../../lib/common/events'); - const eventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: events, - DomainEvents: DomainEvents - }); - - eventInterceptor.init(); -}; diff --git a/ghost/core/core/server/services/posts/posts-service.js b/ghost/core/core/server/services/posts/posts-service.js index 0824941ff99..0dc40e4a3f0 100644 --- a/ghost/core/core/server/services/posts/posts-service.js +++ b/ghost/core/core/server/services/posts/posts-service.js @@ -12,7 +12,6 @@ const getPostServiceInstance = () => { const emailService = require('../email-service'); const settingsCache = require('../../../shared/settings-cache'); const settingsHelpers = require('../settings-helpers'); - const collectionsService = require('../collections'); const postStats = new PostStats(); @@ -38,8 +37,7 @@ const getPostServiceInstance = () => { isSet: flag => labs.isSet(flag), // don't use bind, that breaks test subbing of labs stats: postStats, emailService: emailService.service, - postsExporter, - collectionsService: collectionsService.api + postsExporter }); }; diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index de241ce0365..090616e73ca 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -5,7 +5,6 @@ const apiMw = require('../../middleware'); const mw = require('./middleware'); const shared = require('../../../shared'); -const labs = require('../../../../../shared/labs'); /** * @returns {import('express').Router} @@ -22,14 +21,6 @@ module.exports = function apiRoutes() { router.get('/site', mw.publicAdminApi, http(api.site.read)); router.post('/mail_events', mw.publicAdminApi, http(api.mailEvents.add)); - // ## Collections - router.get('/collections', mw.authAdminApi, http(api.collections.browse)); - router.get('/collections/:id', mw.authAdminApi, http(api.collections.read)); - router.get('/collections/slug/:slug', mw.authAdminApi, http(api.collections.read)); - router.post('/collections', mw.authAdminApi, labs.enabledMiddleware('collections'), http(api.collections.add)); - router.put('/collections/:id', mw.authAdminApi, labs.enabledMiddleware('collections'), http(api.collections.edit)); - router.del('/collections/:id', mw.authAdminApi, labs.enabledMiddleware('collections'), http(api.collections.destroy)); - // ## Configuration router.get('/config', mw.authAdminApi, http(api.config.read)); diff --git a/ghost/core/core/server/web/api/endpoints/content/routes.js b/ghost/core/core/server/web/api/endpoints/content/routes.js index 1db03d362a3..b76676fcb37 100644 --- a/ghost/core/core/server/web/api/endpoints/content/routes.js +++ b/ghost/core/core/server/web/api/endpoints/content/routes.js @@ -41,9 +41,6 @@ module.exports = function apiRoutes() { router.get('/tiers', mw.authenticatePublic, http(api.tiersPublic.browse)); router.get('/offers/:id', mw.authenticatePublic, http(api.offersPublic.read)); - router.get('/collections/:id', mw.authenticatePublic, http(api.collectionsPublic.readById)); - router.get('/collections/slug/:slug', mw.authenticatePublic, http(api.collectionsPublic.readBySlug)); - // ## Recommendations router.get('/recommendations', mw.authenticatePublic, http(api.recommendationsPublic.browse)); diff --git a/ghost/core/package.json b/ghost/core/package.json index ee117d58715..851a822499f 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -71,7 +71,6 @@ "@tryghost/audience-feedback": "0.0.0", "@tryghost/bookshelf-plugins": "0.6.25", "@tryghost/bootstrap-socket": "0.0.0", - "@tryghost/collections": "0.0.0", "@tryghost/color-utils": "0.2.2", "@tryghost/config-url-helpers": "1.0.12", "@tryghost/constants": "0.0.0", @@ -131,7 +130,6 @@ "@tryghost/metrics": "1.0.34", "@tryghost/milestones": "0.0.0", "@tryghost/minifier": "0.0.0", - "@tryghost/model-to-domain-event-interceptor": "0.0.0", "@tryghost/mw-api-version-mismatch": "0.0.0", "@tryghost/mw-cache-control": "0.0.0", "@tryghost/mw-error-handler": "0.0.0", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap deleted file mode 100644 index 8c24463095a..00000000000 --- a/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap +++ /dev/null @@ -1,2173 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Collections API Add Can add a Collection 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Test Collection Description", - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection", - "title": "Test Collection", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Add Can add a Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "277", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a featured filter 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Test Collection Description", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured-filter", - "title": "Test Featured Collection", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a featured filter 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "300", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a featured filter 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": " * Lorem - * Aliquam - * Tortor - * Morbi - * Praesent - * Pellentesque - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. - -1234abcdefghijkl - -Definition listConsectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim venia", - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": true, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.

1234
abcd
efgh
ijkl
Definition list
Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Lorem ipsum dolor sit amet
Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  • Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, euismod in, pharetra a, ultricies in, diam. Sed arcu. Cras consequat.
  • Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.
  • Phasellus ultrices nulla quis nibh. Quisque a lectus. Donec consectetuer ligula vulputate sem tristique cursus. Nam nulla quam, gravida non, commodo a, sodales sit amet, nisi.
  • Pellentesque fermentum dolor. Aliquam quam lectus, facilisis auctor, ultrices ut, elementum vulputate, nunc.

\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "not-so-short-bit-complex", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Not so short, bit complex", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "testing - - -mctesters - - - * test - * line - * items -", - "feature_image": "http://placekitten.com/500/200", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": true, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": "meta description for short and sweet", - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"## testing\\\\n\\\\nmctesters\\\\n\\\\n- test\\\\n- line\\\\n- items\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 0, - "slug": "short-and-sweet", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Short and Sweet", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a featured filter 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "12649", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Test Collection Description with published_at filter", - "feature_image": null, - "filter": "published_at:>=2022-05-25", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "published-at-filter", - "title": "Test Collection with published_at filter", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "357", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 9, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "Welcome to my invisible post!", - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

Welcome to my invisible post!

\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 0, - "slug": "scheduled-post", - "status": "scheduled", - "tags": Any, - "tiers": Any, - "title": "This is a scheduled post!!", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": "meta description for draft post", - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "unfinished", - "status": "draft", - "tags": Any, - "tiers": Any, - "title": "Not finished yet", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "We've crammed the most important information to help you get started with Ghost into this one post. It's your cheat-sheet to get started, and your shortcut to advanced features.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "We've crammed the most important information to help you get started with Ghost into this one post. It's your cheat-sheet to get started, and your shortcut to advanced features.", - "feature_image": "https://static.ghost.org/v4.0.0/images/welcome-to-ghost.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"hr\\",{}],[\\"hr\\",{}]],\\"markups\\":[[\\"strong\\"],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/design/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/write/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/portal/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/sell/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/grow/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/integrations/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/pricing/\\"]],[\\"em\\"],[\\"a\\",[\\"href\\",\\"https://forum.ghost.org\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[0],1,\\"Hey there\\"],[0,[],0,\\", welcome to your new home on the web! \\"]]],[1,\\"p\\",[[0,[],0,\\"Unlike social networks, this one is all yours. Publish your work on a custom domain, invite your audience to subscribe, send them new content by email newsletter, and offer premium subscriptions to generate sustainable recurring revenue to fund your work. \\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost is an independent, open source app, which means you can customize absolutely everything. Inside the admin area, you'll find straightforward controls for changing themes, colors, navigation, logos and settings — so you can set your site up just how you like it. No technical knowledge required.\\"]]],[1,\\"p\\",[[0,[],0,\\"If you're feeling a little more adventurous, there's really no limit to what's possible. With just a little bit of HTML and CSS you can modify or build your very own theme from scratch, or connect to Zapier to explore advanced integrations. Advanced developers can go even further and build entirely custom workflows using the Ghost API.\\"]]],[1,\\"p\\",[[0,[],0,\\"This level of customization means that Ghost grows with you. It's easy to get started, but there's always another level of what's possible. So, you won't find yourself outgrowing the app in a few months time and wishing you'd chosen something more powerful!\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"For now, you're probably just wondering what to do first. To help get you going as quickly as possible, we've populated your site with starter content (like this post!) covering all the key concepts and features of the product.\\"]]],[1,\\"p\\",[[0,[],0,\\"You'll find an outline of all the different topics below, with links to each section so you can explore the parts that interest you most.\\"]]],[1,\\"p\\",[[0,[],0,\\"Once you're ready to begin publishing and want to clear out these starter posts, you can delete the \\\\\\"Ghost\\\\\\" staff user. Deleting an author will automatically remove all of their posts, leaving you with a clean blank canvas.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Your guide to Ghost\\"]]],[3,\\"ul\\",[[[0,[1],1,\\"Customizing your brand and site settings\\"]],[[0,[2],1,\\"Writing & managing content, an advanced guide for creators\\"]],[[0,[3],1,\\"Building your audience with subscriber signups\\"]],[[0,[4],1,\\"Selling premium memberships with recurring revenue\\"]],[[0,[5],1,\\"How to grow your business around an audience\\"]],[[0,[6],1,\\"Setting up custom integrations and apps\\"]]]],[1,\\"p\\",[[0,[],0,\\"If you get through all those and you're hungry for more, you can find an extensive library of content for creators over on \\"],[0,[7],1,\\"the Ghost blog\\"],[0,[],0,\\".\\"]]],[10,1],[1,\\"h2\\",[[0,[],0,\\"Getting help\\"]]],[1,\\"p\\",[[0,[],0,\\"If you need help, \\"],[0,[8],1,\\"Ghost(Pro)\\"],[0,[],0,\\" customers can always reach our full-time support team by clicking on the \\"],[0,[9],1,\\"Ghost(Pro)\\"],[0,[],0,\\" link inside their admin panel.\\"]]],[1,\\"p\\",[[0,[],0,\\"If you're a developer working with the codebase in a self-managed install, check out our \\"],[0,[10],1,\\"developer community forum\\"],[0,[],0,\\" to chat with other users.\\"]]],[1,\\"p\\",[[0,[],0,\\"Have fun!\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 2, - "slug": "welcome", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Start here for a quick overview of everything you need to know", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "How to tweak a few settings in Ghost to transform your site from a generic template to a custom brand with style and personality.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "How to tweak a few settings in Ghost to transform your site from a generic template to a custom brand with style and personality.", - "feature_image": "https://static.ghost.org/v4.0.0/images/publishing-options.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/brandsettings.png\\",\\"width\\":3456,\\"height\\":2338,\\"cardWidth\\":\\"wide\\",\\"caption\\":\\"Ghost Admin → Settings → Branding\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/themesettings.png\\",\\"width\\":3208,\\"height\\":1618,\\"cardWidth\\":\\"wide\\",\\"caption\\":\\"Ghost Admin → Settings → Theme\\"}],[\\"code\\",{\\"code\\":\\"{{#post}}\\\\n
\\\\n\\\\n

{{title}}

\\\\n \\\\n {{#if feature_image}}\\\\n \\\\t\\\\\\"Feature\\\\n {{/if}}\\\\n \\\\n {{content}}\\\\n\\\\n
\\\\n{{/post}}\\",\\"language\\":\\"handlebars\\",\\"caption\\":\\"A snippet from a post template\\"}]],\\"markups\\":[[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/welcome/\\"]],[\\"strong\\"],[\\"em\\"],[\\"a\\",[\\"href\\",\\"https://ghost.org/themes/\\"]],[\\"a\\",[\\"href\\",\\"https://github.com/tryghost/casper/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/docs/themes/\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"As discussed in the \\"],[0,[0],1,\\"introduction\\"],[0,[],0,\\" post, one of the best things about Ghost is just how much you can customize to turn your site into something unique. Everything about your layout and design can be changed, so you're not stuck with yet another clone of a social network profile.\\"]]],[1,\\"p\\",[[0,[],0,\\"How far you want to go with customization is completely up to you, there's no right or wrong approach! The majority of people use one of Ghost's built-in themes to get started, and then progress to something more bespoke later on as their site grows. \\"]]],[1,\\"p\\",[[0,[],0,\\"The best way to get started is with Ghost's branding settings, where you can set up colors, images and logos to fit with your brand.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"Any Ghost theme that's up to date and compatible with Ghost 4.0 and higher will reflect your branding settings in the preview window, so you can see what your site will look like as you experiment with different options.\\"]]],[1,\\"p\\",[[0,[],0,\\"When selecting an accent color, try to choose something which will contrast well with white text. Many themes will use your accent color as the background for buttons, headers and navigational elements. Vibrant colors with a darker hue tend to work best, as a general rule.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Installing Ghost themes\\"]]],[1,\\"p\\",[[0,[],0,\\"By default, new sites are created with Ghost's friendly publication theme, called Casper. Everything in Casper is optimized to work for the most common types of blog, newsletter and publication that people create with Ghost — so it's a perfect place to start.\\"]]],[1,\\"p\\",[[0,[],0,\\"However, there are hundreds of different themes available to install, so you can pick out a look and feel that suits you best.\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"Inside Ghost's theme settings you'll find 4 more official themes that can be directly installed and activated. Each theme is suited to slightly different use-cases.\\"]]],[3,\\"ul\\",[[[0,[1],1,\\"Casper\\"],[0,[],0,\\" \\"],[0,[2],1,\\"(default)\\"],[0,[],0,\\" — Made for all sorts of blogs and newsletters\\"]],[[0,[1],1,\\"Edition\\"],[0,[],0,\\" — A beautiful minimal template for newsletter authors\\"]],[[0,[1],1,\\"Alto\\"],[0,[],0,\\" — A slick news/magazine style design for creators\\"]],[[0,[1],1,\\"London\\"],[0,[],0,\\" — A light photography theme with a bold grid\\"]],[[0,[1],1,\\"Ease\\"],[0,[],0,\\" — A library theme for organizing large content archives\\"]]]],[1,\\"p\\",[[0,[],0,\\"And if none of those feel quite right, head on over to the \\"],[0,[3],1,\\"Ghost Marketplace\\"],[0,[],0,\\", where you'll find a huge variety of both free and premium themes.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Building something custom\\"]]],[1,\\"p\\",[[0,[],0,\\"Finally, if you want something completely bespoke for your site, you can always build a custom theme from scratch and upload it to your site.\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost's theming template files are very easy to work with, and can be picked up in the space of a few hours by anyone who has just a little bit of knowledge of HTML and CSS. Templates from other platforms can also be ported to Ghost with relatively little effort.\\"]]],[1,\\"p\\",[[0,[],0,\\"If you want to take a quick look at the theme syntax to see what it's like, you can \\"],[0,[4],1,\\"browse through the files of the default Casper theme\\"],[0,[],0,\\". We've added tons of inline code comments to make it easy to learn, and the structure is very readable.\\"]]],[10,2],[1,\\"p\\",[[0,[],0,\\"See? Not that scary! But still completely optional. \\"]]],[1,\\"p\\",[[0,[],0,\\"If you're interested in creating your own Ghost theme, check out our extensive \\"],[0,[5],1,\\"theme documentation\\"],[0,[],0,\\" for a full guide to all the different template variables and helpers which are available.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 3, - "slug": "design", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Customizing your brand and design settings", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "A full overview of all the features built into the Ghost editor, including powerful workflow automations to speed up your creative process.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "A full overview of all the features built into the Ghost editor, including powerful workflow automations to speed up your creative process.", - "feature_image": "https://static.ghost.org/v4.0.0/images/writing-posts-with-ghost.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/editor.png\\",\\"width\\":3182,\\"height\\":1500,\\"cardWidth\\":\\"wide\\",\\"caption\\":\\"The Ghost editor. Also available in dark-mode, for late night writing sessions.\\"}],[\\"bookmark\\",{\\"type\\":\\"bookmark\\",\\"url\\":\\"https://opensubscriptionplatforms.com/\\",\\"metadata\\":{\\"url\\":\\"https://opensubscriptionplatforms.com\\",\\"title\\":\\"Open Subscription Platforms\\",\\"description\\":\\"A shared movement for independent subscription data.\\",\\"author\\":null,\\"publisher\\":\\"Open Subscription Platforms\\",\\"thumbnail\\":\\"https://opensubscriptionplatforms.com/images/osp-card.png\\",\\"icon\\":\\"https://opensubscriptionplatforms.com/images/favicon.png\\"}}],[\\"embed\\",{\\"url\\":\\"https://www.youtube.com/watch?v=hmH3XMlms8E\\",\\"html\\":\\"\\",\\"type\\":\\"video\\",\\"metadata\\":{\\"title\\":\\"\\\\\\"VELA\\\\\\" Episode 1 of 4 | John John Florence\\",\\"author_name\\":\\"John John Florence\\",\\"author_url\\":\\"https://www.youtube.com/c/JJF\\",\\"height\\":113,\\"width\\":200,\\"version\\":\\"1.0\\",\\"provider_name\\":\\"YouTube\\",\\"provider_url\\":\\"https://www.youtube.com/\\",\\"thumbnail_height\\":360,\\"thumbnail_width\\":480,\\"thumbnail_url\\":\\"https://i.ytimg.com/vi/hmH3XMlms8E/hqdefault.jpg\\"}}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/andreas-selter-xSMqGH7gi6o-unsplash.jpg\\",\\"width\\":6000,\\"height\\":4000,\\"cardWidth\\":\\"full\\",\\"caption\\":\\"\\"}],[\\"gallery\\",{\\"images\\":[{\\"fileName\\":\\"andreas-selter-e4yK8QQlZa0-unsplash.jpg\\",\\"row\\":0,\\"width\\":4572,\\"height\\":3048,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/andreas-selter-e4yK8QQlZa0-unsplash.jpg\\"},{\\"fileName\\":\\"steve-carter-Ixp4YhCKZkI-unsplash.jpg\\",\\"row\\":0,\\"width\\":4032,\\"height\\":2268,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/steve-carter-Ixp4YhCKZkI-unsplash.jpg\\"}],\\"caption\\":\\"\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/lukasz-szmigiel-jFCViYFYcus-unsplash.jpg\\",\\"width\\":2560,\\"height\\":1705,\\"cardWidth\\":\\"wide\\"}],[\\"gallery\\",{\\"images\\":[{\\"fileName\\":\\"jd-mason-hPiEFq6-Eto-unsplash.jpg\\",\\"row\\":0,\\"width\\":5184,\\"height\\":3888,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/jd-mason-hPiEFq6-Eto-unsplash.jpg\\"},{\\"fileName\\":\\"jp-valery-OBpOP9GVH9U-unsplash.jpg\\",\\"row\\":0,\\"width\\":5472,\\"height\\":3648,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/jp-valery-OBpOP9GVH9U-unsplash.jpg\\"}],\\"caption\\":\\"Peaceful places\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/createsnippet.png\\",\\"width\\":2282,\\"height\\":1272,\\"cardWidth\\":\\"wide\\"}],[\\"hr\\",{}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/preview.png\\",\\"width\\":3166,\\"height\\":2224,\\"cardWidth\\":\\"wide\\"}],[\\"hr\\",{}]],\\"markups\\":[[\\"em\\"],[\\"code\\"]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"Ghost comes with a best-in-class editor which does its very best to get out of the way, and let you focus on your content. Don't let its minimal looks fool you, though, beneath the surface lies a powerful editing toolset designed to accommodate the extensive needs of modern creators.\\"]]],[1,\\"p\\",[[0,[],0,\\"For many, the base canvas of the Ghost editor will feel familiar. You can start writing as you would expect, highlight content to access the toolbar you would expect, and generally use all of the keyboard shortcuts you would expect.\\"]]],[1,\\"p\\",[[0,[],0,\\"Our main focus in building the Ghost editor is to try and make as many things that you hope/expect might work: actually work. \\"]]],[3,\\"ul\\",[[[0,[],0,\\"You can copy and paste raw content from web pages, and Ghost will do its best to correctly preserve the formatting. \\"]],[[0,[],0,\\"Pasting an image from your clipboard will upload inline.\\"]],[[0,[],0,\\"Pasting a social media URL will automatically create an embed.\\"]],[[0,[],0,\\"Highlight a word in the editor and paste a URL from your clipboard on top: Ghost will turn it into a link.\\"]],[[0,[],0,\\"You can also paste (or write!) Markdown and Ghost will usually be able to auto-convert it into fully editable, formatted content.\\"]]]],[10,0],[1,\\"p\\",[[0,[],0,\\"The goal, as much as possible, is for things to work so that you don't have to \\"],[0,[0],1,\\"think\\"],[0,[],0,\\" so much about the editor. You won't find any disastrous \\\\\\"block builders\\\\\\" here, where you have to open 6 submenus and choose from 18 different but identical alignment options. That's not what Ghost is about.\\"]]],[1,\\"p\\",[[0,[],0,\\"What you will find though, is dynamic cards which allow you to embed rich media into your posts and create beautifully laid out stories.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Using cards\\"]]],[1,\\"p\\",[[0,[],0,\\"You can insert dynamic cards inside post content using the \\"],[0,[1],1,\\"+\\"],[0,[],0,\\" button, which appears on new lines, or by typing \\"],[0,[1],1,\\"/\\"],[0,[],0,\\" on a new line to trigger the card menu. Many of the choices are simple and intuitive, like bookmark cards, which allow you to create rich links with embedded structured data:\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"or embed cards which make it easy to insert content you want to share with your audience, from external services:\\"]]],[10,2],[1,\\"p\\",[[0,[],0,\\"But, dig a little deeper, and you'll also find more advanced cards, like one that only shows up in email newsletters (great for personalized introductions) and a comprehensive set of specialized cards for different types of images and galleries.\\"]]],[1,\\"blockquote\\",[[0,[],0,\\"Once you start mixing text and image cards creatively, the whole narrative of the story changes. Suddenly, you're working in a new format.\\"]]],[10,3],[1,\\"p\\",[[0,[],0,\\"As it turns out, sometimes pictures and a thousand words go together really well. Telling people a great story often has much more impact if they can feel, even for a moment, as though they were right there with you.\\"]]],[10,4],[10,5],[10,6],[1,\\"p\\",[[0,[],0,\\"Galleries and image cards can be combined in so many different ways — the only limit is your imagination.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Build workflows with snippets\\"]]],[1,\\"p\\",[[0,[],0,\\"One of the most powerful features of the Ghost editor is the ability to create and re-use content snippets. If you've ever used an email client with a concept of \\"],[0,[0],1,\\"saved replies\\"],[0,[],0,\\" then this will be immediately intuitive.\\"]]],[1,\\"p\\",[[0,[],0,\\"To create a snippet, select a piece of content in the editor that you'd like to re-use in future, then click on the snippet icon in the toolbar. Give your snippet a name, and you're all done. Now your snippet will be available from within the card menu, or you can search for it directly using the \\"],[0,[1],1,\\"/\\"],[0,[],0,\\" command.\\"]]],[1,\\"p\\",[[0,[],0,\\"This works really well for saving images you might want to use often, like a company logo or team photo, links to resources you find yourself often linking to, or introductions and passages that you want to remember.\\"]]],[10,7],[1,\\"p\\",[[0,[],0,\\"You can even build entire post templates or outlines to create a quick, re-usable workflow for publishing over time. Or build custom design elements for your post with an HTML card, and use a snippet to insert it.\\"]]],[1,\\"p\\",[[0,[],0,\\"Once you get a few useful snippets set up, it's difficult to go back to the old way of diving through media libraries and trawling for that one thing you know you used somewhere that one time.\\"]]],[10,8],[1,\\"h2\\",[[0,[],0,\\"Publishing and newsletters the easy way\\"]]],[1,\\"p\\",[[0,[],0,\\"When you're ready to publish, Ghost makes it as simple as possible to deliver your new post to all your existing members. Just hit the \\"],[0,[0],1,\\"Preview\\"],[0,[],0,\\" link and you'll get a chance to see what your content looks like on Web, Mobile, Email and Social.\\"]]],[10,9],[1,\\"p\\",[[0,[],0,\\"You can send yourself a test newsletter to make sure everything looks good in your email client, and then hit the \\"],[0,[0],1,\\"Publish\\"],[0,[],0,\\" button to decide who to deliver it to.\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost comes with a streamlined, optimized email newsletter template that has settings built-in for you to customize the colors and typography. We've spent countless hours refining the template to make sure it works great across all email clients, and performs well for email deliverability.\\"]]],[1,\\"p\\",[[0,[],0,\\"So, you don't need to fight the awful process of building a custom email template from scratch. It's all done already!\\"]]],[10,10],[1,\\"p\\",[[0,[],0,\\"The Ghost editor is powerful enough to do whatever you want it to do. With a little exploration, you'll be up and running in no time.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 5, - "slug": "write", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Writing and managing content in Ghost, an advanced guide", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "How Ghost allows you to turn anonymous readers into an audience of active subscribers, so you know what's working and what isn't.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "How Ghost allows you to turn anonymous readers into an audience of active subscribers, so you know what's working and what isn't.", - "feature_image": "https://static.ghost.org/v4.0.0/images/creating-a-custom-theme.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/portalsettings.png\\",\\"width\\":2924,\\"height\\":1810,\\"cardWidth\\":\\"wide\\"}],[\\"hr\\",{}]],\\"markups\\":[[\\"em\\"],[\\"a\\",[\\"href\\",\\"#/portal\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/sell/\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"What sets Ghost apart from other products is that you can publish content and grow your audience using the same platform. Rather than just endlessly posting and hoping someone is listening, you can track real signups against your work and have them subscribe to be notified of future posts. The feature that makes all this possible is called \\"],[0,[0],1,\\"Portal\\"],[0,[],0,\\".\\"]]],[1,\\"p\\",[[0,[],0,\\"Portal is an embedded interface for your audience to sign up to your site. It works on every Ghost site, with every theme, and for any type of publisher. \\"]]],[1,\\"p\\",[[0,[],0,\\"You can customize the design, content and settings of Portal to suit your site, whether you just want people to sign up to your newsletter — or you're running a full premium publication with user sign-ins and private content.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"Once people sign up to your site, they'll receive an email confirmation with a link to click. The link acts as an automatic sign-in, so subscribers will be automatically signed-in to your site when they click on it. There are a couple of interesting angles to this:\\"]]],[1,\\"p\\",[[0,[],0,\\"Because subscribers are automatically able to sign in and out of your site as registered members: You can (optionally) restrict access to posts and pages depending on whether people are signed-in or not. So if you want to publish some posts for free, but keep some really great stuff for members-only, this can be a great draw to encourage people to sign up!\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost members sign in using email authentication links, so there are no passwords for people to set or forget. You can turn any list of email subscribers into a database of registered members who can sign in to your site. Like magic.\\"]]],[1,\\"p\\",[[0,[],0,\\"Portal makes all of this possible, and it appears by default as a floating button in the bottom-right corner of your site. When people are logged out, clicking it will open a sign-up/sign-in window. When members are logged in, clicking the Portal button will open the account menu where they can edit their name, email, and subscription settings.\\"]]],[1,\\"p\\",[[0,[],0,\\"The floating Portal button is completely optional. If you prefer, you can add manual links to your content, navigation, or theme to trigger it instead.\\"]]],[1,\\"p\\",[[0,[],0,\\"Like this! \\"],[0,[1],1,\\"Sign up here\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"As you start to grow your registered audience, you'll be able to get a sense of who you're publishing \\"],[0,[0],1,\\"for\\"],[0,[],0,\\" and where those people are coming \\"],[0,[0],1,\\"from\\"],[0,[],0,\\". Best of all: You'll have a straightforward, reliable way to connect with people who enjoy your work.\\"]]],[1,\\"p\\",[[0,[],0,\\"Social networks go in and out of fashion all the time. Email addresses are timeless.\\"]]],[1,\\"p\\",[[0,[],0,\\"Growing your audience is valuable no matter what type of site you run, but if your content \\"],[0,[0],1,\\"is\\"],[0,[],0,\\" your business, then you might also be interested in \\"],[0,[2],1,\\"setting up premium subscriptions\\"],[0,[],0,\\".\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 2, - "slug": "portal", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Building your audience with subscriber signups", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "For creators and aspiring entrepreneurs looking to generate a sustainable recurring revenue stream from their creative work, Ghost has built-in payments allowing you to create a subscription commerce business. - -Connect your Stripe account to Ghost, and you'll be able to quickly and easily create monthly and yearly premium plans for members to subscribe to, as well as complimentary plans for friends and family. - -Ghost takes 0% payment fees, so everything you make is yours to keep! - -Using subscrip", - "feature_image": "https://static.ghost.org/v4.0.0/images/organizing-your-content.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/thebrowser.jpg\\",\\"width\\":1600,\\"height\\":2000,\\"href\\":\\"https://thebrowser.com\\",\\"caption\\":\\"The Browser has over 10,000 paying subscribers\\"}],[\\"paywall\\",{}]],\\"markups\\":[[\\"a\\",[\\"href\\",\\"https://stripe.com\\"]],[\\"strong\\"],[\\"a\\",[\\"href\\",\\"https://stratechery.com\\"]],[\\"a\\",[\\"href\\",\\"https://www.theinformation.com\\"]],[\\"a\\",[\\"href\\",\\"https://thebrowser.com\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"For creators and aspiring entrepreneurs looking to generate a sustainable recurring revenue stream from their creative work, Ghost has built-in payments allowing you to create a subscription commerce business.\\"]]],[1,\\"p\\",[[0,[],0,\\"Connect your \\"],[0,[0],1,\\"Stripe\\"],[0,[],0,\\" account to Ghost, and you'll be able to quickly and easily create monthly and yearly premium plans for members to subscribe to, as well as complimentary plans for friends and family.\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost takes \\"],[0,[1],1,\\"0% payment fees\\"],[0,[],0,\\", so everything you make is yours to keep!\\"]]],[1,\\"p\\",[[0,[],0,\\"Using subscriptions, you can build an independent media business like \\"],[0,[2],1,\\"Stratechery\\"],[0,[],0,\\", \\"],[0,[3],1,\\"The Information\\"],[0,[],0,\\", or \\"],[0,[4],1,\\"The Browser\\"],[0,[],0,\\".\\"]]],[1,\\"p\\",[[0,[],0,\\"The creator economy is just getting started, and Ghost allows you to build something based on technology that you own and control.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"Most successful subscription businesses publish a mix of free and paid posts to attract a new audience, and upsell the most loyal members to a premium offering. You can also mix different access levels within the same post, showing a free preview to logged out members and then, right when you're ready for a cliffhanger, that's a good time to...\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"Hold back some of the best bits for paying members only! \\"]]],[1,\\"p\\",[[0,[],0,\\"The \\"],[0,[1],1,\\"Public preview\\"],[0,[],0,\\" card allows to create a divide between how much of your post should be available as a public free-preview, and how much should be restricted based on the post access level.\\"]]],[1,\\"p\\",[[0,[],0,\\"These last paragraphs are only visible on the site if you're logged in as a paying member. To test this out, you can connect a Stripe account, create a member account for yourself, and give yourself a complimentary premium plan.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "sell", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Selling premium memberships with recurring revenue", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "paid", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "A guide to collaborating with other staff users to publish, and some resources to help you with the next steps of growing your business", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "A guide to collaborating with other staff users to publish, and some resources to help you with the next steps of growing your business", - "feature_image": "https://static.ghost.org/v4.0.0/images/admin-settings.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}]],\\"cards\\":[[\\"hr\\",{}]],\\"markups\\":[[\\"strong\\"],[\\"a\\",[\\"href\\",\\"https://ghost.org/pricing/\\"]],[\\"em\\"],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/how-to-create-a-newsletter/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/membership-sites/\\"]],[\\"a\\",[\\"href\\",\\"https://newsletterguide.org/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/find-your-niche-creator-economy/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/newsletter-referral-programs/\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"As you grow, you'll probably want to start inviting team members and collaborators to your site. Ghost has a number of different user roles for your team:\\"]]],[1,\\"p\\",[[0,[0],1,\\"Contributors\\"],[1,[],0,0],[0,[],0,\\"This is the base user level in Ghost. Contributors can create and edit their own draft posts, but they are unable to edit drafts of others or publish posts. Contributors are \\"],[0,[0],1,\\"untrusted\\"],[0,[],0,\\" users with the most basic access to your publication.\\"]]],[1,\\"p\\",[[0,[0],1,\\"Authors\\"],[1,[],0,1],[0,[],0,\\"Authors are the 2nd user level in Ghost. Authors can write, edit and publish their own posts. Authors are \\"],[0,[0],1,\\"trusted\\"],[0,[],0,\\" users. If you don't trust users to be allowed to publish their own posts, they should be set as Contributors.\\"]]],[1,\\"p\\",[[0,[0],1,\\"Editors\\"],[1,[],0,2],[0,[],0,\\"Editors are the 3rd user level in Ghost. Editors can do everything that an Author can do, but they can also edit and publish the posts of others - as well as their own. Editors can also invite new Contributors & Authors to the site.\\"]]],[1,\\"p\\",[[0,[0],1,\\"Administrators\\"],[1,[],0,3],[0,[],0,\\"The top user level in Ghost is Administrator. Again, administrators can do everything that Authors and Editors can do, but they can also edit all site settings and data, not just content. Additionally, administrators have full access to invite, manage or remove any other user of the site.\\"],[1,[],0,4],[1,[],0,5],[0,[0],1,\\"The Owner\\"],[1,[],0,6],[0,[],0,\\"There is only ever one owner of a Ghost site. The owner is a special user which has all the same permissions as an Administrator, but with two exceptions: The Owner can never be deleted. And in some circumstances the owner will have access to additional special settings if applicable. For example: billing details, if using \\"],[0,[1,0],2,\\"Ghost(Pro)\\"],[0,[],0,\\".\\"]]],[1,\\"blockquote\\",[[0,[2],1,\\"Ask all of your users to fill out their user profiles, including bio and social links. These will populate rich structured data for posts and generally create more opportunities for themes to fully populate their design.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"If you're looking for insights, tips and reference materials to expand your content business, here's 5 top resources to get you started:\\"]]],[3,\\"ul\\",[[[0,[3,0],2,\\"How to create a premium newsletter (+ some case studies)\\"],[0,[0],1,\\" \\"],[0,[],0,\\" \\"],[1,[],0,7],[0,[],0,\\"Learn how others run successful paid email newsletter products\\"]],[[0,[0,4],2,\\"The ultimate guide to membership websites for creators\\"],[1,[],0,8],[0,[],0,\\"Tips to help you build, launch and grow your new membership business\\"]],[[0,[0,5],2,\\"The Newsletter Guide\\"],[1,[],0,9],[0,[],0,\\"A 201 guide for taking your newsletters to the next level\\"]],[[0,[6,0],2,\\"The proven way to find your niche, explained\\"],[1,[],0,10],[0,[],0,\\"Find the overlap and find a monetizable niche that gets noticed\\"]],[[0,[0,7],2,\\"Should you launch a referral program? \\"],[1,[],0,11],[0,[],0,\\"Strategies for building a sustainable referral growth machine\\"]]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 2, - "slug": "grow", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "How to grow your business around an audience", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "Work with all your favorite apps and tools or create your own custom integrations using the Ghost API.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "Work with all your favorite apps and tools or create your own custom integrations using the Ghost API.", - "feature_image": "https://static.ghost.org/v4.0.0/images/app-integrations.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/integrations-icons.png\\",\\"cardWidth\\":\\"full\\"}],[\\"markdown\\",{\\"markdown\\":\\"\\\\n\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/iawriter-integration.png\\",\\"width\\":2244,\\"height\\":936}]],\\"markups\\":[[\\"a\\",[\\"href\\",\\"https://ghost.org/integrations/\\"]],[\\"strong\\"]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"It's possible to extend your Ghost site and connect it with hundreds of the most popular apps and tools using integrations. \\"]]],[1,\\"p\\",[[0,[],0,\\"Whether you need to automatically publish new posts on social media, connect your favorite analytics tool, sync your community or embed forms into your content — our \\"],[0,[0],1,\\"integrations library\\"],[0,[],0,\\" has got it all covered with hundreds of integration tutorials.\\"]]],[1,\\"p\\",[[0,[],0,\\"Many integrations are as simple as inserting an embed by pasting a link, or copying a snippet of code directly from an app and pasting it into Ghost. Our integration tutorials are used by creators of all kinds to get apps and integrations up and running in no time — no technical knowledge required.\\"]]],[10,0],[1,\\"h2\\",[[0,[],0,\\"Zapier\\"]]],[1,\\"p\\",[[0,[],0,\\"Zapier is a no-code tool that allows you to build powerful automations, and our official integration allows you to connect your Ghost site to more than 1,000 external services.\\"]]],[1,\\"blockquote\\",[[0,[1],1,\\"Example\\"],[0,[],0,\\": When someone new subscribes to a newsletter on a Ghost site (Trigger) then the contact information is automatically pushed into MailChimp (Action).\\"]]],[1,\\"p\\",[[0,[1],1,\\"Here's a few of the most popular automation templates:\\"],[0,[],0,\\" \\"]]],[10,1],[1,\\"h2\\",[[0,[],0,\\"Custom integrations\\"]]],[1,\\"p\\",[[0,[],0,\\"For more advanced automation, it's possible to create custom Ghost integrations with dedicated API keys from the Integrations page within Ghost Admin. \\"]]],[10,2],[1,\\"p\\",[[0,[],0,\\"These custom integrations allow you to use the Ghost API without needing to write code, and create powerful workflows such as sending content from your favorite desktop editor into Ghost as a new draft.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "integrations", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Setting up apps and custom integrations", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "78560", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "BACON!", - "feature_image": null, - "filter": "tag:['bacon']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "bacon-tag-expansion", - "title": "Test Collection with tag filter alias", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "296", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", - "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "ghostly-kitchen-sink", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "Ghostly Kitchen Sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "This is my custom excerpt!", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "This is my custom excerpt!", - "feature_image": "https://example.com/super_photo.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "html-ipsum", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "HTML Ipsum", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14423", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "BACON!", - "feature_image": null, - "filter": "tags:['bacon']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "tag-filter", - "title": "Test Collection with tag filter", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "282", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", - "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "ghostly-kitchen-sink", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "Ghostly Kitchen Sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "This is my custom excerpt!", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "This is my custom excerpt!", - "feature_image": "https://example.com/super_photo.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

HTML Ipsum Presents

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. Aliquam tincidunt mauris eu risus.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

Header Level 3

  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • Aliquam tincidunt mauris eu risus.
#header h1 a{display: block;width: 300px;height: 80px;}
\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "html-ipsum", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "HTML Ipsum", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14423", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Browse Can browse Collections 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], - "meta": Object { - "pagination": Object { - "limit": 2, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, -} -`; - -exports[`Collections API Browse Can browse Collections 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "576", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Browse Can browse Collections and include the posts count 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 13, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], - "meta": Object { - "pagination": Object { - "limit": 2, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, -} -`; - -exports[`Collections API Browse Can browse Collections and include the posts count 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "617", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Browse Makes limited DB queries when browsing 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], - "meta": Object { - "pagination": Object { - "limit": 2, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, -} -`; - -exports[`Collections API Browse Makes limited DB queries when browsing 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "576", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 5: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 3, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 7: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 8: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "266", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "286", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 5: [body] 1`] = ` -Object { - "bulk": Object { - "meta": Object { - "stats": Object { - "successful": 11, - "unsuccessful": 0, - }, - }, - }, -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 6: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 11, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 7: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "287", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 8: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 9: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "286", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 5: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 3, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 7: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 8: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Can delete a Collection 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-delete", - "title": "Test Collection to Delete", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Delete Can delete a Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "272", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Can delete a Collection 3: [body] 1`] = `Object {}`; - -exports[`Collections API Delete Can delete a Collection 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Can delete a Collection 5: [body] 1`] = ` -Object { - "errors": Array [ - Object { - "code": null, - "context": "Collection not found.", - "details": null, - "ghostErrorCode": null, - "help": null, - "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "Resource not found error, cannot read collection.", - "property": null, - "type": "NotFoundError", - }, - ], -} -`; - -exports[`Collections API Delete Can delete a Collection 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "254", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Cannot delete a built in collection 1: [body] 1`] = ` -Object { - "errors": Array [ - Object { - "code": null, - "context": Any, - "details": null, - "ghostErrorCode": null, - "help": null, - "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "Method not allowed, cannot delete collection.", - "property": null, - "type": "MethodNotAllowedError", - }, - ], -} -`; - -exports[`Collections API Delete Cannot delete a built in collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "355", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Edit Can edit a Collection 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-edit", - "title": "Test Collection Edited", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Edit Can edit a Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "267", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Edit Fails to edit unexistent Collection 1: [body] 1`] = ` -Object { - "errors": Array [ - Object { - "code": null, - "context": "Collection not found.", - "details": null, - "ghostErrorCode": null, - "help": null, - "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "Resource not found error, cannot edit collection.", - "property": null, - "type": "NotFoundError", - }, - ], -} -`; - -exports[`Collections API Edit Fails to edit unexistent Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "254", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-read", - "title": "Test Collection to Read", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "268", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-read", - "title": "Test Collection to Read", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "268", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 5: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-read", - "title": "Test Collection to Read", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "268", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; diff --git a/ghost/core/test/e2e-api/admin/posts-bulk.test.js b/ghost/core/test/e2e-api/admin/posts-bulk.test.js index 7d545348194..a1f526e4e44 100644 --- a/ghost/core/test/e2e-api/admin/posts-bulk.test.js +++ b/ghost/core/test/e2e-api/admin/posts-bulk.test.js @@ -322,13 +322,6 @@ describe('Posts Bulk API', function () { assert(amount > 0, 'Expect at least one post to be affected for this test to work'); - await agent - .get('posts/?collection=latest') - .expectStatus(200) - .expect((res) => { - assert(res.body.posts.length > 0, 'Expect latest collection to have some posts'); - }); - const response = await agent .delete('/posts/?filter=' + encodeURIComponent(filter)) .expectStatus(200) @@ -366,10 +359,6 @@ describe('Posts Bulk API', function () { // Check if all posts were deleted const posts = await models.Post.findPage({filter, status: 'all'}); assert.equal(posts.meta.pagination.total, 0, `Expect all matching posts (${amount}) to be deleted`); - - let latestCollection = await models.Collection.findPage({filter: 'slug:latest', limit: 1, withRelated: ['collectionPosts']}); - latestCollection = latestCollection.data[0].toJSON().collectionPosts.length; - assert.equal(latestCollection, 0, 'Expect to have no collection posts'); }); }); }); diff --git a/ghost/core/test/e2e-api/content/__snapshots__/collections.test.js.snap b/ghost/core/test/e2e-api/content/__snapshots__/collections.test.js.snap deleted file mode 100644 index 58709651a12..00000000000 --- a/ghost/core/test/e2e-api/content/__snapshots__/collections.test.js.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Collections Content API Can request a collection by slug and id 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections Content API Can request a collection by slug and id 2: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; diff --git a/ghost/core/test/unit/server/services/collections/CollectionsServiceWrapper.test.js b/ghost/core/test/unit/server/services/collections/CollectionsServiceWrapper.test.js deleted file mode 100644 index a130eb56671..00000000000 --- a/ghost/core/test/unit/server/services/collections/CollectionsServiceWrapper.test.js +++ /dev/null @@ -1,11 +0,0 @@ -const assert = require('assert/strict'); -const collectionsServiceWrapper = require('../../../../../core/server/services/collections'); -const {CollectionsService} = require('@tryghost/collections'); - -describe('CollectionsServiceWrapper', function () { - it('Exposes a valid instance of CollectionsServiceWrapper', async function () { - assert.ok(collectionsServiceWrapper); - assert.ok(collectionsServiceWrapper.api); - assert.ok(collectionsServiceWrapper.api instanceof CollectionsService); - }); -}); diff --git a/ghost/model-to-domain-event-interceptor/.eslintrc.js b/ghost/model-to-domain-event-interceptor/.eslintrc.js deleted file mode 100644 index cb690be63fa..00000000000 --- a/ghost/model-to-domain-event-interceptor/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/ts' - ] -}; diff --git a/ghost/model-to-domain-event-interceptor/README.md b/ghost/model-to-domain-event-interceptor/README.md deleted file mode 100644 index 0c47b244ad3..00000000000 --- a/ghost/model-to-domain-event-interceptor/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Model To Domain Event Interceptor - -Model event interceptor that maps legacy model events to Domain event - - -## Usage - - -## Develop - -This is a monorepo package. - -Follow the instructions for the top-level repo. -1. `git clone` this repo & `cd` into it as usual -2. Run `yarn` to install top-level dependencies. - - - -## Test - -- `yarn lint` run just eslint -- `yarn test` run lint and tests - diff --git a/ghost/model-to-domain-event-interceptor/package.json b/ghost/model-to-domain-event-interceptor/package.json deleted file mode 100644 index 70383339d97..00000000000 --- a/ghost/model-to-domain-event-interceptor/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@tryghost/model-to-domain-event-interceptor", - "version": "0.0.0", - "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/model-to-domain-event-interceptor", - "author": "Ghost Foundation", - "private": true, - "main": "build/index.js", - "types": "build/index.d.ts", - "scripts": { - "build": "yarn build:ts", - "build:ts": "tsc", - "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot -r ts-node/register './test/**/*.test.ts'", - "test": "yarn test:types && yarn test:unit", - "test:types": "tsc --noEmit", - "lint:code": "eslint src/ --ext .ts --cache", - "lint": "yarn lint:code && yarn lint:test", - "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" - }, - "files": [ - "build" - ], - "devDependencies": { - "@tryghost/domain-events": "0.0.0", - "c8": "8.0.1", - "mocha": "10.2.0", - "sinon": "15.2.0" - }, - "dependencies": { - "@tryghost/collections": "0.0.0", - "@tryghost/post-events": "0.0.0" - }, - "c8": { - "exclude": [ - "src/**/*.d.ts" - ] - } -} diff --git a/ghost/model-to-domain-event-interceptor/src/ModelToDomainEventInterceptor.ts b/ghost/model-to-domain-event-interceptor/src/ModelToDomainEventInterceptor.ts deleted file mode 100644 index 46b6518b35e..00000000000 --- a/ghost/model-to-domain-event-interceptor/src/ModelToDomainEventInterceptor.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {PostDeletedEvent} from '@tryghost/post-events'; -import {PostAddedEvent, PostEditedEvent, TagDeletedEvent} from '@tryghost/collections'; - -type ModelToDomainEventInterceptorDeps = { - ModelEvents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hasRegisteredListener: (event: any, listenerName: string) => boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (eventName: string, callback: (data: any) => void) => void; - }, - DomainEvents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatch: (event: any) => void; - } -} - -export class ModelToDomainEventInterceptor { - ModelEvents; - DomainEvents; - - constructor(deps: ModelToDomainEventInterceptorDeps) { - this.ModelEvents = deps.ModelEvents; - this.DomainEvents = deps.DomainEvents; - } - - init() { - const ghostModelUpdateEvents = [ - 'post.added', - 'post.deleted', - 'post.edited', - // NOTE: currently unmapped and unused event - 'tag.added', - 'tag.deleted' - ]; - - for (const modelEventName of ghostModelUpdateEvents) { - if (!this.ModelEvents.hasRegisteredListener(modelEventName, 'collectionListener')) { - const dispatcher = this.domainEventDispatcher.bind(this); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const listener = function (data: any) { - dispatcher(modelEventName, data); - }; - Object.defineProperty(listener, 'name', {value: `${modelEventName}.domainEventInterceptorListener`, writable: false}); - - this.ModelEvents.on(modelEventName, listener); - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - domainEventDispatcher(modelEventName: string, data: any) { - let event; - - switch (modelEventName) { - case 'post.deleted': - event = PostDeletedEvent.create({ - id: data.id || data._previousAttributes?.id - }); - break; - case 'post.added': - event = PostAddedEvent.create({ - id: data.id, - featured: data.attributes.featured, - status: data.attributes.status, - published_at: data.attributes.published_at - }); - break; - case 'post.edited': - event = PostEditedEvent.create({ - id: data.id, - current: { - id: data.id, - title: data.attributes.title, - status: data.attributes.status, - featured: data.attributes.featured, - published_at: data.attributes.published_at, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: data.relations?.tags?.models.map((tag: any) => ({ - slug: tag.get('slug') - })) - }, - // @NOTE: this will need to represent the previous state of the post - // will be needed to optimize the query for the collection - previous: { - id: data.id, - title: data._previousAttributes?.title, - status: data._previousAttributes?.status, - featured: data._previousAttributes?.featured, - published_at: data._previousAttributes?.published_at, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: data._previousRelations?.tags?.models.map((tag: any) => ({ - slug: tag.get('slug') - })) - } - }); - break; - case 'tag.deleted': - event = TagDeletedEvent.create({ - id: data.id || data._previousAttributes?.id, - slug: data.attributes?.slug || data._previousAttributes?.slug - }); - break; - default: - } - - if (event) { - this.DomainEvents.dispatch(event); - } - } -} diff --git a/ghost/model-to-domain-event-interceptor/src/index.ts b/ghost/model-to-domain-event-interceptor/src/index.ts deleted file mode 100644 index 21da506fde7..00000000000 --- a/ghost/model-to-domain-event-interceptor/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ModelToDomainEventInterceptor'; diff --git a/ghost/model-to-domain-event-interceptor/src/libraries.d.ts b/ghost/model-to-domain-event-interceptor/src/libraries.d.ts deleted file mode 100644 index 8e8a65f91a8..00000000000 --- a/ghost/model-to-domain-event-interceptor/src/libraries.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@tryghost/domain-events' diff --git a/ghost/model-to-domain-event-interceptor/test/.eslintrc.js b/ghost/model-to-domain-event-interceptor/test/.eslintrc.js deleted file mode 100644 index 6fe6dc1504a..00000000000 --- a/ghost/model-to-domain-event-interceptor/test/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/model-to-domain-event-interceptor/test/model-to-domain-event-interceptor.test.ts b/ghost/model-to-domain-event-interceptor/test/model-to-domain-event-interceptor.test.ts deleted file mode 100644 index 8f98808d004..00000000000 --- a/ghost/model-to-domain-event-interceptor/test/model-to-domain-event-interceptor.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import assert from 'assert/strict'; -import events from 'events'; -import sinon from 'sinon'; -import DomainEvents from '@tryghost/domain-events'; -import { - PostDeletedEvent -} from '@tryghost/post-events'; -import { - PostEditedEvent, - PostAddedEvent, - TagDeletedEvent -} from '@tryghost/collections'; - -import {ModelToDomainEventInterceptor} from '../src'; - -class EventRegistry extends events.EventEmitter { - hasRegisteredListener(eventName: string, listenerName: string) { - return !!(this.listeners(eventName).find(listener => (listener.name === listenerName))); - } -} - -describe('ModelToDomainEventInterceptor', function () { - it('Can instantiate a ModelToDomainEventInterceptor', function () { - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: new EventRegistry(), - DomainEvents: DomainEvents - }); - - assert.ok(modelToDomainEventInterceptor); - }); - - it('Starts event listeners after initialization', function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - assert.ok(eventRegistry.hasRegisteredListener('post.added', 'post.added.domainEventInterceptorListener'), 'post.added listener is registered'); - }); - - it('Intercepts post.added Model event and dispatches PostAddedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostAddedEvent, (event: any) => { - assert.equal(event.id, '1234-added'); - interceptedEvent = event; - }); - - eventRegistry.emit('post.added', { - id: '1234-added', - attributes: { - status: 'draft', - featured: false, - published_at: new Date() - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts post.edited Model event and dispatches PostEditedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostEditedEvent, async (event: any) => { - assert.equal(event.id, '1234-edited'); - assert.ok(event.data); - assert.ok(event.data.current); - assert.equal(event.data.current.status, 'draft'); - assert.equal(event.data.previous.status, 'published'); - - assert.deepEqual(event.data.current.tags[0], {slug: 'tag-current-slug'}); - assert.deepEqual(event.data.previous.tags[0], {slug: 'tag-previous-slug'}); - interceptedEvent = event; - }); - - eventRegistry.emit('post.edited', { - id: '1234-edited', - attributes: { - status: 'draft', - featured: false, - published_at: new Date() - }, - _previousAttributes: { - status: 'published', - featured: true - }, - relations: { - tags: { - models: [{ - get: function (key: string) { - return `tag-current-${key}`; - } - }] - } - }, - _previousRelations: { - tags: { - models: [{ - get: function (key: string) { - return `tag-previous-${key}`; - } - }] - } - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts post.deleted Model event and dispatches PostAddedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostDeletedEvent, (event: any) => { - assert.equal(event.id, '1234-deleted'); - interceptedEvent = event; - }); - - eventRegistry.emit('post.deleted', { - id: '1234-deleted' - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts post.deleted Model event without an id property and dispatches PostAddedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostDeletedEvent, (event: any) => { - assert.equal(event.id, '1234-deleted'); - interceptedEvent = event; - }); - - eventRegistry.emit('post.deleted', { - _previousAttributes: { - id: '1234-deleted' - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts tag.deleted Model event and dispatches TagDeletedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(TagDeletedEvent, (event: TagDeletedEvent) => { - assert.equal(event.id, '1234-deleted'); - assert.equal(event.data.slug, 'tag-slug'); - interceptedEvent = event; - }); - - eventRegistry.emit('tag.deleted', { - _previousAttributes: { - id: '1234-deleted', - slug: 'tag-slug' - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts tag.deleted Model event without an id property and dispatches TagDeletedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(TagDeletedEvent, (event: TagDeletedEvent) => { - assert.equal(event.id, '1234-deleted'); - assert.equal(event.data.slug, 'tag-slug'); - interceptedEvent = event; - }); - - eventRegistry.emit('tag.deleted', { - id: '1234-deleted', - attributes: { - slug: 'tag-slug' - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts unmapped Model event and dispatches nothing', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - const domainEventsSpy = sinon.spy(DomainEvents, 'dispatch'); - - modelToDomainEventInterceptor.init(); - - eventRegistry.emit('tag.added', { - id: '1234-tag' - }); - - await DomainEvents.allSettled(); - - assert.equal(domainEventsSpy.called, false); - }); -}); diff --git a/ghost/model-to-domain-event-interceptor/tsconfig.json b/ghost/model-to-domain-event-interceptor/tsconfig.json deleted file mode 100644 index 7f7ed386648..00000000000 --- a/ghost/model-to-domain-event-interceptor/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": [ - "src/**/*" - ], - "compilerOptions": { - "outDir": "build" - } -} diff --git a/ghost/nql-filter-expansions/.eslintrc.js b/ghost/nql-filter-expansions/.eslintrc.js deleted file mode 100644 index cb690be63fa..00000000000 --- a/ghost/nql-filter-expansions/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/ts' - ] -}; diff --git a/ghost/nql-filter-expansions/README.md b/ghost/nql-filter-expansions/README.md deleted file mode 100644 index 9d4b19a78be..00000000000 --- a/ghost/nql-filter-expansions/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Nql Filter Expansions - -NQL Filter Expansions for entities/models/resources - - -## Usage - - -## Develop - -This is a monorepo package. - -Follow the instructions for the top-level repo. -1. `git clone` this repo & `cd` into it as usual -2. Run `yarn` to install top-level dependencies. - - - -## Test - -- `yarn lint` run just eslint -- `yarn test` run lint and tests - diff --git a/ghost/nql-filter-expansions/package.json b/ghost/nql-filter-expansions/package.json deleted file mode 100644 index f835e9f73f8..00000000000 --- a/ghost/nql-filter-expansions/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@tryghost/nql-filter-expansions", - "version": "0.0.0", - "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/nql-filter-expansions", - "author": "Ghost Foundation", - "private": true, - "main": "build/index.js", - "types": "build/index.d.ts", - "scripts": { - "build": "yarn build:ts", - "build:ts": "tsc", - "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot -r ts-node/register './test/**/*.test.ts'", - "test": "yarn test:types && yarn test:unit", - "test:types": "tsc --noEmit", - "lint:code": "eslint src/ --ext .ts --cache", - "lint": "yarn lint:code && yarn lint:test", - "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" - }, - "files": [ - "build" - ], - "devDependencies": { - "c8": "8.0.1", - "mocha": "10.2.0", - "sinon": "15.2.0" - }, - "dependencies": {} -} diff --git a/ghost/nql-filter-expansions/src/index.ts b/ghost/nql-filter-expansions/src/index.ts deleted file mode 100644 index 17292679bd7..00000000000 --- a/ghost/nql-filter-expansions/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './nql-filter-expansions'; diff --git a/ghost/nql-filter-expansions/src/nql-filter-expansions.ts b/ghost/nql-filter-expansions/src/nql-filter-expansions.ts deleted file mode 100644 index b45bb237b22..00000000000 --- a/ghost/nql-filter-expansions/src/nql-filter-expansions.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const posts = [{ - key: 'primary_tag', - replacement: 'tags.slug', - expansion: 'posts_tags.sort_order:0+tags.visibility:public' -}, { - key: 'primary_author', - replacement: 'authors.slug', - expansion: 'posts_authors.sort_order:0+authors.visibility:public' -}, { - key: 'authors', - replacement: 'authors.slug' -}, { - key: 'author', - replacement: 'authors.slug' -}, { - key: 'tag', - replacement: 'tags.slug' -}, { - key: 'tags', - replacement: 'tags.slug' -}]; diff --git a/ghost/nql-filter-expansions/test/.eslintrc.js b/ghost/nql-filter-expansions/test/.eslintrc.js deleted file mode 100644 index 6fe6dc1504a..00000000000 --- a/ghost/nql-filter-expansions/test/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/nql-filter-expansions/test/nql-filter-expansions.test.ts b/ghost/nql-filter-expansions/test/nql-filter-expansions.test.ts deleted file mode 100644 index dddbd595659..00000000000 --- a/ghost/nql-filter-expansions/test/nql-filter-expansions.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import assert from 'assert/strict'; -import {posts} from '../src/index'; - -describe('Expansions', function () { - it('Exposes correct expansions', function () { - assert.ok(posts); - assert.equal(posts[0].key, 'primary_tag'); - }); -}); diff --git a/ghost/nql-filter-expansions/tsconfig.json b/ghost/nql-filter-expansions/tsconfig.json deleted file mode 100644 index 9d02e69445a..00000000000 --- a/ghost/nql-filter-expansions/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": [ - "src/**/*" - ], - "compilerOptions": { - "outDir": "build" - } - } diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index 98cf806dd14..b62b6e19dfd 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -22,19 +22,16 @@ const messages = { invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter', unsupportedBulkAction: 'Unsupported bulk action', postNotFound: 'Post not found.', - collectionNotFound: 'Collection not found.' }; class PostsService { - constructor({urlUtils, models, isSet, stats, emailService, postsExporter, collectionsService}) { + constructor({urlUtils, models, isSet, stats, emailService, postsExporter}) { this.urlUtils = urlUtils; this.models = models; this.isSet = isSet; this.stats = stats; this.emailService = emailService; this.postsExporter = postsExporter; - /** @type {import('@tryghost/collections').CollectionsService} */ - this.collectionsService = collectionsService; } /** @@ -43,45 +40,7 @@ class PostsService { * @returns {Promise} */ async browsePosts(options) { - let posts; - if (options.collection) { - let collection = await this.collectionsService.getById(options.collection, {transaction: options.transacting}); - - if (!collection) { - collection = await this.collectionsService.getBySlug(options.collection, {transaction: options.transacting}); - } - - if (!collection) { - throw new errors.NotFoundError({ - message: tpl(messages.collectionNotFound) - }); - } - - const postIds = collection.posts.map(post => post.id); - - if (postIds.length !== 0) { - options.filter = `id:[${postIds.join(',')}]+type:post`; - options.status = 'all'; - posts = await this.models.Post.findPage(options); - } else { - posts = { - data: [], - meta: { - pagination: { - page: 1, - pages: 1, - total: 0, - limit: options.limit || 15, - next: null, - prev: null - } - } - }; - } - } else { - posts = await this.models.Post.findPage(options); - } - + const posts = await this.models.Post.findPage(options); return posts; } @@ -94,13 +53,7 @@ class PostsService { }); } - const dto = model.toJSON(frame.options); - - if (this.isSet('collections') && frame?.original?.query?.include?.includes('collections')) { - dto.collections = await this.collectionsService.getCollectionsForPost(model.id); - } - - return dto; + return model.toJSON(frame.options); } /** @@ -131,54 +84,6 @@ class PostsService { } } - if (this.isSet('collections') && frame.data.posts[0].collections) { - const existingCollections = await this.collectionsService.getCollectionsForPost(frame.options.id); - for (const collection of frame.data.posts[0].collections) { - let collectionId = null; - if (typeof collection === 'string') { - collectionId = collection; - } - if (typeof collection?.id === 'string') { - collectionId = collection.id; - } - if (!collectionId) { - continue; - } - const existingCollection = existingCollections.find(c => c.id === collectionId); - if (existingCollection) { - continue; - } - const found = await this.collectionsService.getById(collectionId); - if (!found) { - continue; - } - if (found.type !== 'manual') { - continue; - } - await this.collectionsService.addPostToCollection(collectionId, { - id: frame.options.id, - featured: frame.data.posts[0].featured, - published_at: frame.data.posts[0].published_at - }); - } - for (const existingCollection of existingCollections) { - // we only remove posts from manual collections - if (existingCollection.type !== 'manual') { - continue; - } - - if (frame.data.posts[0].collections.find((item) => { - if (typeof item === 'string') { - return item === existingCollection.id; - } - return item.id === existingCollection.id; - })) { - continue; - } - await this.collectionsService.removePostFromCollection(existingCollection.id, frame.options.id); - } - } - const model = await this.models.Post.edit(frame.data.posts[0], frame.options); /**Handle newsletter email */ @@ -202,12 +107,6 @@ class PostsService { const dto = model.toJSON(frame.options); - if (this.isSet('collections')) { - if (frame?.original?.query?.include?.includes('collections') || frame.data.posts[0].collections) { - dto.collections = await this.collectionsService.getCollectionsForPost(model.id); - } - } - if (typeof options?.eventHandler === 'function') { await options.eventHandler(this.getChanges(model), dto); }