From 8788d07d6d4105284a0f1c1c97f4049ae665cd51 Mon Sep 17 00:00:00 2001 From: Adam Matthiesen Date: Fri, 12 Jan 2024 23:20:44 -0800 Subject: [PATCH] beta.0 initial comit testing --- README.md | 5 + index.ts | 2 + package.json | 42 +++++ src/Content-API/api-client.js | 8 + src/Content-API/ghostContentAPI.js | 294 +++++++++++++++++++++++++++++ src/Types/ghost.ts | 252 +++++++++++++++++++++++++ src/ghost.ts | 63 +++++++ 7 files changed, 666 insertions(+) create mode 100644 README.md create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/Content-API/api-client.js create mode 100644 src/Content-API/ghostContentAPI.js create mode 100644 src/Types/ghost.ts create mode 100644 src/ghost.ts diff --git a/README.md b/README.md new file mode 100644 index 00000000..302f0a2e --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Welcome to Astro-GhostCMS + +This addon uses the `@tryghost/content-api` and creates astro friendly functions to interface between ghost and astro. + +## Work In Progress README (*More Information will be provided as time goes on...*) \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 00000000..de5558c0 --- /dev/null +++ b/index.ts @@ -0,0 +1,2 @@ +export { getGhostPosts, getGhostRecentPosts, getGhostFeaturedPosts, getGhostPostbySlug, getGhostPostsbyTag, getGhostTags, getGhostTagbySlug, getGhostAuthors, getGhostPages, getGhostPage, getGhostSettings } from './src/ghost'; +export { api } from './src/Content-API/api-client'; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 00000000..71be937b --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@adammatthiesen/astro-ghostcms", + "description": "Astro GhostCMS integration to allow easier importing of GhostCMS Content", + "version": "0.0.1-beta.0", + "author": "Adam Matthiesen ", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Adammatthiesen/astro-ghostcms.git" + }, + "bugs": { + "url": "https://github.com/Adammatthiesen/astro-ghostcms/issues" + }, + "homepage": "https://github.com/Adammatthiesen/astro-ghostcms", + "exports": { + ".": "./index.ts" + }, + "files": [ + "src", + "index.ts" + ], + "keywords": [ + "astro-component", + "withastro", + "ghost", + "ghostcms" + ], + "scripts": {}, + "devDependencies": { + "astro": "^4.1.1" + }, + "peerDependencies": { + "astro": "^4.0.0" + }, + "dependencies": { + "@astrojs/check": "^0.3.4", + "typescript": "^5.3.3", + "axios": "^1.0.0" + }, + "main": "index.ts" +} diff --git a/src/Content-API/api-client.js b/src/Content-API/api-client.js new file mode 100644 index 00000000..4fd9fbf7 --- /dev/null +++ b/src/Content-API/api-client.js @@ -0,0 +1,8 @@ +import GhostContentAPI from './ghostContentAPI'; + +// CALL GHOST VARS AND CREATE CLIENT +const key = import.meta.env.CONTENT_API_KEY; +const url = import.meta.env.CONTENT_API_URL; +const version = "v5.0" + +export const api = new GhostContentAPI({ key, url, version }) \ No newline at end of file diff --git a/src/Content-API/ghostContentAPI.js b/src/Content-API/ghostContentAPI.js new file mode 100644 index 00000000..167faac5 --- /dev/null +++ b/src/Content-API/ghostContentAPI.js @@ -0,0 +1,294 @@ +'use strict'; + +import axios from 'axios'; + +var name$1 = "@tryghost/content-api"; +var version = "1.11.20"; +var repository = "https://github.com/TryGhost/SDK/tree/main/packages/content-api"; +var author = "Ghost Foundation"; +var license = "MIT"; +var main = "cjs/content-api.js"; +var unpkg = "umd/content-api.min.js"; +var module$1 = "es/content-api.js"; +var source = "lib/content-api.js"; +var files = [ + "LICENSE", + "README.md", + "cjs/", + "lib/", + "umd/", + "es/" +]; +var scripts = { + dev: "echo \"Implement me!\"", + pretest: "yarn build", + test: "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'", + build: "rollup -c", + lint: "eslint . --ext .js --cache", + prepare: "NODE_ENV=production yarn build", + posttest: "yarn lint" +}; +var publishConfig = { + access: "public" +}; +var devDependencies = { + "@babel/core": "7.23.3", + "@babel/polyfill": "7.12.1", + "@babel/preset-env": "7.23.3", + "@rollup/plugin-json": "6.0.1", + c8: "8.0.1", + "core-js": "3.33.2", + "eslint-plugin-ghost": "3.4.0", + mocha: "10.2.0", + rollup: "2.79.1", + "rollup-plugin-babel": "4.4.0", + "rollup-plugin-commonjs": "10.1.0", + "rollup-plugin-node-resolve": "5.2.0", + "rollup-plugin-polyfill-node": "0.12.0", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-terser": "7.0.2", + should: "13.2.3", + sinon: "17.0.1" +}; +var dependencies = { + axios: "^1.0.0" +}; +var gitHead = "4839d3f97de2120d98fa47677eed7591dfa20e64"; +var packageInfo = { + name: name$1, + version: version, + repository: repository, + author: author, + license: license, + main: main, + "umd:main": "umd/content-api.min.js", + unpkg: unpkg, + module: module$1, + source: source, + files: files, + scripts: scripts, + publishConfig: publishConfig, + devDependencies: devDependencies, + dependencies: dependencies, + gitHead: gitHead +}; + +// @NOTE: this value is dynamically replaced based on browser/node environment +const USER_AGENT_DEFAULT = true; + +const packageVersion = packageInfo.version; + +const defaultAcceptVersionHeader = 'v5.0'; +const supportedVersions = ['v2', 'v3', 'v4', 'v5', 'canary']; +const name = '@tryghost/content-api'; + +/** + * This method can go away in favor of only sending 'Accept-Version` headers + * once the Ghost API removes a concept of version from it's URLS (with Ghost v5) + * + * @param {string} [version] version in `v{major}` format + * @returns {string} + */ +const resolveAPIPrefix = (version) => { + let prefix; + + // NOTE: the "version.match(/^v5\.\d+/)" expression should be changed to "version.match(/^v\d+\.\d+/)" once Ghost v5 is out + if (version === 'v5' || version === undefined || version.match(/^v5\.\d+/)) { + prefix = `/content/`; + } else if (version.match(/^v\d+\.\d+/)) { + const versionPrefix = /^(v\d+)\.\d+/.exec(version)[1]; + prefix = `/${versionPrefix}/content/`; + } else { + prefix = `/${version}/content/`; + } + + return prefix; +}; + +const defaultMakeRequest = ({url, method, params, headers}) => { + return axios[method](url, { + params, + paramsSerializer: (parameters) => { + return Object.keys(parameters).reduce((parts, k) => { + const val = encodeURIComponent([].concat(parameters[k]).join(',')); + return parts.concat(`${k}=${val}`); + }, []).join('&'); + }, + headers + }); +}; + +/** + * + * @param {Object} options + * @param {String} options.url + * @param {String} options.key + * @param {String} [options.ghostPath] + * @param {String|Boolean} options.version - a version string like v3, v4, v5 or boolean value identifying presence of Accept-Version header + * @param {String|Boolean} [options.userAgent] - value controlling the 'User-Agent' header should be sent with a request + * @param {Function} [options.makeRequest] + * @param {String} [options.host] Deprecated + */ +function GhostContentAPI({url, key, host, version, userAgent, ghostPath = 'ghost', makeRequest = defaultMakeRequest}) { + /** + * host parameter is deprecated + * @deprecated use "url" instead + */ + if (host) { + // eslint-disable-next-line + console.warn(`${name}: The 'host' parameter is deprecated, please use 'url' instead`); + if (!url) { + url = host; + } + } + + if (this instanceof GhostContentAPI) { + return GhostContentAPI({url, key, version, userAgent, ghostPath, makeRequest}); + } + + if (version === undefined) { + throw new Error(`${name} Config Missing: 'version' is required. E.g. ${supportedVersions.join(',')}`); + } + + let acceptVersionHeader; + if (typeof version === 'boolean') { + if (version === true) { + acceptVersionHeader = defaultAcceptVersionHeader; + } + version = undefined; + } else if (version && !supportedVersions.includes(version) && !(version.match(/^v\d+\.\d+/))) { + throw new Error(`${name} Config Invalid: 'version' ${version} is not supported`); + } else { + if (version === 'canary') { + // eslint-disable-next-line + console.warn(`${name}: The 'version' parameter has a deprecated format 'canary', please use 'v{major}.{minor}' format instead`); + + acceptVersionHeader = defaultAcceptVersionHeader; + } else if (version.match(/^v\d+$/)) { + // eslint-disable-next-line + console.warn(`${name}: The 'version' parameter has a deprecated format 'v{major}', please use 'v{major}.{minor}' format instead`); + + acceptVersionHeader = `${version}.0`; + } else { + acceptVersionHeader = version; + } + } + + if (!url) { + throw new Error(`${name} Config Missing: 'url' is required. E.g. 'https://site.com'`); + } + if (!/https?:\/\//.test(url)) { + throw new Error(`${name} Config Invalid: 'url' ${url} requires a protocol. E.g. 'https://site.com'`); + } + if (url.endsWith('/')) { + throw new Error(`${name} Config Invalid: 'url' ${url} must not have a trailing slash. E.g. 'https://site.com'`); + } + if (ghostPath.endsWith('/') || ghostPath.startsWith('/')) { + throw new Error(`${name} Config Invalid: 'ghostPath' ${ghostPath} must not have a leading or trailing slash. E.g. 'ghost'`); + } + if (key && !/[0-9a-f]{26}/.test(key)) { + throw new Error(`${name} Config Invalid: 'key' ${key} must have 26 hex characters`); + } + + if (userAgent === undefined) { + userAgent = USER_AGENT_DEFAULT; + } + + const api = ['posts', 'authors', 'tags', 'pages', 'settings', 'tiers', 'newsletters', 'offers'].reduce((apiObject, resourceType) => { + function browse(options = {}, memberToken) { + return makeApiRequest(resourceType, options, null, memberToken); + } + function read(data, options = {}, memberToken) { + if (!data || !data.id && !data.slug) { + return Promise.reject(new Error(`${name} read requires an id or slug.`)); + } + + const params = Object.assign({}, data, options); + + return makeApiRequest(resourceType, params, data.id || `slug/${data.slug}`, memberToken); + } + + return Object.assign(apiObject, { + [resourceType]: { + read, + browse + } + }); + }, {}); + + // Settings, tiers & newsletters only have browse methods, offers only has read + delete api.settings.read; + delete api.tiers.read; + delete api.newsletters.read; + delete api.offers.browse; + + return api; + + function makeApiRequest(resourceType, params, id, membersToken = null) { + if (!membersToken && !key) { + return Promise.reject( + new Error(`${name} Config Missing: 'key' is required.`) + ); + } + delete params.id; + + const headers = membersToken ? { + Authorization: `GhostMembers ${membersToken}` + } : {}; + + if (userAgent) { + if (typeof userAgent === 'boolean') { + headers['User-Agent'] = `GhostContentSDK/${packageVersion}`; + } else { + headers['User-Agent'] = userAgent; + } + } + + if (acceptVersionHeader) { + headers['Accept-Version'] = acceptVersionHeader; + } + + params = Object.assign({key}, params); + const apiUrl = `${url}/${ghostPath}/api${resolveAPIPrefix(version)}${resourceType}/${id ? id + '/' : ''}`; + + return makeRequest({ + url: apiUrl, + method: 'get', + params, + headers + }) + .then((res) => { + if (!Array.isArray(res.data[resourceType])) { + return res.data[resourceType]; + } + if (res.data[resourceType].length === 1 && !res.data.meta) { + return res.data[resourceType][0]; + } + return Object.assign(res.data[resourceType], {meta: res.data.meta}); + }).catch((err) => { + if (err.response && err.response.data && err.response.data.errors) { + const props = err.response.data.errors[0]; + const toThrow = new Error(props.message); + const keys = Object.keys(props); + + toThrow.name = props.type; + + keys.forEach((k) => { + toThrow[k] = props[k]; + }); + + toThrow.response = err.response; + + // @TODO: remove in 2.0. We have enhanced the error handling, but we don't want to break existing implementations. + toThrow.request = err.request; + toThrow.config = err.config; + + throw toThrow; + } else { + throw err; + } + }); + } +} + +export default GhostContentAPI; diff --git a/src/Types/ghost.ts b/src/Types/ghost.ts new file mode 100644 index 00000000..a0b24742 --- /dev/null +++ b/src/Types/ghost.ts @@ -0,0 +1,252 @@ +export type ArrayOrValue = T | T[]; +export type Nullable = T | null; + +export interface Pagination { + page: number; + limit: number; + pages: number; + total: number; + next: Nullable; + prev: Nullable; +} + +export interface Identification { + slug: string; + id: string; +} + +export interface Metadata { + meta_title?: Nullable | undefined; + meta_description?: Nullable | undefined; +} + +export interface Excerpt { + excerpt?: string | undefined; + custom_excerpt?: string | undefined; +} + +export interface CodeInjection { + codeinjection_head?: Nullable | undefined; + codeinjection_foot?: Nullable | undefined; +} + +/** Metadata for Facebook */ +export interface Facebook { + og_image?: Nullable | undefined; + og_title?: Nullable | undefined; + og_description?: Nullable | undefined; +} + +export interface Twitter { + twitter_image?: Nullable | undefined; + twitter_title?: Nullable | undefined; + twitter_description?: Nullable | undefined; +} + +export interface SocialMedia extends Facebook, Twitter { +} + +export interface Settings extends Metadata, CodeInjection, SocialMedia { + title?: string | undefined; + description?: string | undefined; + logo?: string | undefined; + icon?: string | undefined; + cover_image?: string | undefined; + facebook?: string | undefined; + twitter?: string | undefined; + lang?: string | undefined; + timezone?: string | undefined; + ghost_head?: Nullable | undefined; + ghost_foot?: Nullable | undefined; + navigation?: + | Array<{ + label: string; + url: string; + }> + | undefined; + secondary_navigation?: + | Array<{ + label: string; + url: string; + }> + | undefined; + url?: string | undefined; +} + +export interface Author extends Identification, Metadata { + name?: string | undefined; + profile_image?: Nullable | undefined; + cover_image?: Nullable | undefined; + bio?: Nullable | undefined; + website?: Nullable | undefined; + location?: Nullable | undefined; + facebook?: Nullable | undefined; + twitter?: Nullable | undefined; + url?: Nullable | undefined; + count?: { + posts: number; + } | undefined; +} + +export type TagVisibility = "public" | "internal"; + +export interface Tag extends Identification, Metadata, SocialMedia { + name?: string | undefined; + description?: Nullable | undefined; + feature_image?: Nullable | undefined; + visibility?: TagVisibility | undefined; + url?: string | undefined; + canonical_url?: Nullable | undefined; + accent_color?: Nullable | undefined; + count?: { + posts: number; + } | undefined; +} + +export interface PostOrPage extends Identification, Excerpt, CodeInjection, Metadata, SocialMedia { + // Identification + uuid?: string | undefined; + comment_id?: string | undefined; + featured?: boolean | undefined; + + // Post or Page + title?: string | undefined; + html?: Nullable | undefined; + plaintext?: Nullable | undefined; + + // Image + feature_image?: Nullable | undefined; + feature_image_alt?: Nullable | undefined; + feature_image_caption?: Nullable | undefined; + + // Dates + created_at?: string | undefined; + updated_at?: Nullable | undefined; + published_at?: Nullable | undefined; + + // Custom Template for posts and pages + custom_template?: Nullable | undefined; + + // Post or Page + page?: boolean | undefined; + + // Reading time + reading_time?: number | undefined; + + // Tags - Only shown when using Include param + tags?: Tag[] | undefined; + primary_tag?: Nullable | undefined; + + // Authors - Only shown when using Include Param + authors?: Author[] | undefined; + primary_author?: Nullable | undefined; + + url?: string | undefined; + canonical_url?: Nullable | undefined; +} + +export type GhostData = PostOrPage | Author | Tag | Settings; + +export type IncludeParam = "authors" | "tags" | "count.posts"; + +export type FieldParam = string; + +export type FormatParam = "html" | "plaintext"; + +export type FilterParam = string; + +export type LimitParam = number | string; + +export type PageParam = number; + +export type OrderParam = string; + +export interface Params { + include?: ArrayOrValue | undefined; + fields?: ArrayOrValue | undefined; + formats?: ArrayOrValue | undefined; + filter?: ArrayOrValue | undefined; + limit?: ArrayOrValue | undefined; + page?: ArrayOrValue | undefined; + order?: ArrayOrValue | undefined; +} + +export interface BrowseFunction { + (options?: Params, memberToken?: Nullable): Promise; +} + +export interface ReadFunction { + ( + data: { id: Nullable } | { slug: Nullable }, + options?: Params, + memberToken?: Nullable, + ): Promise; +} + +interface BrowseResults extends Array { + meta: { pagination: Pagination }; +} + +export interface PostsOrPages extends BrowseResults { +} + +export interface Authors extends BrowseResults { +} + +export interface Tags extends BrowseResults { +} + +export interface SettingsResponse extends Settings { + meta: any; +} + +export interface GhostError { + errors: Array<{ + message: string; + errorType: string; + }>; +} + +export interface GhostContentAPIOptions { + url: string; + /** + * Version of GhostContentAPI + * + * Supported Versions: 'v2', 'v3', 'v4', 'v5.0', 'canary' + */ + version: "v2" | "v3" | "v4" | "v5.0" | "canary"; + key: string; + /** @deprecated since version v2 */ + host?: string | undefined; + /** @default "ghost" */ + ghostPath?: string | undefined; +} + +export interface GhostAPI { + posts: { + browse: BrowseFunction; + read: ReadFunction; + }; + authors: { + browse: BrowseFunction; + read: ReadFunction; + }; + tags: { + browse: BrowseFunction; + read: ReadFunction; + }; + pages: { + browse: BrowseFunction; + read: ReadFunction; + }; + settings: { + browse: BrowseFunction; + }; +} + +declare var GhostContentAPI: { + (options: GhostContentAPIOptions): GhostAPI; + new(options: GhostContentAPIOptions): GhostAPI; +}; + +export default GhostContentAPI; diff --git a/src/ghost.ts b/src/ghost.ts new file mode 100644 index 00000000..8ac87bdc --- /dev/null +++ b/src/ghost.ts @@ -0,0 +1,63 @@ +// IMPORT Ghost Types +import type { PostOrPage, PostsOrPages, Authors, Tag, Tags, ArrayOrValue, IncludeParam, LimitParam, Settings, Nullable } from './Types/ghost'; + +// IMPORT Ghost API Client +import { api } from './Content-API/api-client'; + +// SET Include params +const include:ArrayOrValue = ['authors', 'tags']; + +// Get Posts (General "ALL") +export const getGhostPosts = async () => { + const ghostPosts:PostsOrPages = await api.posts.browse({include,filter:'visibility:public'}) + return ghostPosts; } + +// Get Posts (Recent "setLimit?") +export const getGhostRecentPosts = async (setLimit?:ArrayOrValue) => { + const ghostRecentPosts:PostsOrPages = await api.posts.browse({limit:setLimit?setLimit:"6",include,filter:'visibility:public'}); + return ghostRecentPosts; } + +// Get Posts (Featured "setLimit?") +export const getGhostFeaturedPosts = async (setLimit?:ArrayOrValue) => { + const ghostFeaturedPosts:PostsOrPages = await api.posts.browse({limit:setLimit?setLimit:"1",include,filter:'featured:true'}); + return ghostFeaturedPosts; } + +// Get Post (By Slug) +export const getGhostPostbySlug = async (slug:Nullable) => { + const ghostPostbySlug:PostOrPage = await api.posts.read({slug},{include}); + return ghostPostbySlug; } + +// Get Post (By Tag) +export const getGhostPostsbyTag = async (slug:Nullable) => { + const ghostPostsbyTag:PostsOrPages = await api.posts.browse({filter:`tag:${slug}`,include}); + return ghostPostsbyTag; } + +// Get Tags (General "ALL") +export const getGhostTags = async () => { + const ghostTags:Tags = await api.tags.browse({include:`count.posts`}); + return ghostTags; } + +// Get Tag (By Slug) +export const getGhostTagbySlug = async (slug:Nullable) => { + const ghostTagbySlug:Tag = await api.tags.read({slug},{include:`count.posts`}); + return ghostTagbySlug; } + +// Get Authors (General "ALL") +export const getGhostAuthors = async () => { + const ghostAuthors:Authors = await api.authors.browse(); + return ghostAuthors; } + +// Get Pages (ALL) +export const getGhostPages = async () => { + const ghostPages:PostsOrPages = await api.pages.browse(); + return ghostPages; } + +// Get Page (by Slug) +export const getGhostPage = async (slug:Nullable) => { + const ghostPage:PostOrPage = await api.pages.read({slug}); + return ghostPage; } + +// Get Settings +export const getGhostSettings = async () => { + const ghostSettings:Settings = await api.settings.browse(); + return ghostSettings; } \ No newline at end of file