integrate API into starlight-ghostcms... more progress almost ready to build a special starlight playground for the starlight stuff.

This commit is contained in:
Adam Matthiesen 2024-02-19 19:32:27 -08:00
parent a12adca567
commit 795c075405
37 changed files with 1705 additions and 135 deletions

17
packages/starlight-ghostcms/astro.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
declare module 'astro:content' {
export interface AstroCollectionEntry<TData> {
body: string
collection: string
data: TData
id: string
render: () => Promise<{
Content: import('astro').MarkdownInstance<object>['Content']
}>
slug: string
}
export function getCollection<TData>(
collection: string,
filter?: (entry: AstroCollectionEntry<TData>) => boolean,
): Promise<AstroCollectionEntry<TData>[]>
}

View File

@ -17,6 +17,7 @@ export default function starlightBlogPlugin(userConfig?: StarlightGhostConfig):
...starlightConfig.components,
...overrideStarlightComponent(starlightConfig.components, logger, 'MarkdownContent'),
...overrideStarlightComponent(starlightConfig.components, logger, 'Sidebar'),
...overrideStarlightComponent(starlightConfig.components, logger, "SiteTitle"),
}
})

View File

@ -32,22 +32,29 @@
"email": "issues@astro-ghostcms.xyz"
},
"main": "index.ts",
"types": "types.d.ts",
"types": "index.ts",
"files": [
"src",
".env.demo",
"index.ts",
"tsconfig.json",
"types.d.ts"
],
"exports": {
".": "./index.ts"
".": "./index.ts",
"./overrides/MarkdownContent.astro": "./src/overrides/MarkdownContent.astro",
"./overrides/Sidebar.astro": "./src/overrides/SideBar.astro",
"./overrides/SiteTitle.astro": "./src/overrides/SiteTitle.astro",
"./routes/index.astro": "./src/routes/index.astro",
"./routes/[slug].astro": "./src/routes/[slug].astro",
"./schema": "./src/schemas/config.ts",
"./package.json": "./package.json"
},
"scripts": {
},
"devDependencies": {
"@astrojs/starlight": "0.19.0",
"@ts-ghost/core-api": "5.1.2",
"vite": "^5.1.2",
"astro": "4.3.7"
},
"peerdependencies": {

View File

@ -1,21 +1,21 @@
---
import { getBlogEntryMetadata, type StarlightBlogEntry } from '../utils/content'
import type { Post } from '../schemas/posts'
import Author from './Author.astro'
interface Props {
entry: StarlightBlogEntry
entry: Post
}
const { entry } = Astro.props
const { authors, date } = getBlogEntryMetadata(entry)
const { authors, published_at, created_at } = entry
const hasAuthors = authors.length > 0
const hasAuthors = authors !== undefined
---
<div class="metadata not-content">
<time datetime={entry.data.date.toISOString()}>
{date}
<time datetime={published_at?published_at:created_at.toString()}>
{published_at}
</time>
{
hasAuthors ? (

View File

@ -1,10 +1,9 @@
---
import type { StarlightBlogEntry } from '../utils/content'
import Preview from './Preview.astro'
import type { Post } from '../schemas/posts'
interface Props {
entries: StarlightBlogEntry[]
entries: Post[]
}
const { entries } = Astro.props

View File

@ -1,26 +1,25 @@
---
import { getBlogEntryExcerpt, type StarlightBlogEntry } from '../utils/content'
import type { Post } from '../schemas/posts'
import Metadata from './Metadata.astro'
interface Props {
entry: StarlightBlogEntry
entry: Post
}
const { entry } = Astro.props
const Excerpt = await getBlogEntryExcerpt(entry)
const Excerpt = entry.excerpt
---
<article class="preview">
<header>
<h2>
<a href={`/${entry.slug}`}>{entry.data.title}</a>
<a href={`/${entry.slug}`}>{entry.title}</a>
</h2>
<Metadata entry={entry} />
</header>
<div class="sl-markdown-content">
{typeof Excerpt === 'string' ? Excerpt : <Excerpt />}
{typeof Excerpt === 'string' ? Excerpt : entry.excerpt}
</div>
</article>

View File

@ -0,0 +1,30 @@
---
import type { Props } from "@astrojs/starlight/props";
import AstrolightSiteTitle from "@astrojs/starlight/components/SiteTitle.astro";
//import config from 'virtual:starlight-ghost-config'
---
<AstrolightSiteTitle {...Astro.props} />
<div>
<a href="/blog">Blog</a>
</div>
<style>
div {
border-inline-start: 1px solid var(--sl-color-gray-5);
display: none;
padding-inline-start: 1rem;
}
@media (min-width: 50rem) {
div {
display: flex;
}
}
a {
color: var(--sl-color-text-accent);
font-weight: 600;
text-decoration: none;
}
</style>

View File

@ -0,0 +1,37 @@
---
import type { InferGetStaticPropsType } from 'astro'
import config from 'virtual:starlight-ghost-config'
import Page from '../components/Page.astro'
import Posts from '../components/Posts.astro'
import PrevNextLinks from '../components/PrevNextLinks.astro'
import { getPageProps } from '../utils/page'
export const prerender = true
export function getStaticPaths() {
}
type Props = InferGetStaticPropsType<typeof getStaticPaths>
const { entries, nextLink, prevLink } = Astro.props
const pageProps = getPageProps(config.title)
---
<Page {...pageProps}>
<Posts {entries} />
<footer class="not-content">
<PrevNextLinks next={nextLink} prev={prevLink} />
</footer>
</Page>
<style>
:global(.content-panel:first-of-type) {
display: none;
}
:global(.content-panel:nth-of-type(2)) {
border-top: none;
}
</style>

View File

@ -1,9 +1,16 @@
/** @ts-expect-error */
import { AstroError } from 'astro/errors'
import { z } from 'astro/zod'
const configSchema = z
.object({
/**
* The number of blog posts to display per page in the blog post list.
*/
postCount: z.number().min(1).default(5),
/**
* The number of recent blog posts to display in the sidebar.
*/
recentPostCount: z.number().min(1).default(10),
/**
* The title of the blog.
*/

View File

@ -0,0 +1,54 @@
import {
ghostCodeInjectionSchema,
ghostIdentitySchema,
ghostMetadataSchema,
ghostSocialMediaSchema,
ghostVisibilitySchema,
} from "@ts-ghost/core-api";
import { z } from "astro/zod";
import { authorsSchema } from "./authors";
import { tagsSchema } from "./tags";
const postsAuthorSchema = authorsSchema.extend({
url: z.string().nullish(),
});
export const postsSchema = z.object({
...ghostIdentitySchema.shape,
...ghostMetadataSchema.shape,
title: z.string(),
html: z.string().catch(""),
plaintext: z.string().nullish(),
comment_id: z.string().nullable(),
feature_image: z.string().nullable(),
feature_image_alt: z.string().nullable(),
feature_image_caption: z.string().nullable(),
featured: z.boolean(),
custom_excerpt: z.string().nullable(),
...ghostCodeInjectionSchema.shape,
...ghostSocialMediaSchema.shape,
visibility: ghostVisibilitySchema,
custom_template: z.string().nullable(),
canonical_url: z.string().nullable(),
authors: z.array(postsAuthorSchema).optional(),
tags: z.array(tagsSchema).optional(),
primary_author: postsAuthorSchema.nullish(),
primary_tag: tagsSchema.nullish(),
url: z.string(),
excerpt: z.string().catch(""),
reading_time: z.number().optional().default(0),
created_at: z.string(),
updated_at: z.string().nullish(),
published_at: z.string().nullish(),
access: z.boolean(),
comments: z.boolean(),
email_subject: z.string().nullish(),
});
export type Post = z.infer<typeof postsSchema>;
export const postsIncludeSchema = z.object({
authors: z.literal(true).optional(),
tags: z.literal(true).optional(),
});
export type PostsIncludeSchema = z.infer<typeof postsIncludeSchema>;

View File

@ -0,0 +1,34 @@
import {
ghostCodeInjectionSchema,
ghostIdentitySchema,
ghostMetadataSchema,
ghostSocialMediaSchema,
ghostVisibilitySchema,
} from "@ts-ghost/core-api";
import { z } from "astro/zod";
export const tagsSchema = z.object({
...ghostIdentitySchema.shape,
...ghostMetadataSchema.shape,
...ghostCodeInjectionSchema.shape,
...ghostSocialMediaSchema.shape,
name: z.string(),
description: z.string().nullable(),
feature_image: z.string().nullable(),
visibility: ghostVisibilitySchema,
canonical_url: z.string().nullable(),
accent_color: z.string().nullable(),
url: z.string(),
count: z
.object({
posts: z.number(),
})
.optional(),
});
export type Tag = z.infer<typeof tagsSchema>;
export const tagsIncludeSchema = z.object({
"count.posts": z.literal(true).optional(),
});
export type TagsIncludeSchema = z.infer<typeof tagsIncludeSchema>;

View File

@ -0,0 +1,124 @@
import { TS_API } from "./content-api";
import type { Page, Post } from "./content-api/schemas";
import type { ContentAPICredentials } from './content-api/content-api'
// LOAD ENVIRONMENT VARIABLES
import { loadEnv } from "vite";
import { invariant } from "./invariant";
const { CONTENT_API_KEY, CONTENT_API_URL } = loadEnv(
"all",
process.cwd(),
"CONTENT_",
);
invariant(CONTENT_API_KEY)
invariant(CONTENT_API_URL)
const key:ContentAPICredentials["key"] = CONTENT_API_KEY;
const url:ContentAPICredentials["url"] = CONTENT_API_URL;
const version = "v5.0";
const api = new TS_API(url, key, version);
export const getAllAuthors = async () => {
const results = await api.authors
.browse()
.include({ "count.posts": true })
.fetch();
if (!results.success) {
throw new Error(results.errors.map((e) => e.message).join(", "));
}
return {
authors: results.data,
meta: results.meta,
};
};
export const getPosts = async () => {
const results = await api.posts
.browse()
.include({
authors: true,
tags: true,
})
.fetch();
if (!results.success) {
throw new Error(results.errors.map((e) => e.message).join(", "));
}
return {
posts: results.data,
meta: results.meta,
};
};
export const getAllPosts = async () => {
const posts: Post[] = [];
let cursor = await api.posts
.browse()
.include({
authors: true,
tags: true,
})
.paginate();
if (cursor.current.success) posts.push(...cursor.current.data);
while (cursor.next) {
cursor = await cursor.next.paginate();
if (cursor.current.success) posts.push(...cursor.current.data);
}
return posts;
};
export const getAllPages = async () => {
const pages: Page[] = [];
let cursor = await api.pages
.browse()
.include({
authors: true,
tags: true,
})
.paginate();
if (cursor.current.success) pages.push(...cursor.current.data);
while (cursor.next) {
cursor = await cursor.next.paginate();
if (cursor.current.success) pages.push(...cursor.current.data);
}
return pages;
};
export const getSettings = async () => {
const res = await api.settings.fetch();
if (res.success) {
return res.data;
}
return null;
};
export const getAllTags = async () => {
const results = await api.tags
.browse()
.include({ "count.posts": true })
.fetch();
if (!results.success) {
throw new Error(results.errors.map((e) => e.message).join(", "));
}
return {
tags: results.data,
meta: results.meta,
};
};
export const getFeaturedPosts = async () => {
const results = await api.posts
.browse({ filter: "featured:true" })
.include({
authors: true,
tags: true,
})
.fetch();
if (!results.success) {
throw new Error(results.errors.map((e) => e.message).join(", "));
}
return {
posts: results.data,
meta: results.meta,
};
};

View File

@ -0,0 +1,84 @@
import { assert, beforeEach, describe, expect, test } from "vitest";
import TS_API from "./content-api";
describe("content-api", () => {
let api: TS_API;
beforeEach(() => {
api = new TS_API("https://ghost.org", "59d4bf56c73c04a18c867dc3ba", "v5.0");
});
test("content-api", () => {
expect(api).toBeDefined();
});
test("content-api shouldn't instantiate with an incorrect url", () => {
assert.throws(() => {
const api = new TS_API("ghost.org", "59d4bf56c73c04a18c867dc3ba", "v5.0");
api.settings;
});
});
test("content-api shouldn't instantiate with an incorrect key", () => {
assert.throws(() => {
const api = new TS_API("https://ghost.org", "a", "v5.0");
api.settings;
});
});
test("content-api shouldn't instantiate with an incorrect version", () => {
assert.throws(() => {
const api = new TS_API(
"https://ghost.org",
"1efedd9db174adee2d23d982:4b74dca0219bad629852191af326a45037346c2231240e0f7aec1f9371cc14e8",
// @ts-expect-error
"v4.0",
);
api.settings;
});
});
test("content-api.posts", () => {
expect(api.posts).toBeDefined();
expect(api.posts.browse).toBeDefined();
expect(api.posts.read).toBeDefined();
});
test("content-api.pages", () => {
expect(api.pages).toBeDefined();
expect(api.pages.browse).toBeDefined();
expect(api.pages.read).toBeDefined();
});
test("content-api.tags", () => {
expect(api.tags).toBeDefined();
expect(api.tags.browse).toBeDefined();
expect(api.tags.read).toBeDefined();
});
test("content-api.tiers", () => {
expect(api.tiers).toBeDefined();
expect(api.tiers.browse).toBeDefined();
expect(api.tiers.read).toBeDefined();
});
test("content-api.authors", () => {
expect(api.authors).toBeDefined();
expect(api.authors.browse).toBeDefined();
expect(api.authors.read).toBeDefined();
// @ts-expect-error
expect(api.authors.add).toBeUndefined();
// @ts-expect-error
expect(api.authors.edit).toBeUndefined();
expect(api.authors).toBeDefined();
});
test("content-api.settings", () => {
expect(api.settings).toBeDefined();
expect(api.settings.fetch).toBeDefined();
// @ts-expect-error
expect(api.settings.read).toBeUndefined();
// @ts-expect-error
expect(api.settings.browse).toBeUndefined();
});
});

View File

@ -0,0 +1,116 @@
import {
APIComposer,
BasicFetcher,
HTTPClient,
contentAPICredentialsSchema,
slugOrIdSchema,
} from "@ts-ghost/core-api";
import {
authorsIncludeSchema,
authorsSchema,
pagesIncludeSchema,
pagesSchema,
postsIncludeSchema,
postsSchema,
settingsSchema,
tagsIncludeSchema,
tagsSchema,
tiersIncludeSchema,
tiersSchema,
} from "./schemas";
export type { ContentAPICredentials, APIVersions } from "@ts-ghost/core-api";
export enum BrowseEndpointType {
authors = "authors",
tiers = "tiers",
posts = "posts",
pages = "pages",
tags = "tags",
settings = "settings",
}
export default class TS_API<Version extends `v5.${string}` = any> {
private httpClient: HTTPClient;
constructor(
protected readonly url: string,
protected readonly key: string,
protected readonly version: Version,
) {
const apiCredentials = contentAPICredentialsSchema.parse({
key,
version,
url,
});
this.httpClient = new HTTPClient({
...apiCredentials,
endpoint: "content",
});
}
get authors() {
return new APIComposer(
"authors",
{
schema: authorsSchema,
identitySchema: slugOrIdSchema,
include: authorsIncludeSchema,
},
this.httpClient,
).access(["read", "browse"]);
}
get tiers() {
return new APIComposer(
"tiers",
{
schema: tiersSchema,
identitySchema: slugOrIdSchema,
include: tiersIncludeSchema,
},
this.httpClient,
).access(["browse", "read"]);
}
get posts() {
return new APIComposer(
"posts",
{
schema: postsSchema,
identitySchema: slugOrIdSchema,
include: postsIncludeSchema,
},
this.httpClient,
).access(["browse", "read"]);
}
get pages() {
return new APIComposer(
"pages",
{
schema: pagesSchema,
identitySchema: slugOrIdSchema,
include: pagesIncludeSchema,
},
this.httpClient,
).access(["browse", "read"]);
}
get tags() {
return new APIComposer(
"tags",
{
schema: tagsSchema,
identitySchema: slugOrIdSchema,
include: tagsIncludeSchema,
},
this.httpClient,
).access(["browse", "read"]);
}
get settings() {
return new BasicFetcher(
"settings",
{ output: settingsSchema },
this.httpClient,
);
}
}

View File

@ -0,0 +1,8 @@
export { default as TS_API } from "./content-api";
export * from "./schemas";
export type {
InferFetcherDataShape,
InferResponseDataShape,
BrowseParams,
} from "@ts-ghost/core-api";

View File

@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import createFetchMock from "vitest-fetch-mock";
import TS_API from "../../content-api";
const fetchMocker = createFetchMock(vi);
describe("authors api .browse() Args Type-safety", () => {
const url = process.env.VITE_GHOST_URL || "https://my-ghost-blog.com";
const key =
process.env.VITE_GHOST_CONTENT_API_KEY || "59d4bf56c73c04a18c867dc3ba";
const api = new TS_API(url, key, "v5.0");
test(".browse() params shouldnt accept invalid params", () => {
// @ts-expect-error - shouldnt accept invalid params
const browse = api.authors.browse({ pp: 2 });
expect(browse.getParams().browseParams).toStrictEqual({});
});
test(".browse() 'order' params should ony accept fields values", () => {
// @ts-expect-error - order should ony contain field
expect(() => api.authors.browse({ order: "foo ASC" })).toThrow();
// valid
expect(
api.authors.browse({ order: "name ASC" }).getParams().browseParams,
).toStrictEqual({
order: "name ASC",
});
expect(
api.authors.browse({ order: "name ASC,slug DESC" }).getParams()
.browseParams,
).toStrictEqual({
order: "name ASC,slug DESC",
});
expect(
api.authors
.browse({ order: "name ASC,slug DESC,location ASC" })
.getParams().browseParams,
).toStrictEqual({
order: "name ASC,slug DESC,location ASC",
});
// @ts-expect-error - order should ony contain field (There is a typo in location)
expect(() =>
api.authors.browse({ order: "name ASC,slug DESC,locaton ASC" }),
).toThrow();
});
test(".browse() 'filter' params should ony accept valid field", () => {
expect(() =>
api.authors.browse({
// @ts-expect-error - order should ony contain field
filter: "foo:bar",
}),
).toThrow();
expect(
api.authors
.browse({
filter: "name:bar",
})
.getParams().browseParams,
).toStrictEqual({
filter: "name:bar",
});
expect(
api.authors
.browse({
filter: "name:bar+slug:-test",
})
.getParams().browseParams,
).toStrictEqual({
filter: "name:bar+slug:-test",
});
});
test(".browse 'fields' argument should ony accept valid fields", () => {
expect(
api.authors
.browse()
.fields({
// @ts-expect-error - order should ony contain field
foo: true,
})
.getOutputFields(),
).toEqual([]);
expect(
api.authors.browse().fields({ location: true }).getOutputFields(),
).toEqual(["location"]);
expect(
api.authors
.browse()
.fields({ name: true, website: true })
.getOutputFields(),
).toEqual(["name", "website"]);
});
});
describe("authors resource mocked", () => {
let api: TS_API;
beforeEach(() => {
api = new TS_API(
"https://my-ghost-blog.com",
"59d4bf56c73c04a18c867dc3ba",
"v5.0",
);
fetchMocker.enableMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("aouthors should be fetched correctly", async () => {
const authors = api.authors;
expect(authors).not.toBeUndefined();
const browseQuery = authors
.browse({
page: 2,
})
.fields({
name: true,
id: true,
});
expect(browseQuery).not.toBeUndefined();
expect(browseQuery.getOutputFields()).toStrictEqual(["name", "id"]);
fetchMocker.doMockOnce(
JSON.stringify({
authors: [
{
name: "foo",
id: "eaoizdjoa1321123",
},
],
meta: {
pagination: {
page: 1,
limit: 15,
pages: 1,
total: 1,
next: null,
prev: null,
},
},
}),
);
const result = await browseQuery.fetch();
expect(fetchMocker).toHaveBeenCalledTimes(1);
expect(fetchMocker).toHaveBeenCalledWith(
"https://my-ghost-blog.com/ghost/api/content/authors/?page=2&fields=name%2Cid&key=59d4bf56c73c04a18c867dc3ba",
{
headers: {
"Content-Type": "application/json",
"Accept-Version": "v5.0",
},
},
);
expect(result).not.toBeUndefined();
if (result.success) {
expect(result.data.length).toBe(1);
expect(result.data[0].name).toBe("foo");
expect(result.data[0].id).toBe("eaoizdjoa1321123");
}
});
});

View File

@ -0,0 +1,37 @@
import {
ghostIdentitySchema,
ghostMetaSchema,
ghostMetadataSchema,
} from "@ts-ghost/core-api";
import { z } from "zod";
export const authorsSchema = z.object({
...ghostIdentitySchema.shape,
...ghostMetadataSchema.shape,
name: z.string(),
profile_image: z.string().nullable(),
cover_image: z.string().nullable(),
bio: z.string().nullable(),
website: z.string().nullable(),
location: z.string().nullable(),
facebook: z.string().nullable(),
twitter: z.string().nullable(),
count: z
.object({
posts: z.number(),
})
.optional(),
url: z.string(),
});
export type Author = z.infer<typeof authorsSchema>;
export const ghostFetchAuthorsSchema = z.object({
meta: ghostMetaSchema,
authors: z.array(authorsSchema),
});
export const authorsIncludeSchema = z.object({
"count.posts": z.literal(true).optional(),
});
export type AuthorsIncludeSchema = z.infer<typeof authorsIncludeSchema>;

View File

@ -0,0 +1 @@
export * from "./authors";

View File

@ -0,0 +1 @@
export * from "./socials";

View File

@ -0,0 +1,32 @@
// Transformed to TypeScript, original Code by Ghost Foundation, License:
// MIT License
// Copyright (c) 2013-2022 Ghost Foundation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
export const twitter = (username: string) => {
// Creates the canonical twitter URL without the '@'
return `https://twitter.com/${username.replace(/^@/, "")}`;
};
export const facebook = (username: string) => {
// Handles a starting slash, this shouldn't happen, but just in case
return `https://www.facebook.com/${username.replace(/^\//, "")}`;
};

View File

@ -0,0 +1,7 @@
export * from "./authors";
export * from "./helpers";
export * from "./pages";
export * from "./posts";
export * from "./settings";
export * from "./tags";
export * from "./tiers";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1,55 @@
import {
ghostCodeInjectionSchema,
ghostIdentitySchema,
ghostMetadataSchema,
ghostSocialMediaSchema,
ghostVisibilitySchema,
} from "@ts-ghost/core-api";
import { z } from "zod";
import { authorsSchema } from "../authors";
import { tagsSchema } from "../tags";
const postsAuthorSchema = authorsSchema.extend({
url: z.string().nullish(),
});
export const pagesSchema = z.object({
...ghostIdentitySchema.shape,
...ghostMetadataSchema.shape,
title: z.string(),
html: z.string().catch(""),
plaintext: z.string().nullish(),
comment_id: z.string().nullable(),
feature_image: z.string().nullable(),
feature_image_alt: z.string().nullable(),
feature_image_caption: z.string().nullable(),
featured: z.boolean(),
custom_excerpt: z.string().nullable(),
...ghostCodeInjectionSchema.shape,
...ghostSocialMediaSchema.shape,
visibility: ghostVisibilitySchema,
custom_template: z.string().nullable(),
canonical_url: z.string().nullable(),
authors: z.array(postsAuthorSchema).optional(),
tags: z.array(tagsSchema).optional(),
primary_author: postsAuthorSchema.nullish(),
primary_tag: tagsSchema.nullish(),
url: z.string(),
excerpt: z.string().catch(""),
reading_time: z.number().optional().default(0),
created_at: z.string(),
updated_at: z.string(),
published_at: z.string(),
access: z.boolean(),
comments: z.boolean(),
email_subject: z.string().nullish(),
});
export type Page = z.infer<typeof pagesSchema>;
export const pagesIncludeSchema = z.object({
authors: z.literal(true).optional(),
tags: z.literal(true).optional(),
});
export type PagesIncludeSchema = z.infer<typeof pagesIncludeSchema>;

View File

@ -0,0 +1 @@
export * from "./posts";

View File

@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import TS_API from "../../content-api";
import type { Post } from "./posts";
const url = process.env.VITE_GHOST_URL || "https://my-ghost-blog.com";
const key =
process.env.VITE_GHOST_CONTENT_API_KEY || "59d4bf56c73c04a18c867dc3ba";
describe("posts api .browse() Args Type-safety", () => {
const api = new TS_API(url, key, "v5.0");
test(".browse() params shouldnt accept invalid params", () => {
// @ts-expect-error - shouldnt accept invalid params
const browse = api.posts.browse({ pp: 2 });
expect(browse.getParams().browseParams).toStrictEqual({});
const outputFields = {
slug: true,
title: true,
// @ts-expect-error - shouldnt accept invalid params
foo: true,
} satisfies { [k in keyof Post]?: true | undefined };
// biome-ignore lint/style/useConst: <explanation>
let test = api.posts
.browse()
// @ts-expect-error - shouldnt accept invalid params
.fields(outputFields);
expect(test.getOutputFields()).toEqual(["slug", "title"]);
const fields = ["slug", "title", "foo"] as const;
const unknownOriginFields = fields.reduce(
(acc, k) => {
acc[k as keyof Post] = true;
return acc;
},
{} as { [k in keyof Post]?: true | undefined },
);
const result = api.posts.browse().fields(unknownOriginFields);
expect(result.getOutputFields()).toEqual(["slug", "title"]);
});
test(".browse() params, output fields declare const", () => {
const outputFields = {
slug: true,
title: true,
} satisfies { [k in keyof Post]?: true | undefined };
// biome-ignore lint/style/useConst: <explanation>
let test = api.posts.browse().fields(outputFields);
expect(test.getOutputFields()).toEqual(["slug", "title"]);
// @ts-expect-error - shouldnt accept invalid params
expect(() => api.posts.browse({ filter: "slugg:test" })).toThrow();
// @ts-expect-error - shouldnt accept invalid params
expect(() =>
api.posts.browse({ filter: "slug:test,foo:-[bar,baz]" }),
).toThrow();
expect(
api.posts.browse({ filter: "slug:test,tags:-[bar,baz]" }),
).toBeDefined();
expect(
api.posts.browse({ filter: "slug:test,tags:[bar,baz]" }),
).toBeDefined();
// @ts-expect-error - shouldnt accept invalid params
expect(() =>
api.posts.browse({ filter: "slug:test,food:-[bar,baz]" }),
).toThrow();
});
});

View File

@ -0,0 +1,54 @@
import {
ghostCodeInjectionSchema,
ghostIdentitySchema,
ghostMetadataSchema,
ghostSocialMediaSchema,
ghostVisibilitySchema,
} from "@ts-ghost/core-api";
import { z } from "zod";
import { authorsSchema } from "../authors";
import { tagsSchema } from "../tags";
const postsAuthorSchema = authorsSchema.extend({
url: z.string().nullish(),
});
export const postsSchema = z.object({
...ghostIdentitySchema.shape,
...ghostMetadataSchema.shape,
title: z.string(),
html: z.string().catch(""),
plaintext: z.string().nullish(),
comment_id: z.string().nullable(),
feature_image: z.string().nullable(),
feature_image_alt: z.string().nullable(),
feature_image_caption: z.string().nullable(),
featured: z.boolean(),
custom_excerpt: z.string().nullable(),
...ghostCodeInjectionSchema.shape,
...ghostSocialMediaSchema.shape,
visibility: ghostVisibilitySchema,
custom_template: z.string().nullable(),
canonical_url: z.string().nullable(),
authors: z.array(postsAuthorSchema).optional(),
tags: z.array(tagsSchema).optional(),
primary_author: postsAuthorSchema.nullish(),
primary_tag: tagsSchema.nullish(),
url: z.string(),
excerpt: z.string().catch(""),
reading_time: z.number().optional().default(0),
created_at: z.string(),
updated_at: z.string().nullish(),
published_at: z.string().nullish(),
access: z.boolean(),
comments: z.boolean(),
email_subject: z.string().nullish(),
});
export type Post = z.infer<typeof postsSchema>;
export const postsIncludeSchema = z.object({
authors: z.literal(true).optional(),
tags: z.literal(true).optional(),
});
export type PostsIncludeSchema = z.infer<typeof postsIncludeSchema>;

View File

@ -0,0 +1 @@
export * from "./settings";

View File

@ -0,0 +1,40 @@
import { z } from "zod";
export const settingsSchema = z.object({
title: z.string(),
description: z.string(),
logo: z.string().nullable(),
icon: z.string().nullable(),
accent_color: z.string().nullable(),
cover_image: z.string().nullable(),
facebook: z.string().nullable(),
twitter: z.string().nullable(),
lang: z.string(),
timezone: z.string(),
codeinjection_head: z.string().nullable(),
codeinjection_foot: z.string().nullable(),
navigation: z.array(
z.object({
label: z.string(),
url: z.string(),
}),
),
secondary_navigation: z.array(
z.object({
label: z.string(),
url: z.string(),
}),
),
meta_title: z.string().nullable(),
meta_description: z.string().nullable(),
og_image: z.string().nullable(),
og_title: z.string().nullable(),
og_description: z.string().nullable(),
twitter_image: z.string().nullable(),
twitter_title: z.string().nullable(),
twitter_description: z.string().nullable(),
members_support_address: z.string(),
url: z.string(),
});
export type Settings = z.infer<typeof settingsSchema>;

View File

@ -0,0 +1 @@
export * from "./tags";

View File

@ -0,0 +1,34 @@
import {
ghostCodeInjectionSchema,
ghostIdentitySchema,
ghostMetadataSchema,
ghostSocialMediaSchema,
ghostVisibilitySchema,
} from "@ts-ghost/core-api";
import { z } from "zod";
export const tagsSchema = z.object({
...ghostIdentitySchema.shape,
...ghostMetadataSchema.shape,
...ghostCodeInjectionSchema.shape,
...ghostSocialMediaSchema.shape,
name: z.string(),
description: z.string().nullable(),
feature_image: z.string().nullable(),
visibility: ghostVisibilitySchema,
canonical_url: z.string().nullable(),
accent_color: z.string().nullable(),
url: z.string(),
count: z
.object({
posts: z.number(),
})
.optional(),
});
export type Tag = z.infer<typeof tagsSchema>;
export const tagsIncludeSchema = z.object({
"count.posts": z.literal(true).optional(),
});
export type TagsIncludeSchema = z.infer<typeof tagsIncludeSchema>;

View File

@ -0,0 +1 @@
export * from "./tiers";

View File

@ -0,0 +1,40 @@
import { ghostIdentitySchema, ghostVisibilitySchema } from "@ts-ghost/core-api";
import { z } from "zod";
export const tiersSchema = z.object({
...ghostIdentitySchema.shape,
name: z.string(),
description: z.string().nullable(),
active: z.boolean(),
type: z.union([z.literal("free"), z.literal("paid")]),
welcome_page_url: z.string().nullable(),
created_at: z.string(),
updated_at: z.string().nullable(),
stripe_prices: z
.array(z.number())
.optional()
.transform((v) => (v?.length ? v : [])),
monthly_price: z
.number()
.nullable()
.optional()
.transform((v) => (v ? v : null)),
yearly_price: z
.number()
.nullable()
.optional()
.transform((v) => (v ? v : null)),
benefits: z.array(z.string()),
visibility: ghostVisibilitySchema,
currency: z.string().nullish(),
trial_days: z.number().default(0),
});
export type Tier = z.infer<typeof tiersSchema>;
export const tiersIncludeSchema = z.object({
monthly_price: z.literal(true).optional(),
yearly_price: z.literal(true).optional(),
benefits: z.literal(true).optional(),
});
export type TiersIncludeSchema = z.infer<typeof tiersIncludeSchema>;

View File

@ -0,0 +1,3 @@
export * from "./api-functions";
export * from "./content-api/schemas";
export * from "./invariant";

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
// Modified version of invariant script to allow tests
const isProduction = false;
const prefix: string = "Invariant failed";
function invariant(condition: any, message?: string | (() => string)) {
if (condition) {
return;
}
if (isProduction) {
throw new Error(prefix);
}
const provided: string | undefined =
typeof message === "function" ? message() : message;
const value: string = provided ? `${prefix}: ${provided}` : prefix;
return value;
}
// TEST SECTION
const testTrue = true;
const testFalse = false;
describe("test invariant", () => {
it("Test `true` value", () => {
invariant(testTrue, "This should not error");
expect(null);
});
it("Test `false` value", () => {
invariant(testFalse, "This should Error");
expect(String("Invariant failed"));
});
});

View File

@ -0,0 +1,48 @@
/** MIT License
Copyright (c) 2019 Alexander Reardon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
const tinyinvariant = "merged";
const isProduction: boolean = process.env.NODE_ENV === "production";
const prefix: string = "Invariant failed";
/** Throw an error if the condition is false
* @example
* import { invariant } from '@matthiesenxyz/astro-ghostcms/api';
* invariant(var, "var is false but its not supposed to be!")
*/
export function invariant(
condition: any,
message?: string | (() => string),
): asserts condition {
if (condition) {
return;
}
if (isProduction) {
throw new Error(prefix);
}
const provided: string | undefined =
typeof message === "function" ? message() : message;
const value: string = provided ? `${prefix}: ${provided}` : prefix;
throw new Error(value);
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"esModuleInterop": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ESNext",
"useDefineForClassFields": true,
"verbatimModuleSyntax": true
}
}

File diff suppressed because it is too large Load Diff