Merge pull request #1 from MatthiesenXYZ/testing

Upgrade to V2.0.0 - Upgrade from Astro Addon to Astro Integration.
This commit is contained in:
Adam Matthiesen 2024-01-17 02:33:09 -08:00 committed by GitHub
commit 3f8cddfa4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 4372 additions and 41 deletions

2
.env.demo Normal file
View File

@ -0,0 +1,2 @@
CONTENT_API_KEY=a33da3965a3a9fb2c6b3f63b48
CONTENT_API_URL=https://ghostdemo.matthiesen.xyz

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# build output
dist
.vercel
# generated types
.astro/
# dependencies
node_modules/
.snowpack/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# editor
.idea
package-lock.json

View File

@ -1,42 +1,95 @@
# Welcome to Astro-GhostCMS # Welcome to Astro-GhostCMS
This addon uses the `@tryghost/content-api` and creates astro friendly functions to interface between ghost and astro. Astro minimum Version: **Astro v4.0**
*This package contains a independent copy of the tryghost content-api.js that is used to establish the connection so this package dose not depend on `@tryghost/content-api` package.* This Integration is 2 parts. Firstly, there is the API portion that uses the `@tryghost/content-api` to create the link between astro and GhostCMS. From there we move to the Second Part, which is a theme pre-programmed to pull ALL of its data from GhostCMS iteself instead of storing any data locally outside of Build.
## Astro Integration Mode *(Planned for V2)* - *This package contains a independent copy of the tryghost content-api.js that is used to establish the connection so this package dose not depend on `@tryghost/content-api` package.*
- If you are looking for a more Customizable option please check [astro-ghostcms-basetheme](https://github.com/MatthiesenXYZ/astro-ghostcms-basetheme)
- This project is not setup for SSR in Integration mode. As such is will most likely not function properly in that mode. You will need to build your own project around the API or customize the *basetheme* linked above.
This is coming soon. And will allow the user to utilize the prebuilt astro-ghostcms-basetheme to be integrated through this main project. This feature is not yet setup or integrated. If you want a easy quick and simple deploy please copy this Template Repo, [astro-ghostcms-basetheme](https://github.com/MatthiesenXYZ/astro-ghostcms-basetheme) This will get you setup and ready to deploy in minutes using this addon! ## Astro Integration Mode
## Manual Installation In this mode, the addon will not be just an API, but will be a full Route takeover, there is plans to add more themes in time, but for now there is only the base Casper theme based on Ghost's main Theme.
### Astro Add Installation *(Coming Soon)*
```
# NOT READY YET DO NOT USE
astro add @matthiesenxyz/astro-ghostcms
```
### Manual Installation
``` ```
npm i @matthiesenxyz/astro-ghostcms npm i @matthiesenxyz/astro-ghostcms
``` ```
Must create `.env` with the following: Then set your astro.config.ts to look like this:
```ts
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import GhostCMS from '@matthiesenxyz/astro-ghostcms';
// https://astro.build/config
export default defineConfig({
site: "https://YOUR-DOMAIN-HERE.com"
integrations: [sitemap(), GhostCMS()],
});
```
### Dont forget to set your environment Variables!
You must also create 2 environment variables in a `.env` file with the following:
```env ```env
CONTENT_API_KEY= CONTENT_API_KEY=a33da3965a3a9fb2c6b3f63b48
CONTENT_API_URL= CONTENT_API_URL=https://ghostdemo.matthiesen.xyz
``` ```
**When you deploy your install dont forget to set the above ENVIRONMENT VARIABLES!** **When you deploy your install dont forget to set the above ENVIRONMENT VARIABLES!**
Astro minimum Version: **Astro v4.0** #### Created Routes
Dependencies: The routes are the same as a standard Ghost Blog so you can migrate to Astro easily.
- **Axios v1.0** *Will be auto installed*
- **Typescript v5.3.3** *Will be auto installed*
## Function Usage Examples: | Route | Content |
| --------------------- | ----------------------------------------- |
| `/` | Homepage with recents/features Blog Posts |
| `/[slug]` | Post or Page |
| `/author/[slug]` | Author page with related posts |
| `/authors` | All the authors |
| `/tag[slug]` | Tag page with related posts |
| `/tags` | All the tags |
| `/archives/[...page]` | All the posts, paginated |
## Manual Function Mode (DIY MODE)
In this mode the integration will not deploy routes at all. you will have to build your own website to utilize the exported functions listed below.
```
npm i @matthiesenxyz/astro-ghostcms
```
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!**
## Manual Function Usage Examples:
### getGhostPosts() - Get list of posts ### getGhostPosts() - Get list of posts
```astro ```astro
--- ---
// IMPORT {GET} GhostPosts Function // IMPORT {GET} GhostPosts Function
import { getGhostPosts } from '@matthiesenxyz/astro-ghostcms'; import { getGhostPosts } from '@matthiesenxyz/astro-ghostcms/api';
// GET LIST OF ALL POSTS // GET LIST OF ALL POSTS
const ghostPosts = await getGhostPosts(); const ghostPosts = await getGhostPosts();
--- ---
@ -47,7 +100,7 @@ const ghostPosts = await getGhostPosts();
```astro ```astro
--- ---
// IMPORT {GET} GhostFeaturedPosts Function // IMPORT {GET} GhostFeaturedPosts Function
import { getGhostRecentPosts } from "@matthiesenxyz/astro-ghostcms"; import { getGhostRecentPosts } from "@matthiesenxyz/astro-ghostcms/api";
// CREATE INTERFACE TO PASS 'setLimit' for POST LIMIT // CREATE INTERFACE TO PASS 'setLimit' for POST LIMIT
interface Props { interface Props {
setLimit?:number; setLimit?:number;
@ -64,7 +117,7 @@ const ghostPosts = await getGhostRecentPosts(setLimit);
```astro ```astro
--- ---
// IMPORT {GET} GhostFeaturedPosts Function // IMPORT {GET} GhostFeaturedPosts Function
import { getGhostFeaturedPosts } from "@matthiesenxyz/astro-ghostcms"; import { getGhostFeaturedPosts } from "@matthiesenxyz/astro-ghostcms/api";
// CREATE INTERFACE TO PASS 'setLimit' for POST LIMIT // CREATE INTERFACE TO PASS 'setLimit' for POST LIMIT
interface Props { interface Props {
setLimit?:number; setLimit?:number;
@ -81,7 +134,7 @@ const ghostPosts = await getGhostFeaturedPosts(setLimit);
```astro ```astro
--- ---
// IMPORT {GET} GhostPostbySlug Function // IMPORT {GET} GhostPostbySlug Function
import { getGhostPostbySlug } from '@matthiesenxyz/astro-ghostcms'; import { getGhostPostbySlug } from '@matthiesenxyz/astro-ghostcms/api';
// GET SLUG from /blog/[slug] // GET SLUG from /blog/[slug]
const { slug } = Astro.params; const { slug } = Astro.params;
// GET CURRENT POST BY PASSING SLUG TO FUNCTION // GET CURRENT POST BY PASSING SLUG TO FUNCTION
@ -94,7 +147,7 @@ const ghostPost = await getGhostPostbySlug(slug);
```astro ```astro
--- ---
// IMPORT {GET} GhostPostsbyTag, and GhostTagbySlug Functions // IMPORT {GET} GhostPostsbyTag, and GhostTagbySlug Functions
import { getGhostPostsbyTag, getGhostTagbySlug } from '@matthiesenxyz/astro-ghostcms'; import { getGhostPostsbyTag, getGhostTagbySlug } from '@matthiesenxyz/astro-ghostcms/api';
// GET SLUG from /blog/tag/[slug] // GET SLUG from /blog/tag/[slug]
const { slug } = Astro.params; const { slug } = Astro.params;
// GET TAG BY SLUG TO DISPLAY TAG INFO // GET TAG BY SLUG TO DISPLAY TAG INFO
@ -109,7 +162,7 @@ const ghostPosts = await getGhostPostsbyTag(slug)
```astro ```astro
--- ---
// IMPORT {GET} GhostTags Function // IMPORT {GET} GhostTags Function
import { getGhostTags } from "@matthiesenxyz/astro-ghostcms"; import { getGhostTags } from "@matthiesenxyz/astro-ghostcms/api";
// GET LIST OF ALL TAGS // GET LIST OF ALL TAGS
const ghostTags = await getGhostTags(); const ghostTags = await getGhostTags();
--- ---
@ -120,7 +173,7 @@ const ghostTags = await getGhostTags();
```astro ```astro
--- ---
// IMPORT {GET} GhostAuthors Function // IMPORT {GET} GhostAuthors Function
import { getGhostAuthors } from "@matthiesenxyz/astro-ghostcms"; import { getGhostAuthors } from "@matthiesenxyz/astro-ghostcms/api";
// GET LIST OF ALL AUTHORS // GET LIST OF ALL AUTHORS
const ghostAuthors = await getGhostAuthors(); const ghostAuthors = await getGhostAuthors();
--- ---
@ -131,7 +184,7 @@ const ghostAuthors = await getGhostAuthors();
```astro ```astro
--- ---
// IMPORT {GET} GhostAuthors Function // IMPORT {GET} GhostAuthors Function
import { getGhostPages } from "@matthiesenxyz/astro-ghostcms"; import { getGhostPages } from "@matthiesenxyz/astro-ghostcms/api";
// GET LIST OF ALL AUTHORS // GET LIST OF ALL AUTHORS
const ghostPages = await getGhostPages(); const ghostPages = await getGhostPages();
--- ---
@ -142,7 +195,7 @@ const ghostPages = await getGhostPages();
```astro ```astro
--- ---
// IMPORT {GET} GhostPostbySlug Function // IMPORT {GET} GhostPostbySlug Function
import { getGhostPage } from '@matthiesenxyz/astro-ghostcms'; import { getGhostPage } from '@matthiesenxyz/astro-ghostcms/api';
// GET SLUG from /blog/[slug] // GET SLUG from /blog/[slug]
const { slug } = Astro.params; const { slug } = Astro.params;
// GET CURRENT POST BY PASSING SLUG TO FUNCTION // GET CURRENT POST BY PASSING SLUG TO FUNCTION
@ -155,7 +208,7 @@ const ghostpage = await getGhostPage(slug);
```astro ```astro
--- ---
// IMPORT {GET} GhostAuthors Function // IMPORT {GET} GhostAuthors Function
import { getGhostSettings } from "@matthiesenxyz/astro-ghostcms"; import { getGhostSettings } from "@matthiesenxyz/astro-ghostcms/api";
// GET LIST OF ALL AUTHORS // GET LIST OF ALL AUTHORS
const ghostSettings = await getGhostSettings(); const ghostSettings = await getGhostSettings();
--- ---

View File

@ -1,5 +1,51 @@
// FUNCTION EXPORTS import type { AstroIntegration } from "astro"
export { getGhostPosts, getGhostRecentPosts, getGhostFeaturedPosts, getGhostPostbySlug, getGhostPostsbyTag, getGhostTags, getGhostTagbySlug, getGhostAuthors, getGhostPages, getGhostPage, getGhostSettings } from './src/functions';
// TYPE EXPORTS export default function GhostCMS(): AstroIntegration {
export type { PostOrPage, ArrayOrValue, Author, Authors, BrowseFunction, CodeInjection, Excerpt, Facebook, FieldParam, FilterParam, FormatParam, GhostAPI, GhostContentAPIOptions, GhostData, GhostError, Identification, IncludeParam, LimitParam, Metadata, Nullable, OrderParam, PageParam, Pagination, Params, PostsOrPages, ReadFunction, Settings, SettingsResponse, SocialMedia, Tag, TagVisibility, Tags, Twitter } from './index.d'; return {
name: 'astro-ghostcms',
hooks: {
'astro:config:setup': async ({
injectRoute,
logger,
}) => {
injectRoute({
pattern: '/',
entrypoint: '@matthiesenxyz/astro-ghostcms/index.astro'
})
injectRoute({
pattern: '/[slug]',
entrypoint: '@matthiesenxyz/astro-ghostcms/[slug].astro'
})
injectRoute({
pattern: '/tags',
entrypoint: '@matthiesenxyz/astro-ghostcms/tags.astro'
})
injectRoute({
pattern: '/authors',
entrypoint: '@matthiesenxyz/astro-ghostcms/authors.astro'
})
injectRoute({
pattern: '/tag/[slug]',
entrypoint: '@matthiesenxyz/astro-ghostcms/tag/[slug].astro'
})
injectRoute({
pattern: '/author/[slug]',
entrypoint: '@matthiesenxyz/astro-ghostcms/author/[slug].astro'
})
injectRoute({
pattern: '/archives/[...page]',
entrypoint: '@matthiesenxyz/astro-ghostcms/archives/[...page].astro'
})
logger.info('Astro GhostCMS Plugin Loaded!')
}
}
}
}

View File

@ -1,7 +1,7 @@
{ {
"name": "@matthiesenxyz/astro-ghostcms", "name": "@matthiesenxyz/astro-ghostcms",
"description": "Astro GhostCMS integration to allow easier importing of GhostCMS Content", "description": "Astro GhostCMS integration to allow easier importing of GhostCMS Content",
"version": "1.0.6", "version": "2.0.0",
"author": "MatthiesenXYZ (https://matthiesen.xyz)", "author": "MatthiesenXYZ (https://matthiesen.xyz)",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -14,31 +14,63 @@
}, },
"homepage": "https://github.com/MatthiesenXYZ/astro-ghostcms", "homepage": "https://github.com/MatthiesenXYZ/astro-ghostcms",
"exports": { "exports": {
".": "./index.ts" "./api": "./src/api/index.ts",
"./index.astro": "./src/routes/index.astro",
"./[slug].astro": "./src/routes/[slug].astro",
"./tags.astro": "./src/routes/tags.astro",
"./authors.astro": "./src/routes/authors.astro",
"./tag/[slug].astro": "./src/routes/tag/[slug].astro",
"./author/[slug].astro": "./src/routes/author/[slug].astro",
"./archives/[...page].astro": "./src/routes/archives/[...page].astro",
"./DefaultLayout": "./src/layouts/default.astro"
}, },
"main": "index.ts", "main": "index.ts",
"types": "index.d.ts", "types": "src/api/ghosttypes.ts",
"files": [ "files": [
"src", "src",
"index.ts", "index.ts"
"index.d.ts"
], ],
"keywords": [ "keywords": [
"astro-component", "astro-component",
"astro-integration",
"withastro", "withastro",
"astro",
"blog",
"content",
"integration",
"ghost", "ghost",
"ghostcms" "ghostcms",
"ghostcms-theme",
"ghost-theme",
"astro-theme"
], ],
"scripts": {}, "scripts": {
"devDependencies": { "dev": "astro dev",
"astro": "^4.1.1" "build": "astro build",
}, "typecheck": "astro check && tsc --noEmit",
"peerDependencies": { "preview": "astro preview",
"astro": "^4.0.0" "format": "prettier --write .",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint ."
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.3.4", "@astrojs/check": "^0.3.4",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"axios": "^1.0.0" "axios": "^1.0.0",
"astro-font": "^0.0.72",
"@astrojs/renderer-svelte": "0.5.2",
"@astrojs/rss": "^4.0.2",
"@astrojs/sitemap": "^3.0.4",
"@snowpack/plugin-dotenv": "^2.2.0",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"astro": "^4.1.2",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-astro": "^0.29.0",
"prettier": "^3.0.3",
"prettier-plugin-astro": "^0.12.0",
"sass": "^1.66.1",
"tiny-invariant": "^1.3.1",
"vite": "^4.4.9"
} }
} }

View File

@ -1,5 +1,5 @@
// IMPORT Ghost Types // IMPORT Ghost Types
import type { PostOrPage, PostsOrPages, Authors, Tag, Tags, ArrayOrValue, IncludeParam, LimitParam, Settings, Nullable } from '..'; import type { PostOrPage, PostsOrPages, Authors, Tag, Tags, ArrayOrValue, IncludeParam, LimitParam, Settings, Nullable } from './ghosttypes';
// IMPORT Ghost API Client // IMPORT Ghost API Client
import api from './interface'; import api from './interface';

5
src/api/index.ts Normal file
View File

@ -0,0 +1,5 @@
// FUNCTION EXPORTS
export { getGhostPosts, getGhostRecentPosts, getGhostFeaturedPosts, getGhostPostbySlug, getGhostPostsbyTag, getGhostTags, getGhostTagbySlug, getGhostAuthors, getGhostPages, getGhostPage, getGhostSettings } from './functions';
// TYPE EXPORTS
export type { PostOrPage, ArrayOrValue, Author, Authors, BrowseFunction, CodeInjection, Excerpt, Facebook, FieldParam, FilterParam, FormatParam, GhostAPI, GhostContentAPIOptions, GhostData, GhostError, Identification, IncludeParam, LimitParam, Metadata, Nullable, OrderParam, PageParam, Pagination, Params, PostsOrPages, ReadFunction, Settings, SettingsResponse, SocialMedia, Tag, TagVisibility, Tags, Twitter } from './ghosttypes';

View File

@ -0,0 +1,80 @@
---
import { getGhostImgPath } from "../utils";
import type { Settings, Author } from "../api";
export type Props = {
author: Author;
wide?: boolean;
addClass?: string;
settings: Settings;
showCover?: boolean;
};
const {
author,
wide = false,
settings,
showCover = true,
} = Astro.props as Props;
---
<div class={`card author-card author-${author.slug} ${wide ? "wide" : ""}`}>
<figure class="author-card-cover">
{author.cover_image && showCover && (
<img
class="lazyautosizes lazyloaded"
data-srcset={`
${getGhostImgPath(settings.url, author.cover_image, 300)} 300w,
${getGhostImgPath(settings.url, author.cover_image, 600)} 600w
`}
srcset={`
${getGhostImgPath(settings.url, author.cover_image, 300)} 300w,
${getGhostImgPath(settings.url, author.cover_image, 600)} 600w
`}
data-sizes="auto"
data-src={getGhostImgPath(settings.url, author.cover_image, 300)}
src={getGhostImgPath(settings.url, author.cover_image, 300)}
alt={author.name}
sizes="316px"
/>
)}
</figure>
{author.profile_image && (
<a href={`/author/${author.slug}`} class="author-card-media">
<img
class="author-card-img"
data-srcset={`
${getGhostImgPath(settings.url, author.profile_image, 100)} 100w,
${getGhostImgPath(settings.url, author.profile_image, 300)} 300w
`}
srcset={`
${getGhostImgPath(settings.url, author.profile_image, 100)} 100w,
${getGhostImgPath(settings.url, author.profile_image, 300)} 300w
`}
data-sizes="auto"
data-src={getGhostImgPath(settings.url, author.profile_image, 300)}
src={getGhostImgPath(settings.url, author.profile_image, 300)}
alt={author.name}
sizes="316px"
/>
</a>
)}
<div class="author-card-content">
<div class="author-card-name">
<a href={`/author/${author.slug}`}>{author.name}</a>
</div>
{author.bio && <div class="author-card-descr">{author.bio}</div>}
<div class="author-card-details">
{author.count && author.count.posts && (
<div>
{author.count.posts > 0 ? `${author.count.posts} posts` : "No posts"}
</div>
)}
</div>
</div>
</div>
<style lang="scss">
</style>

View File

@ -0,0 +1,75 @@
---
import { getGhostImgPath } from "../utils";
import type { Settings, PostOrPage } from "../api";
export type Props = {
post: PostOrPage;
settings: Settings;
};
const { post, settings } = Astro.props as Props;
---
<ul class="author-list">
{post.authors && post.authors.map((author) => (
<li class="author-list-item">
{author.profile_image ? (
<a href={`/author/${author.slug}`} class="author-avatar">
<img
class="author-profile-image"
src={getGhostImgPath(settings.url, author.profile_image, 100)}
alt={author.name}
/>
</a>
) : (
<a href={`/author/${author.slug}`} class="author-avatar">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path
d="M3.513 18.998C4.749 15.504 8.082 13 12 13s7.251 2.504 8.487 5.998C18.47 21.442 15.417 23 12 23s-6.47-1.558-8.487-4.002zM12 12c2.21 0 4-2.79 4-5s-1.79-4-4-4-4 1.79-4 4 1.79 5 4 5z"
fill="#FFF"
/>
</g>
</svg>
</a>
)}
</li>
))}
</ul>
<style lang="scss">
@use "sass:color";
@import "/src/styles/variables";
.author-avatar {
display: block;
overflow: hidden;
margin: 0 -4px;
width: 50px;
height: 50px;
border: #fff 2px solid;
border-radius: 100%;
transition: all 0.5s cubic-bezier(0.4, 0.01, 0.165, 0.99) 700ms;
}
.author-list {
display: flex;
flex-wrap: wrap;
margin: 0 0 0 4px;
padding: 0;
list-style: none;
}
.author-list-item {
position: relative;
flex-shrink: 0;
margin: 0;
padding: 0;
}
.author-profile-image {
display: block;
width: 100%;
height: 100%;
background: color.scale($color-lightgrey, $lightness: +10%);
border-radius: 100%;
object-fit: cover;
}
</style>

View File

@ -0,0 +1,86 @@
---
import { AstroFont } from "astro-font";
import { ViewTransitions } from 'astro:transitions';
import type { Settings } from "../api";
export type Props = {
title: string;
description: string;
permalink?: string;
image?: string;
settings: Settings;
};
const { description, permalink, image, settings, title } = Astro.props as Props;
---
<AstroFont
config={[
{
src: [],
name: "Inter",
preload: false,
display: "swap",
selector: "html",
fallback: "sans-serif",
googleFontsURL: 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
},
]}
/>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<ViewTransitions />
<meta name="title" content={title} />
{description && <meta name="description" content={description} />}
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href={settings.icon} />
<link rel="shortcut icon" type="image/png" sizes="16x16" href={settings.icon} />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="theme-color" content="#ffffff" />
<!-- Open Graph Tags (Facebook) -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
{permalink && <meta property="og:url" content={permalink} />}
{description && <meta property="og:description" content={description} />}
{image && <meta property="og:image" content={image} />}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={title} />
{permalink && <meta property="twitter:url" content={permalink} />}
{description && <meta property="twitter:description" content={description} />}
{image && <meta property="twitter:image" content={image} />}
<!-- Link to the global style, or the file that imports constructs -->
<link
rel="preload"
href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/themes/prism.min.css"
as="style"
/>
<link
rel="preload"
href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"
as="script"
/>
<link
rel="preload stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/themes/prism.min.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
crossorigin
/>
<script
async
defer
src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"
>
</script>

View File

@ -0,0 +1,32 @@
---
import { getGhostImgPath } from "../utils";
import type { Settings } from "../api";
export type Props = {
image: string;
alt?: string;
caption?: string;
settings: Settings;
transitionName?: string;
};
const { image, alt, caption = "", settings, transitionName } = Astro.props as Props;
---
<figure class="article-image">
<img
srcset={`
${getGhostImgPath(settings.url, image, 300)} 300w,
${getGhostImgPath(settings.url, image, 600)} 600w,
${getGhostImgPath(settings.url, image, 1000)} 1000w,
${getGhostImgPath(settings.url, image, 2000)} 2000w
`}
sizes="(min-width: 1400px) 1400px, 92vw"
src={getGhostImgPath(settings.url, image, 2000)}
alt={alt}
transition:name={transitionName}
/>
{caption && <figcaption>{caption}</figcaption>}
</figure>
<style lang="scss">
</style>

112
src/components/Footer.astro Normal file
View File

@ -0,0 +1,112 @@
---
import type { Settings } from "../api";
export type Props = {
settings: Settings;
};
const { settings } = Astro.props as Props;
---
<footer class="site-footer outer">
<div class="inner">
<section class="copyright">
<a href="/">{settings.title}</a> &copy; 2024
</section>
<nav class="site-footer-nav">
{settings.secondary_navigation && settings.secondary_navigation.length > 0 && (
<ul class="nav secondary">
{settings.secondary_navigation.map(({ label, url }) => (
<li class="">
<a href={url}>{label}</a>
</li>
))}
</ul>
)}
</nav>
<div>
<a href="https://ghost.org/" target="_blank" rel="noopener"
>Powered by Ghost
</a>
</div>
</div>
</footer>
<style lang="scss">
.site-footer {
position: relative;
margin: 40px 0 0 0;
padding: 40px 4vmin 140px;
color: #fff;
background: var(--color-darkgrey);
}
.site-footer .inner {
display: grid;
grid-gap: 40px;
grid-template-columns: auto 1fr auto;
color: rgba(255, 255, 255, 0.7);
font-size: 1.3rem;
}
.site-footer .copyright a {
color: #fff;
letter-spacing: -0.015em;
font-weight: 500;
}
.site-footer a {
color: rgba(255, 255, 255, 0.7);
}
.site-footer a:hover {
color: rgba(255, 255, 255, 1);
text-decoration: none;
}
.site-footer-nav ul {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin: 0 0 20px;
padding: 0;
list-style: none;
}
.site-footer-nav li {
display: inline-flex;
align-items: center;
padding: 0;
margin: 0;
line-height: 2em;
}
.site-footer-nav a {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 10px;
}
.site-footer-nav li:not(:first-child) a:before {
content: "";
display: block;
width: 2px;
height: 2px;
margin: 0 10px 0 0;
background: #fff;
border-radius: 100%;
}
@media (max-width: 800px) {
.site-footer .inner {
max-width: 500px;
grid-template-columns: 1fr;
grid-gap: 0;
text-align: center;
}
.site-footer .copyright,
.site-footer .copyright a {
color: #fff;
font-size: 1.5rem;
}
}
</style>

437
src/components/Header.astro Normal file
View File

@ -0,0 +1,437 @@
---
import type { Settings } from "../api";
export type Props = {
settings: Settings;
};
const { settings } = Astro.props as Props;
---
<header
id="gh-head"
class={`gh-head ${settings.cover_image ? "has-cover" : ""} js-header`}
>
<nav class="gh-head-inner inner gh-container">
<div class="gh-head-brand">
<a class="gh-head-logo" href="/">
{settings.logo && <img src={settings.logo} alt={settings.title} />}
{!settings.logo && settings.title}
</a>
<a class="gh-burger" role="button">
<div class="gh-burger-box">
<div class="gh-burger-inner"></div>
</div>
</a>
</div>
<div class="gh-head-menu">
<ul class="nav">
{settings.navigation && settings.navigation.map(({ label, url }) => (
<li class="nav__item">
<a href={url}>{label}</a>
</li>
))}
</ul>
</div>
<div class="gh-head-actions">
<div class="gh-social">
{settings.facebook && (
<a
class="gh-social-facebook"
href={"https://facebook.com/" + settings.facebook}
title="Facebook"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0c8.837 0 16 7.163 16 16s-7.163 16-16 16S0 24.837 0 16 7.163 0 16 0zm5.204 4.911h-3.546c-2.103 0-4.443.885-4.443 3.934.01 1.062 0 2.08 0 3.225h-2.433v3.872h2.509v11.147h4.61v-11.22h3.042l.275-3.81h-3.397s.007-1.695 0-2.187c0-1.205 1.253-1.136 1.329-1.136h2.054V4.911z" />
</svg>
</a>
)}
{settings.twitter && (
<a
class="gh-social-twitter"
href={"https://twitter.com/" + settings.twitter}
title="Twitter"
target="_blank"
rel="noopener"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M30.063 7.313c-.813 1.125-1.75 2.125-2.875 2.938v.75c0 1.563-.188 3.125-.688 4.625a15.088 15.088 0 0 1-2.063 4.438c-.875 1.438-2 2.688-3.25 3.813a15.015 15.015 0 0 1-4.625 2.563c-1.813.688-3.75 1-5.75 1-3.25 0-6.188-.875-8.875-2.625.438.063.875.125 1.375.125 2.688 0 5.063-.875 7.188-2.5-1.25 0-2.375-.375-3.375-1.125s-1.688-1.688-2.063-2.875c.438.063.813.125 1.125.125.5 0 1-.063 1.5-.25-1.313-.25-2.438-.938-3.313-1.938a5.673 5.673 0 0 1-1.313-3.688v-.063c.813.438 1.688.688 2.625.688a5.228 5.228 0 0 1-1.875-2c-.5-.875-.688-1.813-.688-2.75 0-1.063.25-2.063.75-2.938 1.438 1.75 3.188 3.188 5.25 4.25s4.313 1.688 6.688 1.813a5.579 5.579 0 0 1 1.5-5.438c1.125-1.125 2.5-1.688 4.125-1.688s3.063.625 4.188 1.813a11.48 11.48 0 0 0 3.688-1.375c-.438 1.375-1.313 2.438-2.563 3.188 1.125-.125 2.188-.438 3.313-.875z" />
</svg>
</a>
)}
</div>
</div>
</nav>
</header>
<style lang="scss" is:global>
.gh-head {
padding: 1vmin 4vmin;
font-size: 1.6rem;
line-height: 1.3em;
color: #fff;
background: var(--ghost-accent-color);
}
.gh-head a {
color: inherit;
text-decoration: none;
}
.gh-head-inner {
display: grid;
grid-gap: 2.5vmin;
grid-template-columns: auto auto 1fr;
grid-auto-flow: row dense;
}
/* Brand
/* ---------------------------------------------------------- */
.gh-head-brand {
display: flex;
align-items: center;
height: 40px;
max-width: 200px;
text-align: center;
word-break: break-all;
}
.gh-head-logo {
display: block;
padding: 10px 0;
font-weight: 700;
font-size: 2rem;
line-height: 1.2em;
letter-spacing: -0.02em;
}
.gh-head-logo img {
max-height: 26px;
}
/* Primary Navigation
/* ---------------------------------------------------------- */
.gh-head-menu {
display: flex;
align-items: center;
font-weight: 500;
}
.gh-head-menu .nav {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
.gh-head-menu .nav li {
margin: 0 2.5vmin 0 0;
padding: 0;
}
.gh-head-menu .nav a {
display: inline-block;
padding: 5px 0;
opacity: 0.8;
}
.gh-head-menu .nav a:hover {
opacity: 1;
}
/* Secondary Navigation
/* ---------------------------------------------------------- */
.gh-head-actions {
display: flex;
justify-content: flex-end;
align-items: center;
list-style: none;
text-align: right;
}
.gh-head-actions-list {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
.gh-head-actions-list a:not([class]) {
display: inline-block;
margin: 0 0 0 1.5vmin;
padding: 5px 0;
}
.gh-social {
margin: 0 1.5vmin 0 0;
}
.gh-social a {
opacity: 0.8;
}
.gh-social a + a {
margin-left: 0.8rem;
}
.gh-social a:hover {
opacity: 1;
}
.gh-social svg {
height: 22px;
width: 22px;
fill: #fff;
}
.gh-social-facebook svg {
height: 20px;
width: 20px;
}
a.gh-head-button {
display: block;
padding: 8px 15px;
color: var(--color-darkgrey);
font-weight: 500;
letter-spacing: -0.015em;
font-size: 1.5rem;
line-height: 1em;
background: #fff;
border-radius: 30px;
}
/* Mobile Menu Trigger
/* ---------------------------------------------------------- */
.gh-burger {
position: relative;
display: none;
cursor: pointer;
}
.gh-burger-box {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 33px;
height: 33px;
}
.gh-burger-inner {
width: 100%;
height: 100%;
}
.gh-burger-box::before {
position: absolute;
display: block;
top: 0;
left: 0;
bottom: 0;
margin: auto;
content: "";
width: 100%;
height: 1px;
background: currentcolor;
transition: transform 300ms cubic-bezier(0.2, 0.6, 0.3, 1),
width 300ms cubic-bezier(0.2, 0.6, 0.3, 1);
will-change: transform, width;
}
.gh-burger-inner::before,
.gh-burger-inner::after {
position: absolute;
display: block;
top: 0;
left: 0;
bottom: 0;
margin: auto;
content: "";
width: 100%;
height: 1px;
background: currentcolor;
transition: transform 250ms cubic-bezier(0.2, 0.7, 0.3, 1),
width 250ms cubic-bezier(0.2, 0.7, 0.3, 1);
will-change: transform, width;
}
.gh-burger-inner::before {
transform: translatey(-6px);
}
.gh-burger-inner::after {
transform: translatey(6px);
}
body:not(.gh-head-open) .gh-burger:hover .gh-burger-inner::before {
transform: translatey(-8px);
}
body:not(.gh-head-open) .gh-burger:hover .gh-burger-inner::after {
transform: translatey(8px);
}
.gh-head-open .gh-burger-box::before {
width: 0;
transform: translatex(19px);
transition: transform 200ms cubic-bezier(0.2, 0.7, 0.3, 1),
width 200ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.gh-head-open .gh-burger-inner::before {
width: 26px;
transform: translatex(6px) rotate(135deg);
}
.gh-head-open .gh-burger-inner::after {
width: 26px;
transform: translatex(6px) rotate(-135deg);
}
/* Mobile Menu
/* ---------------------------------------------------------- */
/* IDs needed to ensure sufficient specificity */
@media (max-width: 900px) {
.gh-burger {
display: inline-block;
}
#gh-head {
transition: all 0.4s ease-out;
overflow: hidden;
}
#gh-head .gh-head-inner {
height: 100%;
grid-template-columns: 1fr;
}
#gh-head .gh-head-brand {
position: relative;
z-index: 10;
grid-column-start: auto;
max-width: none;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.home-template #gh-head .gh-head-brand {
justify-content: flex-end;
}
#gh-head .gh-head-menu {
align-self: center;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 0 10vh 0;
font-weight: 300;
font-size: 3.6rem;
line-height: 1.1em;
}
#gh-head .gh-head-menu .nav li {
margin: 5px 0;
}
#gh-head .gh-head-menu .nav a {
padding: 8px 0;
}
#gh-head .gh-head-menu .nav {
display: flex;
flex-direction: column;
align-items: center;
}
#gh-head .gh-head-actions {
padding: 20px 0;
justify-content: center;
text-align: left;
}
#gh-head .gh-head-actions a {
margin: 0 10px;
}
/* Hide collapsed content */
#gh-head .gh-head-actions,
#gh-head .gh-head-menu {
display: none;
}
/* Open the menu */
.gh-head-open {
overflow: hidden;
height: 100vh;
}
.gh-head-open #gh-head {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 3999999;
overflow-y: scroll;
}
.gh-head-open #gh-head .gh-head-inner {
grid-template-rows: auto 1fr auto;
}
.gh-head-open #gh-head .gh-head-actions,
.gh-head-open #gh-head .gh-head-menu {
display: flex;
}
}
@media (max-width: 600px) {
#gh-head .gh-head-menu {
font-size: 6vmin;
}
}
.home-template .gh-head {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 2000;
}
.home-template .gh-head.has-cover {
background: transparent;
}
.home-template.gh-head-open .gh-head {
background: var(--ghost-accent-color);
}
.home-template .gh-head-logo {
display: none;
}
.home-template .gh-head-menu {
margin-left: -2.5vmin;
}
</style>
<script lang="ts">
if (!window.handleMenu) {
window.handleMenu = () => {
// menu
const burgerButton = document.querySelector(".gh-burger");
if (burgerButton) {
burgerButton.addEventListener("click", () => {
const body = document.querySelector("body");
body.classList.toggle("gh-head-open");
});
}
};
const callback = () => {
window.handleMenu();
};
if (document.readyState === "complete") {
callback();
} else {
document.addEventListener("DOMContentLoaded", callback);
}
}
</script>

