├── .github └── FUNDING.yml ├── .prettierignore ├── .prettierrc ├── next-env.d.ts ├── public ├── favicon.ico ├── favicon.png ├── gato_git.jpeg ├── site-meta.png ├── ghost-icon.png ├── publication-cover.png └── robots.txt ├── next-sitemap.js ├── .vscode └── launch.json ├── components ├── icons │ ├── RssIcon.tsx │ ├── MoonIcon.tsx │ ├── FacebookIcon.tsx │ ├── AvatarIcon.tsx │ ├── TwitterIcon.tsx │ ├── LoaderIcon.tsx │ └── SunIcon.tsx ├── CommentoComments.tsx ├── StickyNav.tsx ├── DarkMode.tsx ├── SocialRss.tsx ├── meta │ ├── siteDefaults.ts │ ├── seoImage.ts │ └── seo.tsx ├── PostView.tsx ├── PostItems.tsx ├── HeaderPage.tsx ├── DisqusComments.tsx ├── SubscribeButton.tsx ├── HeaderBackground.tsx ├── DarkModeToggle.tsx ├── Subscribe.tsx ├── HeaderPost.tsx ├── NextLink.tsx ├── NextImage.tsx ├── helpers │ ├── PostClass.ts │ └── BodyClass.ts ├── RenderContent.tsx ├── effects │ ├── UseActiveHash.tsx │ ├── HoverOnAvatar.tsx │ └── StickyNavContainer.tsx ├── SocialLinks.tsx ├── CommentoEmbed.tsx ├── HeaderTag.tsx ├── SubscribeOverlay.tsx ├── Navigation.tsx ├── SubscribeForm.tsx ├── DocumentHead.tsx ├── SubscribeSuccess.tsx ├── common │ └── elements.tsx ├── contact │ ├── ContactForm.module.css │ ├── ContactValidation.ts │ ├── ContactSubmit.ts │ └── ContactForm.tsx ├── contexts │ ├── themeProvider.tsx │ └── overlayProvider.tsx ├── HeaderIndex.tsx ├── Page.tsx ├── PreviewPosts.tsx ├── toc │ └── TableOfContents.tsx ├── HeaderAuthor.tsx ├── Layout.tsx ├── PostCard.tsx ├── SiteNav.tsx ├── ContactPage.tsx ├── AuthorList.tsx └── Post.tsx ├── postcss.config.js ├── netlify.toml ├── .editorconfig ├── utils ├── use-lang.ts ├── routing.ts └── rss.ts ├── routesConfig.ts ├── lib ├── collections.ts ├── contactPageDefaults.ts ├── cache.ts ├── toc.ts ├── readingTime.ts ├── images.ts ├── processEnv.ts ├── ghost-normalize.ts └── ghost.ts ├── .gitignore ├── pages ├── _app.tsx ├── api │ └── v1 │ │ ├── preview.ts │ │ └── contact.ts ├── 404.tsx ├── _document.tsx ├── index.tsx ├── tag │ └── [...slug].tsx ├── author │ └── [...slug].tsx └── [...slug].tsx ├── next.config.js ├── styles ├── screen-fixings.css ├── prism.css ├── toc.css ├── dark-mode.css └── global.css ├── LICENSE ├── depcheck.sh ├── types.d.ts ├── appConfig.ts ├── .all-contributorsrc ├── package.json └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [styxlab] 2 | open_collective: jamify-cloud 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | yarn.lock 4 | package-lock.json 5 | public 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 180 5 | } 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearnear/next-cms-ghost/master/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearnear/next-cms-ghost/master/public/favicon.png -------------------------------------------------------------------------------- /public/gato_git.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearnear/next-cms-ghost/master/public/gato_git.jpeg -------------------------------------------------------------------------------- /public/site-meta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearnear/next-cms-ghost/master/public/site-meta.png -------------------------------------------------------------------------------- /public/ghost-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearnear/next-cms-ghost/master/public/ghost-icon.png -------------------------------------------------------------------------------- /public/publication-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearnear/next-cms-ghost/master/public/publication-cover.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: https://next.jamify.org 7 | 8 | # Sitemaps 9 | Sitemap: https://next.jamify.org/sitemap.xml 10 | -------------------------------------------------------------------------------- /next-sitemap.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // see https://github.com/iamvishnusankar/next-sitemap 3 | siteUrl: 'https://next.jamify.org', 4 | generateRobotsTxt: true, 5 | sitemapSize: 7000, 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Launch Program", 8 | "skipFiles": ["/**"], 9 | "port": 9229 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /components/icons/RssIcon.tsx: -------------------------------------------------------------------------------- 1 | export const RssIcon = () => ( 2 | 3 | 4 | 5 | 6 | ) 7 | -------------------------------------------------------------------------------- /components/CommentoComments.tsx: -------------------------------------------------------------------------------- 1 | import { CommentoEmbed } from '@components/CommentoEmbed' 2 | 3 | interface CommentoCommentsProps { 4 | id: string 5 | url: string 6 | } 7 | 8 | export const CommentoComments = (props: CommentoCommentsProps) => ( 9 |
10 | 11 |
12 | ) 13 | -------------------------------------------------------------------------------- /components/StickyNav.tsx: -------------------------------------------------------------------------------- 1 | import { SiteNav } from '@components/SiteNav' 2 | import { SiteNavProps } from '@components/SiteNav' 3 | 4 | export const StickyNav = (props: SiteNavProps) => ( 5 |
6 |
7 | 8 |
9 |
10 | ) 11 | -------------------------------------------------------------------------------- /components/icons/MoonIcon.tsx: -------------------------------------------------------------------------------- 1 | export const MoonIcon = () => ( 2 | 3 | ) 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-easy-import': {}, 4 | 'postcss-color-mod-function': {}, 5 | 'postcss-preset-env': { 6 | autoprefixer: { 7 | flexbox: 'no-2009', 8 | }, 9 | stage: 3, 10 | features: { 11 | 'custom-properties': false, 12 | }, 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build" 3 | functions = "out_functions" 4 | publish = "out_publish" 5 | 6 | [build.environment] 7 | JAMIFY_NEXT_ISR = "false" 8 | JAMIFY_NEXT_FEATURE_IMAGES = "false" 9 | JAMIFY_NEXT_INLINE_IMAGES = "false" 10 | 11 | [[plugins]] 12 | package = "@netlify/plugin-nextjs" 13 | 14 | [[plugins]] 15 | package = "netlify-plugin-cache-nextjs" 16 | -------------------------------------------------------------------------------- /components/icons/FacebookIcon.tsx: -------------------------------------------------------------------------------- 1 | export const FacebookIcon = () => ( 2 | 3 | 4 | 5 | ) 6 | -------------------------------------------------------------------------------- /components/DarkMode.tsx: -------------------------------------------------------------------------------- 1 | import { DarkModeToggle } from '@components/DarkModeToggle' 2 | import { GhostSettings } from '@lib/ghost' 3 | 4 | interface DarkModeProps { 5 | settings: GhostSettings 6 | } 7 | 8 | export const DarkMode = ({ settings }: DarkModeProps) => { 9 | const { darkMode } = settings.processEnv 10 | if (darkMode.defaultMode === null) return null 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /components/icons/AvatarIcon.tsx: -------------------------------------------------------------------------------- 1 | export const AvatarIcon = () => ( 2 | 3 | 4 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /components/SocialRss.tsx: -------------------------------------------------------------------------------- 1 | import { RssIcon } from '@icons/RssIcon' 2 | import { resolve } from 'url' 3 | 4 | interface SocialRssProps { 5 | siteUrl: string 6 | } 7 | 8 | export const SocialRss = ({ siteUrl }: SocialRssProps) => ( 9 | 10 | 11 | 12 | ) 13 | -------------------------------------------------------------------------------- /components/meta/siteDefaults.ts: -------------------------------------------------------------------------------- 1 | // Defaults for meta, if not configured in CMS 2 | export const siteTitleMeta = 'Jamify - Next.js Headless Ghost with Casper skin' 3 | export const siteDescriptionMeta = 'Jamify blog system powered by Next.js and headless Ghost featuring Casper skin.' 4 | 5 | // Defaults, if not configured in CMS 6 | // Images can be fund in the /public folder 7 | export const siteIcon = 'favicon.png' 8 | export const siteImage = 'site-meta.png' 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.hbs] 14 | insert_final_newline = false 15 | 16 | [*.json] 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [*.{yml,yaml}] 23 | indent_size = 2 24 | 25 | [Makefile] 26 | indent_style = tab 27 | -------------------------------------------------------------------------------- /components/PostView.tsx: -------------------------------------------------------------------------------- 1 | import { PostItems } from '@components/PostItems' 2 | import { GhostPostsOrPages, GhostSettings } from '@lib/ghost' 3 | 4 | interface PostViewProps { 5 | settings: GhostSettings 6 | posts: GhostPostsOrPages 7 | isHome?: boolean 8 | } 9 | 10 | export const PostView = (props: PostViewProps) => ( 11 |
12 |
13 | 14 |
15 |
16 | ) 17 | -------------------------------------------------------------------------------- /components/PostItems.tsx: -------------------------------------------------------------------------------- 1 | import { PostCard } from '@components/PostCard' 2 | import { GhostPostsOrPages, GhostSettings } from '@lib/ghost' 3 | 4 | interface PostItemsProps { 5 | settings: GhostSettings 6 | posts: GhostPostsOrPages 7 | isHome?: boolean 8 | } 9 | 10 | export const PostItems = ({ settings, posts, isHome }: PostItemsProps) => ( 11 | <> 12 | {posts.map((post, i) => ( 13 | 14 | ))} 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /components/HeaderPage.tsx: -------------------------------------------------------------------------------- 1 | import { GhostSettings } from '@lib/ghost' 2 | import { SiteNav } from '@components/SiteNav' 3 | 4 | interface HeaderPageProps { 5 | settings: GhostSettings 6 | } 7 | 8 | export const HeaderPage = ({ settings }: HeaderPageProps) => ( 9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 | ) 17 | -------------------------------------------------------------------------------- /utils/use-lang.ts: -------------------------------------------------------------------------------- 1 | import { lang, StringKeyObjectMap } from '@utils/lang' 2 | 3 | const useLang = (locale: string = 'en') => { 4 | return lang[locale] 5 | } 6 | 7 | const get = (text: StringKeyObjectMap) => (name: string, fallback?: string) => { 8 | if (text[name] === undefined && fallback === null) { 9 | throw new Error(`Cannot find ${name} in lang file.`) 10 | } 11 | 12 | if (text[name] === undefined) { 13 | return fallback || 'UNDEFINED' 14 | } 15 | 16 | return text[name] 17 | } 18 | 19 | export { useLang, get } 20 | -------------------------------------------------------------------------------- /components/DisqusComments.tsx: -------------------------------------------------------------------------------- 1 | import { DiscussionEmbed } from 'disqus-react' 2 | import { GhostPostOrPage } from '@lib/ghost' 3 | 4 | interface DisqusCommentsProps { 5 | post: GhostPostOrPage 6 | shortname: string 7 | } 8 | 9 | export const DisqusComments = ({ post, shortname }: DisqusCommentsProps) => { 10 | const { url, id: identifier, title } = post 11 | const config = { url, identifier, title } 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /routesConfig.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from '@lib/collections' 2 | import { GhostPostOrPage } from '@lib/ghost' 3 | 4 | export const collections: Collection[] = [] 5 | 6 | //export const collections: Collection[] = [{ 7 | // path: `themes`, 8 | // selector: node => node.primary_tag && node.primary_tag.slug === `themes` 9 | //}] 10 | 11 | //export const collections: Collection[] = [{ 12 | // path: `luther`, 13 | // selector: node => node.authors && node.authors.filter(a => a.slug === `martin`).length > 0, 14 | //}] 15 | -------------------------------------------------------------------------------- /lib/collections.ts: -------------------------------------------------------------------------------- 1 | import { collections as config } from '@routesConfig' 2 | 3 | export interface Collection { 4 | path: string, 5 | selector: (node: T) => boolean | null | undefined 6 | } 7 | 8 | export class Collections { 9 | collections: Collection[] 10 | 11 | constructor(config: Collection[]) { 12 | this.collections = config 13 | } 14 | 15 | getCollectionByNode(node: T) { 16 | const { path } = this.collections.find(collection => collection.selector(node)) || { path: '/' } 17 | return path 18 | } 19 | } 20 | 21 | export const collections = new Collections(config) 22 | -------------------------------------------------------------------------------- /components/SubscribeButton.tsx: -------------------------------------------------------------------------------- 1 | import { useLang, get } from '@utils/use-lang' 2 | import { useOverlay } from '@components/contexts/overlayProvider' 3 | 4 | // The actual component 5 | export const SubscribeButton = () => { 6 | const text = get(useLang()) 7 | const { handleOpen } = useOverlay() 8 | 9 | return ( 10 | 11 | {text(`SUBSCRIBE`)} 12 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /compare 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # post build 38 | /public/*.xml 39 | /public/images 40 | 41 | # cache 42 | /.cache 43 | -------------------------------------------------------------------------------- /components/HeaderBackground.tsx: -------------------------------------------------------------------------------- 1 | import { ReactFragment } from "react" 2 | 3 | interface HeaderBackgroundProps { 4 | srcImg: string 5 | children: ReactFragment 6 | } 7 | 8 | export const HeaderBackground = ({ srcImg, children }: HeaderBackgroundProps) => { 9 | return ( 10 | <> 11 | {srcImg ? ( 12 |
13 | {children} 14 |
15 | ) : ( 16 |
17 | {children} 18 |
19 | )} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@components/contexts/themeProvider' 2 | import { useLang, get } from '@utils/use-lang' 3 | import { MoonIcon } from '@icons/MoonIcon' 4 | import { SunIcon } from '@icons/SunIcon' 5 | 6 | export const DarkModeToggle = () => { 7 | const { dark, toggleDark } = useTheme() 8 | const text = get(useLang()) 9 | 10 | return ( 11 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /components/Subscribe.tsx: -------------------------------------------------------------------------------- 1 | import { GhostSettings } from '@lib/ghost' 2 | import { useLang, get } from '@utils/use-lang' 3 | import { SubscribeForm } from '@components/SubscribeForm' 4 | 5 | export const Subscribe = ({ settings }: { settings: GhostSettings }) => { 6 | const text = get(useLang()) 7 | const title = text(`SITE_TITLE`, settings.title) 8 | 9 | return ( 10 |
11 |

