├── .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 | 
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 | 
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 |
24 | ))}
25 |
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 | }
--------------------------------------------------------------------------------