View File

@ -0,0 +1,131 @@
---
import { getGhostImgPath } from "../utils";
import type { Settings } from "../api";
export type Props = {
featureImg?: string;
mainTitle?: string;
settings: Settings;
description?: string;
addClass?: string;
};
const {
featureImg = "https://static.ghost.org/v4.0.0/images/publication-cover.jpg",
mainTitle = "",
settings,
description = "",
} = Astro.props as Props;
---
<div class="site-header-content">
{featureImg && (
<img
class="site-header-cover"
data-srcset={`
${getGhostImgPath(settings.url, featureImg, 300)} 300w,
${getGhostImgPath(settings.url, featureImg, 600)} 600w
${getGhostImgPath(settings.url, featureImg, 1000)} 1000w
${getGhostImgPath(settings.url, featureImg, 2000)} 2000w
`}
srcset={`
${getGhostImgPath(settings.url, featureImg, 300)} 300w,
${getGhostImgPath(settings.url, featureImg, 600)} 600w
${getGhostImgPath(settings.url, featureImg, 1000)} 1000w
${getGhostImgPath(settings.url, featureImg, 2000)} 2000w
`}
data-sizes="auto"
data-src={getGhostImgPath(settings.url, featureImg, 2000)}
src={getGhostImgPath(settings.url, featureImg, 2000)}
alt={mainTitle}
sizes="100vw"
/>
)}
<slot name="title">
<h1 class="site-title">
{settings.logo ? (
<img
class="site-logo"
src={getGhostImgPath(settings.url, settings.logo, 300)}
alt={settings.title}
/>
) : (
settings.title
)}
</h1>
</slot>
<p>{description}</p>
</div>
<style lang="scss">
.site-header {
position: relative;
color: #fff;
background: var(--ghost-accent-color);
}
.site-header-cover {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.site-header-content {
position: relative;
z-index: 100;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 6vw 3vw;
min-height: 200px;
max-height: 340px;
text-align: center;
}
.site-title {
z-index: 10;
margin: 0 0 0.15em;
padding: 0;
}
.site-logo {
max-height: 55px;
}
.site-header-content p {
z-index: 10;
max-width: 600px;
margin: 0 auto;
line-height: 1.2em;
opacity: 0.8;
}
@media (max-width: 600px) {
.site-header-content p {
max-width: 80vmin;
font-size: 1.8rem;
}
}
/* 4.1 Home header
/* ---------------------------------------------------------- */
.site-home-header {
position: relative;
z-index: 1000;
overflow: hidden;
}
.site-header-content {
padding: 18vmin 4vmin;
font-size: 2.5rem;
font-weight: 400;
color: #fff;
background: var(--ghost-accent-color);
}
</style>

View File

@ -0,0 +1,23 @@
---
import Header from "./Header.astro";
import Footer from "./Footer.astro";
import type { Settings } from "../api";
export type Props = {
settings: Settings;
};
const { settings } = Astro.props as Props;
---
<div class="viewport">
<Header settings={settings} />
<div class="site-content">
<slot></slot>
</div>
<Footer settings={settings} />
</div>
<style>
.content {
min-height: 580px;
}
</style>

569
src/components/Page.astro Normal file
View File

@ -0,0 +1,569 @@
---
import FeatureImage from "./FeatureImage.astro";
import type { Settings, PostOrPage } from "../api";
export type Props = {
page: PostOrPage;
settings: Settings;
pageClass?: string;
};
const { page, settings, pageClass } = Astro.props as Props;
---
<main id="site-main" class="site-main">
<article class={`article page ${pageClass}`}>
<header class="article-header gh-canvas">
{page.feature_image && (
<FeatureImage
image={page.feature_image}
alt={page.feature_image_alt ? page.feature_image_alt : page.title}
caption={page.feature_image_caption || ""}
settings={settings}
/>
)}
</header>
<section class="gh-content gh-canvas">
<h1 class="article-title">{page.title}</h1>
<Fragment set:html={page.html} />
</section>
</article>
</main>
<style lang="scss" is:global>
.article {
padding: 8vmin 0;
word-break: break-word;
}
.article-header {
padding: 0 0 6vmin 0;
}
.article-tag {
display: flex;
justify-content: flex-start;
align-items: center;
margin: 0 0 0.5rem;
color: var(--color-midgrey);
font-size: 1.3rem;
line-height: 1.4em;
letter-spacing: 0.02em;
font-weight: 600;
text-transform: uppercase;
}
.article-tag a {
color: var(--ghost-accent-color);
}
.article-title {
color: color-mod(var(--color-darkgrey) l(-5%));
}
.article-excerpt {
margin: 0 0 1rem;
font-size: 2rem;
line-height: 1.4em;
opacity: 0.6;
}
.gh-canvas .article-image {
grid-column: wide-start / wide-end;
width: 100%;
margin: 6vmin 0 0;
}
.gh-canvas .article-image img {
display: block;
margin-left: auto;
margin-right: auto;
width: 100%;
}
@media (max-width: 600px) {
.article-excerpt {
font-size: 1.8rem;
}
}
/* -------- */
/* Content grid
/* ---------------------------------------------------------- */
/* Canvas creates a multi-column, centered grid which the post
is laid out on top of. Canvas just defines the grid, we don't
use it for applying any other styles. */
.gh-canvas {
display: grid;
grid-template-columns:
[full-start]
minmax(4vmin, auto)
[wide-start]
minmax(auto, 240px)
[main-start]
min(720px, calc(100% - 8vw))
[main-end]
minmax(auto, 240px)
[wide-end]
minmax(4vmin, auto)
[full-end];
}
.gh-canvas > * {
grid-column: main-start / main-end;
}
.kg-width-wide {
grid-column: wide-start / wide-end;
}
.kg-width-full {
grid-column: full-start / full-end;
}
.kg-width-full img {
width: 100%;
}
/* Default vertical spacing */
.gh-content > * + * {
margin-top: 4vmin;
margin-bottom: 0;
}
/* [id] represents all headings h1-h6, reset all margins */
.gh-content > [id] {
margin: 0;
color: var(--color-darkgrey);
}
/* Add back a top margin to all headings, unless a heading
is the very first element in the post content */
.gh-content > [id]:not(:first-child) {
margin: 2em 0 0;
}
/* Add a small margin between a heading and anything after it */
.gh-content > [id] + * {
margin-top: 1.5rem !important;
}
/* A larger margin before/after HRs and blockquotes */
.gh-content > hr,
.gh-content > blockquote {
position: relative;
margin-top: 6vmin;
}
.gh-content > hr + *,
.gh-content > blockquote + * {
margin-top: 6vmin !important;
}
/* Now the content typography styles */
.gh-content a {
color: var(--ghost-accent-color);
text-decoration: underline;
word-break: break-word;
}
.gh-content > blockquote,
.gh-content > ol,
.gh-content > ul,
.gh-content > dl,
.gh-content > p {
font-family: var(--font-serif);
font-weight: 400;
font-size: 2.1rem;
line-height: 1.6em;
}
.gh-content > ul,
.gh-content > ol,
.gh-content > dl {
padding-left: 1.9em;
}
.gh-content > blockquote {
position: relative;
font-style: italic;
padding: 0;
}
.gh-content > blockquote::before {
content: "";
position: absolute;
left: -1.5em;
top: 0;
bottom: 0;
width: 0.3rem;
background: var(--ghost-accent-color);
}
.gh-content :not(pre) > code {
vertical-align: middle;
padding: 0.15em 0.4em 0.15em;
border: #e1eaef 1px solid;
font-weight: 400 !important;
font-size: 0.9em;
line-height: 1em;
color: #15171a;
background: #f0f6f9;
border-radius: 0.25em;
}
.gh-content pre {
overflow: auto;
padding: 16px 20px;
color: var(--color-wash);
font-size: 1.4rem;
line-height: 1.5em;
background: var(--color-darkgrey);
border-radius: 5px;
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.4);
}
@media (max-width: 650px) {
.gh-content blockquote,
.gh-content ol,
.gh-content ul,
.gh-content dl,
.gh-content p {
font-size: 1.7rem;
}
.gh-content blockquote::before {
left: -4vmin;
}
}
/* Cards
/* ---------------------------------------------------------- */
/* Cards are dynamic blocks of content which appear within Ghost
posts, for example: embedded videos, tweets, galleries, or
specially styled bookmark links. We add extra styling here to
make sure they look good, and are given a bit of extra spacing. */
/* Add extra margin before/after any cards,
except for when immediately preceeded by a heading */
.gh-content :not(.kg-card):not([id]) + .kg-card {
margin-top: 6vmin;
margin-bottom: 0;
}
.gh-content .kg-card + :not(.kg-card) {
margin-top: 6vmin;
margin-bottom: 0;
}
/* This keeps small embeds centered */
.kg-embed-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
/* This keeps small iamges centered */
.kg-image-card img {
margin: auto;
}
/* Captions */
figcaption {
padding: 1.5rem 1.5rem 0;
text-align: center;
color: rgba(0, 0, 0, 0.5);
font-weight: 600;
font-size: 1.3rem;
line-height: 1.4em;
}
figcaption strong {
color: rgba(0, 0, 0, 0.8);
}
figcaption a {
color: var(--ghost-accent-color);
}
/* Highly specific styles for traditional Instagram embeds */
iframe.instagram-media {
margin-top: 6vmin !important;
margin-left: auto !important;
margin-right: auto !important;
margin-bottom: 0 !important;
}
iframe.instagram-media + script + :not([id]) {
margin-top: 6vmin;
}
/* Galleries
/* ---------------------------------------------------------- */
/* When there galleries are mixed with images, reduce margin
between them, so it looks like 1 big gallery */
.kg-image-card + .kg-gallery-card,
.kg-gallery-card + .kg-image-card,
.kg-gallery-card + .kg-gallery-card {
margin-top: 0.75em;
}
.kg-image-card.kg-card-hascaption + .kg-gallery-card,
.kg-gallery-card.kg-card-hascaption + .kg-image-card,
.kg-gallery-card.kg-card-hascaption + .kg-gallery-card {
margin-top: 1.75em;
}
.kg-gallery-container {
position: relative;
}
.kg-gallery-row {
display: flex;
flex-direction: row;
justify-content: center;
}
.kg-gallery-image img {
display: block;
margin: 0;
width: 100%;
height: 100%;
}
.kg-gallery-row:not(:first-of-type) {
margin: 0.75em 0 0 0;
}
.kg-gallery-image:not(:first-of-type) {
margin: 0 0 0 0.75em;
}
/* Bookmark Cards
/* ---------------------------------------------------------- */
/* These are styled links with structured data, similar to a
Twitter card. These styles roughly match what you see in the
Ghost editor. */
.kg-bookmark-card,
.kg-bookmark-publisher {
position: relative;
width: 100%;
}
.kg-bookmark-container,
.kg-bookmark-container:hover {
display: flex;
color: currentColor;
font-family: var(--font-sans-serif);
text-decoration: none !important;
background: rgba(255, 255, 255, 0.6);
border-radius: 5px;
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.kg-bookmark-content {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-basis: 100%;
align-items: flex-start;
justify-content: flex-start;
padding: 20px;
}
.kg-bookmark-title {
font-size: 1.5rem;
line-height: 1.4em;
font-weight: 600;
color: #15171a;
}
.kg-bookmark-description {
display: -webkit-box;
font-size: 1.4rem;
line-height: 1.5em;
margin-top: 3px;
color: #626d79;
font-weight: 400;
max-height: 44px;
overflow-y: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kg-bookmark-metadata {
display: flex;
align-items: center;
margin-top: 22px;
width: 100%;
color: #394047;
font-size: 1.4rem;
font-weight: 500;
}
.kg-bookmark-icon {
width: 20px;
height: 20px;
margin-right: 6px;
}
.kg-bookmark-author,
.kg-bookmark-publisher {
display: inline;
}
.kg-bookmark-publisher {
text-overflow: ellipsis;
overflow: hidden;
max-width: 240px;
white-space: nowrap;
display: block;
line-height: 1.65em;
}
.kg-bookmark-metadata > span:nth-of-type(2) {
color: #626d79;
font-weight: 400;
}
.kg-bookmark-metadata > span:nth-of-type(2):before {
content: "•";
color: #394047;
margin: 0 6px;
}
.kg-bookmark-thumbnail {
position: relative;
flex-grow: 1;
min-width: 33%;
}
.kg-bookmark-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
border-radius: 0 4px 4px 0;
}
/* Card captions
/* ---------------------------------------------------------- */
.kg-width-full.kg-card-hascaption {
display: grid;
grid-template-columns: inherit;
}
.kg-width-wide.kg-card-hascaption img {
grid-column: wide-start / wide-end;
}
.kg-width-full.kg-card-hascaption img {
grid-column: 1 / -1;
}
.kg-width-full.kg-card-hascaption figcaption {
grid-column: main-start / main-end;
}
.article-comments {
margin: 6vmin 0 0 0;
}
/* -----old------ */
.footnotes-sep {
margin-bottom: 30px;
}
.footnotes {
font-size: 1.5rem;
}
.footnotes p {
margin: 0;
}
.footnote-backref {
font-size: 1.2rem;
font-weight: bold;
text-decoration: none !important;
box-shadow: none !important;
}
/* Tables */
.post-full-content table {
display: inline-block;
overflow-x: auto;
margin: 0.5em 0 2.5em;
max-width: 100%;
width: auto;
border-spacing: 0;
border-collapse: collapse;
font-family: var(--font-sans-serif);
font-size: 1.6rem;
white-space: nowrap;
vertical-align: top;
}
.post-full-content table {
-webkit-overflow-scrolling: touch;
background: radial-gradient(
ellipse at left,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0) 75%
)
0 center,
radial-gradient(
ellipse at right,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0) 75%
)
100% center;
background-attachment: scroll, scroll;
background-size: 10px 100%, 10px 100%;
background-repeat: no-repeat;
}
.post-full-content table td:first-child {
background-image: linear-gradient(
to right,
rgba(255, 255, 255, 1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 20px 100%;
background-repeat: no-repeat;
}
.post-full-content table td:last-child {
background-image: linear-gradient(
to left,
rgba(255, 255, 255, 1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-position: 100% 0;
background-size: 20px 100%;
background-repeat: no-repeat;
}
.post-full-content table th {
color: var(--color-darkgrey);
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.2px;
text-align: left;
text-transform: uppercase;
background-color: color-mod(var(--color-wash) l(+4%));
}
.post-full-content table th,
.post-full-content table td {
padding: 6px 12px;
border: color-mod(var(--color-wash) l(-1%) s(-5%)) 1px solid;
}
</style>

View File

@ -0,0 +1,26 @@
---
import type { Page } from 'astro';
const { page } = Astro.props as {page: Page};
---
<div class="page__actions">
{page.url.prev && (
<a class="action__go-to-x" href={page.url.prev} title="Go to Previous">
&larr; Prev
</a>
)}
{page.url.next && (
<a class="action__go-to-x" href={page.url.next} title="Go to Next">
Next &rarr;
</a>
)}
</div>
<style>
/* .page__actions {
@apply flex justify-center md:justify-end py-6 gap-2;
}
.action__go-to-x {
@apply text-base uppercase text-gray-500 dark:text-gray-400 hover:underline;
} */
</style>

570
src/components/Post.astro Normal file
View File

@ -0,0 +1,570 @@
---
import PostHero from "./PostHero.astro";
import PostFooter from "./PostFooter.astro";
import invariant from "tiny-invariant";
import type {PostOrPage, PostsOrPages, Settings } from "../api";
export type Props = {
post: PostOrPage;
settings: Settings;
postClass?: string;
posts: PostsOrPages;
};
const { post, settings, postClass, posts } = Astro.props as Props;
invariant(settings, "Settings not found");
---
<main id="site-main" class="site-main">
<article class={`article post ${postClass}`}>
<PostHero post={post} settings={settings} />
<section class="gh-content gh-canvas">
<Fragment set:html={post.html} />
</section>
</article>
<PostFooter post={post} settings={settings} posts={posts} />
</main>
<style lang="scss" is:global>
.article {
padding: 8vmin 0;
word-break: break-word;
}
.article-header {
padding: 0 0 6vmin 0;
}
.article-tag {
display: flex;
justify-content: flex-start;
align-items: center;
margin: 0 0 0.5rem;
color: var(--color-midgrey);
font-size: 1.3rem;
line-height: 1.4em;
letter-spacing: 0.02em;
font-weight: 600;
text-transform: uppercase;
}
.article-tag a {
color: var(--ghost-accent-color);
}
.article-title {
color: color-mod(var(--color-darkgrey) l(-5%));
}
.article-excerpt {
margin: 0 0 1rem;
font-size: 2rem;
line-height: 1.4em;
opacity: 0.6;
}
.gh-canvas .article-image {
grid-column: wide-start / wide-end;
width: 100%;
margin: 6vmin 0 0;
}
.gh-canvas .article-image img {
display: block;
margin-left: auto;
margin-right: auto;
width: 100%;
}
@media (max-width: 600px) {
.article-excerpt {
font-size: 1.8rem;
}
}
/* -------- */
/* Content grid
/* ---------------------------------------------------------- */
/* Canvas creates a multi-column, centered grid which the post
is laid out on top of. Canvas just defines the grid, we don't
use it for applying any other styles. */
.gh-canvas {
display: grid;
grid-template-columns:
[full-start]
minmax(4vmin, auto)
[wide-start]
minmax(auto, 240px)
[main-start]
min(720px, calc(100% - 8vw))
[main-end]
minmax(auto, 240px)
[wide-end]
minmax(4vmin, auto)
[full-end];
}
.gh-canvas > * {
grid-column: main-start / main-end;
}
.kg-width-wide {
grid-column: wide-start / wide-end;
}
.kg-width-full {
grid-column: full-start / full-end;
}
.kg-width-full img {
width: 100%;
}
/* Content
/* ---------------------------------------------------------- */
/* Content refers to styling all page and post content that is
created within the Ghost editor. The main content handles
headings, text, images and lists. We deal with cards lower down. */
/* Default vertical spacing */
.gh-content > * + * {
margin-top: 4vmin;
margin-bottom: 0;
}
/* [id] represents all headings h1-h6, reset all margins */
.gh-content > [id] {
margin: 0;
color: var(--color-darkgrey);
}
/* Add back a top margin to all headings, unless a heading
is the very first element in the post content */
.gh-content > [id]:not(:first-child) {
margin: 2em 0 0;
}
/* Add a small margin between a heading and anything after it */
.gh-content > [id] + * {
margin-top: 1.5rem !important;
}
/* A larger margin before/after HRs and blockquotes */
.gh-content > hr,
.gh-content > blockquote {
position: relative;
margin-top: 6vmin;
}
.gh-content > hr + *,
.gh-content > blockquote + * {
margin-top: 6vmin !important;
}
/* Now the content typography styles */
.gh-content a {
color: var(--ghost-accent-color);
text-decoration: underline;
word-break: break-word;
}
.gh-content > blockquote,
.gh-content > ol,
.gh-content > ul,
.gh-content > dl,
.gh-content > p {
font-family: var(--font-serif);
font-weight: 400;
font-size: 2.1rem;
line-height: 1.6em;
}
.gh-content > ul,
.gh-content > ol,
.gh-content > dl {
padding-left: 1.9em;
}
.gh-content > blockquote {
position: relative;
font-style: italic;
padding: 0;
}
.gh-content > blockquote::before {
content: "";
position: absolute;
left: -1.5em;
top: 0;
bottom: 0;
width: 0.3rem;
background: var(--ghost-accent-color);
}
.gh-content :not(pre) > code {
vertical-align: middle;
padding: 0.15em 0.4em 0.15em;
border: #e1eaef 1px solid;
font-weight: 400 !important;
font-size: 0.9em;
line-height: 1em;
color: #15171a;
background: #f0f6f9;
border-radius: 0.25em;
}
.gh-content pre {
overflow: auto;
padding: 16px 20px;
color: var(--color-wash);
font-size: 1.4rem;
line-height: 1.5em;
background: var(--color-darkgrey);
border-radius: 5px;
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.4);
}
@media (max-width: 650px) {
.gh-content blockquote,
.gh-content ol,
.gh-content ul,
.gh-content dl,
.gh-content p {
font-size: 1.7rem;
}
.gh-content blockquote::before {
left: -4vmin;
}
}
/* Cards
/* ---------------------------------------------------------- */
/* Cards are dynamic blocks of content which appear within Ghost
posts, for example: embedded videos, tweets, galleries, or
specially styled bookmark links. We add extra styling here to
make sure they look good, and are given a bit of extra spacing. */
/* Add extra margin before/after any cards,
except for when immediately preceeded by a heading */
.gh-content :not(.kg-card):not([id]) + .kg-card {
margin-top: 6vmin;
margin-bottom: 0;
}
.gh-content .kg-card + :not(.kg-card) {
margin-top: 6vmin;
margin-bottom: 0;
}
/* This keeps small embeds centered */
.kg-embed-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
/* This keeps small iamges centered */
.kg-image-card img {
margin: auto;
}
/* Captions */
figcaption {
padding: 1.5rem 1.5rem 0;
text-align: center;
color: rgba(0, 0, 0, 0.5);
font-weight: 600;
font-size: 1.3rem;
line-height: 1.4em;
}
figcaption strong {
color: rgba(0, 0, 0, 0.8);
}
figcaption a {
color: var(--ghost-accent-color);
}
/* Highly specific styles for traditional Instagram embeds */
iframe.instagram-media {
margin-top: 6vmin !important;
margin-left: auto !important;
margin-right: auto !important;
margin-bottom: 0 !important;
}
iframe.instagram-media + script + :not([id]) {
margin-top: 6vmin;
}
/* Galleries
/* ---------------------------------------------------------- */
/* When there galleries are mixed with images, reduce margin
between them, so it looks like 1 big gallery */
.kg-image-card + .kg-gallery-card,
.kg-gallery-card + .kg-image-card,
.kg-gallery-card + .kg-gallery-card {
margin-top: 0.75em;
}
.kg-image-card.kg-card-hascaption + .kg-gallery-card,
.kg-gallery-card.kg-card-hascaption + .kg-image-card,
.kg-gallery-card.kg-card-hascaption + .kg-gallery-card {
margin-top: 1.75em;
}
.kg-gallery-container {
position: relative;
}
.kg-gallery-row {
display: flex;
flex-direction: row;
justify-content: center;
}
.kg-gallery-image img {
display: block;
margin: 0;
width: 100%;
height: 100%;
}
.kg-gallery-row:not(:first-of-type) {
margin: 0.75em 0 0 0;
}
.kg-gallery-image:not(:first-of-type) {
margin: 0 0 0 0.75em;
}
/* Bookmark Cards
/* ---------------------------------------------------------- */
/* These are styled links with structured data, similar to a
Twitter card. These styles roughly match what you see in the
Ghost editor. */
.kg-bookmark-card,
.kg-bookmark-publisher {
position: relative;
width: 100%;
}
.kg-bookmark-container,
.kg-bookmark-container:hover {
display: flex;
color: currentColor;
font-family: var(--font-sans-serif);
text-decoration: none !important;
background: rgba(255, 255, 255, 0.6);
border-radius: 5px;
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.kg-bookmark-content {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-basis: 100%;
align-items: flex-start;
justify-content: flex-start;
padding: 20px;
}
.kg-bookmark-title {
font-size: 1.5rem;
line-height: 1.4em;
font-weight: 600;
color: #15171a;
}
.kg-bookmark-description {
display: -webkit-box;
font-size: 1.4rem;
line-height: 1.5em;
margin-top: 3px;
color: #626d79;
font-weight: 400;
max-height: 44px;
overflow-y: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kg-bookmark-metadata {
display: flex;
align-items: center;
margin-top: 22px;
width: 100%;
color: #394047;
font-size: 1.4rem;
font-weight: 500;
}
.kg-bookmark-icon {
width: 20px;
height: 20px;
margin-right: 6px;
}
.kg-bookmark-author,
.kg-bookmark-publisher {
display: inline;
}
.kg-bookmark-publisher {
text-overflow: ellipsis;
overflow: hidden;
max-width: 240px;
white-space: nowrap;
display: block;
line-height: 1.65em;
}
.kg-bookmark-metadata > span:nth-of-type(2) {
color: #626d79;
font-weight: 400;
}
.kg-bookmark-metadata > span:nth-of-type(2):before {
content: "•";
color: #394047;
margin: 0 6px;
}
.kg-bookmark-thumbnail {
position: relative;
flex-grow: 1;
min-width: 33%;
}
.kg-bookmark-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
border-radius: 0 4px 4px 0;
}
/* Card captions
/* ---------------------------------------------------------- */
.kg-width-full.kg-card-hascaption {
display: grid;
grid-template-columns: inherit;
}
.kg-width-wide.kg-card-hascaption img {
grid-column: wide-start / wide-end;
}
.kg-width-full.kg-card-hascaption img {
grid-column: 1 / -1;
}
.kg-width-full.kg-card-hascaption figcaption {
grid-column: main-start / main-end;
}
.article-comments {
margin: 6vmin 0 0 0;
}
/* -----old------ */
.footnotes-sep {
margin-bottom: 30px;
}
.footnotes {
font-size: 1.5rem;
}
.footnotes p {
margin: 0;
}
.footnote-backref {
font-size: 1.2rem;
font-weight: bold;
text-decoration: none !important;
box-shadow: none !important;
}
/* Tables */
.post-full-content table {
display: inline-block;
overflow-x: auto;
margin: 0.5em 0 2.5em;
max-width: 100%;
width: auto;
border-spacing: 0;
border-collapse: collapse;
font-family: var(--font-sans-serif);
font-size: 1.6rem;
white-space: nowrap;
vertical-align: top;
}
.post-full-content table {
-webkit-overflow-scrolling: touch;
background: radial-gradient(
ellipse at left,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0) 75%
)
0 center,
radial-gradient(
ellipse at right,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0) 75%
)
100% center;
background-attachment: scroll, scroll;
background-size: 10px 100%, 10px 100%;
background-repeat: no-repeat;
}
.post-full-content table td:first-child {
background-image: linear-gradient(
to right,
rgba(255, 255, 255, 1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 20px 100%;
background-repeat: no-repeat;
}
.post-full-content table td:last-child {
background-image: linear-gradient(
to left,
rgba(255, 255, 255, 1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-position: 100% 0;
background-size: 20px 100%;
background-repeat: no-repeat;
}
.post-full-content table th {
color: var(--color-darkgrey);
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.2px;
text-align: left;
text-transform: uppercase;
background-color: color-mod(var(--color-wash) l(+4%));
}
.post-full-content table th,
.post-full-content table td {
padding: 6px 12px;
border: color-mod(var(--color-wash) l(-1%) s(-5%)) 1px solid;
}
</style>

View File

@ -0,0 +1,125 @@
---
import PostPreview from "./PostPreview.astro";
import type { Settings, PostOrPage, PostsOrPages } from "../api";
export type Props = {
post: PostOrPage;
settings: Settings;
posts: PostsOrPages;
};
const { post, settings, posts } = Astro.props as Props;
---
<section class="footer-cta">
<div class="inner">
<h2>Sign up for more like this.</h2>
<a class="footer-cta-button" href="#/portal">
<div>Enter your email</div>
<span>Subscribe</span>
</a>
</div>
</section>
<aside class="read-more-wrap">
<div class="read-more inner">
{posts
.filter((p: PostOrPage) => p.id !== post.id)
.slice(0, 3)
.map((post: PostOrPage) => <PostPreview post={post} settings={settings} />)}
</div>
</aside>
<style lang="scss">
/* 7.3. Subscribe
/* ---------------------------------------------------------- */
.footer-cta {
position: relative;
padding: 9vmin 4vmin 10vmin;
color: #fff;
text-align: center;
background: var(--color-darkgrey);
}
/* Increases the default h2 size by 15%, for small and large screens */
.footer-cta h2 {
margin: 0 0 30px;
font-size: 3.2rem;
}
@media (max-width: 600px) {
.footer-cta h2 {
font-size: 2.65rem;
}
}
.footer-cta-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 500px;
padding: 5px 5px 5px 15px;
font-size: 1.8rem;
color: var(--color-midgrey);
background: #fff;
border-radius: 8px;
}
.footer-cta-button span {
display: inline-block;
padding: 10px 20px;
color: #fff;
font-weight: 500;
background: var(--ghost-accent-color);
border-radius: 5px;
}
/* 7.4. Read more
/* ---------------------------------------------------------- */
.read-more-wrap {
width: 100%;
padding: 4vmin;
margin: 0 auto -40px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: color-mod(var(--color-darkgrey) l(-5%));
}
.read-more {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 4vmin;
}
.read-more .post-card-title {
color: #fff;
opacity: 0.8;
}
.read-more .post-card-excerpt {
color: rgba(255, 255, 255, 0.6);
}
.read-more .post-card-byline-content a {
color: #fff;
}
@media (max-width: 1000px) {
.read-more {
grid-template-columns: 1fr 1fr;
}
.read-more article:nth-child(3) {
display: none;
}
}
@media (max-width: 700px) {
.read-more {
grid-template-columns: 1fr;
}
.read-more article:nth-child(2) {
display: none;
}
}
</style>

View File

@ -0,0 +1,110 @@
---
import FeatureImage from "./FeatureImage.astro";
import AuthorList from "./AuthorList.astro";
import { formatDate } from "../utils";
import type { Settings, PostOrPage } from "../api";
export type Props = {
post: PostOrPage;
settings: Settings;
};
const { post, settings } = Astro.props as Props;
---
<header class="article-header gh-canvas">
{post.primary_tag && (
<section class="article-tag">
<a href={`/tag/${post.primary_tag.slug}`}>{post.primary_tag.name}</a>
</section>
)}
<h1 class="article-title" transition:name={post.title}>{post.title}</h1>
{post.custom_excerpt && <p class="article-excerpt">{post.custom_excerpt}</p>}
<div class="article-byline">
<section class="article-byline-content">
<AuthorList post={post} settings={settings} />
<div class="article-byline-meta">
{ post.authors && post.authors.length > 1 && (
<h4 class="author-name">
{post.authors.map((author) => author.name).join(", ")}
</h4>
)}
<div class="byline-meta-content">
{post.created_at && (
<time class="byline-meta-date" datetime={formatDate(post.created_at)}
>{formatDate(post.created_at)}
</time>
)}
<span class="byline-reading-time"
><span class="bull">&bull;</span>
{post.reading_time} min read
</span>
</div>
</div>
</section>
</div>
{post.feature_image && (
<FeatureImage
image={post.feature_image}
alt={post.feature_image_alt ? post.feature_image_alt : post.title}
caption={post.feature_image_caption || "" }
settings={settings}
transitionName={`img-${post.title}`}
/>
)}
</header>
<style lang="scss">
.article-byline {
display: flex;
justify-content: space-between;
margin: 20px 0 0;
}
.article-byline-content {
flex-grow: 1;
display: flex;
align-items: center;
}
.article-byline-content .author-list {
justify-content: flex-start;
padding: 0 12px 0 0;
}
.article-byline-meta {
color: color-mod(var(--color-midgrey));
font-size: 1.4rem;
line-height: 1.2em;
}
.article-byline-meta h4 {
margin: 0 0 3px;
font-size: 1.6rem;
}
.article-byline-meta .bull {
display: inline-block;
margin: 0 2px;
opacity: 0.6;
}
.basic-info .avatar-wrapper {
position: relative;
margin: 0;
width: 60px;
height: 60px;
border: none;
background: rgba(229, 239, 245, 0.1);
}
.basic-info .avatar-wrapper svg {
margin: 0;
width: 60px;
height: 60px;
opacity: 0.15;
}
.page-template .article-title {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,301 @@
---
import { getGhostImgPath, formatDate } from "../utils";
import AuthorList from "./AuthorList.astro";
import type { Settings, PostOrPage, Tag } from "../api";
export type Props = {
post: PostOrPage;
settings: Settings;
index?: number;
isHome?: boolean;
};
const { post, settings, index, isHome = false } = Astro.props as Props;
---
<article
class={`post-card ${post.tags && post.tags
.map((tag: Tag) => `tag-${tag.slug}`)
.join(" ")} ${
isHome && post.feature_image && index == 0 ? "post-card-large" : ""
}`}
>
<a class="post-card-image-link" href={`/${post.slug}`}>
<img
class="post-card-image"
srcset={`
${getGhostImgPath(settings.url, post.feature_image || "", 300)} 300w,
${getGhostImgPath(settings.url, post.feature_image || "", 600)} 600w
${getGhostImgPath(settings.url, post.feature_image || "", 1000)} 1000w
${getGhostImgPath(settings.url, post.feature_image || "", 2000)} 2000w
`}
src={`${getGhostImgPath(settings.url, post.feature_image || "", 600)}`}
alt={post.title}
sizes="(max-width: 1000px) 400px, 800px"
loading="lazy"
transition:name={`img-${post.title}`}
/>
</a>
<div class="post-card-content">
<a class="post-card-content-link" href={`/${post.slug}`}>
<header class="post-card-header">
{post.primary_tag && (
<div class="post-card-primary-tag">{post.primary_tag.name}</div>
)}
<h2 class="post-card-title" transition:name={post.title}>{post.title}</h2>
</header>
<div class="post-card-excerpt">
<p>{post.excerpt}</p>
</div>
</a>
<footer class="post-card-meta">
<AuthorList post={post} settings={settings} />
<div class="post-card-byline-content">
<span>{post.primary_author?.name ?? ""}</span>
<span class="post-card-byline-date"
>{post.created_at && (<time datetime={formatDate(post.created_at)}
>{formatDate(post.created_at)}
</time>)}
<span class="bull">&bull;</span>
{post.reading_time} min read
</span>
</div>
</footer>
</div>
</article>
<style lang="scss" is:global>
@use "sass:color";
@import "/src/styles/variables";
.post-card {
position: relative;
flex: 1 1 301px;
display: flex;
flex-direction: column;
min-height: 220px;
background-size: cover;
word-break: break-word;
}
.post-card-excerpt {
max-width: 56em;
color: color.scale($color-midgrey, $lightness: -8%);
}
.post-card-image-link {
position: relative;
display: block;
overflow: hidden;
border-radius: 3px;
}
.post-card-image {
width: 100%;
height: 200px;
background: $color-lightgrey no-repeat center center;
object-fit: cover;
}
.post-card-content-link {
position: relative;
display: block;
color: $color-darkgrey;
}
.post-card-content-link:hover {
text-decoration: none;
}
.post-card-header {
margin: 20px 0 0;
}
.post-feed .no-image .post-card-content-link {
padding: 0;
}
.no-image .post-card-header {
margin-top: 0;
}
.post-card-primary-tag {
margin: 0 0 0.2em;
color: var(--ghost-accent-color);
font-size: 1.2rem;
font-weight: 500;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.post-card-title {
margin: 0 0 0.4em;
font-size: 2.4rem;
transition: color 0.2s ease-in-out;
}
.no-image .post-card-title {
margin-top: 0;
}
.post-card-content {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.post-card-excerpt p {
margin-bottom: 1em;
display: -webkit-box;
overflow-y: hidden;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-word;
}
.post-card-meta {
display: flex;
align-items: center;
padding: 0;
}
.avatar-wrapper {
display: block;
width: 100%;
height: 100%;
background: color.scale($color-lightgrey, $lightness: +8%);
border-radius: 100%;
object-fit: cover;
}
.post-card-meta .profile-image-wrapper,
.post-card-meta .avatar-wrapper {
position: relative;
}
.static-avatar {
display: block;
overflow: hidden;
margin: 0 0 0 -6px;
width: 36px;
height: 36px;
border-radius: 100%;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.post-card-byline-content {
flex: 1 1 50%;
display: flex;
flex-direction: column;
margin: 0 0 0 8px;
color: color.scale($color-midgrey, $lightness: +10%);
font-size: 1.4rem;
line-height: 1.2em;
font-weight: 400;
}
.post-card-byline-content span {
margin: 0;
}
.post-card-byline-content a {
color: color.scale($color-darkgrey, $lightness: +15%);
font-weight: 600;
}
.post-card-byline-date {
font-size: 1.3rem;
line-height: 1.5em;
}
.post-card-byline-date .bull {
display: inline-block;
margin: 0 2px;
opacity: 0.6;
}
.single-author-byline {
display: flex;
flex-direction: column;
margin-left: 5px;
color: color.scale($color-midgrey, $lightness: -10%);
font-size: 1.3rem;
line-height: 1.4em;
font-weight: 500;
}
.single-author {
display: flex;
align-items: center;
}
.single-author .static-avatar {
margin-left: -2px;
}
.single-author-name {
display: inline-block;
}
/* Special Styling for home page grid (below):
The first post in the list is styled to be bigger than the others and take over
the full width of the grid to give it more emphasis. Wrapped in a media query to
make sure this only happens on large viewports / desktop-ish devices.
*/
@media (min-width: 1001px) {
.post-card-large {
grid-column: 1 / span 3;
display: grid;
grid-gap: 4vmin;
grid-template-columns: 1fr 1fr 1fr;
min-height: 280px;
border-top: 0;
}
.post-card-large:not(.no-image) .post-card-header {
margin-top: 0;
}
.post-card-large .post-card-image-link {
position: relative;
grid-column: 1 / span 2;
margin-bottom: 0;
min-height: 380px;
}
.post-card-large .post-card-image {
position: absolute;
width: 100%;
height: 100%;
}
.post-card-large .post-card-content {
justify-content: center;
}
.post-card-large .post-card-title {
margin-top: 0;
font-size: 3.2rem;
}
.post-card-large .post-card-excerpt p {
margin-bottom: 1.5em;
font-size: 1.7rem;
line-height: 1.55em;
-webkit-line-clamp: 8;
}
}
@media (max-width: 500px) {
.post-card-title {
font-size: 1.9rem;
}
.post-card-excerpt {
font-size: 1.6rem;
}
}
</style>

View File

@ -0,0 +1,43 @@
---
import PostPreview from "./PostPreview.astro";
import type { Settings, PostOrPage } from "../api";
export type Props = {
posts: PostOrPage[];
settings: Settings;
isHome?: boolean;
};
const { posts, settings, isHome = false } = Astro.props as Props;
---
<div class="post-feed">
{posts.map((post: PostOrPage, index: number) => (
<PostPreview
post={post}
index={index}
settings={settings}
isHome={isHome}
/>
))}
</div>
<style lang="scss">
.post-feed {
position: relative;
display: grid;
grid-gap: 4vmin;
grid-template-columns: 1fr 1fr 1fr;
padding: 4vmin 0;
}
@media (max-width: 1000px) {
.post-feed {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 700px) {
.post-feed {
grid-template-columns: 1fr;
grid-gap: 40px;
}
}
</style>

View File

@ -0,0 +1,51 @@
---
import { getGhostImgPath } from "../utils";
import type { Settings, Tag } from "../api";
export type Props = {
tag: Tag;
addClass?: string;
settings: Settings;
};
const { tag, addClass = "", settings } = Astro.props;
---
<a
href={`/tag/${tag.slug}`}
title={tag.name}
aria-label={tag.name}
class={`tag-card ${addClass ? addClass : ""}`}
style={tag.accent_color ? `--color-accent:${tag.accent_color}` : ""}
>
{
tag.feature_image && (
<div class="tag-card-media">
<img
class="tag-card-img"
data-srcset={`
${getGhostImgPath(settings.url, tag.feature_image, 100)} 100w,
${getGhostImgPath(settings.url, tag.feature_image, 300)} 300w
`}
srcset={`
${getGhostImgPath(settings.url, tag.feature_image, 100)} 100w,
${getGhostImgPath(settings.url, tag.feature_image, 300)} 300w
`}
data-sizes="auto"
data-src={getGhostImgPath(settings.url, tag.feature_image, 300)}
src={getGhostImgPath(settings.url, tag.feature_image, 300)}
alt={tag.name}
sizes="200px"
/>
</div>
)
}
<div class="tag-card-content">
<h4 class="tag-card-name">{tag.name}</h4>
{tag.count && <span class="tag-card-count">{tag.count.posts}+ Posts</span>}
</div>
</a>
<style lang="scss">
.tag-card {
text-align: center;
}
</style>

1
src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

60
src/layouts/default.astro Normal file
View File

@ -0,0 +1,60 @@
---
import type { Settings } from "../api";
import BaseHead from "../components/BaseHead.astro";
import MainLayout from "../components/MainLayout.astro";
export type Props = {
content?: {
title: string|undefined;
description: string|undefined;
};
bodyClass?: string;
settings: Settings;
};
const { content, settings, bodyClass = "" } = Astro.props as Props;
const ghostAccentColor = settings.accent_color;
console.log(settings.accent_color)
---
<html lang="en" data-color-scheme="light">
<head>
<BaseHead
title={content?.title ? `${settings.title} | ${content.title}` : "" }
description={content?.description ?? "Description"}
settings={settings}
/>
</head>
<body class={bodyClass}>
<MainLayout settings={settings}>
<slot />
</MainLayout>
<style
lang="scss"
is:global
define:vars={{ "ghost-accent-color": ghostAccentColor }}
>
@import "../styles/reset.scss";
@import "../styles/app.scss";
@mixin mq-sm {
@media only screen and (min-width: 36em) {
@content;
}
}
@mixin mq-md {
@media only screen and (min-width: 48em) {
@content;
}
}
@mixin mq-lg {
@media only screen and (min-width: 62em) {
@content;
}
}
@mixin mq-xl {
@media only screen and (min-width: 75em) {
@content;
}
}
</style>
</body>
</html>

46
src/routes/[slug].astro Normal file
View File

@ -0,0 +1,46 @@
---
import type { InferGetStaticPropsType } from 'astro';
import DefaultPageLayout from "../layouts/default.astro";
import Page from "../components/Page.astro";
import Post from "../components/Post.astro";
import { getGhostSettings, getGhostPages, getGhostPosts } from "../api";
import invariant from 'tiny-invariant';
export async function getStaticPaths() {
const [posts, pages, settings] = await Promise.all([getGhostPosts(), await getGhostPages(), await getGhostSettings()]);
const allPosts = [...posts, ...pages];
return allPosts.map((post) => ({
params: { slug: post.slug },
props: { post, posts, settings },
}));
}
export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const {post, posts, settings} = Astro.props as Props;
invariant(settings, "Settings are required");
const postClass = post.tags?.map((tag) => "tag-" + tag.slug).join(" ");
const bodyClass = `post-template ${postClass}`;
---
<DefaultPageLayout
content={{ title: post.title, description: post.excerpt }}
settings={settings}
bodyClass={bodyClass}
>
{
post.primary_author ? (
<Post
post={post}
settings={settings}
postClass={postClass}
posts={posts}
/>
) : (
<Page page={post} settings={settings} pageClass={postClass} />
)
}
</DefaultPageLayout>
<style lang="scss"></style>

View File

@ -0,0 +1,51 @@
---
import type { GetStaticPathsOptions, Page } from 'astro';
import invariant from "tiny-invariant";
import DefaultPageLayout from "../../layouts/default.astro";
import PostPreviewList from "../../components/PostPreviewList.astro";
import HeroContent from "../../components/HeroContent.astro";
import Paginator from "../../components/Paginator.astro";
import { getGhostSettings, getGhostPosts } from "../../api";
import type { PostOrPage } from '../../api';
export async function getStaticPaths({ paginate }:GetStaticPathsOptions) {
const posts = await getGhostPosts();
return paginate(posts, {
pageSize: 5,
});
}
export type Props = {
page: Page<PostOrPage>
};
const settings = await getGhostSettings();
invariant(settings, "Settings are required");
const title = settings.title;
const description = settings.description;
const { page } = Astro.props as Props;
---
<DefaultPageLayout
content={{ title, description }}
settings={settings}
bodyClass={"home-template"}
>
<HeroContent
mainTitle={"Archives"}
description={"All the posts"}
featureImg={settings.cover_image || ""}
settings={settings}
addClass={"hero-cta bg-gradient"}
>
<h1 class="site-title" slot="title">Archives</h1>
</HeroContent>
<main id="site-main" class="site-main outer">
<div class="inner posts">
<PostPreviewList posts={page.data} settings={settings} />
<Paginator {page} />
</div>
</main>
</DefaultPageLayout>

View File

@ -0,0 +1,121 @@
---
import type { InferGetStaticParamsType, InferGetStaticPropsType } from 'astro';
import DefaultPageLayout from "../../layouts/default.astro";
import PostPreviewList from "../../components/PostPreviewList.astro";
import { getGhostPosts, getGhostAuthors, getGhostSettings } from "../../api";
import invariant from "tiny-invariant";
export async function getStaticPaths() {
const posts = await getGhostPosts();
const authors = await getGhostAuthors();
const settings = await getGhostSettings();
return authors.map((author) => {
const filteredPosts = posts.filter((post) =>
post.authors?.map((author) => author.slug).includes(author.slug)
);
return {
params: { slug: author.slug },
props: {
posts: filteredPosts,
settings,
author,
},
};
});
}
export type Params = InferGetStaticParamsType<typeof getStaticPaths>;
export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { posts, settings, author } = Astro.props;
invariant(settings, "Settings are required");
const title = `Posts by author: ${author.name}`;
const description = `All of the articles we've posted and linked so far under the author: ${author.name}`;
---
<DefaultPageLayout
bodyClass={`author-template author-${author.slug}`}
content={{ title, description }}
settings={settings}
>
<section class="outer author-template">
<div class="inner posts">
<header class="author-profile">
<div class="author-profile-content">
{author.profile_image ? (
<img
class="author-profile-pic"
src={author.profile_image}
alt={author.name}
/>
) : (
<span class="author-profile-pic">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path
d="M3.513 18.998C4.749 15.504 8.082 13 12 13s7.251 2.504 8.487 5.998C18.47 21.442 15.417 23 12 23s-6.47-1.558-8.487-4.002zM12 12c2.21 0 4-2.79 4-5s-1.79-4-4-4-4 1.79-4 4 1.79 5 4 5z"
fill="#FFF"
/>
</g>
</svg>
</span>
)}
<h1>{author.name}</h1>
<p>
{author.bio
? author.bio
: author.count?.posts || 0 > 0
? `${author.count?.posts} Posts`
: ""}
</p>
<div class="author-profile-meta">
{author.location && (
<div class="author-profile-location">📍 {author.location}</div>
)}
{author.website && (
<span>
<a
class="author-profile-social-link"
href={author.website}
target="_blank"
rel="noopener"
>
{author.website}
</a>
</span>
)}
{author.twitter && (
<span>
<a
class="author-profile-social-link"
href={author.twitter}
target="_blank"
rel="noopener"
>
{author.twitter}
</a>
</span>
)}
{author.facebook && (
<span>
<a
class="author-profile-social-link"
href={author.facebook}
target="_blank"
rel="noopener"
>
{author.facebook}
</a>
</span>
)}
</div>
</div>
</header>
<PostPreviewList posts={posts} settings={settings} />
</div>
</section>
</DefaultPageLayout>

44
src/routes/authors.astro Normal file
View File

@ -0,0 +1,44 @@
---
import DefaultPageLayout from "../layouts/default.astro";
import AuthorCard from "../components/AuthorCard.astro";
import { getGhostAuthors, getGhostSettings } from "../api";
import invariant from "tiny-invariant";
let title = "All Authors";
let description = "All the authors";
const authors = await getGhostAuthors();
const settings = await getGhostSettings();
invariant(settings, 'Settings not found');
---
<DefaultPageLayout content={{ title, description }} settings={settings}>
<section class="outer">
<div class="inner posts">
<h1>
{settings.title}
</h1>
<div class="page__excerpt m-t text-acc-3 text-center text-lg">
Collection of Tags
</div>
<div class="author-feed">
{authors.map((author) => (
<article class="post-card ">
<AuthorCard author={author} settings={settings} />
</article>
))}
</div>
</div>
</section>
</DefaultPageLayout>
<style lang="scss">
.author-feed {
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-auto-rows: 1fr;
grid-column-gap: 9vh;
grid-row-gap: 10vh;
padding: 4vmin 0;
}
</style>

33
src/routes/index.astro Normal file
View File

@ -0,0 +1,33 @@
---
import DefaultPageLayout from "../layouts/default.astro";
import PostPreviewList from "../components/PostPreviewList.astro";
import HeroContent from "../components/HeroContent.astro";
import { getGhostPosts, getGhostSettings } from "../api";
import invariant from "tiny-invariant";
const posts = await getGhostPosts();
const settings = await getGhostSettings();
invariant(settings, "Settings not found");
const title = settings.title;
const description = settings.description;
---
<DefaultPageLayout
content={{ title, description }}
settings={settings}
bodyClass={"home-template"}
>
<HeroContent
mainTitle={settings.title}
description={settings.description}
featureImg={settings?.cover_image || ""}
settings={settings}
addClass={"hero-cta bg-gradient"}
/>
<main id="site-main" class="site-main outer">
<div class="inner posts">
<PostPreviewList posts={posts} settings={settings} isHome={true} />
</div>
</main>
</DefaultPageLayout>

102
src/routes/tag/[slug].astro Normal file
View File

@ -0,0 +1,102 @@
---
import type { InferGetStaticParamsType, InferGetStaticPropsType } from 'astro';
import DefaultPageLayout from "../../layouts/default.astro";
import PostPreview from "../../components/PostPreview.astro";
import { getGhostPosts, getGhostTags, getGhostSettings } from "../../api";
import { getGhostImgPath } from "../../utils";
import invariant from "tiny-invariant";
export async function getStaticPaths() {
const posts = await getGhostPosts();
const tags = await getGhostTags();
const settings = await getGhostSettings();
return tags.map((tag) => {
const filteredPosts = posts.filter((post) =>
post.tags?.map((tag) => tag.slug).includes(tag.slug)
);
return {
params: { slug: tag.slug },
props: {
posts: filteredPosts,
settings,
tag,
},
};
});
}
export type Params = InferGetStaticParamsType<typeof getStaticPaths>;
export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { posts, settings, tag } = Astro.props;
invariant(settings, 'Settings not found');
const title = `Posts by Tag: ${tag.name}`;
const description = `all of the articles we've posted and linked so far under the tag: ${tag.name}`;
---
<DefaultPageLayout
bodyClass={`tag-template tag-${tag.slug}`}
content={{ title, description }}
settings={settings}
>
<main id="site-main" class="site-main outer">
<div class="inner posts">
<div class="post-feed">
<section class="post-card post-card-large">
{tag.feature_image && (
<div class="post-card-image-link">
<img
class="post-card-image"
srcset={`${getGhostImgPath(
settings.url,
tag.feature_image,
300
)} 300w,
${getGhostImgPath(
settings.url,
tag.feature_image,
600
)} 600w,
${getGhostImgPath(
settings.url,
tag.feature_image,
1000
)} 1000w,
${getGhostImgPath(
settings.url,
tag.feature_image,
2000
)} 2000w`}
sizes="(max-width: 1000px) 400px, 800px"
src={getGhostImgPath(settings.url, tag.feature_image, 600)}
alt={tag.name}
loading="lazy"
/>
</div>
)}
<div class="post-card-content">
<div class="post-card-content-link">
<header class="post-card-header">
<div class="post-card-primary-tag">Tagged</div>
<h2 class="post-card-title">{tag.name}</h2>
</header>
<div class="post-card-excerpt">
<p>
{tag.description
? tag.description
: `A collection of ${tag.count?.posts || 0 } Post${
tag.count?.posts ?? 0 > 1 ? "s" : ""
}`}
</p>
</div>
</div>
</div>
</section>
{posts.map((post, index) => (
<PostPreview post={post} index={index} settings={settings} />
))}
</div>
</div>
</main>
</DefaultPageLayout>

48
src/routes/tags.astro Normal file
View File

@ -0,0 +1,48 @@
---
import DefaultPageLayout from "../layouts/default.astro";
import TagCard from "../components/TagCard.astro";
import { getGhostSettings, getGhostTags } from "../api";
import invariant from 'tiny-invariant';
let title = "All Tags";
let description = "All the tags used so far...";
const tags = await getGhostTags();
const settings = await getGhostSettings();
invariant(settings, "Settings not found");
---
<DefaultPageLayout content={{ title, description }} settings={settings}>
<section class="outer">
<div class="inner posts">
<h1>
{settings.title}
</h1>
<div class="page__excerpt m-t text-acc-3 text-center text-lg">
Collection of Tags
</div>
<div class="tag-feed">
{
tags.map((tag) => (
<article class="post-card ">
<TagCard tag={tag} settings={settings} />
</article>
))
}
</div>
</div>
</section>
</DefaultPageLayout>
<style lang="scss">
.tag-feed {
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-auto-rows: 1fr;
grid-column-gap: 9vh;
grid-row-gap: 10vh;
padding: 4vmin 0;
}
</style>

390
src/styles/app.scss Normal file
View File

@ -0,0 +1,390 @@
/* Reset
/* ---------------------------------------------------------- */
@import "./reset";
@import "./variables";
/* 1. Global - Set up the things
/* ---------------------------------------------------------- */
/* Import CSS reset and base styles */
:root {
/* Colours */
--color-green: #a4d037;
--color-yellow: #fecd35;
--color-red: #f05230;
--color-darkgrey: #15171a;
--color-midgrey: #738a94;
--color-lightgrey: #c5d2d9;
--color-wash: #e5eff5;
--color-darkmode: #151719;
/*
An accent color is also set by Ghost itself in
Ghost Admin > Settings > Brand
--ghost-accent-color: {value};
You can use this variale throughout your styles
*/
/* Fonts */
--font-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
--font-serif: Georgia, Times, serif;
--font-mono: Menlo, Courier, monospace;
}
/* 2. Layout - Page building blocks
/* ---------------------------------------------------------- */
.viewport {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.site-content {
flex-grow: 1;
}
/* Full width page blocks */
.outer {
position: relative;
padding: 0 4vmin;
}
/* Centered content container blocks */
.inner {
margin: 0 auto;
max-width: 1200px;
width: 100%;
}
/* 9. Error Template
/* ---------------------------------------------------------- */
.error-content {
padding: 14vw 4vw 6vw;
}
.error-message {
padding-bottom: 10vw;
text-align: center;
}
.error-code {
margin: 0;
color: var(--ghost-accent-color);
font-size: 12vw;
line-height: 1em;
letter-spacing: -5px;
}
.error-description {
margin: 0;
color: var(--color-midgrey);
font-size: 3.2rem;
line-height: 1.3em;
font-weight: 400;
}
.error-link {
display: inline-block;
margin-top: 5px;
}
@media (min-width: 940px) {
.error-content .post-card {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
}
@media (max-width: 800px) {
.error-content {
padding-top: 24vw;
}
.error-code {
font-size: 11.2rem;
}
.error-message {
padding-bottom: 16vw;
}
.error-description {
margin: 5px 0 0 0;
font-size: 1.8rem;
}
}
@media (max-width: 500px) {
.error-content {
padding-top: 28vw;
}
.error-message {
padding-bottom: 14vw;
}
}
.author-template .posts {
position: relative;
height: 100%;
display: grid;
grid-template-columns: 200px 1fr 1fr;
grid-gap: 4vmin;
}
.author-template .posts .post-feed {
grid-column: 2 / 4;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 900px) {
.author-template .posts .post-feed {
grid-template-columns: 1fr;
}
}
@media (max-width: 650px) {
.author-template .posts {
grid-template-columns: 1fr;
grid-gap: 0;
}
.author-template .posts .post-feed {
grid-column: 1 / auto;
}
.author-profile {
padding-right: 0;
}
.author-profile-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
}
/* 11. Site Footer
/* ---------------------------------------------------------- */
/* 12. Dark Mode
/* ---------------------------------------------------------- */
/* If you prefer a dark color scheme, you can enable dark mode
by adding the following code to the Head section of "Code Injection"
settings inside: Ghost Admin > Settings > Advanced
<script>document.documentElement.classList.add('dark-mode');</script>
Or you can just edit default.hbs and add the .dark-mode class directly
to the html tag on the very first line of the file.
*/
html.dark-mode body {
color: rgba(255, 255, 255, 0.75);
background: var(--color-darkmode);
}
html.dark-mode img {
opacity: 0.9;
}
html.dark-mode .post-card,
html.dark-mode .post-card:hover {
border-bottom-color: color-mod(var(--color-darkmode) l(+8%));
}
html.dark-mode .post-card-byline-content a {
color: rgba(255, 255, 255, 0.75);
}
html.dark-mode .post-card-byline-content a:hover {
color: #fff;
}
html.dark-mode .post-card-image {
background: var(--color-darkmode);
}
html.dark-mode .post-card-title {
color: rgba(255, 255, 255, 0.85);
}
html.dark-mode .post-card-excerpt {
color: color-mod(var(--color-midgrey) l(+10%));
}
html.dark-mode .post-full-content {
background: var(--color-darkmode);
}
html.dark-mode .article-title {
color: rgba(255, 255, 255, 0.9);
}
html.dark-mode .article-excerpt {
color: color-mod(var(--color-midgrey) l(+10%));
}
html.dark-mode .post-full-image {
background-color: color-mod(var(--color-darkmode) l(+8%));
}
html.dark-mode .article-byline {
border-top-color: color-mod(var(--color-darkmode) l(+15%));
}
html.dark-mode .article-byline-meta h4 a {
color: rgba(255, 255, 255, 0.75);
}
html.dark-mode .article-byline-meta h4 a:hover {
color: #fff;
}
html.dark-mode .no-image .author-social-link a {
color: rgba(255, 255, 255, 0.75);
}
html.dark-mode .gh-content h1,
html.dark-mode .gh-content h2,
html.dark-mode .gh-content h3,
html.dark-mode .gh-content h4,
html.dark-mode .gh-content h5,
html.dark-mode .gh-content h6 {
color: rgba(255, 255, 255, 0.9);
}
html.dark-mode .gh-content pre {
background: color-mod(var(--color-darkgrey) l(-8%));
}
html.dark-mode .gh-content :not(pre) > code {
background: color-mod(var(--color-darkgrey) l(+6%));
border-color: color-mod(var(--color-darkmode) l(+8%));
color: var(--color-wash);
}
html.dark-mode .post-full-content a {
color: #fff;
box-shadow: inset 0 -1px 0 #fff;
}
html.dark-mode .post-full-content strong {
color: #fff;
}
html.dark-mode .post-full-content em {
color: #fff;
}
html.dark-mode .post-full-content code {
color: #fff;
background: #000;
}
html.dark-mode hr {
border-top-color: color-mod(var(--color-darkmode) l(+8%));
}
html.dark-mode .post-full-content hr:after {
background: color-mod(var(--color-darkmode) l(+8%));
box-shadow: var(--color-darkmode) 0 0 0 5px;
}
html.dark-mode .gh-content figcaption {
color: rgba(255, 255, 255, 0.6);
}
html.dark-mode .post-full-content table td:first-child {
background-image: linear-gradient(
to right,
var(--color-darkmode) 50%,
color-mod(var(--color-darkmode) a(0%)) 100%
);
}
html.dark-mode .post-full-content table td:last-child {
background-image: linear-gradient(
to left,
var(--color-darkmode) 50%,
color-mod(var(--color-darkmode) a(0%)) 100%
);
}
html.dark-mode .post-full-content table th {
color: rgba(255, 255, 255, 0.85);
background-color: color-mod(var(--color-darkmode) l(+8%));
}
html.dark-mode .post-full-content table th,
html.dark-mode .post-full-content table td {
border: color-mod(var(--color-darkmode) l(+8%)) 1px solid;
}
html.dark-mode .post-full-content .kg-bookmark-container,
html.dark-mode .post-full-content .kg-bookmark-container:hover {
color: rgba(255, 255, 255, 0.75);
box-shadow: 0 0 1px rgba(255, 255, 255, 0.9);
}
html.dark-mode .post-full-content input {
color: color-mod(var(--color-midgrey) l(-30%));
}
html.dark-mode .kg-bookmark-title {
color: #fff;
}
html.dark-mode .kg-bookmark-description {
color: rgba(255, 255, 255, 0.75);
}
html.dark-mode .kg-bookmark-metadata {
color: rgba(255, 255, 255, 0.75);
}
html.dark-mode .site-archive-header .no-image {
color: rgba(255, 255, 255, 0.9);
background: var(--color-darkmode);
}
html.dark-mode .subscribe-form {
border: none;
background: linear-gradient(
color-mod(var(--color-darkmode) l(-6%)),
color-mod(var(--color-darkmode) l(-3%))
);
}
html.dark-mode .subscribe-form-title {
color: rgba(255, 255, 255, 0.9);
}
html.dark-mode .subscribe-form p {
color: rgba(255, 255, 255, 0.7);
}
html.dark-mode .subscribe-email {
border-color: color-mod(var(--color-darkmode) l(+6%));
color: rgba(255, 255, 255, 0.9);
background: color-mod(var(--color-darkmode) l(+3%));
}
html.dark-mode .subscribe-email:focus {
border-color: color-mod(var(--color-darkmode) l(+25%));
}
html.dark-mode .subscribe-form button {
opacity: 0.9;
}
html.dark-mode .subscribe-form .invalid .message-error,
html.dark-mode .subscribe-form .error .message-error {
color: color-mod(var(--color-red) l(+5%) s(-5%));
}
html.dark-mode .subscribe-form .success .message-success {
color: color-mod(var(--color-green) l(+5%) s(-5%));
}

462
src/styles/reset.scss Normal file
View File

@ -0,0 +1,462 @@
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font: inherit;
font-size: 100%;
vertical-align: baseline;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {
border-spacing: 0;
border-collapse: collapse;
}
img {
display: block;
max-width: 100%;
height: auto;
}
html {
box-sizing: border-box;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
a {
background-color: transparent;
}
a:active,
a:hover {
outline: 0;
}
b,
strong {
font-weight: bold;
}
i,
em,
dfn {
font-style: italic;
}
h1 {
margin: 0.67em 0;
font-size: 2em;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
img {
border: 0;
}
svg:not(:root) {
overflow: hidden;
}
mark {
background-color: #fdffb6;
}
code,
kbd,
pre,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
button,
input,
optgroup,
select,
textarea {
margin: 0; /* 3 */
color: inherit; /* 1 */
font: inherit; /* 2 */
}
button {
overflow: visible;
border: none;
}
button,
select {
text-transform: none;
}
button,
html input[type="button"],
/* 1 */
input[type="reset"],
input[type="submit"] {
cursor: pointer; /* 3 */
-webkit-appearance: button; /* 2 */
}
button[disabled],
html input[disabled] {
cursor: default;
}
button::-moz-focus-inner,
input::-moz-focus-inner {
padding: 0;
border: 0;
}
input {
line-height: normal;
}
input:focus {
outline: none;
}
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
height: auto;
}
input[type="search"] {
box-sizing: content-box; /* 2 */
-webkit-appearance: textfield; /* 1 */
}
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
legend {
padding: 0; /* 2 */
border: 0; /* 1 */
}
textarea {
overflow: auto;
}
table {
border-spacing: 0;
border-collapse: collapse;
}
td,
th {
padding: 0;
}
/* ==========================================================================
Base styles: opinionated defaults
========================================================================== */
html {
font-size: 62.5%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
body {
color: #35373a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
font-size: 1.6rem;
line-height: 1.6em;
font-weight: 400;
font-style: normal;
letter-spacing: 0;
text-rendering: optimizeLegibility;
background: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-moz-font-feature-settings: "liga" on;
}
::selection {
text-shadow: none;
background: #daf2fd;
}
hr {
position: relative;
display: block;
width: 100%;
margin: 2.5em 0 3.5em;
padding: 0;
height: 1px;
border: 0;
border-top: 1px solid #f0f0f0;
}
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
textarea {
resize: vertical;
}
::not(.gh-content) p,
::not(.gh-content) ul,
::not(.gh-content) ol,
::not(.gh-content) dl,
::not(.gh-content) blockquote {
margin: 0 0 1.5em 0;
}
ol,
ul {
padding-left: 1.3em;
padding-right: 1.5em;
}
ol ol,
ul ul,
ul ol,
ol ul {
margin: 0.5em 0 1em;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
ul,
ol {
max-width: 100%;
}
li {
padding-left: 0.3em;
line-height: 1.6em;
}
li + li {
margin-top: 0.5em;
}
dt {
float: left;
margin: 0 20px 0 0;
width: 120px;
color: #daf2fd;
font-weight: 500;
text-align: right;
}
dd {
margin: 0 0 5px 0;
text-align: left;
}
blockquote {
margin: 1.5em 0;
padding: 0 1.6em 0 1.6em;
border-left: #daf2fd;
}
blockquote p {
margin: 0.8em 0;
font-size: 1.2em;
font-weight: 300;
}
blockquote small {
display: inline-block;
margin: 0.8em 0 0.8em 1.5em;
font-size: 0.9em;
opacity: 0.8;
}
/* Quotation marks */
blockquote small:before {
content: "\2014 \00A0";
}
blockquote cite {
font-weight: bold;
}
blockquote cite a {
font-weight: normal;
}
a {
color: #15171a;
text-decoration: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
line-height: 1.15;
font-weight: 600;
text-rendering: optimizeLegibility;
letter-spacing: -0.01em;
}
h1 {
margin: 0 0 0.5em 0;
font-size: 4.8rem;
font-weight: 700;
letter-spacing: -0.015em;
}
@media (max-width: 600px) {
h1 {
font-size: 2.8rem;
}
}
h2 {
margin: 1.5em 0 0.5em 0;
font-size: 2.8rem;
font-weight: 700;
}
@media (max-width: 600px) {
h2 {
font-size: 2.3rem;
}
}
h3 {
margin: 1.5em 0 0.5em 0;
font-size: 2.4rem;
font-weight: 600;
}
@media (max-width: 600px) {
h3 {
font-size: 1.7rem;
}
}
h4 {
margin: 1.5em 0 0.5em 0;
font-size: 2.2rem;
}
h5 {
margin: 1.5em 0 0.5em 0;
font-size: 2rem;
}
h6 {
margin: 1.5em 0 0.5em 0;
font-size: 1.8rem;
}

View File

@ -0,0 +1,8 @@
$color-green: #a4d037;
$color-yellow: #fecd35;
$color-red: #f05230;
$color-darkgrey: #15171a;
$color-midgrey: #738a94;
$color-lightgrey: #c5d2d9;
$color-wash: #e5eff5;
$color-darkmode: #151719;

32
src/utils/index.ts Normal file
View File

@ -0,0 +1,32 @@
export const getGhostImgPath = (
baseUrl: string,
imgUrl: string,
width = 0
): string => {
if (!imgUrl) return "";
if (!imgUrl.startsWith(baseUrl)) {
return imgUrl;
}
const relativePath = imgUrl.substring(`${baseUrl}/content/images`.length);
const cleanedBaseUrl = baseUrl.replace(/\/~/, "");
if (width && width > 0) {
return `${cleanedBaseUrl}/content/images/size/w${width}/${relativePath}`;
}
return `${cleanedBaseUrl}/content/images/${width}${relativePath}`;
};
export const truncate = (input: string, size: number): string =>
input.length > size ? `${input.substring(0, size)}...` : input;
export const formatDate = (dateInput: string): string => {
const dateObject = new Date(dateInput);
return dateObject.toDateString();
};
export const uniqWith = <T>(
arr: Array<T>,
fn: (element: T, step: T) => number
): Array<T> =>
arr.filter(
(element, index) => arr.findIndex((step) => fn(element, step)) === index
);