{text(`SUBSCRIBE_TO`)} {title}

12 |

{text(`SUBSCRIBE_SECTION`)}

13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/HeaderPost.tsx: -------------------------------------------------------------------------------- 1 | import { GhostSettings } from 'lib/ghost' 2 | import { SiteNav } from '@components/SiteNav' 3 | import { StickyNavContainer } from '@effects/StickyNavContainer' 4 | 5 | interface HeaderPostProps { 6 | settings: GhostSettings, 7 | title?: string 8 | sticky: StickyNavContainer 9 | } 10 | 11 | export const HeaderPost = ({ settings, title, sticky }: HeaderPostProps) => ( 12 |
13 |
14 |
15 | 16 |
17 |
18 |
19 | ) 20 | -------------------------------------------------------------------------------- /components/NextLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { ComponentPropsWithNode } from 'rehype-react' 3 | import { Node } from 'unist' 4 | 5 | import { RenderContent } from '@components/RenderContent' 6 | 7 | interface PropertyProps { 8 | href?: string 9 | } 10 | 11 | export const NextLink = (props: ComponentPropsWithNode) => { 12 | const { href } = props.node?.properties as PropertyProps 13 | const [child] = props.node?.children as Node[] 14 | 15 | return ( 16 | <> 17 | {!!href && ( 18 | 19 | 20 | 21 | 22 | 23 | )} 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app' 2 | import { OverlayProvider } from '@components/contexts/overlayProvider' 3 | import { ThemeProvider } from '@components/contexts/themeProvider' 4 | import { processEnv } from '@lib/processEnv' 5 | 6 | import '@styles/screen.css' 7 | import '@styles/screen-fixings.css' 8 | import '@styles/dark-mode.css' 9 | import '@styles/prism.css' 10 | import '@styles/toc.css' 11 | 12 | function App({ Component, pageProps }: AppProps) { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default App 23 | -------------------------------------------------------------------------------- /components/NextImage.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { ComponentPropsWithNode } from 'rehype-react' 3 | import { Dimensions } from '@lib/images' 4 | 5 | interface PropertyProps { 6 | src: string 7 | className?: string[] 8 | } 9 | 10 | export const NextImage = (props: ComponentPropsWithNode) => { 11 | const { node } = props 12 | if (!node) return null 13 | const imageDimensions = node.imageDimensions as Dimensions 14 | const { src, className: classArray } = node.properties as PropertyProps 15 | const className = classArray?.join(' ') 16 | 17 | return ( 18 |
19 |
20 | 21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/helpers/PostClass.ts: -------------------------------------------------------------------------------- 1 | import { Tag } from "@tryghost/content-api" 2 | 3 | interface PostClassProps { 4 | tags: Tag[] | undefined 5 | isFeatured?: boolean 6 | isImage?: boolean 7 | isPage?: boolean 8 | } 9 | 10 | export const PostClass = ({ tags, isFeatured, isImage, isPage }: PostClassProps) => { 11 | let classes = [`post`] 12 | 13 | isFeatured = isFeatured || false 14 | isImage = isImage || false 15 | isPage = isPage || false 16 | 17 | if (tags && tags.length > 0) { 18 | classes = classes.concat(tags.map((tag) => (`tag-` + tag.slug))) 19 | } 20 | 21 | if (isFeatured) { 22 | classes.push(`featured`) 23 | } 24 | 25 | if (!isImage) { 26 | classes.push(`no-image`) 27 | } 28 | 29 | if (isPage) { 30 | classes.push(`page`) 31 | } 32 | 33 | const result = classes.reduce((memo, item) => (memo + ` ` + item), ``) 34 | 35 | return result.trim() 36 | } 37 | -------------------------------------------------------------------------------- /components/icons/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | export const TwitterIcon = () => ( 2 | 3 | 4 | 5 | ) 6 | -------------------------------------------------------------------------------- /components/icons/LoaderIcon.tsx: -------------------------------------------------------------------------------- 1 | export const LoaderIcon = () => ( 2 | 4 | 7 | 8 | 9 | 10 | 11 | ) 12 | -------------------------------------------------------------------------------- /components/icons/SunIcon.tsx: -------------------------------------------------------------------------------- 1 | export const SunIcon = () => ( 2 | 3 | ) 4 | -------------------------------------------------------------------------------- /lib/contactPageDefaults.ts: -------------------------------------------------------------------------------- 1 | import { GhostPostOrPage } from "./ghost" 2 | import { ServiceConfig } from '@components/contact/ContactForm' 3 | 4 | export interface ContactPage extends GhostPostOrPage { 5 | form_topics: string[] 6 | serviceConfig: ServiceConfig 7 | } 8 | 9 | export const defaultPage: ContactPage = { 10 | id: 'custom-page-contact', 11 | slug: 'contact', 12 | url: '/contact', 13 | title: 'Contact Us', 14 | custom_excerpt: 'Want to get in touch with the team? Just drop us a line!', 15 | form_topics: ['I want to give feedback', 'I want to ask a question'], 16 | meta_title: 'Contact Us', 17 | meta_description: 'A contact form page.', 18 | html: '', 19 | serviceConfig: { 20 | url: '/api/v1/contact', 21 | contentType: 'application/json', 22 | }, 23 | featureImage: { 24 | url: 'https://static.gotsby.org/v1/assets/images/gatsby-ghost-contact.png', 25 | dimensions: { 26 | width: 1040, 27 | height: 250 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true', 3 | }) 4 | 5 | module.exports = withBundleAnalyzer({ 6 | ...(process.env.NETLIFY === 'true' && { target: 'serverless' }), 7 | images: { 8 | deviceSizes: [320, 500, 680, 1040, 2080, 2048, 3120], 9 | domains: [ 10 | 'localhost', 11 | 'images.unsplash.com', 12 | 'static.gotsby.org', 13 | 'static.ghost.org', 14 | 'hooshmand.net', 15 | 'cms.jamify.org', 16 | 'demo.jamify.org', 17 | 'www.jamify.org', 18 | 'www.gatsbyjs.org', 19 | 'cdn.commento.io', 20 | 'gatsby.ghost.io', 21 | 'ghost.org', 22 | 'repository-images.githubusercontent.com', 23 | 'www.gravatar.com', 24 | 'github.githubassets.com', 25 | 'www.crio.do', 26 | 'drive.google.com', 27 | 'lh3.googleusercontent.com', 28 | 'lh6.googleusercontent.com', 29 | '35.177.223.238', 30 | ], 31 | }, 32 | reactStrictMode: true, 33 | }) 34 | -------------------------------------------------------------------------------- /components/RenderContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import rehypeReact, { ComponentProps, ComponentPropsWithNode } from 'rehype-react' 3 | import unified from 'unified' 4 | import { Node } from 'unist' 5 | 6 | import { NextLink } from '@components/NextLink' 7 | import { NextImage } from '@components/NextImage' 8 | 9 | const options = { 10 | createElement: React.createElement, 11 | Fragment: React.Fragment, 12 | passNode: true, 13 | components: { 14 | Link: (props: ComponentProps) => , 15 | Image: (props: ComponentProps) => , 16 | }, 17 | } 18 | 19 | const renderAst = unified().use(rehypeReact, options) 20 | 21 | interface RenderContentProps { 22 | htmlAst: Node | null 23 | } 24 | 25 | export const RenderContent = ({ htmlAst }: RenderContentProps) => { 26 | if (!htmlAst) return null 27 | return <>{renderAst.stringify(htmlAst)} 28 | } 29 | 30 | //
{renderAst.stringify(htmlAst)}
31 | -------------------------------------------------------------------------------- /styles/screen-fixings.css: -------------------------------------------------------------------------------- 1 | html.casper body { 2 | transition: opacity 0.5s ease; 3 | } 4 | 5 | html.casper body.fade-in { 6 | opacity: 0; 7 | } 8 | 9 | html.casper .next-image-wrapper { 10 | display: flex; 11 | } 12 | 13 | { 14 | /* 15 | html.casper .next-image-wrapper img { 16 | background-color: #eee; 17 | } 18 | */ 19 | } 20 | 21 | @media (min-width: 1040px) { 22 | html.casper .post-full-content .kg-image-card.kg-width-wide .next-image-wrapper { 23 | width: 1040px; 24 | } 25 | html.casper .post-full-content .kg-image-card.kg-width-full .next-image-wrapper { 26 | width: 100vw; 27 | } 28 | } 29 | 30 | @media (max-width: 1040px) { 31 | html.casper .post-full-content .kg-image-card.kg-width-wide .next-image-wrapper, 32 | html.casper .post-full-content .kg-image-card.kg-width-full .kg-image { 33 | width: 100%; 34 | } 35 | } 36 | 37 | html.casper .kg-bookmark-thumbnail .next-image-wrapper > div > div { 38 | position: absolute !important; 39 | top: 0; 40 | left: 0; 41 | width: 100%; 42 | height: 100%; 43 | border-radius: 0 3px 3px 0; 44 | object-fit: cover; 45 | } 46 | -------------------------------------------------------------------------------- /components/meta/seoImage.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { resolve } from 'url' 3 | 4 | import { siteImage } from '@meta/siteDefaults' 5 | import { imageDimensions, imageDimensionsFromFile, Dimensions } from '@lib/images' 6 | 7 | export interface ISeoImage { 8 | url: string 9 | dimensions: Dimensions 10 | } 11 | 12 | interface SeoImageProps { 13 | siteUrl: string 14 | imageUrl?: string | null 15 | imageName?: string 16 | } 17 | 18 | export const seoImage = async (props: SeoImageProps): Promise => { 19 | const { siteUrl, imageUrl, imageName } = props 20 | const defaultDimensions = { width: 1200, height: 800 } 21 | 22 | if (imageUrl) { 23 | const url = imageUrl 24 | const dimensions = await imageDimensions(url) || defaultDimensions 25 | return { url, dimensions } 26 | } 27 | 28 | const publicRoot = path.join(process.cwd(), 'public') 29 | const file = path.join(publicRoot, imageName || siteImage) 30 | const dimensions = await imageDimensionsFromFile(file) || defaultDimensions 31 | const url = resolve(siteUrl, imageName || siteImage) 32 | 33 | return { url, dimensions } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 styxlab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/cache.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { fileCache } from '@appConfig' 4 | 5 | const cacheRoot = path.join(process.cwd(), '.cache') 6 | 7 | const makeDirectory = (path: string) => { 8 | if (fs.existsSync(path)) return true 9 | try { 10 | fs.mkdirSync(path) 11 | } catch { 12 | return false 13 | } 14 | return true 15 | } 16 | 17 | export function getCache(key: string | null): T | null { 18 | if (!fileCache || !key) return null 19 | 20 | const filePath = path.join(cacheRoot, `${key}.txt`) 21 | if (fs.existsSync(filePath)) { 22 | const value = fs.readFileSync(filePath) 23 | return JSON.parse(value.toString()) as T 24 | } 25 | 26 | return null 27 | } 28 | 29 | export function setCache(key: string | null, object: unknown): void { 30 | if (!fileCache || !key) return 31 | if (!makeDirectory(cacheRoot)) return 32 | 33 | const filePath = path.join(cacheRoot, `${key}.txt`) 34 | try { 35 | fs.writeFileSync(filePath, JSON.stringify(object as JSON)) 36 | } catch (error) { 37 | console.warn('Could not write to file cache. This is expected during ISR, but not during deploy.', error) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components/effects/UseActiveHash.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Further info 👉🏼 https://github.com/gatsbyjs/gatsby/blob/master/www/src/hooks/use-active-hash.js 4 | * 5 | */ 6 | 7 | import { useEffect, useState } from "react" 8 | 9 | export const useActiveHash = (itemIds: string[], rootMargin: string | undefined = undefined) => { 10 | const [activeHash, setActiveHash] = useState(``) 11 | 12 | useEffect(() => { 13 | const observer = new IntersectionObserver((entries) => { 14 | entries.forEach((entry) => { 15 | if (entry.isIntersecting) { 16 | setActiveHash(entry.target.id) 17 | } 18 | }) 19 | }, { rootMargin: rootMargin || `0% 0% -90% 0%` }) 20 | 21 | const trigger = (id: string, key: 'observe' | 'unobserve') => { 22 | const element = document.getElementById(id) 23 | element && typeof element === `object` ? observer[key](element) : null 24 | } 25 | 26 | itemIds.forEach(id => trigger(id, `observe`)) 27 | 28 | return () => itemIds.forEach(id => trigger(id, `unobserve`)) 29 | }, [itemIds, rootMargin, setActiveHash]) 30 | 31 | return activeHash 32 | } 33 | -------------------------------------------------------------------------------- /components/helpers/BodyClass.ts: -------------------------------------------------------------------------------- 1 | import { PostOrPage, Author, Tag } from "@tryghost/content-api" 2 | 3 | interface BodyClassProps { 4 | isPost?: boolean 5 | isHome?: boolean 6 | author?: Author 7 | tags?: Tag[] 8 | page?: PostOrPage 9 | } 10 | 11 | export const BodyClass = ({ isHome, isPost, author, tags, page }: BodyClassProps) => { 12 | let classes = [] 13 | 14 | const isAuthor = author && author.slug || false 15 | const isPage = page && page.slug || false 16 | 17 | isHome = isHome || false 18 | isPost = isPost || false 19 | 20 | if (isHome) { 21 | classes.push(`home-template`) 22 | } else if (isPost) { 23 | classes.push(`post-template`) 24 | } else if (isPage) { 25 | classes.push(`page-template`) 26 | classes.push(`page-${page?.slug}`) 27 | } else if (tags && tags.length > 0) { 28 | classes.push(`tag-template`) 29 | } else if (isAuthor) { 30 | classes.push(`author-template`) 31 | classes.push(`author-${author?.slug}`) 32 | } 33 | 34 | if (tags) { 35 | classes = classes.concat( 36 | tags.map(({ slug }) => `tag-${slug}`) 37 | ) 38 | } 39 | 40 | //if (context.includes('paged')) { 41 | // classes.push('paged'); 42 | //} 43 | 44 | return classes.join(` `).trim() 45 | } 46 | -------------------------------------------------------------------------------- /components/SocialLinks.tsx: -------------------------------------------------------------------------------- 1 | import { TwitterIcon } from '@icons/TwitterIcon' 2 | import { FacebookIcon } from '@icons/FacebookIcon' 3 | 4 | import { SocialRss } from '@components/SocialRss' 5 | import { GhostSettings } from '@lib/ghost' 6 | 7 | interface SocialLinkProps { 8 | siteUrl: string 9 | site: GhostSettings 10 | } 11 | 12 | export const SocialLinks = ({ siteUrl, site }: SocialLinkProps) => { 13 | const twitterUrl = site.twitter && `https://twitter.com/${site.twitter.replace(/^@/, ``)}` 14 | const facebookUrl = site.facebook && `https://www.facebook.com/${site.facebook.replace(/^\//, ``)}` 15 | 16 | const { processEnv } = site 17 | const { memberSubscriptions } = processEnv 18 | 19 | return ( 20 | <> 21 | {site.facebook && ( 22 | 23 | 24 | 25 | )} 26 | {site.twitter && ( 27 | 28 | 29 | 30 | )} 31 | {!memberSubscriptions && } 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/CommentoEmbed.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | /** 4 | * 5 | * Credits to 👉 https://nehalist.io/adding-commento-to-react-apps-like-gatsby/#commento-login-box-container 6 | * 7 | */ 8 | 9 | // Helper to add scripts to our page 10 | const insertScript = (src: string, id: string, parentElement: HTMLElement) => { 11 | const script = window.document.createElement(`script`) 12 | script.async = true 13 | script.src = src 14 | script.id = id 15 | parentElement.appendChild(script) 16 | return script 17 | } 18 | 19 | // Helper to remove scripts from our page 20 | const removeScript = (id: string, parentElement: HTMLElement) => { 21 | const script = window.document.getElementById(id) 22 | if (script) { 23 | parentElement.removeChild(script) 24 | } 25 | } 26 | 27 | interface CommentoEmbedProps { 28 | id: string 29 | url: string 30 | } 31 | 32 | export const CommentoEmbed = ({ id, url }: CommentoEmbedProps) => { 33 | useEffect(() => { 34 | if (!url) return 35 | 36 | if (window.document.getElementById(`commento`)) { 37 | //url: 38 | insertScript(`${url}/js/commento.js`, `commento-script`, document.body) 39 | } 40 | return () => removeScript(`commento-script`, document.body) 41 | }, [id, url]) 42 | 43 | return
44 | } 45 | -------------------------------------------------------------------------------- /components/HeaderTag.tsx: -------------------------------------------------------------------------------- 1 | import { Tag } from '@tryghost/content-api' 2 | import { GhostSettings } from '@lib/ghost' 3 | import { SiteNav } from '@components/SiteNav' 4 | import { HeaderBackground } from '@components/HeaderBackground' 5 | import { useLang, get } from '@utils/use-lang' 6 | 7 | interface HeaderTagProps { 8 | settings: GhostSettings 9 | tag: Tag 10 | } 11 | 12 | export const HeaderTag = ({ settings, tag }: HeaderTagProps) => { 13 | const text = get(useLang()) 14 | const featureImg = tag.feature_image || '' 15 | const numberOfPosts = tag.count?.posts 16 | 17 | return ( 18 |
19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 |

{tag.name}

27 |

28 | {tag.description || 29 | `${text(`A_COLLECTION_OF`)} ${(numberOfPosts && numberOfPosts > 0 && (numberOfPosts === 1 ? `1 ${text(`POST`)}` : `${numberOfPosts} ${text(`POSTS`)}`)) || `${text(`POSTS`)}`}`} 30 |

31 |
32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /depcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm-upgrade 4 | 5 | mkdir ./compare 6 | 7 | github="https://raw.githubusercontent.com" 8 | repo="TryGhost/Casper" 9 | branch="master" 10 | 11 | wget -q -P compare -N ${github}/${repo}/${branch}/assets/css/global.css 12 | wget -q -P compare -N ${github}/${repo}/${branch}/assets/css/screen.css 13 | 14 | cat ./styles/global.css \ 15 | | sed 's/html\.casper {/html {/g' \ 16 | | sed 's/html\.casper //g' \ 17 | | sed 's/#0078d0/#3eb0ef/g' \ 18 | > compare/global-used.css 19 | 20 | cat ./styles/dark-mode.css \ 21 | | sed "s/@import 'global.css';//g" \ 22 | | sed 's/body.dark {/body {/g' \ 23 | | sed 's/body.dark //g' \ 24 | > compare/dark-mode.css 25 | 26 | cat ./styles/screen.css compare/dark-mode.css \ 27 | | sed 's/html\.casper {/html {/g' \ 28 | | sed 's/html\.casper //g' \ 29 | | sed 's/color-mod(var(--midgrey) l(-8%));/var(--midgrey);/g' \ 30 | | sed 's/color-mod(var(--midgrey) l(-7%));/color-mod(var(--midgrey) l(+10%));/g' \ 31 | > compare/screen-used.css 32 | 33 | 34 | 35 | cat compare/screen.css \ 36 | | sed '/@media (prefers-color-scheme: dark) {/d' \ 37 | | head -n -1 > compare/screen-original.css 38 | mv compare/screen-original.css compare/screen.css 39 | 40 | 41 | diff --ignore-all-space -B -q -s compare/global.css compare/global-used.css 42 | diff --ignore-all-space -B -q -s compare/screen.css compare/screen-used.css 43 | 44 | rm -rf compare out 45 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'hast-util-to-string' { 2 | import { Node } from 'unist' 3 | export default function toString(node: Node): string 4 | } 5 | 6 | declare module 'probe-image-size' { 7 | /** 8 | * Get image size without full download. Supported image types: JPG, GIF, PNG, WebP, BMP, TIFF, SVG, PSD. 9 | */ 10 | declare function probe(source: string, opts: probe.ProbeOptions, callback: probe.ProbeCallback): void; 11 | declare function probe(source: string, opts?: probe.ProbeOptions): Promise; 12 | declare function probe(source: string | NodeJS.ReadableStream, callback: probe.ProbeCallback): void; 13 | declare function probe(source: NodeJS.ReadableStream): Promise; 14 | 15 | declare namespace probe { 16 | interface ProbeResult { 17 | width: number; 18 | height: number; 19 | length: number; 20 | type: string; 21 | mime: string; 22 | wUnits: string; 23 | hUnits: string; 24 | url: string; 25 | } 26 | 27 | interface ProbeOptions { 28 | open_timeout?: number; 29 | response_timeout?: number; 30 | read_timeout?: number; 31 | follow_max?: number; 32 | } 33 | 34 | interface ProbeError extends Error { 35 | code?: 'ECONTENT'; 36 | status?: number; 37 | } 38 | 39 | type ProbeCallback = (err: ProbeError | null, result: ProbeResult) => void; 40 | 41 | function sync(data: Buffer): ProbeResult | null; 42 | } 43 | 44 | export = probe; 45 | } 46 | -------------------------------------------------------------------------------- /pages/api/v1/preview.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { getAllSettings, getPostBySlug } from '@lib/ghost' 3 | import { resolveUrl } from '@utils/routing' 4 | import { collections } from '@lib/collections' 5 | 6 | /** 7 | * 8 | * Currently only posts are implemented for preview 9 | * 10 | */ 11 | 12 | // The preview mode cookies expire in 1 hour 13 | const maxAge = 60 * 60 14 | 15 | export async function verifySlug(postSlug: string): Promise { 16 | const post = await getPostBySlug(postSlug) 17 | if (!post) return null 18 | 19 | const collectionPath = collections.getCollectionByNode(post) 20 | const { slug, url } = post 21 | 22 | const settings = await getAllSettings() 23 | const { url: cmsUrl } = settings 24 | return resolveUrl({ cmsUrl, collectionPath, slug, url }) 25 | } 26 | 27 | export default async (req: NextApiRequest, res: NextApiResponse): Promise => { 28 | if (req.query.secret !== process.env.JAMIFY_PREVIEW_TOKEN || !req.query.slug) { 29 | return res.status(401).json({ message: 'Invalid token' }) 30 | } 31 | 32 | const slug = Array.isArray(req.query.slug) ? req.query.slug[0] : req.query.slug 33 | const url = await verifySlug(slug) 34 | console.log(url) 35 | 36 | if (!url) { 37 | return res.status(401).json({ message: 'Invalid slug' }) 38 | } 39 | 40 | res.setPreviewData({}, { maxAge }) 41 | res.redirect(url) 42 | 43 | // TODO: Option for cookie clearing 44 | // res.clearPreviewData() 45 | } 46 | -------------------------------------------------------------------------------- /components/SubscribeOverlay.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { CSSProperties } from 'react' 3 | 4 | import { useOverlay } from '@components/contexts/overlayProvider' 5 | import { GhostSettings } from '@lib/ghost' 6 | import { useLang, get } from '@utils/use-lang' 7 | import { siteIcon } from '@meta/siteDefaults' 8 | 9 | import { SubscribeForm } from '@components/SubscribeForm' 10 | 11 | export const SubscribeOverlay = ({ settings }: { settings: GhostSettings }) => { 12 | const text = get(useLang()) 13 | const { isOpen, handleClose } = useOverlay() 14 | 15 | const title = text(`SITE_TITLE`, settings.title) 16 | const siteLogo = settings.logo || siteIcon 17 | const openingStyle: CSSProperties = { opacity: 1, pointerEvents: `auto` } 18 | const closingStyle: CSSProperties = { opacity: 0, pointerEvents: `none` } 19 | 20 | return ( 21 |
22 | 23 | 24 |
25 | {siteLogo && 26 | {title} 27 | } 28 |
29 |

{text(`SUBSCRIBE_TO`)} {title}

30 |

{text(`SUBSCRIBE_OVERLAY`)}

31 | 32 |
33 |
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { ReactFragment } from 'react' 3 | import { NavItem } from '@lib/ghost' 4 | 5 | /** 6 | * Navigation component 7 | * 8 | * The Navigation component takes an array of your Ghost 9 | * navigation property that is fetched from the settings. 10 | * It differentiates between absolute (external) and relative link (internal). 11 | * You can pass it a custom class for your own styles, but it will always fallback 12 | * to a `site-nav-item` class. 13 | * 14 | */ 15 | 16 | interface NavigationProps { 17 | data?: NavItem[] 18 | navClass?: string 19 | } 20 | 21 | export const Navigation = ({ data, navClass }: NavigationProps) => { 22 | const items: ReactFragment[] = [] 23 | 24 | data?.map((navItem, i) => { 25 | if (navItem.url.match(/^\s?http(s?)/gi)) { 26 | items.push( 27 |
  • 28 | 29 | {navItem.label} 30 | 31 |
  • 32 | ) 33 | } else { 34 | items.push( 35 |
  • 36 |
    37 | 38 | {navItem.label} 39 | 40 |
    41 |
  • 42 | ) 43 | } 44 | }) 45 | 46 | return ( 47 |
      48 | {items} 49 |
    50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /utils/routing.ts: -------------------------------------------------------------------------------- 1 | // higher order function 2 | const withPrefixPath = (prefixPath: string) => (path: string) => normalizePath(`/${prefixPath}/${path}/`) 3 | 4 | const trimSlash = (text: string) => text.replace(/^\//, '').replace(/\/$/, '') 5 | 6 | const normalizePath = (path: string) => { 7 | const normalize = `/${trimSlash(path)}/` 8 | return normalize.replace(`////`, `/`).replace(`///`, `/`).replace(`//`, `/`) 9 | } 10 | 11 | //const splitUrl = (url: string) => { 12 | // // Regexp to extract the absolute part of the CMS url 13 | // const regexp = /^(([\w-]+:\/\/?|www[.])[^\s()<>^/]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/)))/ 14 | // 15 | // const [absolute] = url.match(regexp) || [] 16 | // const relative = url.split(absolute, 2).join(`/`) 17 | // return { absolute, relative } 18 | //} 19 | 20 | interface ResolveUrlProps { 21 | cmsUrl?: string 22 | collectionPath?: string 23 | slug?: string 24 | url?: string 25 | } 26 | 27 | export const resolveUrl = ({ cmsUrl, collectionPath = `/`, slug, url }: ResolveUrlProps) => { 28 | const resolvePath = withPrefixPath(collectionPath) 29 | 30 | if (!slug || slug.length === 0) return normalizePath(resolvePath(`/`)) 31 | 32 | if (!cmsUrl || cmsUrl.length === 0) return resolvePath(slug) 33 | if (!url || url.length === 0) return resolvePath(slug) 34 | if (trimSlash(url) === slug) return resolvePath(slug) 35 | if (!url.startsWith(cmsUrl)) return resolvePath(slug) 36 | 37 | //const { absolute: cmsUrl, relative: dirUrl } = splitUrl(url) 38 | const dirUrl = url.replace(cmsUrl, '/').replace('//', '/') 39 | return resolvePath(dirUrl) 40 | } 41 | -------------------------------------------------------------------------------- /components/effects/HoverOnAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { Component, RefObject, createRef } from 'react' 2 | 3 | interface HoverOnAvatarProps { 4 | activeClass: string 5 | render: (arg: HoverOnAvatar) => JSX.Element 6 | } 7 | 8 | export class HoverOnAvatar extends Component { 9 | anchorRef: RefObject 10 | activeClass: string 11 | hoverTimeout: NodeJS.Timeout | undefined 12 | state: { 13 | currentClass: string 14 | } 15 | 16 | constructor(props: HoverOnAvatarProps) { 17 | super(props) 18 | this.anchorRef = createRef() 19 | this.activeClass = this.props.activeClass 20 | this.hoverTimeout = undefined 21 | this.state = { 22 | currentClass: '' 23 | } 24 | } 25 | 26 | componentDidMount() { 27 | this.anchorRef?.current?.addEventListener(`mouseout`, this.onHoverOut, { passive: true }) 28 | this.anchorRef?.current?.addEventListener(`mouseover`, this.onHoverIn, { passive: true }) 29 | } 30 | 31 | componentWillUnmount() { 32 | this.hoverTimeout && clearTimeout(this.hoverTimeout) 33 | this.anchorRef?.current?.removeEventListener(`mouseover`, this.onHoverIn) 34 | this.anchorRef?.current?.removeEventListener(`mouseout`, this.onHoverOut) 35 | } 36 | 37 | onHoverIn = () => { 38 | this.hoverTimeout && clearTimeout(this.hoverTimeout) 39 | this.setState({ currentClass: this.activeClass }) 40 | } 41 | 42 | onHoverOut = () => { 43 | // no delay for multiple authors 44 | this.hoverTimeout = setTimeout(() => { 45 | this.setState({ currentClass: `` }) 46 | }, 50) 47 | } 48 | 49 | render() { 50 | return this.props.render(this) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Link from 'next/link' 3 | import { GetStaticProps } from 'next' 4 | 5 | import { Layout } from '@components/Layout' 6 | import { HeaderPage } from '@components/HeaderPage' 7 | import { PostCard } from '@components/PostCard' 8 | 9 | import { getAllPosts, getAllSettings, GhostSettings, GhostPostsOrPages } from '@lib/ghost' 10 | import { useLang, get } from '@utils/use-lang' 11 | import { BodyClass } from '@helpers/BodyClass' 12 | 13 | export const getStaticProps: GetStaticProps = async () => { 14 | const posts = await getAllPosts({ limit: 3 }) 15 | const settings = await getAllSettings() 16 | 17 | return { 18 | props: { 19 | settings, 20 | posts, 21 | bodyClass: BodyClass({}) 22 | }, 23 | } 24 | } 25 | 26 | interface Custom404Props { 27 | posts: GhostPostsOrPages 28 | settings: GhostSettings 29 | bodyClass: string 30 | } 31 | 32 | export default function Custom404({ posts, settings, bodyClass }: Custom404Props) { 33 | const text = get(useLang()) 34 | 35 | return ( 36 | } errorClass="error-content"> 37 |
    38 |
    39 |

    404

    40 |

    {text(`PAGE_NOT_FOUND`)}

    41 | {text(`GOTO_FRONT_PAGE`)} → 42 |
    43 | 44 |
    45 | {posts.map((post, i) => ( 46 | 47 | ))} 48 |
    49 | 50 |
    51 |
    52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /components/SubscribeForm.tsx: -------------------------------------------------------------------------------- 1 | import { GhostSettings } from "@lib/ghost" 2 | import { useLang, get } from '@utils/use-lang' 3 | import { useOverlay } from '@components/contexts/overlayProvider' 4 | 5 | import { LoaderIcon } from '@icons/LoaderIcon' 6 | 7 | export const SubscribeForm = ({ settings }: { settings: GhostSettings }) => { 8 | const text = get(useLang()) 9 | const { message, handleSubmit, email, handleChange } = useOverlay() 10 | 11 | return ( 12 |
    handleSubmit(ev, settings.url)}> 13 |
    14 | 25 | 28 | 32 |
    33 |
    34 | {`${text(`GREAT`)}!`} {text(`CHECK_YOUR_INBOX`)}. 35 |
    36 |
    37 | {text(`ENTER_VALID_EMAIL`)}! 38 |
    39 |
    40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /components/DocumentHead.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useRouter } from 'next/router' 3 | import { useTheme } from '@components/contexts/themeProvider' 4 | import { DarkMode } from '@appConfig' 5 | 6 | interface DocumentHeadProps { 7 | className: string 8 | } 9 | 10 | interface ClassProps { 11 | className: string 12 | } 13 | 14 | interface AddDarkClassProps extends ClassProps { 15 | dark: DarkMode 16 | } 17 | 18 | interface AddActionClassProps extends ClassProps { 19 | action?: string | string[] 20 | success?: string | string[] 21 | } 22 | 23 | const addDarkClass = ({ className, dark }: AddDarkClassProps) => ( 24 | `${className} ${dark === `dark` ? dark : ``}` 25 | ) 26 | 27 | const addActionClass = ({ className, action = `ssr`, success }: AddActionClassProps) => { 28 | if (!success || Array.isArray(action) || Array.isArray(success)) { 29 | return className 30 | } 31 | return ( 32 | `${className} ${action === `subscribe` ? success === `true` ? ` subscribe-success` : ` subscribe-failure` : ``}` 33 | ) 34 | } 35 | 36 | export const DocumentHead = ({ className }: DocumentHeadProps) => { 37 | const { getDark } = useTheme() 38 | const router = useRouter() 39 | const { action, success } = router.query 40 | const cln = addActionClass({ className, action, success }) 41 | 42 | const dark = getDark() 43 | const bodyClass = addDarkClass({ className: cln, dark }) 44 | 45 | /** 46 | * Not declarative, but allows to get rid of Helmet which 47 | * 1. saves 5 KB in bundle size 48 | * 2. allows strict mode in next.config 49 | * 50 | */ 51 | useEffect(() => { 52 | const body = document.querySelector('body') 53 | if (body) body.className = bodyClass 54 | }, [bodyClass]) 55 | 56 | return null 57 | } 58 | -------------------------------------------------------------------------------- /components/SubscribeSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { useLang, get } from '@utils/use-lang' 3 | import { useRouter } from 'next/router' 4 | 5 | export const SubscribeSuccess = ({ title }: { title: string }) => { 6 | const text = get(useLang()) 7 | const router = useRouter() 8 | const { action, success } = router.query 9 | 10 | const [type, setType] = useState('') 11 | const [closeState, setCloseState] = useState('') 12 | const [closeButtonOpacity, setCloseButtonOpacity] = useState(0) 13 | const showBanner = action && action === `subscribe` && success !== undefined 14 | const message = success === `true` ? `${text(`SUBSCRIBED_TO`)} ${title}!` : `${text(`SUBSCRIBED_FAILED`)}` 15 | 16 | useEffect(() => { 17 | const timer = setTimeout(() => setCloseButtonOpacity(1), 1000) 18 | setType( 19 | success === `true` ? `success` : `failure` 20 | ) 21 | return () => clearTimeout(timer) 22 | }, [setType, setCloseButtonOpacity, action]) 23 | 24 | return ( 25 |
    26 | 41 | { 43 | e.preventDefault() 44 | setCloseState(` close`) 45 | }} 46 | className="subscribe-close-button" 47 | > 48 | 49 | {message} 50 |
    51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document' 3 | import { resolve } from 'url' 4 | import { processEnv } from '@lib/processEnv' 5 | 6 | export default class MyDocument extends Document { 7 | 8 | static async getInitialProps(ctx: DocumentContext) { 9 | return await super.getInitialProps(ctx) 10 | } 11 | 12 | render() { 13 | const { pageProps } = this.props.__NEXT_DATA__.props 14 | const { cmsData, settings } = pageProps || { cmsData: null, settings: null } 15 | const { settings: cmsSettings , bodyClass } = cmsData || { settings: null, bodyClass: '' } 16 | const { lang } = settings || cmsSettings || { lang: 'en' } 17 | 18 | return ( 19 | 20 | 21 | 27 | 28 | 29 | 76 | 77 | ) 78 | } 79 | 80 | export const authorSameAs = (author: Author) => { 81 | const { website, twitter, facebook } = author 82 | 83 | const authorProfiles = [ 84 | website, 85 | twitter && `https://twitter.com/${twitter.replace(/^@/, ``)}/`, 86 | facebook && `https://www.facebook.com/${facebook.replace(/^\//, ``)}/` 87 | ].filter(element => !!element) 88 | 89 | return authorProfiles.length > 0 && `["${authorProfiles.join(`", "`)}"]` || undefined 90 | } 91 | 92 | const getJsonLd = ({ title, description, canonical, seoImage, settings, sameAs, article }: SEOProps) => { 93 | const siteUrl = settings.processEnv.siteUrl 94 | const pubLogoUrl = settings.logo || url.resolve(siteUrl, siteIcon) 95 | const type = article ? 'Article' : 'WebSite' 96 | 97 | return { 98 | '@context': `https://schema.org/`, 99 | '@type': type, 100 | sameAs, 101 | url: canonical, 102 | ...article && { ...getArticleJsonLd(article) }, 103 | image: { 104 | ...seoImage && { 105 | '@type': `ImageObject`, 106 | url: seoImage.url, 107 | ...seoImage.dimensions, 108 | } 109 | }, 110 | publisher: { 111 | '@type': `Organization`, 112 | name: title, 113 | logo: { 114 | '@type': `ImageObject`, 115 | url: pubLogoUrl, 116 | width: 60, 117 | height: 60, 118 | }, 119 | }, 120 | mainEntityOfPage: { 121 | '@type': `WebPage`, 122 | '@id': siteUrl, 123 | }, 124 | description 125 | } 126 | } 127 | 128 | const getArticleJsonLd = (article: PostOrPage) => { 129 | const { published_at, updated_at, primary_author, tags, meta_title, title } = article 130 | const name = primary_author?.name 131 | const image = primary_author?.profile_image 132 | const sameAs = primary_author && authorSameAs(primary_author) || undefined 133 | const publicTags = getPublicTags(tags) 134 | const keywords = publicTags?.length ? publicTags.join(`, `) : undefined 135 | const headline = meta_title || title 136 | 137 | return { 138 | datePublished: published_at, 139 | dateModified: updated_at, 140 | author: { 141 | '@type': "Article", 142 | name, 143 | image, 144 | sameAs, 145 | }, 146 | keywords, 147 | headline, 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /pages/[...slug].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, GetStaticPaths } from 'next' 2 | import { useRouter } from 'next/router' 3 | import { Post } from '@components/Post' 4 | import { Page } from '@components/Page' 5 | 6 | import { getPostsByTag, getTagBySlug, GhostPostOrPage, GhostPostsOrPages, GhostSettings } from '@lib/ghost' 7 | 8 | import { getPostBySlug, getPageBySlug, getAllPosts, getAllPages, getAllSettings, getAllPostSlugs } from '@lib/ghost' 9 | import { resolveUrl } from '@utils/routing' 10 | import { collections } from '@lib/collections' 11 | 12 | import { customPage } from '@appConfig' 13 | import { ContactPage, defaultPage } from '@lib/contactPageDefaults' 14 | import { imageDimensions } from '@lib/images' 15 | 16 | import { Contact } from '@components/ContactPage' 17 | import { ISeoImage, seoImage } from '@meta/seoImage' 18 | import { processEnv } from '@lib/processEnv' 19 | import { BodyClass } from '@helpers/BodyClass' 20 | 21 | /** 22 | * 23 | * Renders a single post or page and loads all content. 24 | * 25 | */ 26 | 27 | interface CmsDataCore { 28 | post: GhostPostOrPage 29 | page: GhostPostOrPage 30 | contactPage: ContactPage 31 | settings: GhostSettings 32 | seoImage: ISeoImage 33 | previewPosts?: GhostPostsOrPages 34 | prevPost?: GhostPostOrPage 35 | nextPost?: GhostPostOrPage 36 | bodyClass: string 37 | } 38 | 39 | interface CmsData extends CmsDataCore { 40 | isPost: boolean 41 | } 42 | 43 | interface PostOrPageProps { 44 | cmsData: CmsData 45 | } 46 | 47 | const PostOrPageIndex = ({ cmsData }: PostOrPageProps) => { 48 | const router = useRouter() 49 | if (router.isFallback) return
    Loading...
    50 | 51 | const { isPost, contactPage } = cmsData 52 | if (isPost) { 53 | return 54 | } else if (!!contactPage) { 55 | const { contactPage, previewPosts, settings, seoImage, bodyClass } = cmsData 56 | return 57 | } else { 58 | return 59 | } 60 | } 61 | 62 | export default PostOrPageIndex 63 | 64 | export const getStaticProps: GetStaticProps = async ({ params }) => { 65 | if (!(params && params.slug && Array.isArray(params.slug))) throw Error('getStaticProps: wrong parameters.') 66 | const [slug] = params.slug.reverse() 67 | 68 | const settings = await getAllSettings() 69 | 70 | let post: GhostPostOrPage | null = null 71 | let page: GhostPostOrPage | null = null 72 | let contactPage: ContactPage | null = null 73 | 74 | post = await getPostBySlug(slug) 75 | const isPost = !!post 76 | if (!isPost) { 77 | page = await getPageBySlug(slug) 78 | } else if (post?.primary_tag) { 79 | const primaryTag = await getTagBySlug(post?.primary_tag.slug) 80 | post.primary_tag = primaryTag 81 | } 82 | 83 | // Add custom contact page 84 | let isContactPage = false 85 | if (processEnv.contactPage) { 86 | contactPage = { ...defaultPage, ...customPage } 87 | isContactPage = contactPage?.slug === slug 88 | if (!isContactPage) contactPage = null 89 | 90 | const url = contactPage?.feature_image 91 | if (!contactPage?.featureImage && contactPage && url) { 92 | const dimensions = await imageDimensions(url) 93 | if (dimensions) contactPage.featureImage = { url, dimensions } 94 | } 95 | } 96 | 97 | if (!post && !page && !isContactPage) { 98 | return { 99 | notFound: true, 100 | } 101 | } 102 | 103 | let previewPosts: GhostPostsOrPages | never[] = [] 104 | let prevPost: GhostPostOrPage | null = null 105 | let nextPost: GhostPostOrPage | null = null 106 | 107 | if (isContactPage) { 108 | previewPosts = await getAllPosts({ limit: 3 }) 109 | } else if (isPost && post?.id && post?.slug) { 110 | const tagSlug = post?.primary_tag?.slug 111 | previewPosts = (tagSlug && (await getPostsByTag(tagSlug, 3, post?.id))) || [] 112 | 113 | const postSlugs = await getAllPostSlugs() 114 | const index = postSlugs.indexOf(post?.slug) 115 | const prevSlug = index > 0 ? postSlugs[index - 1] : null 116 | const nextSlug = index < postSlugs.length - 1 ? postSlugs[index + 1] : null 117 | 118 | prevPost = (prevSlug && (await getPostBySlug(prevSlug))) || null 119 | nextPost = (nextSlug && (await getPostBySlug(nextSlug))) || null 120 | } 121 | 122 | const siteUrl = settings.processEnv.siteUrl 123 | const imageUrl = (post || contactPage || page)?.feature_image || undefined 124 | const image = await seoImage({ siteUrl, imageUrl }) 125 | 126 | const tags = (contactPage && contactPage.tags) || (page && page.tags) || undefined 127 | 128 | return { 129 | props: { 130 | cmsData: { 131 | settings, 132 | post, 133 | page, 134 | contactPage, 135 | isPost, 136 | seoImage: image, 137 | previewPosts, 138 | prevPost, 139 | nextPost, 140 | bodyClass: BodyClass({ isPost, page: contactPage || page || undefined, tags }), 141 | }, 142 | }, 143 | ...(processEnv.isr.enable && { revalidate: 1 }), // re-generate at most once every second 144 | } 145 | } 146 | 147 | export const getStaticPaths: GetStaticPaths = async () => { 148 | const { enable, maxNumberOfPosts, maxNumberOfPages } = processEnv.isr 149 | const limitForPosts = (enable && { limit: maxNumberOfPosts }) || undefined 150 | const limitForPages = (enable && { limit: maxNumberOfPages }) || undefined 151 | const posts = await getAllPosts(limitForPosts) 152 | const pages = await getAllPages(limitForPages) 153 | const settings = await getAllSettings() 154 | const { url: cmsUrl } = settings 155 | 156 | const postRoutes = (posts as GhostPostsOrPages).map((post) => { 157 | const collectionPath = collections.getCollectionByNode(post) 158 | const { slug, url } = post 159 | return resolveUrl({ cmsUrl, collectionPath, slug, url }) 160 | }) 161 | 162 | let contactPageRoute: string | null = null 163 | if (processEnv.contactPage) { 164 | const contactPage = { ...defaultPage, ...customPage } 165 | const { slug, url } = contactPage 166 | contactPageRoute = resolveUrl({ cmsUrl, slug, url }) 167 | } 168 | 169 | const customRoutes = (contactPageRoute && [contactPageRoute]) || [] 170 | const pageRoutes = (pages as GhostPostsOrPages).map(({ slug, url }) => resolveUrl({ cmsUrl, slug, url })) 171 | const paths = [...postRoutes, ...pageRoutes, ...customRoutes] 172 | 173 | return { 174 | paths, 175 | fallback: enable && 'blocking', 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /styles/dark-mode.css: -------------------------------------------------------------------------------- 1 | @import 'global.css'; 2 | 3 | /* 12. Dark Mode 4 | /* ---------------------------------------------------------- */ 5 | 6 | html.casper body.dark { 7 | color: rgba(255, 255, 255, 0.75); 8 | background: var(--darkmode); 9 | } 10 | 11 | html.casper body.dark img { 12 | opacity: 0.9; 13 | } 14 | 15 | html.casper body.dark .site-header-background:before { 16 | background: rgba(0, 0, 0, 0.6); 17 | } 18 | 19 | html.casper body.dark .post-feed { 20 | background: var(--darkmode); 21 | } 22 | 23 | html.casper body.dark .post-card, 24 | html.casper body.dark .post-card:hover { 25 | border-bottom-color: color-mod(var(--darkmode) l(+8%)); 26 | } 27 | 28 | html.casper body.dark .author-profile-image { 29 | background: var(--darkmode); 30 | } 31 | 32 | html.casper body.dark .post-card-byline-content a { 33 | color: rgba(255, 255, 255, 0.75); 34 | } 35 | 36 | html.casper body.dark .post-card-byline-content a:hover { 37 | color: #fff; 38 | } 39 | 40 | html.casper body.dark .post-card-image { 41 | background: var(--darkmode); 42 | } 43 | 44 | html.casper body.dark .post-card-title { 45 | color: rgba(255, 255, 255, 0.85); 46 | } 47 | 48 | html.casper body.dark .post-card-excerpt { 49 | color: color-mod(var(--midgrey) l(+10%)); 50 | } 51 | 52 | html.casper body.dark .author-avatar, 53 | html.casper body.dark .static-avatar { 54 | border-color: color-mod(var(--darkgrey) l(+2%)); 55 | } 56 | 57 | html.casper body.dark .site-main, 58 | html.casper body.dark .post-template .site-main, 59 | html.casper body.dark .page-template .site-main { 60 | background: var(--darkmode); 61 | } 62 | 63 | html.casper body.dark .post-full-content { 64 | background: var(--darkmode); 65 | } 66 | 67 | html.casper body.dark .post-full-title { 68 | color: rgba(255, 255, 255, 0.9); 69 | } 70 | 71 | html.casper body.dark .post-full-custom-excerpt { 72 | color: color-mod(var(--midgrey) l(+10%)); 73 | } 74 | 75 | html.casper body.dark .post-full-image { 76 | background-color: color-mod(var(--darkmode) l(+8%)); 77 | } 78 | 79 | html.casper body.dark .post-full-byline { 80 | border-top-color: color-mod(var(--darkmode) l(+15%)); 81 | } 82 | 83 | html.casper body.dark .post-full-byline-meta h4 a { 84 | color: rgba(255, 255, 255, 0.75); 85 | } 86 | 87 | html.casper body.dark .post-full-byline-meta h4 a:hover { 88 | color: #fff; 89 | } 90 | 91 | html.casper body.dark .author-list-item .author-card { 92 | background: color-mod(var(--darkmode) l(+4%)); 93 | box-shadow: 0 12px 26px rgba(0, 0, 0, 0.4); 94 | } 95 | 96 | html.casper body.dark .author-list-item .author-card:before { 97 | border-top-color: color-mod(var(--darkmode) l(+4%)); 98 | } 99 | 100 | html.casper body.dark .no-image .author-social-link a { 101 | color: rgba(255, 255, 255, 0.75); 102 | } 103 | 104 | html.casper body.dark .post-full-content h1, 105 | html.casper body.dark .post-full-content h2, 106 | html.casper body.dark .post-full-content h3, 107 | html.casper body.dark .post-full-content h4, 108 | html.casper body.dark .post-full-content h6 { 109 | color: rgba(255, 255, 255, 0.9); 110 | } 111 | 112 | html.casper body.dark .post-full-content a { 113 | color: #fff; 114 | box-shadow: inset 0 -1px 0 #fff; 115 | } 116 | 117 | html.casper body.dark .post-full-content strong { 118 | color: #fff; 119 | } 120 | 121 | html.casper body.dark .post-full-content em { 122 | color: #fff; 123 | } 124 | 125 | html.casper body.dark .post-full-content code { 126 | color: #fff; 127 | background: #000; 128 | } 129 | 130 | html.casper body.dark hr { 131 | border-top-color: color-mod(var(--darkmode) l(+8%)); 132 | } 133 | 134 | html.casper .post-full-content hr:after { 135 | background: color-mod(var(--darkmode) l(+8%)); 136 | box-shadow: var(--darkmode) 0 0 0 5px; 137 | } 138 | 139 | html.casper body.dark .post-full-content figcaption { 140 | color: rgba(255, 255, 255, 0.6); 141 | } 142 | 143 | html.casper body.dark .post-full-content table td:first-child { 144 | background-image: linear-gradient(to right, var(--darkmode) 50%, color-mod(var(--darkmode) a(0%)) 100%); 145 | } 146 | 147 | html.casper body.dark .post-full-content table td:last-child { 148 | background-image: linear-gradient(to left, var(--darkmode) 50%, color-mod(var(--darkmode) a(0%)) 100%); 149 | } 150 | 151 | html.casper body.dark .post-full-content table th { 152 | color: rgba(255, 255, 255, 0.85); 153 | background-color: color-mod(var(--darkmode) l(+8%)); 154 | } 155 | 156 | html.casper body.dark .post-full-content table th, 157 | html.casper body.dark .post-full-content table td { 158 | border: color-mod(var(--darkmode) l(+8%)) 1px solid; 159 | } 160 | 161 | html.casper body.dark .post-full-content .kg-bookmark-container, 162 | html.casper body.dark .post-full-content .kg-bookmark-container:hover { 163 | color: rgba(255, 255, 255, 0.75); 164 | box-shadow: 0 0 1px rgba(255, 255, 255, 0.9); 165 | } 166 | 167 | html.casper .post-full-content input { 168 | color: color-mod(var(--midgrey) l(-30%)); 169 | } 170 | 171 | html.casper body.dark .kg-bookmark-title { 172 | color: #fff; 173 | } 174 | 175 | html.casper body.dark .kg-bookmark-description { 176 | color: rgba(255, 255, 255, 0.75); 177 | } 178 | 179 | html.casper body.dark .kg-bookmark-metadata { 180 | color: rgba(255, 255, 255, 0.75); 181 | } 182 | 183 | html.casper body.dark .site-archive-header .no-image { 184 | color: rgba(255, 255, 255, 0.9); 185 | background: var(--darkmode); 186 | } 187 | 188 | html.casper body.dark .site-archive-header .no-image .site-header-content { 189 | border-bottom-color: color-mod(var(--darkmode) l(+15%)); 190 | } 191 | 192 | html.casper body.dark .site-header-content .author-profile-image { 193 | box-shadow: 0 0 0 6px hsla(0, 0%, 100%, 0.04); 194 | } 195 | 196 | html.casper body.dark .subscribe-form { 197 | border: none; 198 | background: linear-gradient(color-mod(var(--darkmode) l(-6%)), color-mod(var(--darkmode) l(-3%))); 199 | } 200 | 201 | html.casper body.dark .subscribe-form-title { 202 | color: rgba(255, 255, 255, 0.9); 203 | } 204 | 205 | html.casper body.dark .subscribe-form p { 206 | color: rgba(255, 255, 255, 0.7); 207 | } 208 | 209 | html.casper body.dark .subscribe-email { 210 | border-color: color-mod(var(--darkmode) l(+6%)); 211 | color: rgba(255, 255, 255, 0.9); 212 | background: color-mod(var(--darkmode) l(+3%)); 213 | } 214 | 215 | html.casper body.dark .subscribe-email:focus { 216 | border-color: color-mod(var(--darkmode) l(+25%)); 217 | } 218 | 219 | html.casper body.dark .subscribe-form button { 220 | opacity: 0.9; 221 | } 222 | 223 | html.casper body.dark .subscribe-form .invalid .message-error, 224 | html.casper body.dark .subscribe-form .error .message-error { 225 | color: color-mod(var(--red) l(+5%) s(-5%)); 226 | } 227 | 228 | html.casper body.dark .subscribe-form .success .message-success { 229 | color: color-mod(var(--green) l(+5%) s(-5%)); 230 | } 231 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 6 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, // "declaration": true, /* Generates corresponding '.d.ts' file. */ 10 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 11 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 12 | // "outFile": "./", /* Concatenate and emit output to single file. */ 13 | // "outDir": "./", /* Redirect output structure to the directory. */ 14 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 15 | // "composite": true, /* Enable project compilation */ 16 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true /* Import emit helpers from 'tslib'. */, 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | /* Strict Type-Checking Options */ 23 | "strict": true /* Enable all strict type-checking options. */, 24 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 25 | // "strictNullChecks": true, /* Enable strict null checks. */ 26 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 27 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | /* Additional Checks */ 32 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 33 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 34 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 35 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 36 | /* Module Resolution Options */ 37 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 38 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 39 | "paths": { 40 | "@siteOptions": ["siteOptions"], 41 | "@siteConfig": ["siteConfig"], 42 | "@mediaConfig": ["mediaConfig"], 43 | "@appConfig": ["appConfig"], 44 | "@routesConfig": ["routesConfig"], 45 | "@lib/*": ["lib/*"], 46 | "@pages/*": ["pages/*"], 47 | "@styles/*": ["styles/*"], 48 | "@utils/*": ["utils/*"], 49 | "@dashboard/*": ["pages/dashboard/*"], 50 | "@components/*": ["components/*"], 51 | "@common/*": ["components/common/*"], 52 | "@icons/*": ["components/icons/*"], 53 | "@effects/*": ["components/effects/*"], 54 | "@helpers/*": ["components/helpers/*"], 55 | "@meta/*": ["components/meta/*"] 56 | } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 57 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 58 | "typeRoots": [ 59 | /* List of folders to include type definitions from. */ 60 | "./types", 61 | "node_modules/@types" 62 | ], 63 | // "types": [], /* Type declaration files to be included in compilation. */ 64 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 65 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 66 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 67 | /* Source Map Options */ 68 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 69 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 70 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 71 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 72 | /* Experimental Options */ 73 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 74 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 75 | /* Advanced Options */ 76 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 77 | "lib": ["dom", "dom.iterable", "esnext"], 78 | "allowJs": true, 79 | "skipLibCheck": true, 80 | "noEmit": true, 81 | "moduleResolution": "node", 82 | "resolveJsonModule": true, 83 | "isolatedModules": true 84 | }, 85 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], 86 | "exclude": ["node_modules", "types", ".next", "out"] 87 | } 88 | 89 | //include[".next/common/icons/avatar-icon.tsx"] 90 | -------------------------------------------------------------------------------- /components/Post.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import Link from 'next/link' 3 | import dayjs from 'dayjs' 4 | 5 | import { readingTime as readingTimeHelper } from '@lib/readingTime' 6 | 7 | import { resolveUrl } from '@utils/routing' 8 | import { useLang, get } from '@utils/use-lang' 9 | 10 | import { Layout } from '@components/Layout' 11 | import { HeaderPost } from '@components/HeaderPost' 12 | import { AuthorList } from '@components/AuthorList' 13 | import { PreviewPosts } from '@components/PreviewPosts' 14 | import { RenderContent } from '@components/RenderContent' 15 | import { CommentoComments } from '@components/CommentoComments' 16 | import { DisqusComments } from '@components/DisqusComments' 17 | import { Subscribe } from '@components/Subscribe' 18 | import { TableOfContents } from '@components/toc/TableOfContents' 19 | 20 | import { StickyNavContainer } from '@effects/StickyNavContainer' 21 | import { SEO } from '@meta/seo' 22 | 23 | import { PostClass } from '@helpers/PostClass' 24 | import { GhostPostOrPage, GhostPostsOrPages, GhostSettings } from '@lib/ghost' 25 | import { collections } from '@lib/collections' 26 | 27 | import { ISeoImage } from '@meta/seoImage' 28 | 29 | import React from 'react' 30 | 31 | interface PostProps { 32 | cmsData: { 33 | post: GhostPostOrPage 34 | settings: GhostSettings 35 | seoImage: ISeoImage 36 | previewPosts?: GhostPostsOrPages 37 | prevPost?: GhostPostOrPage 38 | nextPost?: GhostPostOrPage 39 | bodyClass: string 40 | } 41 | } 42 | 43 | export const Post = ({ cmsData }: PostProps) => { 44 | const { post, settings, seoImage, previewPosts, prevPost, nextPost, bodyClass } = cmsData 45 | const { slug, url, meta_description, excerpt } = post 46 | const { url: cmsUrl } = settings 47 | const description = meta_description || excerpt 48 | 49 | const { processEnv } = settings 50 | const { nextImages, toc, memberSubscriptions, commenting } = processEnv 51 | 52 | const text = get(useLang()) 53 | const readingTime = readingTimeHelper(post).replace(`min read`, text(`MIN_READ`)) 54 | const featImg = post.featureImage 55 | const postClass = PostClass({ tags: post.tags, isFeatured: !!featImg, isImage: !!featImg }) 56 | 57 | const htmlAst = post.htmlAst 58 | if (htmlAst === undefined) throw Error('Post.tsx: htmlAst must be defined.') 59 | 60 | const collectionPath = collections.getCollectionByNode(post) 61 | 62 | return ( 63 | <> 64 | 65 | ( 70 | } 73 | previewPosts={} 74 | > 75 |
    76 |
    77 |
    78 | {post.primary_tag && ( 79 |
    80 | 81 | {post.primary_tag.name} 82 | 83 |
    84 | )} 85 | 86 |

    87 | {post.title} 88 |

    89 | 90 | {post.custom_excerpt &&

    {post.custom_excerpt}

    } 91 | 92 |
    93 |
    94 | 95 | 96 |
    97 |

    98 | {post.authors?.map((author, i) => ( 99 |
    100 | {i > 0 ? `, ` : ``} 101 | 102 | {author.name} 103 | 104 |
    105 | ))} 106 |

    107 |
    108 | 111 | 112 | {readingTime} 113 | 114 |
    115 |
    116 |
    117 |
    118 |
    119 | 120 | {featImg && 121 | (nextImages.feature && featImg.dimensions ? ( 122 |
    123 | {post.title} 137 |
    138 | ) : ( 139 | post.feature_image && ( 140 |
    141 | {post.title} 142 |
    143 | ) 144 | ))} 145 | 146 |
    147 | {toc.enable && !!post.toc && } 148 |
    149 | 150 |
    151 |
    152 | 153 | {memberSubscriptions && } 154 | 155 | {commenting.system === 'commento' && } 156 | 157 | {commenting.system === 'disqus' && } 158 |
    159 |
    160 |
    161 | )} 162 | /> 163 | 164 | ) 165 | } 166 | -------------------------------------------------------------------------------- /lib/ghost.ts: -------------------------------------------------------------------------------- 1 | import { parse as urlParse, UrlWithStringQuery } from 'url' 2 | import GhostContentAPI, { Params, PostOrPage, SettingsResponse, Pagination, PostsOrPages, Tag, Author } from '@tryghost/content-api' 3 | import { normalizePost } from '@lib/ghost-normalize' 4 | import { Node } from 'unist' 5 | import { collections as config } from '@routesConfig' 6 | import { Collections } from '@lib/collections' 7 | 8 | import { ghostAPIUrl, ghostAPIKey, processEnv, ProcessEnvProps } from '@lib/processEnv' 9 | import { imageDimensions, normalizedImageUrl, Dimensions } from '@lib/images' 10 | import { IToC } from '@lib/toc' 11 | 12 | import { contactPage } from '@appConfig' 13 | 14 | export interface NextImage { 15 | url: string 16 | dimensions: Dimensions 17 | } 18 | 19 | export interface NavItem { 20 | url: string 21 | label: string 22 | } 23 | 24 | interface BrowseResults extends Array { 25 | meta: { pagination: Pagination } 26 | } 27 | 28 | export interface GhostSettings extends SettingsResponse { 29 | processEnv: ProcessEnvProps 30 | secondary_navigation?: NavItem[] 31 | iconImage?: NextImage 32 | logoImage?: NextImage 33 | coverImage?: NextImage 34 | } 35 | 36 | export interface GhostTag extends Tag { 37 | featureImage?: NextImage 38 | } 39 | 40 | export interface GhostAuthor extends Author { 41 | profileImage?: NextImage 42 | } 43 | 44 | export interface GhostPostOrPage extends PostOrPage { 45 | featureImage?: NextImage | null 46 | htmlAst?: Node | null 47 | toc?: IToC[] | null 48 | } 49 | 50 | export interface GhostPostsOrPages extends BrowseResults {} 51 | 52 | export interface GhostTags extends BrowseResults {} 53 | 54 | export interface GhostAuthors extends BrowseResults {} 55 | 56 | const api = new GhostContentAPI({ 57 | url: ghostAPIUrl, 58 | key: ghostAPIKey, 59 | version: 'v3', 60 | }) 61 | 62 | const postAndPageFetchOptions: Params = { 63 | limit: 'all', 64 | include: ['tags', 'authors', 'count.posts'], 65 | order: ['featured DESC', 'published_at DESC'], 66 | } 67 | 68 | const tagAndAuthorFetchOptions: Params = { 69 | limit: 'all', 70 | include: 'count.posts', 71 | } 72 | 73 | const postAndPageSlugOptions: Params = { 74 | limit: 'all', 75 | fields: 'slug', 76 | } 77 | 78 | const excludePostOrPageBySlug = () => { 79 | if (!contactPage) return '' 80 | return 'slug:-contact' 81 | } 82 | 83 | // helpers 84 | export const createNextImage = async (url?: string | null): Promise => { 85 | if (!url) return undefined 86 | const normalizedUrl = await normalizedImageUrl(url) 87 | const dimensions = await imageDimensions(normalizedUrl) 88 | return (dimensions && { url: normalizedUrl, dimensions }) || undefined 89 | } 90 | 91 | async function createNextFeatureImages(nodes: BrowseResults): Promise { 92 | const { meta } = nodes 93 | const images = await Promise.all(nodes.map((node) => createNextImage(node.feature_image))) 94 | const results = nodes.map((node, i) => ({ ...node, ...(images[i] && { featureImage: images[i] }) })) 95 | return Object.assign(results, { meta }) 96 | } 97 | 98 | async function createNextProfileImages(nodes: BrowseResults): Promise { 99 | const { meta } = nodes 100 | const images = await Promise.all(nodes.map((node) => createNextImage(node.profile_image))) 101 | const results = nodes.map((node, i) => ({ ...node, ...(images[i] && { profileImage: images[i] }) })) 102 | return Object.assign(results, { meta }) 103 | } 104 | 105 | export async function createNextProfileImagesFromAuthors(nodes: Author[] | undefined): Promise { 106 | if (!nodes) return undefined 107 | const images = await Promise.all(nodes.map((node) => createNextImage(node.profile_image))) 108 | return nodes.map((node, i) => ({ ...node, ...(images[i] && { profileImage: images[i] }) })) 109 | } 110 | 111 | async function createNextProfileImagesFromPosts(nodes: BrowseResults): Promise { 112 | const { meta } = nodes 113 | const authors = await Promise.all(nodes.map((node) => createNextProfileImagesFromAuthors(node.authors))) 114 | const results = nodes.map((node, i) => ({ ...node, ...(authors[i] && { authors: authors[i] }) })) 115 | return Object.assign(results, { meta }) 116 | } 117 | 118 | export async function getAllSettings(): Promise { 119 | //const cached = getCache('settings') 120 | //if (cached) return cached 121 | const settings = await api.settings.browse() 122 | settings.url = settings?.url?.replace(/\/$/, ``) 123 | 124 | const iconImage = await createNextImage(settings.icon) 125 | const logoImage = await createNextImage(settings.logo) 126 | const coverImage = await createNextImage(settings.cover_image) 127 | 128 | const result = { 129 | processEnv, 130 | ...settings, 131 | ...(iconImage && { iconImage }), 132 | ...(logoImage && { logoImage }), 133 | ...(coverImage && { coverImage }), 134 | } 135 | //setCache('settings', result) 136 | return result 137 | } 138 | 139 | export async function getAllTags(): Promise { 140 | const tags = await api.tags.browse(tagAndAuthorFetchOptions) 141 | return await createNextFeatureImages(tags) 142 | } 143 | 144 | export async function getAllAuthors() { 145 | const authors = await api.authors.browse(tagAndAuthorFetchOptions) 146 | return await createNextProfileImages(authors) 147 | } 148 | 149 | export async function getAllPosts(props?: { limit: number }): Promise { 150 | const posts = await api.posts.browse({ 151 | ...postAndPageFetchOptions, 152 | filter: excludePostOrPageBySlug(), 153 | ...(props && { ...props }), 154 | }) 155 | const results = await createNextProfileImagesFromPosts(posts) 156 | return await createNextFeatureImages(results) 157 | } 158 | 159 | export async function getAllPostSlugs(): Promise { 160 | const posts = await api.posts.browse(postAndPageSlugOptions) 161 | return posts.map((p) => p.slug) 162 | } 163 | 164 | export async function getAllPages(props?: { limit: number }): Promise { 165 | const pages = await api.pages.browse({ 166 | ...postAndPageFetchOptions, 167 | filter: excludePostOrPageBySlug(), 168 | ...(props && { ...props }), 169 | }) 170 | return await createNextFeatureImages(pages) 171 | } 172 | 173 | // specific data by slug 174 | export async function getTagBySlug(slug: string): Promise { 175 | return await api.tags.read({ 176 | ...tagAndAuthorFetchOptions, 177 | slug, 178 | }) 179 | } 180 | export async function getAuthorBySlug(slug: string): Promise { 181 | const author = await api.authors.read({ 182 | ...tagAndAuthorFetchOptions, 183 | slug, 184 | }) 185 | const profileImage = await createNextImage(author.profile_image) 186 | const result = { 187 | ...author, 188 | ...(profileImage && { profileImage }), 189 | } 190 | return result 191 | } 192 | 193 | export async function getPostBySlug(slug: string): Promise { 194 | let result: GhostPostOrPage 195 | try { 196 | const post = await api.posts.read({ 197 | ...postAndPageFetchOptions, 198 | slug, 199 | }) 200 | // older Ghost versions do not throw error on 404 201 | if (!post) return null 202 | 203 | const { url } = await getAllSettings() 204 | result = await normalizePost(post, (url && urlParse(url)) || undefined) 205 | } catch (error) { 206 | if (error.response?.status !== 404) throw new Error(error) 207 | return null 208 | } 209 | return result 210 | } 211 | 212 | export async function getPageBySlug(slug: string): Promise { 213 | let result: GhostPostOrPage 214 | try { 215 | const page = await api.pages.read({ 216 | ...postAndPageFetchOptions, 217 | slug, 218 | }) 219 | 220 | // older Ghost versions do not throw error on 404 221 | if (!page) return null 222 | 223 | const { url } = await getAllSettings() 224 | result = await normalizePost(page, (url && urlParse(url)) || undefined) 225 | } catch (error) { 226 | if (error.response?.status !== 404) throw new Error(error) 227 | return null 228 | } 229 | return result 230 | } 231 | 232 | // specific data by author/tag slug 233 | export async function getPostsByAuthor(slug: string): Promise { 234 | const posts = await api.posts.browse({ 235 | ...postAndPageFetchOptions, 236 | filter: `authors.slug:${slug}`, 237 | }) 238 | return await createNextFeatureImages(posts) 239 | } 240 | 241 | export async function getPostsByTag(slug: string, limit?: number, excludeId?: string): Promise { 242 | const exclude = (excludeId && `+id:-${excludeId}`) || `` 243 | const posts = await api.posts.browse({ 244 | ...postAndPageFetchOptions, 245 | ...(limit && { limit: `${limit}` }), 246 | filter: `tags.slug:${slug}${exclude}`, 247 | }) 248 | return await createNextFeatureImages(posts) 249 | } 250 | 251 | // Collections 252 | export const collections = new Collections(config) 253 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | /* Variables 2 | /* ---------------------------------------------------------- */ 3 | 4 | :root { 5 | /* Colours */ 6 | --blue: #0078d0; 7 | --green: #a4d037; 8 | --purple: #ad26b4; 9 | --yellow: #fecd35; 10 | --red: #f05230; 11 | --darkgrey: #15171A; 12 | --midgrey: #738a94; 13 | --lightgrey: #c5d2d9; 14 | --whitegrey: #e5eff5; 15 | --pink: #fa3a57; 16 | --brown: #a3821a; 17 | --darkmode: color-mod(var(--darkgrey) l(+2%)); 18 | } 19 | 20 | /* Reset 21 | /* ---------------------------------------------------------- */ 22 | 23 | html, 24 | body, 25 | div, 26 | span, 27 | applet, 28 | object, 29 | iframe, 30 | h1, 31 | h2, 32 | h3, 33 | h4, 34 | h5, 35 | h6, 36 | p, 37 | blockquote, 38 | pre, 39 | a, 40 | abbr, 41 | acronym, 42 | address, 43 | big, 44 | cite, 45 | code, 46 | del, 47 | dfn, 48 | em, 49 | img, 50 | ins, 51 | kbd, 52 | q, 53 | s, 54 | samp, 55 | small, 56 | strike, 57 | strong, 58 | sub, 59 | sup, 60 | tt, 61 | var, 62 | dl, 63 | dt, 64 | dd, 65 | ol, 66 | ul, 67 | li, 68 | fieldset, 69 | form, 70 | label, 71 | legend, 72 | table, 73 | caption, 74 | tbody, 75 | tfoot, 76 | thead, 77 | tr, 78 | th, 79 | td, 80 | article, 81 | aside, 82 | canvas, 83 | details, 84 | embed, 85 | figure, 86 | figcaption, 87 | footer, 88 | header, 89 | hgroup, 90 | menu, 91 | nav, 92 | output, 93 | ruby, 94 | section, 95 | summary, 96 | time, 97 | mark, 98 | audio, 99 | video { 100 | margin: 0; 101 | padding: 0; 102 | border: 0; 103 | font: inherit; 104 | font-size: 100%; 105 | vertical-align: baseline; 106 | } 107 | body { 108 | line-height: 1; 109 | } 110 | ol, 111 | ul { 112 | list-style: none; 113 | } 114 | blockquote, 115 | q { 116 | quotes: none; 117 | } 118 | blockquote:before, 119 | blockquote:after, 120 | q:before, 121 | q:after { 122 | content: ""; 123 | content: none; 124 | } 125 | table { 126 | border-spacing: 0; 127 | border-collapse: collapse; 128 | } 129 | img { 130 | max-width: 100%; 131 | } 132 | html { 133 | box-sizing: border-box; 134 | font-family: sans-serif; 135 | 136 | -ms-text-size-adjust: 100%; 137 | -webkit-text-size-adjust: 100%; 138 | } 139 | *, 140 | *:before, 141 | *:after { 142 | box-sizing: inherit; 143 | } 144 | a { 145 | background-color: transparent; 146 | } 147 | a:active, 148 | a:hover { 149 | outline: 0; 150 | } 151 | b, 152 | strong { 153 | font-weight: bold; 154 | } 155 | i, 156 | em, 157 | dfn { 158 | font-style: italic; 159 | } 160 | h1 { 161 | margin: 0.67em 0; 162 | font-size: 2em; 163 | } 164 | small { 165 | font-size: 80%; 166 | } 167 | sub, 168 | sup { 169 | position: relative; 170 | font-size: 75%; 171 | line-height: 0; 172 | vertical-align: baseline; 173 | } 174 | sup { 175 | top: -0.5em; 176 | } 177 | sub { 178 | bottom: -0.25em; 179 | } 180 | img { 181 | border: 0; 182 | } 183 | svg:not(:root) { 184 | overflow: hidden; 185 | } 186 | mark { 187 | background-color: #fdffb6; 188 | } 189 | code, 190 | kbd, 191 | pre, 192 | samp { 193 | font-family: monospace, monospace; 194 | font-size: 1em; 195 | } 196 | button, 197 | input, 198 | optgroup, 199 | select, 200 | textarea { 201 | margin: 0; /* 3 */ 202 | color: inherit; /* 1 */ 203 | font: inherit; /* 2 */ 204 | } 205 | button { 206 | overflow: visible; 207 | border: none; 208 | } 209 | button, 210 | select { 211 | text-transform: none; 212 | } 213 | button, 214 | html input[type="button"], 215 | /* 1 */ 216 | input[type="reset"], 217 | input[type="submit"] { 218 | cursor: pointer; /* 3 */ 219 | 220 | -webkit-appearance: button; /* 2 */ 221 | } 222 | button[disabled], 223 | html input[disabled] { 224 | cursor: default; 225 | } 226 | button::-moz-focus-inner, 227 | input::-moz-focus-inner { 228 | padding: 0; 229 | border: 0; 230 | } 231 | input { 232 | line-height: normal; 233 | } 234 | input:focus { 235 | outline: none; 236 | } 237 | input[type="checkbox"], 238 | input[type="radio"] { 239 | box-sizing: border-box; /* 1 */ 240 | padding: 0; /* 2 */ 241 | } 242 | input[type="number"]::-webkit-inner-spin-button, 243 | input[type="number"]::-webkit-outer-spin-button { 244 | height: auto; 245 | } 246 | input[type="search"] { 247 | box-sizing: content-box; /* 2 */ 248 | 249 | -webkit-appearance: textfield; /* 1 */ 250 | } 251 | input[type="search"]::-webkit-search-cancel-button, 252 | input[type="search"]::-webkit-search-decoration { 253 | -webkit-appearance: none; 254 | } 255 | legend { 256 | padding: 0; /* 2 */ 257 | border: 0; /* 1 */ 258 | } 259 | textarea { 260 | overflow: auto; 261 | } 262 | table { 263 | border-spacing: 0; 264 | border-collapse: collapse; 265 | } 266 | td, 267 | th { 268 | padding: 0; 269 | } 270 | 271 | /* ========================================================================== 272 | Base styles: opinionated defaults 273 | ========================================================================== */ 274 | 275 | html { 276 | overflow-x: hidden; 277 | overflow-y: scroll; 278 | font-size: 62.5%; 279 | 280 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 281 | } 282 | body { 283 | overflow-x: hidden; 284 | color: color-mod(var(--midgrey) l(-30%)); 285 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 286 | font-size: 1.6rem; 287 | line-height: 1.6em; 288 | font-weight: 400; 289 | font-style: normal; 290 | letter-spacing: 0; 291 | text-rendering: optimizeLegibility; 292 | background: #fff; 293 | 294 | -webkit-font-smoothing: antialiased; 295 | -moz-osx-font-smoothing: grayscale; 296 | -moz-font-feature-settings: "liga" on; 297 | } 298 | 299 | ::selection { 300 | text-shadow: none; 301 | background: color-mod(var(--blue) lightness(+30%)); 302 | } 303 | 304 | hr { 305 | position: relative; 306 | display: block; 307 | width: 100%; 308 | margin: 2.5em 0 3.5em; 309 | padding: 0; 310 | height: 1px; 311 | border: 0; 312 | border-top: 1px solid color-mod(var(--lightgrey) l(+10%)); 313 | } 314 | 315 | audio, 316 | canvas, 317 | iframe, 318 | img, 319 | svg, 320 | video { 321 | vertical-align: middle; 322 | } 323 | 324 | fieldset { 325 | margin: 0; 326 | padding: 0; 327 | border: 0; 328 | } 329 | 330 | textarea { 331 | resize: vertical; 332 | } 333 | 334 | p, 335 | ul, 336 | ol, 337 | dl, 338 | blockquote { 339 | margin: 0 0 1.5em 0; 340 | } 341 | 342 | ol, 343 | ul { 344 | padding-left: 1.3em; 345 | padding-right: 1.5em; 346 | } 347 | 348 | ol ol, 349 | ul ul, 350 | ul ol, 351 | ol ul { 352 | margin: 0.5em 0 1em; 353 | } 354 | 355 | ul { 356 | list-style: disc; 357 | } 358 | 359 | ol { 360 | list-style: decimal; 361 | } 362 | 363 | ul, 364 | ol { 365 | max-width: 100%; 366 | } 367 | 368 | li { 369 | margin: 0.5em 0; 370 | padding-left: 0.3em; 371 | line-height: 1.6em; 372 | } 373 | 374 | dt { 375 | float: left; 376 | margin: 0 20px 0 0; 377 | width: 120px; 378 | color: var(--darkgrey); 379 | font-weight: 500; 380 | text-align: right; 381 | } 382 | 383 | dd { 384 | margin: 0 0 5px 0; 385 | text-align: left; 386 | } 387 | 388 | blockquote { 389 | margin: 1.5em 0; 390 | padding: 0 1.6em 0 1.6em; 391 | border-left: var(--whitegrey) 0.5em solid; 392 | } 393 | 394 | blockquote p { 395 | margin: 0.8em 0; 396 | font-size: 1.2em; 397 | font-weight: 300; 398 | } 399 | 400 | blockquote small { 401 | display: inline-block; 402 | margin: 0.8em 0 0.8em 1.5em; 403 | font-size: 0.9em; 404 | opacity: 0.8; 405 | } 406 | /* Quotation marks */ 407 | blockquote small:before { 408 | content: "\2014 \00A0"; 409 | } 410 | 411 | blockquote cite { 412 | font-weight: bold; 413 | } 414 | blockquote cite a { 415 | font-weight: normal; 416 | } 417 | 418 | a { 419 | color: color-mod(var(--blue) l(-5%)); 420 | text-decoration: none; 421 | } 422 | 423 | a:hover { 424 | text-decoration: underline; 425 | } 426 | 427 | h1, 428 | h2, 429 | h3, 430 | h4, 431 | h5, 432 | h6 { 433 | margin-top: 0; 434 | line-height: 1.15; 435 | font-weight: 600; 436 | text-rendering: optimizeLegibility; 437 | } 438 | 439 | h1 { 440 | margin: 0 0 0.5em 0; 441 | font-size: 5.5rem; 442 | font-weight: 600; 443 | } 444 | @media (max-width: 500px) { 445 | h1 { 446 | font-size: 2.2rem; 447 | } 448 | } 449 | 450 | h2 { 451 | margin: 1.5em 0 0.5em 0; 452 | font-size: 2.2rem; 453 | } 454 | @media (max-width: 500px) { 455 | h2 { 456 | font-size: 1.8rem; 457 | } 458 | } 459 | 460 | h3 { 461 | margin: 1.5em 0 0.5em 0; 462 | font-size: 1.8rem; 463 | font-weight: 500; 464 | } 465 | @media (max-width: 500px) { 466 | h3 { 467 | font-size: 1.7rem; 468 | } 469 | } 470 | 471 | h4 { 472 | margin: 1.5em 0 0.5em 0; 473 | font-size: 1.6rem; 474 | font-weight: 500; 475 | } 476 | 477 | h5 { 478 | margin: 1.5em 0 0.5em 0; 479 | font-size: 1.4rem; 480 | font-weight: 500; 481 | } 482 | 483 | h6 { 484 | margin: 1.5em 0 0.5em 0; 485 | font-size: 1.4rem; 486 | font-weight: 500; 487 | } 488 | --------------------------------------------------------------------------------