Add new Starlight-GhostCMS plugin (#66)

This commit is contained in:
Adam Matthiesen 2024-02-20 04:37:24 -08:00 committed by GitHub
commit 7a635cb0c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 3044 additions and 95 deletions

View File

@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["playground"]
"ignore": ["playground","starlight-playground"]
}

View File

@ -0,0 +1,5 @@
---
"@matthiesenxyz/starlight-ghostcms": patch
---
Initial Public Release, Added Readme, Basics Working, Ready Set GO!

View File

@ -7,6 +7,7 @@
},
"scripts": {
"dev": "pnpm --filter playground dev",
"starlight:dev": "pnpm --filter starlight-playground dev",
"lint": "biome check .",
"lint:fix": "biome check --apply .",
"ci:version": "pnpm changeset version",
@ -15,7 +16,10 @@
"test:api": "pnpm --filter astro-ghostcms test",
"test:api:watch": "pnpm --filter astro-ghostcms test:watch",
"test:api:coverage": "pnpm --filter astro-ghostcms test:coverage",
"test:create": "pnpm --filter create-astro-ghostcms test"
"test:create": "pnpm --filter create-astro-ghostcms test",
"test:slg": "pnpm --filter starlight-ghostcms test",
"test:slg:watch": "pnpm --filter starlight-ghostcms test:watch",
"test:slg:coverage": "pnpm --filter starlight-ghostcms test:coverage"
},
"devDependencies": {
"@biomejs/biome": "1.5.3",

View File

@ -0,0 +1,75 @@
<img src="https://ghostdemo.matthiesen.xyz/content/images/size/w50/2024/01/logo-1.png" width="64px" />
# Welcome to Starlight-GhostCMS by [Astro-GhostCMS](https://astro-ghostcms.xyz)
Starlight-GhostCMS is a [Starlight](https://starlight.astro.build/) plugin to add a blog to your documentation site.
- Customizable Header link *(Defaults to "Blog")*
- Pulls all the Posts from your GhostCMS install
- Pulls all Authors & Featured Images and adds then to your Posts
- Creates a custom section on your website linkable from the Header
For a full always up to date documentation please checkout [Our Website](https://astro-ghostcms.xyz)
- [Starlight Demo **COMING SOON**](#)
- [Astro-GhostCMS Website](https://astro-ghostcms.xyz) Check the website for the most up-to-date Documentation!
- [Ghost.org](https://ghost.org) Get your own Ghost[^1] Install
*Need help but don't have Github? Email us at [issues@astro-ghostcms.xyz](mailto:issues@astroghostcms.xyz) to create an issue here on github! Or join our [Discord](https://discord.gg/u7NZqUyeAR)*
Astro minimum Version: **Starlight v0.19 & Astro v4**
## Installation
### Prerequisites:
1. You will need to have a Starlight Website Setup. If you dont have one yet, you can follow the ["Getting Started"](https://starlight.astro.build/getting-started) guide in the Starlight docs to create one.
2. You will need a GhostCMS server, and your `CONTENT_API_KEY` & `CONTENT_API_URL`. Your GhostCMS server must also support the `v5` Version of the GhostAPI. If you dont already have your Key and Url, you can find out how to get those on the Ghost docs [HERE](https://ghost.org/docs/content-api/)
### Install the plugin
Starlight GhostCMS is a Starlight [plugin](https://starlight.astro.build/reference/plugins/). Install it using your favorite package manager. Below is 3 examples of what to run. **CHOOSE ONE**.
```
npm install @matthiesenxyz/starlight-ghostcms
pnpm add @matthiesenxyz/starlight-ghostcms
yarn add @matthiesenxyz/starlight-ghostcms
```
### Configure the pluign
The Starlight GhostCMS plugin can be configured in your Starlight [configuration](https://starlight.astro.build/reference/configuration/#plugins) in the `astro.config.mjs` file.
```ts
import starlight from '@astrojs/starlight'
import { defineConfig } from 'astro/config'
import starlightGhostCMS from '@matthiesenxyz/starlight-ghostcms';
export default defineConfig({
integrations: [
starlight({
plugins: [starlightGhostCMS()],
title: 'My Docs',
}),
],
})
```
### Set your GhostCMS ENV Variables
You must also create 2 environment variables in a `.env` file with the following:
```env
CONTENT_API_KEY=a33da3965a3a9fb2c6b3f63b48
CONTENT_API_URL=https://ghostdemo.matthiesen.xyz
```
**When you deploy your install dont forget to set the above ENVIRONMENT VARIABLES on your provider!**
For more information and to see the docs please check our website: [Astro-GhostCMS.xyz](https://astro-ghostcms.xyz)
# Foot Notes & Credits
[^1]: Ghost.org, Ghost.io, Ghost are all trademarks of [The Ghost Foundation](https://ghost.org/). This project is Open Source and not directly related to or provided by The Ghost Foundation and is intended to help create a easier method to utilize their provided JavaScript tools to link a Headless GhostCMS install in to your Astro project.

View File

@ -0,0 +1,66 @@
import type { StarlightPlugin, StarlightUserConfig } from '@astrojs/starlight/types'
import type { AstroIntegrationLogger } from 'astro'
import { type StarlightGhostConfig, validateConfig } from './src/schemas/config'
import { vitePluginStarlightGhostConfig } from './src/integrations/vite'
export type { StarlightGhostConfig }
export default function starlightGhostCMS(userConfig?: StarlightGhostConfig): StarlightPlugin {
const config: StarlightGhostConfig = validateConfig(userConfig)
return {
name: '@matthiesenxyz/starlight-ghostcms-plugin',
hooks: {
setup({ addIntegration, config: starlightConfig, logger, updateConfig: updateStarlightConfig }) {
updateStarlightConfig({
components: {
...starlightConfig.components,
...overrideStarlightComponent(starlightConfig.components, logger, 'MarkdownContent'),
...overrideStarlightComponent(starlightConfig.components, logger, 'Sidebar'),
...overrideStarlightComponent(starlightConfig.components, logger, "SiteTitle"),
}
})
addIntegration({
name: '@matthiesenxyz/starlight-ghostcms',
hooks: {
'astro:config:setup': ({ injectRoute, updateConfig }) => {
injectRoute({
pattern: '/blog',
entrypoint: '@matthiesenxyz/starlight-ghostcms/routes/index.astro'
})
injectRoute({
pattern: '/blog/[slug]',
entrypoint: '@matthiesenxyz/starlight-ghostcms/routes/[slug].astro'
})
updateConfig({
vite: {
plugins: [vitePluginStarlightGhostConfig(config)],
},
})
}
}
})
}
},
}
}
function overrideStarlightComponent(
components: StarlightUserConfig['components'],
logger: AstroIntegrationLogger,
component: keyof NonNullable<StarlightUserConfig['components']>,
) {
if (components?.[component]) {
logger.warn(`It looks like you already have a \`${component}\` component override in your Starlight configuration.`)
logger.warn(`To use \`starlight-ghostcms\`, remove the override for the \`${component}\` component.\n`)
logger.warn("This Warning can be ignored if you know what your doing ;)")
return {}
}
return {
[component]: `@matthiesenxyz/starlight-ghostcms/overrides/${component}.astro`,
}
}

View File

@ -0,0 +1,72 @@
{
"name": "@matthiesenxyz/starlight-ghostcms",
"description": "Starlight GhostCMS plugin to allow easier importing of GhostCMS Content into your starlight website",
"version": "0.0.1",
"homepage": "https://astro-ghostcms.xyz/",
"type": "module",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"sideEffects": false,
"author": {
"email": "adam@matthiesen.xyz",
"name": "Adam Matthiesen - MatthiesenXYZ",
"url": "https://matthiesen.xyz"
},
"keywords": [
"starlight",
"starlight-plugin",
"astro",
"plugin",
"blog",
"content",
"ghost",
"ghostcms"
],
"repository": {
"type": "git",
"url": "git+https://github.com/MatthiesenXYZ/astro-ghostcms.git"
},
"bugs": {
"url": "https://github.com/MatthiesenXYZ/astro-ghostcms/issues",
"email": "issues@astro-ghostcms.xyz"
},
"main": "index.ts",
"types": "index.ts",
"files": [
"src",
"index.ts",
"tsconfig.json",
"types.d.ts"
],
"exports": {
".": "./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"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ci": "vitest run --coverage.enabled --coverage.reporter='text-summary'"
},
"devDependencies": {
"@astrojs/starlight": "0.19.0",
"@ts-ghost/core-api": "5.1.2",
"@ts-ghost/tsconfig": "workspace:*",
"astro": "4.4.0",
"vite": "^5.1.2",
"vite-tsconfig-paths": "^4.2.2",
"vitest": "^1.2.2",
"vitest-fetch-mock": "^0.2.2"
},
"peerdependencies": {
"@astrojs/starlight": ">=0.19.0",
"astro": ">=4.3.7"
}
}

View File

@ -0,0 +1,60 @@
---
import type { Author } from '../schemas/authors'
interface Props {
author: Author
}
const { author } = Astro.props
const isLink = author.website !== undefined
const Element = isLink ? 'a' : 'div'
---
<Element href={isLink ? author.website : undefined} class="author">
{author.profile_image && <img alt={author.name} src={author.profile_image} />}
<div class="text">
<div class="name">{author.name}</div>
{author.bio && <div class="title">{author.bio}</div>}
</div>
</Element>
<style>
.author {
align-items: center;
display: flex;
gap: 0.5rem;
line-height: var(--sl-line-height-headings);
text-decoration: none;
}
.text {
display: flex;
flex-direction: column;
}
.name {
font-size: var(--sl-text-base);
font-weight: 600;
}
.author[href] .name {
color: var(--sl-color-text-accent);
}
.title {
font-size: var(--sl-text-xs);
color: var(--sl-color-text);
}
.author[href]:hover .name {
color: var(--sl-color-text);
}
img {
border: 1px solid var(--sl-color-gray-2);
border-radius: 9999px;
height: 2.5rem;
width: 2.5rem;
}
</style>

View File

@ -0,0 +1,59 @@
---
import type { Post } from '../schemas/posts'
import Author from './Author.astro'
interface Props {
entry: Post
}
const { entry } = Astro.props
const { authors, published_at, created_at } = entry
const dateC = new Date(published_at?published_at:created_at)
const dateISO = dateC.toISOString()
const dateLocal = dateC
.toLocaleDateString(
"en-US", {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
)
const hasAuthors = authors !== undefined
---
<div class="metadata not-content">
<time datetime={dateISO}>
{dateLocal}
</time>
{
hasAuthors ? (
<div class="authors">
{authors.map((author: any) => (
<Author {author} />
))}
</div>
) : null
}
</div>
<style>
.metadata {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
time {
font-size: var(--sl-text-sm);
}
.authors {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
}
</style>

View File

@ -0,0 +1,17 @@
---
import StarlightPage, { type StarlightPageProps as Props } from '@astrojs/starlight/components/StarlightPage.astro'
---
<StarlightPage {...Astro.props}>
<slot />
</StarlightPage>
<style>
:global(:is(.right-sidebar-panel, mobile-starlight-toc)) {
display: none;
}
:global(.main-frame) {
padding-top: var(--sl-nav-height);
}
</style>

View File

@ -0,0 +1,22 @@
---
import Preview from './Preview.astro'
import type { Post } from '../schemas/posts'
interface Props {
entries: Post[]
}
const { entries } = Astro.props
---
<div class="posts">
{entries.map((entry) => <Preview {entry} />)}
</div>
<style>
.posts {
display: flex;
flex-direction: column;
gap: 3rem;
}
</style>

View File

@ -0,0 +1,40 @@
---
import type { Post } from '../schemas/posts'
import Metadata from './Metadata.astro'
interface Props {
entry: Post
}
const { entry } = Astro.props
const Excerpt = entry.excerpt
---
<article class="preview">
<header>
<h2>
<a href={`/blog/${entry.slug}`}>{entry.title}</a>
</h2>
<Metadata entry={entry} />
</header>
<div class="sl-markdown-content">
{typeof Excerpt === 'string' ? Excerpt : entry.excerpt}
</div>
</article>
<style>
.preview {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h2 {
margin-bottom: 1.5rem;
}
h2 a {
text-decoration: none;
}
</style>

View File

@ -0,0 +1,22 @@
import type { ViteUserConfig } from 'astro'
import type { StarlightGhostConfig } from '../schemas/config.ts'
// Expose the starlight-blog plugin configuration.
export function vitePluginStarlightGhostConfig(config: StarlightGhostConfig): VitePlugin {
const moduleId = 'virtual:starlight-ghost-config'
const resolvedModuleId = `\0${moduleId}`
const moduleContent = `export default ${JSON.stringify(config)}`
return {
name: 'vite-plugin-starlight-ghost-config',
load(id) {
return id === resolvedModuleId ? moduleContent : undefined
},
resolveId(id) {
return id === moduleId ? resolvedModuleId : undefined
},
}
}
type VitePlugin = NonNullable<ViteUserConfig['plugins']>[number]

View File

@ -0,0 +1,33 @@
---
import StarlightMarkdownContent from '@astrojs/starlight/components/MarkdownContent.astro'
import type { Props } from '@astrojs/starlight/props'
import { isAnyBlogPostPage } from '../utils/page'
import Metadata from '../components/Metadata.astro'
import type { Post } from '../schemas/posts'
const isBlogPost = isAnyBlogPostPage(Astro.props.slug)
let blogEntry: Post | undefined = undefined
---
<StarlightMarkdownContent {...Astro.props}>
{isBlogPost && blogEntry ? <Metadata entry={blogEntry} /> : null}
<slot />
{
isBlogPost && blogEntry ? (
<div class="post-footer">
</div>
) : null
}
</StarlightMarkdownContent>
<style>
.post-footer {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-block-start: 2rem !important;
}
</style>

View File

@ -0,0 +1,71 @@
---
import StarlightSidebar from '@astrojs/starlight/components/Sidebar.astro'
import type { Props } from '@astrojs/starlight/props'
import config from 'virtual:starlight-ghost-config'
import { isBlogPostPage, isBlogRoot } from '../utils/page'
import { getAllPosts } from '../utils/api'
export async function getRecentBlogEntries(){
const entries = await getAllPosts()
return entries.slice(0, config.recentPostCount)
}
export function checkpath(path: string){
if ( path.slice(0, 5) === "/blog" ){
return true
} else { return false }
}
const isBlog = checkpath(Astro.url.pathname)
const recentEntries = isBlog ? await getRecentBlogEntries() : []
const blogSidebar: Props['sidebar'] = isBlog
? [
{
attrs: {},
badge: undefined,
href: '/blog',
isCurrent: isBlogRoot(Astro.props.slug),
label: 'All posts',
type: 'link',
},
{
badge: undefined,
collapsed: false,
entries: recentEntries.map((blogEntry) => ({
attrs: {},
badge: undefined,
href: `/blog/${blogEntry.slug}`,
isCurrent: isBlogPostPage(Astro.props.slug, `blog/${blogEntry.slug}`),
label: blogEntry.title,
type: 'link',
})),
label: 'Recent posts',
type: 'group',
},
]
: []
---
{
!isBlog && (
<div class="md:sl-hidden">
<a href="/blog">Blog</a>
</div>
)
}
<StarlightSidebar {...Astro.props} sidebar={isBlog ? blogSidebar : Astro.props.sidebar} />
<style>
div {
border-bottom: 1px solid var(--sl-color-gray-6);
padding-bottom: 1rem;
}
div a {
color: var(--sl-color-white);
font-size: var(--sl-text-lg);
font-weight: 600;
text-decoration: none;
display: block;
}
</style>

View File

@ -0,0 +1,32 @@
---
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">{config.title}</a>
</div>
<style>
div {
align-items: center;
gap: var(--sl-nav-gap);
font-size: var(--sl-text-h4);
font-weight: 600;
color: var(--sl-color-text-accent);
text-decoration: none;
white-space: nowrap;
margin-left: 1rem;
border-inline-start: 1px solid var(--sl-color-gray-5);
display: flex;
padding-inline-start: 1rem;
}
a {
color: var(--sl-color-text-accent);
font-weight: 600;
text-decoration: none;
}
</style>

View File

@ -0,0 +1,60 @@
---
import config from 'virtual:starlight-ghost-config'
import { Image } from "astro:assets";
import Page from '../components/Page.astro'
import { getPageProps } from '../utils/page'
import { getAllPosts } from '../utils/api'
import Metadata from '../components/Metadata.astro'
export async function getStaticPaths() {
const entries = await getAllPosts();
return entries.map((post) => ({
params: { slug: post.slug },
props: { post, slug:post.slug },
}));
}
const { post } = Astro.props
const pageProps = getPageProps(post.title)
---
<Page {...pageProps}>
{config.supportGhost && (
<div id="pghost">Powered by <a href="https://ghost.org">Ghost</a></div>
)}
<header>
<Metadata entry={post} />
{post.feature_image && (
<figure>
<Image
src={post.feature_image}
alt={post.feature_image_alt?post.feature_image_alt:""}
title={post.feature_image_alt?post.feature_image_alt:""}
width={1000}
height={800}
/>
<figcaption>
<Fragment set:html={post.feature_image_caption} />
</figcaption>
</figure>
)}
</header>
<br />
<Fragment set:html={post.html} />
<footer class="not-content">
</footer>
</Page>
<style>
#pghost {
color: gray;
position: absolute;
top: 4rem;
}
#pghost a {
color: gray;
}
</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'
import { getAllPosts } from '../utils/api'
const entries = await getAllPosts();
//const { entries, nextLink, prevLink } = Astro.props
const pageProps = getPageProps(config.title)
---
<Page {...pageProps}>
{config.supportGhost && (
<div id="pghost">Powered by <a href="https://ghost.org">Ghost</a></div>
)}
<Posts {entries} />
<footer class="not-content">
<!--PrevNextLinks next={nextLink} prev={prevLink} /-->
</footer>
</Page>
<style>
#pghost {
color: gray;
position: absolute;
top: 4rem;
}
#pghost a {
color: gray;
}
</style>

View File

@ -0,0 +1,37 @@
import {
ghostIdentitySchema,
ghostMetaSchema,
ghostMetadataSchema,
} from "@ts-ghost/core-api";
import { z } from "astro/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,46 @@
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.
*/
title: z.string().default('Blog'),
/**
* Turn on and off "Powered by Ghost"
*/
supportGhost: z.boolean().default(true),
})
.default({})
export function validateConfig(userConfig: unknown): StarlightGhostConfig {
const config = configSchema.safeParse(userConfig)
if (!config.success) {
const errors = config.error.flatten()
throw new AstroError(
`Invalid starlight-GhostCMS configuration:
${errors.formErrors.map((formError) => ` - ${formError}`).join('\n')}
${Object.entries(errors.fieldErrors)
.map(([fieldName, fieldErrors]) => ` - ${fieldName}: ${fieldErrors.join(' - ')}`)
.join('\n')}
`,
"See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/matthiesenxyz/astro-ghostcms/issues/new/choose",
)
}
return config.data
}
export type StarlightGhostConfig = z.infer<typeof configSchema>

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,146 @@
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.js";
const { CONTENT_API_KEY, CONTENT_API_URL } = loadEnv(
"all",
process.cwd(),
"CONTENT_",
);
invariant(
CONTENT_API_KEY,
"CONTENT_API_KEY Missing from .env"
)
invariant(
CONTENT_API_URL,
"CONTENT_API_URL Missing from .env"
)
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 getSluggedPost = async (slug:string) => {
const results = await api.posts
.read({slug: slug})
.include({
authors: true,
tags: true,
}).fetch()
if (!results.success) {
throw new Error(results.errors.map((e) => e.message).join(", "));
}
return {
post: results.data,
};
};
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 "astro/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 "astro/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 "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 @@
export * from "./settings";

View File

@ -0,0 +1,40 @@
import { z } from "astro/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 "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 @@
export * from "./tiers";

View File

@ -0,0 +1,40 @@
import { ghostIdentitySchema, ghostVisibilitySchema } from "@ts-ghost/core-api";
import { z } from "astro/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,33 @@
export function isAnyBlogPage(slug: string) {
return slug.match(/^blog(\/?$|\/.+\/?$)/) !== null
}
export function isBlogRoot(slug: string) {
return slug === 'blog'
}
export function isAnyBlogPostPage(slug: string) {
return slug.match(/^blog\/(?!(\d+\/?|tags\/.+)$).+$/) !== null
}
export function isBlogPostPage(slug: string, postSlug: string) {
return slug === postSlug
}
export function isBlogTagsPage(slug: string, tag: string) {
return slug === `blog/tags/${tag}`
}
export function getPageProps(title: string): StarlightPageProps {
return {
frontmatter: {
title,
},
}
}
interface StarlightPageProps {
frontmatter: {
title: string
}
}

View File

@ -0,0 +1,5 @@
declare module 'virtual:starlight/user-config' {
const Config: import('@astrojs/starlight/types').StarlightConfig
export default Config
}

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
}
}

View File

@ -0,0 +1,5 @@
declare module 'virtual:starlight-ghost-config' {
const StarlightGhostConfig: import('./src/schemas/config').StarlightGhostConfig
export default StarlightGhostConfig
}

View File

@ -0,0 +1,15 @@
/// <reference types="vitest" />
/// <reference types="vite/client" />
import tsconfigPaths from "vite-tsconfig-paths";
import { defineProject } from "vitest/config";
export default defineProject({
plugins: [tsconfigPaths()],
test: {
globals: true,
include: ["./**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
watchExclude: [".*\\/node_modules\\/.*", ".*\\/build\\/.*"],
exclude: ["node_modules", "dist", ".idea", ".git", ".cache"],
},
});

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
packages:
- "packages/*"
- "playground"
- "playground"
- "starlight-playground"

21
starlight-playground/.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View File

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -0,0 +1,29 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlightGhostCMS from '@matthiesenxyz/starlight-ghostcms';
// https://astro.build/config
export default defineConfig({
integrations: [
starlight({
title: 'My Docs',
plugins: [starlightGhostCMS()],
social: {
github: 'https://github.com/withastro/starlight',
},
sidebar: [
{
label: 'Guides',
items: [
// Each item here is one entry in the navigation menu.
{ label: 'Example Guide', link: '/guides/example/' },
],
},
{
label: 'Reference',
autogenerate: { directory: 'reference' },
},
],
}),
],
});

View File

@ -0,0 +1,22 @@
{
"name": "starlight-playground",
"type": "module",
"version": "0.0.1",
"private": true,
"sideEffects": false,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"devDependencies": {
},
"dependencies": {
"@astrojs/starlight": "^0.19.0",
"@matthiesenxyz/starlight-ghostcms": "workspace:*",
"astro": "^4.4.0",
"sharp": "^0.32.5"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,6 @@
import { defineCollection } from 'astro:content';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ schema: docsSchema() }),
};

View File

@ -0,0 +1,11 @@
---
title: Example Guide
description: A guide in my new Starlight docs site.
---
Guides lead a user through a specific task they want to accomplish, often with a sequence of steps.
Writing a good guide requires thinking about what your users are trying to do.
## Further reading
- Read [about how-to guides](https://diataxis.fr/how-to-guides/) in the Diátaxis framework

View File

@ -0,0 +1,36 @@
---
title: Welcome to Starlight
description: Get started building your docs site with Starlight.
template: splash
hero:
tagline: Congrats on setting up a new Starlight project!
image:
file: ../../assets/houston.webp
actions:
- text: Example Guide
link: /guides/example/
icon: right-arrow
variant: primary
- text: Read the Starlight docs
link: https://starlight.astro.build
icon: external
---
import { Card, CardGrid } from '@astrojs/starlight/components';
## Next steps
<CardGrid stagger>
<Card title="Update content" icon="pencil">
Edit `src/content/docs/index.mdx` to see this page change.
</Card>
<Card title="Add new content" icon="add-document">
Add Markdown or MDX files to `src/content/docs` to create new pages.
</Card>
<Card title="Configure your site" icon="setting">
Edit your `sidebar` and other config in `astro.config.mjs`.
</Card>
<Card title="Read the docs" icon="open-book">
Learn more in [the Starlight Docs](https://starlight.astro.build/).
</Card>
</CardGrid>

View File

@ -0,0 +1,11 @@
---
title: Example Reference
description: A reference page in my new Starlight docs site.
---
Reference pages are ideal for outlining how things work in terse and clear terms.
Less concerned with telling a story or addressing a specific use case, they should give a comprehensive outline of what you're documenting.
## Further reading
- Read [about reference](https://diataxis.fr/reference/) in the Diátaxis framework

2
starlight-playground/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}