├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .stackbit └── models │ ├── Button.js │ ├── Card.js │ ├── CardGridSection.js │ ├── FooterConfig.js │ ├── HeroSection.js │ ├── Page.js │ ├── SiteConfig.js │ └── index.js ├── LICENSE ├── README.md ├── components ├── Button.jsx ├── Card.jsx ├── CardGridSection.jsx ├── DynamicComponent.jsx ├── Footer.jsx └── HeroSection.jsx ├── content ├── data │ └── config.json └── pages │ └── index.md ├── netlify.toml ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── [[...slug]].js └── _app.js ├── public ├── bg.svg └── favicon.svg ├── stackbit.config.js ├── styles └── styles.css └── utils └── content.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # stackbit 39 | .cache 40 | .stackbit/cache 41 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 160, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "tabWidth": 4, 6 | "overrides": [ 7 | { 8 | "files": ["*.md", "*.yaml"], 9 | "options": { 10 | "tabWidth": 2 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.stackbit/models/Button.js: -------------------------------------------------------------------------------- 1 | export const Button = { 2 | name: 'Button', 3 | type: 'object', 4 | labelField: 'label', 5 | fields: [ 6 | { type: 'string', name: 'label', default: 'Click Me', required: true }, 7 | { type: 'string', name: 'url', label: 'URL', default: '/', required: true }, 8 | { 9 | type: 'enum', 10 | name: 'theme', 11 | controlType: 'button-group', 12 | label: 'Color Scheme', 13 | default: 'primary', 14 | options: [ 15 | { label: 'Primary', value: 'primary' }, 16 | { label: 'Secondary', value: 'secondary' } 17 | ] 18 | } 19 | ] 20 | }; -------------------------------------------------------------------------------- /.stackbit/models/Card.js: -------------------------------------------------------------------------------- 1 | export const Card = { 2 | name: 'Card', 3 | type: 'object', 4 | labelField: 'heading', 5 | fields: [ 6 | { type: 'string', name: 'heading', default: 'Card Heading' }, 7 | { 8 | type: 'markdown', 9 | name: 'subheading', 10 | default: 'Card description goes here ...' 11 | }, 12 | { type: 'string', name: 'url', label: 'URL', default: '/' } 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /.stackbit/models/CardGridSection.js: -------------------------------------------------------------------------------- 1 | export const CardGridSection = { 2 | name: 'CardGridSection', 3 | type: 'object', 4 | label: 'Card Grid', 5 | labelField: 'heading', 6 | groups: ['SectionComponents'], 7 | fields: [ 8 | { type: 'string', name: 'heading', default: 'Card Grid Heading' }, 9 | { type: 'markdown', name: 'subheading', default: 'Card Grid Subheading' }, 10 | { type: 'list', name: 'cards', items: { type: 'model', models: ['Card'] } } 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /.stackbit/models/FooterConfig.js: -------------------------------------------------------------------------------- 1 | export const FooterConfig = { 2 | name: 'FooterConfig', 3 | type: 'object', 4 | label: 'Footer Config', 5 | labelField: 'body', 6 | fields: [{ type: 'markdown', name: 'body', label: 'Footer Text' }] 7 | }; 8 | -------------------------------------------------------------------------------- /.stackbit/models/HeroSection.js: -------------------------------------------------------------------------------- 1 | export const HeroSection = { 2 | name: 'HeroSection', 3 | type: 'object', 4 | label: 'Hero', 5 | labelField: 'heading', 6 | groups: ['SectionComponents'], 7 | fields: [ 8 | { type: 'string', name: 'heading', default: 'Hero Heading' }, 9 | { type: 'markdown', name: 'subheading', default: 'Hero Subheading' }, 10 | { 11 | type: 'list', 12 | name: 'buttons', 13 | items: { type: 'model', models: ['Button'] } 14 | } 15 | ] 16 | }; 17 | -------------------------------------------------------------------------------- /.stackbit/models/Page.js: -------------------------------------------------------------------------------- 1 | export const Page = { 2 | name: 'Page', 3 | type: 'page', 4 | urlPath: '/{slug}', 5 | filePath: 'content/pages/{slug}.md', 6 | hideContent: true, 7 | fields: [ 8 | { 9 | type: 'string', 10 | name: 'title', 11 | default: 'This is a new page', 12 | required: true 13 | }, 14 | { 15 | type: 'list', 16 | name: 'sections', 17 | items: { type: 'model', models: [], groups: ['SectionComponents'] } 18 | } 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /.stackbit/models/SiteConfig.js: -------------------------------------------------------------------------------- 1 | export const SiteConfig = { 2 | name: 'SiteConfig', 3 | type: 'data', 4 | label: 'Site Config', 5 | singleInstance: true, 6 | fields: [ 7 | { type: 'string', name: 'title', label: 'Site Title' }, 8 | { 9 | type: 'model', 10 | name: 'footer', 11 | label: 'Footer Config', 12 | models: ['FooterConfig'] 13 | } 14 | ] 15 | }; 16 | -------------------------------------------------------------------------------- /.stackbit/models/index.js: -------------------------------------------------------------------------------- 1 | import { Button } from './Button'; 2 | import { Card } from './Card'; 3 | import { CardGridSection } from './CardGridSection'; 4 | import { FooterConfig } from './FooterConfig'; 5 | import { HeroSection } from './HeroSection'; 6 | import { Page } from './Page'; 7 | import { SiteConfig } from './SiteConfig'; 8 | 9 | export const allModels = { 10 | Button, 11 | Card, 12 | CardGridSection, 13 | FooterConfig, 14 | HeroSection, 15 | Page, 16 | SiteConfig 17 | }; 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Stackbit 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimal Next.js Stackbit Starter 2 | 3 |
4 | 5 | ![Full Stackbit Starter](https://assets.stackbit.com/docs/nextjs-starter-thumb.png) 6 | 7 |
8 | 9 | This is a minimal starting point for new Stackbit projects. It is built with Next.js and equipped with visual editing capabilities using Stackbit. It uses markdown files as the content source. See below for [other Stackbit example projects](#other-stackbit-projects). 10 | 11 | **⚡ Demo:** [stackbit-nextjs-starter.netlify.app](https://stackbit-nextjs-starter.netlify.app/) 12 | 13 | ## Getting Started 14 | 15 | The typical development process is to begin by working locally. 16 | 17 | ### Clone Repo Locally 18 | 19 | Create local Stackbit project from this repo: 20 | 21 | ```txt 22 | npx create-stackbit-app@latest --starter nextjs 23 | ``` 24 | 25 | ### Start Dev Server 26 | 27 | Run the Next.js development server: 28 | 29 | ```txt 30 | cd my-stackbit-site 31 | npm run dev 32 | ``` 33 | 34 | ### Start Stackbit App 35 | 36 | Install the Stackbit CLI. Then open a new terminal window in the same project directory and run the Stackbit Dev server: 37 | 38 | ```txt 39 | npm install -g @stackbit/cli 40 | stackbit dev 41 | ``` 42 | 43 | Open `localhost:8090/_stackbit`, register or sign in, and you will be directed to Stackbit's visual editor for your new project. 44 | 45 | ![Next.js Dev + Stackbit Dev](https://assets.stackbit.com/docs/next-dev-stackbit-dev.png) 46 | 47 | ## Next Steps 48 | 49 | Here are a few suggestions on what to do next if you're new to Stackbit: 50 | 51 | - Learn [how Stackbit works](https://docs.stackbit.com/conceptual-guides/how-stackbit-works/) 52 | - Explore the [feature guides](https://docs.stackbit.com/features/) for help while developing your site 53 | 54 | ## Other Stackbit Projects 55 | 56 | Stackbit has a number of examples that you can use to create a new project or evaluate Stackbit. Run the following command to see a list of available examples: 57 | 58 | ```txt 59 | npx create-stackbit-app@latest --help 60 | ``` 61 | 62 | You can also visit [our `stackbit-themes` GitHub organization](https://github.com/stackbit-themes) 63 | 64 | ## Join the Community 65 | 66 | [Join us on Discord](https://discord.gg/HUNhjVkznH) for community support and to showcase what you build with this starter. 67 | -------------------------------------------------------------------------------- /components/Button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Link from 'next/link'; 3 | 4 | export const Button = (props) => { 5 | return ( 6 | 7 | {props.label} 8 | 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /components/Card.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Link from 'next/link'; 3 | import Markdown from 'markdown-to-jsx'; 4 | 5 | export const Card = (props) => { 6 | return ( 7 | 8 | {props.heading && ( 9 |

10 | {props.heading} 11 |

12 | )} 13 | {props.subheading && ( 14 | 15 | {props.subheading} 16 | 17 | )} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /components/CardGridSection.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Markdown from 'markdown-to-jsx'; 3 | 4 | import { Card } from './Card'; 5 | 6 | export const CardGridSection = (props) => { 7 | return ( 8 |
9 |
10 | {props.heading && ( 11 |

12 | {props.heading} 13 |

14 | )} 15 | {props.subheading && ( 16 | 17 | {props.subheading} 18 | 19 | )} 20 | {props.cards?.length > 0 && ( 21 |
22 | {props.cards.map((card, idx) => ( 23 | 24 | ))} 25 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /components/DynamicComponent.jsx: -------------------------------------------------------------------------------- 1 | import { HeroSection } from './HeroSection'; 2 | import { CardGridSection } from './CardGridSection'; 3 | 4 | const componentsMap = { 5 | HeroSection: HeroSection, 6 | CardGridSection: CardGridSection 7 | }; 8 | 9 | export const DynamicComponent = (props) => { 10 | if (!props.type) { 11 | const propsOutput = JSON.stringify(props, null, 2); 12 | throw new Error(`Object does not have a 'type' property: ${propsOutput}`); 13 | } 14 | 15 | const Component = componentsMap[props.type]; 16 | if (!Component) { 17 | throw new Error(`No component is registered for type:'${props.type}`); 18 | } 19 | return ; 20 | }; 21 | -------------------------------------------------------------------------------- /components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Markdown from 'markdown-to-jsx'; 3 | 4 | export const Footer = ({ siteConfig }) => { 5 | const footerObjectId = siteConfig.__id + ':footer'; 6 | return ( 7 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /components/HeroSection.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Markdown from 'markdown-to-jsx'; 3 | 4 | import { Button } from './Button'; 5 | 6 | export const HeroSection = (props) => { 7 | return ( 8 |
9 |
10 | {props.heading && ( 11 |

12 | {props.heading} 13 |

14 | )} 15 | {props.subheading && ( 16 | 17 | {props.subheading} 18 | 19 | )} 20 | {props.buttons?.length > 0 && ( 21 |
22 | {props.buttons.map((button, idx) => ( 23 |
26 | )} 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /content/data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "SiteConfig", 3 | "title": "Stackbit", 4 | "footer": { 5 | "type": "FooterConfig", 6 | "body": "Made by [Stackbit](https://www.stackbit.com/)\n" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /content/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: Page 3 | title: Stackbit Next.js Starter 4 | sections: 5 | - type: HeroSection 6 | heading: Welcome to Stackbit! 7 | subheading: > 8 | You've just [unlocked visual editing 9 | capabilities](https://www.stackbit.com/) in this Next.js app. 10 | buttons: 11 | - label: Start Building 12 | url: 'https://docs.stackbit.com/getting-started/' 13 | theme: primary 14 | - label: Read the Docs 15 | url: 'https://docs.stackbit.com/' 16 | theme: secondary 17 | - type: CardGridSection 18 | heading: Jump to Topic 19 | subheading: | 20 | Or jump right to a specific topic to help you build your site. 21 | cards: 22 | - heading: How Stackbit Works → 23 | subheading: | 24 | Follow an end-to-end guide to learn the inner-workings of Stackbit. 25 | url: 'https://docs.stackbit.com/conceptual-guides/how-stackbit-works/' 26 | - heading: Pages → 27 | subheading: > 28 | Add a new type of page to your site, while touching on content 29 | modeling and data retrieval. 30 | url: 'https://docs.stackbit.com/how-to-guides/content/' 31 | - heading: Components → 32 | subheading: > 33 | Make components editable, add styles, and provide content presets to 34 | speed up content editing. 35 | url: 'https://docs.stackbit.com/how-to-guides/components/' 36 | - heading: Styling → 37 | subheading: > 38 | Set up global styles and add a styling toolbar to individual 39 | components in the visual editor. 40 | url: 'https://docs.stackbit.com/how-to-guides/styles/' 41 | --- 42 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[plugins]] 2 | package = "@netlify/plugin-nextjs" 3 | 4 | [build] 5 | publish = ".next" 6 | command = "npm run build" -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack: (config, { dev }) => { 3 | // Makes webpack not trigger recompiling when files in the content folder are updated. 4 | config.watchOptions.ignored.push('**/content/**'); 5 | return config; 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackbit-nextjs-starter", 3 | "version": "0.2.0", 4 | "repository": "https://github.com/stackbit-themes/nextjs-starter", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "export": "next export", 10 | "start": "next start", 11 | "lint": "next lint" 12 | }, 13 | "dependencies": { 14 | "front-matter": "^4.0.2", 15 | "glob": "^8.0.3", 16 | "markdown-to-jsx": "^7.2.1", 17 | "next": "^13.4.4", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@stackbit/cms-git": "^0.3.5", 23 | "@stackbit/types": "^0.7.2", 24 | "eslint": "^8.42.0", 25 | "eslint-config-next": "^13.4.4" 26 | } 27 | } -------------------------------------------------------------------------------- /pages/[[...slug]].js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | import { DynamicComponent } from '../components/DynamicComponent'; 4 | import { Footer } from '../components/Footer'; 5 | import { pagesByType, siteConfig, urlToContent } from '../utils/content'; 6 | 7 | const FlexiblePage = ({ page, siteConfig }) => { 8 | return ( 9 |
10 | 11 | {page.title} 12 | 13 |
14 | {page.sections?.length > 0 && ( 15 |
16 | {page.sections.map((section, index) => ( 17 | 18 | ))} 19 |
20 | )} 21 |
22 |
23 |
24 | ); 25 | }; 26 | 27 | export default FlexiblePage; 28 | 29 | export function getStaticProps({ params }) { 30 | const url = '/' + (params.slug || []).join('/'); 31 | return { props: { page: urlToContent(url), siteConfig: siteConfig() } }; 32 | } 33 | 34 | export function getStaticPaths() { 35 | const pages = pagesByType('Page'); 36 | return { 37 | paths: Object.keys(pages), 38 | fallback: false 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | import '../styles/styles.css'; 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | export default MyApp; 17 | -------------------------------------------------------------------------------- /public/bg.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /stackbit.config.js: -------------------------------------------------------------------------------- 1 | import { GitContentSource } from '@stackbit/cms-git'; 2 | 3 | import { Button } from './.stackbit/models/Button'; 4 | import { Card } from './.stackbit/models/Card'; 5 | import { Page } from './.stackbit/models/Page'; 6 | import { CardGridSection } from './.stackbit/models/CardGridSection'; 7 | import { FooterConfig } from './.stackbit/models/FooterConfig'; 8 | import { HeroSection } from './.stackbit/models/HeroSection'; 9 | import { SiteConfig } from './.stackbit/models/SiteConfig'; 10 | 11 | export default sbConfig = { 12 | stackbitVersion: '~0.6.0', 13 | ssgName: 'nextjs', 14 | nodeVersion: '16', 15 | contentSources: [ 16 | new GitContentSource({ 17 | rootPath: __dirname, 18 | contentDirs: ['content'], 19 | models: [Button, Card, Page, CardGridSection, FooterConfig, HeroSection, SiteConfig], 20 | assetsConfig: { 21 | referenceType: 'static', 22 | staticDir: 'public', 23 | uploadDir: 'images', 24 | publicPath: '/' 25 | } 26 | }) 27 | ] 28 | }; -------------------------------------------------------------------------------- /styles/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-white: #ffffff; 3 | --color-black: #02001d; 4 | --color-gray-100: #f3f4f6; 5 | --color-gray-500: #75747d; 6 | --color-blue: #4c57c5; 7 | --color-yellow: #ffde00; 8 | 9 | font-size: 16px; 10 | } 11 | 12 | html, 13 | body { 14 | background-color: var(--color-gray-100); 15 | color: var(--color-black); 16 | font-family: 'Helvetica Neue', sans-serif; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | h1, 22 | h2, 23 | h3, 24 | h4, 25 | h5, 26 | h6 { 27 | font-weight: 400; 28 | margin: 0 0 1rem 0; 29 | } 30 | 31 | a { 32 | color: inherit; 33 | text-decoration: none; 34 | } 35 | 36 | p { 37 | line-height: 1.4; 38 | margin-bottom: 1rem; 39 | } 40 | 41 | * { 42 | box-sizing: border-box; 43 | } 44 | 45 | /* --- Layout --- */ 46 | 47 | .outer { 48 | padding-left: 3vw; 49 | padding-right: 3vw; 50 | } 51 | 52 | .inner { 53 | margin-left: auto; 54 | margin-right: auto; 55 | max-width: 48rem; 56 | } 57 | 58 | .small-margin { 59 | margin: 1vw; 60 | } 61 | 62 | /* --- Footer --- */ 63 | .footer { 64 | margin-top: 3rem; 65 | } 66 | 67 | .footer-container { 68 | border-top: 1px solid var(--color-gray-500); 69 | padding-bottom: 1rem; 70 | padding-top: 1rem; 71 | } 72 | 73 | .footer-content a { 74 | border-bottom: 1px solid var(--color-blue); 75 | color: var(--color-blue); 76 | } 77 | 78 | /* --- HeroSection --- */ 79 | 80 | .hero { 81 | background-color: var(--color-black); 82 | background-image: url('/bg.svg'); 83 | background-repeat: repeat; 84 | color: var(--color-gray-100); 85 | padding-bottom: 2rem; 86 | padding-top: 3rem; 87 | } 88 | 89 | .hero-heading { 90 | font-size: 3rem; 91 | } 92 | 93 | @media (min-width: 601px) { 94 | .hero-heading { 95 | font-size: 4rem; 96 | } 97 | } 98 | 99 | .hero-subheading { 100 | font-size: 1.5rem; 101 | margin-bottom: 3rem; 102 | } 103 | 104 | .hero-subheading a { 105 | border-bottom: 1px solid var(--color-white); 106 | } 107 | 108 | .hero-buttons { 109 | display: flex; 110 | flex-wrap: wrap; 111 | gap: 1rem; 112 | } 113 | 114 | /* --- CardGrid --- */ 115 | 116 | .card-grid { 117 | padding-bottom: 3rem; 118 | padding-top: 3rem; 119 | position: relative; 120 | z-index: 1; 121 | } 122 | 123 | .card-grid-heading { 124 | font-size: 2.5rem; 125 | } 126 | 127 | .card-grid-subheading { 128 | font-size: 1.125rem; 129 | } 130 | 131 | .card-grid-cards { 132 | display: grid; 133 | grid-template-columns: 1fr; 134 | grid-gap: 1.5rem; 135 | margin-top: 3rem; 136 | } 137 | 138 | @media (min-width: 601px) { 139 | .card-grid-cards { 140 | grid-template-columns: 1fr 1fr; 141 | } 142 | } 143 | 144 | /* --- Card --- */ 145 | 146 | .card { 147 | border: 1px solid var(--color-blue); 148 | padding: 1.5rem 2rem; 149 | position: relative; 150 | transition: all 0.35s ease; 151 | } 152 | 153 | .card:hover { 154 | border-color: var(--color-blue); 155 | color: var(--color-blue); 156 | } 157 | 158 | .card:before { 159 | border: 1px solid var(--color-blue); 160 | content: ''; 161 | height: 100%; 162 | left: -1px; 163 | position: absolute; 164 | top: -1px; 165 | transform: translate(0, 0); 166 | transition: all 0.35s ease; 167 | width: 100%; 168 | z-index: -1; 169 | } 170 | 171 | .card:hover:before { 172 | transform: translate(0.33333em, 0.33333em); 173 | } 174 | 175 | .card-heading { 176 | font-size: 1.5rem; 177 | } 178 | 179 | /* --- Button --- */ 180 | 181 | .button { 182 | background-color: var(--color-yellow); 183 | border: 0.1rem solid var(--color-yellow); 184 | border-radius: 0.2rem; 185 | color: var(--color-black); 186 | display: inline-block; 187 | font-size: 1.125rem; 188 | padding: 1rem 1.5rem; 189 | transition: all 0.35s ease; 190 | } 191 | 192 | .button.theme-secondary { 193 | background-color: transparent; 194 | border-color: var(--color-white); 195 | color: var(--color-white); 196 | } 197 | 198 | .button:hover { 199 | opacity: 0.85; 200 | } 201 | -------------------------------------------------------------------------------- /utils/content.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import glob from 'glob'; 4 | import frontmatter from 'front-matter'; 5 | 6 | const PAGES_DIR = 'content/pages'; 7 | const SITE_CONFIG_FILE = 'content/data/config.json'; 8 | 9 | const supportedFileTypes = ['md', 'json']; 10 | 11 | function contentFilesInPath(dir) { 12 | const globPattern = `${dir}/**/*.{${supportedFileTypes.join(',')}}`; 13 | return glob.sync(globPattern); 14 | } 15 | 16 | function readContent(file) { 17 | const rawContent = fs.readFileSync(file, 'utf8'); 18 | let content = null; 19 | switch (path.extname(file).substring(1)) { 20 | case 'md': 21 | const parsedMd = frontmatter(rawContent); 22 | content = { 23 | ...parsedMd.attributes, 24 | body: parsedMd.body 25 | }; 26 | break; 27 | case 'json': 28 | content = JSON.parse(rawContent); 29 | break; 30 | default: 31 | throw Error(`Unhandled file type: ${file}`); 32 | } 33 | 34 | content.__id = file; 35 | content.__url = fileToUrl(file); 36 | return content; 37 | } 38 | 39 | function fileToUrl(file) { 40 | if (!file.startsWith(PAGES_DIR)) return null; 41 | 42 | let url = file.slice(PAGES_DIR.length); 43 | url = url.split('.')[0]; 44 | if (url.endsWith('/index')) { 45 | url = url.slice(0, -6) || '/'; 46 | } 47 | return url; 48 | } 49 | 50 | function urlToFilePairs() { 51 | const pageFiles = contentFilesInPath(PAGES_DIR); 52 | return pageFiles.map((file) => [fileToUrl(file), file]); 53 | } 54 | 55 | export function urlToContent(url) { 56 | const urlToFile = Object.fromEntries(urlToFilePairs()); 57 | const file = urlToFile[url]; 58 | return readContent(file); 59 | } 60 | 61 | export function pagesByType(contentType) { 62 | let result = {}; 63 | for (const [url, file] of urlToFilePairs()) { 64 | const content = readContent(file); 65 | if (content.type === contentType) result[url] = content; 66 | } 67 | return result; 68 | } 69 | 70 | export function siteConfig() { 71 | return readContent(SITE_CONFIG_FILE); 72 | } --------------------------------------------------------------------------------