initial commit.

This commit is contained in:
Adam Matthiesen 2024-02-18 18:52:08 -08:00
parent 5694b59633
commit a12adca567
20 changed files with 1530 additions and 19 deletions

View File

@ -0,0 +1,60 @@
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 starlightBlogPlugin(userConfig?: StarlightGhostConfig): StarlightPlugin {
const config: StarlightGhostConfig = validateConfig(userConfig)
return {
name: 'starlight-blog-plugin',
hooks: {
setup({ addIntegration, config: starlightConfig, logger, updateConfig: updateStarlightConfig }) {
updateStarlightConfig({
components: {
...starlightConfig.components,
...overrideStarlightComponent(starlightConfig.components, logger, 'MarkdownContent'),
...overrideStarlightComponent(starlightConfig.components, logger, 'Sidebar'),
}
})
addIntegration({
name: 'starlight-ghostcms',
hooks: {
'astro:config:setup': ({ injectRoute, updateConfig }) => {
injectRoute({
pattern: '',
entrypoint: ''
})
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-blog\`, remove the override for the \`${component}\` component.\n`)
return {}
}
return {
[component]: `starlight-blog/overrides/${component}.astro`,
}
}

View File

@ -0,0 +1,57 @@
{
"name": "@matthiesenxyz/starlight-ghostcms",
"description": "Starlight GhostCMS plugin to allow easier importing of GhostCMS Content into your starlight website",
"version": "0.0.1-dev01",
"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",
"blog",
"content",
"integration",
"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": "types.d.ts",
"files": [
"src",
".env.demo",
"index.ts",
"tsconfig.json",
"types.d.ts"
],
"exports": {
".": "./index.ts"
},
"scripts": {
},
"devDependencies": {
"@astrojs/starlight": "0.19.0",
"@ts-ghost/core-api": "5.1.2",
"astro": "4.3.7"
},
"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,47 @@
---
import { getBlogEntryMetadata, type StarlightBlogEntry } from '../utils/content'
import Author from './Author.astro'
interface Props {
entry: StarlightBlogEntry
}
const { entry } = Astro.props
const { authors, date } = getBlogEntryMetadata(entry)
const hasAuthors = authors.length > 0
---
<div class="metadata not-content">
<time datetime={entry.data.date.toISOString()}>
{date}
</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,23 @@
---
import type { StarlightBlogEntry } from '../utils/content'
import Preview from './Preview.astro'
interface Props {
entries: StarlightBlogEntry[]
}
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,57 @@
---
import type { StarlightBlogLink } from '../utils/content'
interface Props {
next: StarlightBlogLink | undefined
prev: StarlightBlogLink | undefined
}
const { next, prev } = Astro.props
---
{
prev || next ? (
<div class="pagination not-content">
{prev && (
<a href={prev.href} rel="prev">
« {prev.label}
</a>
)}
{next && (
<a href={next.href} rel="next">
{next.label} »
</a>
)}
</div>
) : null
}
<style>
.pagination {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
}
a {
border-radius: 0.5rem;
border: 1px solid var(--sl-color-gray-5);
box-shadow: var(--sl-shadow-md);
color: var(--sl-color-white);
font-size: var(--sl-text-lg);
line-height: 1.4;
padding: 1rem;
text-decoration: none;
}
a:hover {
border-color: var(--sl-color-gray-2);
}
[rel='next'] {
justify-content: end;
grid-column: 2;
text-align: end;
flex-direction: row-reverse;
}
</style>

View File

@ -0,0 +1,41 @@
---
import { getBlogEntryExcerpt, type StarlightBlogEntry } from '../utils/content'
import Metadata from './Metadata.astro'
interface Props {
entry: StarlightBlogEntry
}
const { entry } = Astro.props
const Excerpt = await getBlogEntryExcerpt(entry)
---
<article class="preview">
<header>
<h2>
<a href={`/${entry.slug}`}>{entry.data.title}</a>
</h2>
<Metadata entry={entry} />
</header>
<div class="sl-markdown-content">
{typeof Excerpt === 'string' ? Excerpt : <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,37 @@
---
import StarlightMarkdownContent from '@astrojs/starlight/components/MarkdownContent.astro'
import type { Props } from '@astrojs/starlight/props'
import PrevNextLinks from '../components/PrevNextLinks.astro'
import { getBlogEntry, type StarlightBlogEntryPaginated } from '../utils/content'
import { isAnyBlogPostPage } from '../utils/page'
import Metadata from '../components/Metadata.astro'
const isBlogPost = isAnyBlogPostPage(Astro.props.slug)
let blogEntry: StarlightBlogEntryPaginated | undefined = undefined
if (isBlogPost) {
blogEntry = await getBlogEntry(Astro.url.pathname)
}
---
<StarlightMarkdownContent {...Astro.props}>
{isBlogPost && blogEntry ? <Metadata entry={blogEntry.entry} /> : null}
<slot />
{
isBlogPost && blogEntry ? (
<div class="post-footer">
<PrevNextLinks next={blogEntry.nextLink} prev={blogEntry.prevLink} />
</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,61 @@
---
import StarlightSidebar from '@astrojs/starlight/components/Sidebar.astro'
import type { Props } from '@astrojs/starlight/props'
import { getRecentBlogEntries } from '../utils/content'
import { isAnyBlogPage, isBlogPostPage, isBlogRoot } from '../utils/page'
const isBlog = isAnyBlogPage(Astro.props.slug)
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: `/${blogEntry.slug}`,
isCurrent: isBlogPostPage(Astro.props.slug, blogEntry.slug),
label: blogEntry.data.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,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,35 @@
/** @ts-expect-error */
import { AstroError } from 'astro/errors'
import { z } from 'astro/zod'
const configSchema = z
.object({
/**
* The title of the blog.
*/
title: z.string().default('Blog'),
})
.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,135 @@
import type { z } from 'astro/zod'
import { getCollection, type AstroCollectionEntry } from 'astro:content'
import starlightConfig from 'virtual:starlight/user-config'
import config from 'virtual:starlight-ghost-config'
import type { Author } from '../schemas/authors'
export async function getBlogStaticPaths() {
const entries = await getBlogEntries()
const entryPages: StarlightBlogEntry[][] = []
for (const entry of entries) {
const lastPage = entryPages.at(-1)
if (!lastPage || lastPage.length === config.postCount) {
entryPages.push([entry])
} else {
lastPage.push(entry)
}
}
if (entryPages.length === 0) {
entryPages.push([])
}
return entryPages.map((entries, index) => {
const prevPage = index === 0 ? undefined : entryPages.at(index - 1)
const nextPage = entryPages.at(index + 1)
return {
params: {
page: index === 0 ? undefined : index + 1,
},
props: {
entries,
nextLink: nextPage ? { href: `/blog/${index + 2}`, label: 'Older posts' } : undefined,
prevLink: prevPage ? { href: index === 1 ? '/blog' : `/blog/${index}`, label: 'Newer posts' } : undefined,
} satisfies StarlightBlogStaticProps,
}
})
}
export async function getRecentBlogEntries() {
const entries = await getBlogEntries()
return entries.slice(0, config.recentPostCount)
}
export async function getBlogEntry(slug: string): Promise<StarlightBlogEntryPaginated> {
const entries = await getBlogEntries()
const entryIndex = entries.findIndex((entry) => entry.slug === slug.replace(/^\//, '').replace(/\/$/, ''))
const entry = entries[entryIndex]
if (!entry) {
throw new Error(`Blog post with slug '${slug}' not found.`)
}
const prevEntry = entries[entryIndex - 1]
const nextEntry = entries[entryIndex + 1]
return {
entry,
nextLink: nextEntry ? { href: `/${nextEntry.slug}`, label: nextEntry.data.title } : undefined,
prevLink: prevEntry ? { href: `/${prevEntry.slug}`, label: prevEntry.data.title } : undefined,
}
}
export function getBlogEntryMetadata(entry: StarlightBlogEntry): StarlightBlogEntryMetadata {
const authors: Author[] = []
if (!entry.data.authors) {
authors.push(...Object.values(authors))
} else {
authors.push(entry.data.authors)
}
return {
authors,
date: entry.data.date.toLocaleDateString(starlightConfig.defaultLocale.lang, { dateStyle: 'medium' }),
}
}
export async function getBlogEntries() {
const entries = await getCollection<StarlightEntryData>('docs', ({ id }) => {
return id.startsWith('blog/') && id !== 'blog/index.mdx'
})
return entries.sort((a, b) => {
return b.data.date.getTime() - a.data.date.getTime()
})
}
export async function getBlogEntryExcerpt(entry: StarlightBlogEntry) {
if (entry.data.excerpt) {
return entry.data.excerpt
}
const { Content } = await entry.render()
return Content
}
type StarlightEntryData = z.infer<ReturnType<typeof blogSchema>> & { title: string }
type StarlightEntry = AstroCollectionEntry<StarlightEntryData>
export type StarlightBlogEntry = StarlightEntry & {
data: {
date: Date
}
}
export interface StarlightBlogLink {
href: string
label: string
}
export interface StarlightBlogEntryPaginated {
entry: StarlightBlogEntry
nextLink: StarlightBlogLink | undefined
prevLink: StarlightBlogLink | undefined
}
interface StarlightBlogEntryMetadata {
authors: Author[]
date: string
}
interface StarlightBlogStaticProps {
entries: StarlightBlogEntry[]
nextLink: StarlightBlogLink | undefined
prevLink: StarlightBlogLink | undefined
}

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,5 @@
declare module 'virtual:starlight-ghost-config' {
const StarlightGhostConfig: import('./src/schemas/config').StarlightGhostConfig
export default StarlightGhostConfig
}

File diff suppressed because it is too large Load Diff