Update blog-starter
to App Router (#61170)
### What? This updates the `blog-starter` example to Next 14.1 App Router. ### Why? I checked out a new `blog-starter` project on Vercel and was surprised it was using the Pages Router. I believe the App Router is a better choice. ### How? I tried to keep the implementation logic as close to the original Pages `blog-starter`. --------- Co-authored-by: Sam Ko <sam@vercel.com>
|
@ -1 +0,0 @@
|
|||
declare module "remark-html";
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
This is the existing [blog-starter](https://github.com/vercel/next.js/tree/canary/examples/blog-starter) plus TypeScript.
|
||||
|
||||
This example showcases Next.js's [Static Generation](https://nextjs.org/docs/basic-features/pages) feature using Markdown files as the data source.
|
||||
This example showcases Next.js's [Static Generation](https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts) feature using Markdown files as the data source.
|
||||
|
||||
The blog posts are stored in `/_posts` as Markdown files with front matter support. Adding a new Markdown file in there will create a new blog post.
|
||||
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import Alert from "./alert";
|
||||
import Footer from "./footer";
|
||||
import Meta from "./meta";
|
||||
|
||||
type Props = {
|
||||
preview?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout = ({ preview, children }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Meta />
|
||||
<div className="min-h-screen">
|
||||
<Alert preview={preview} />
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
|
@ -1,44 +0,0 @@
|
|||
import Head from "next/head";
|
||||
import { CMS_NAME, HOME_OG_IMAGE_URL } from "../lib/constants";
|
||||
|
||||
const Meta = () => {
|
||||
return (
|
||||
<Head>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/favicon/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="/favicon/safari-pinned-tab.svg"
|
||||
color="#000000"
|
||||
/>
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#000" />
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||
<meta
|
||||
name="description"
|
||||
content={`A statically generated blog example using Next.js and ${CMS_NAME}.`}
|
||||
/>
|
||||
<meta property="og:image" content={HOME_OG_IMAGE_URL} />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
export default Meta;
|
|
@ -1,5 +0,0 @@
|
|||
const SectionSeparator = () => {
|
||||
return <hr className="border-neutral-200 mt-28 mb-24" />;
|
||||
};
|
||||
|
||||
export default SectionSeparator;
|
|
@ -1,6 +0,0 @@
|
|||
type Author = {
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
||||
|
||||
export default Author;
|
|
@ -3,26 +3,25 @@
|
|||
"scripts": {
|
||||
"dev": "next",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc"
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"date-fns": "^2.28.0",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"next": "latest",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"remark": "^14.0.2",
|
||||
"remark-html": "^15.0.1",
|
||||
"typescript": "^4.7.4"
|
||||
"next": "14.1.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"postcss": "^8.4.14",
|
||||
"tailwindcss": "^3.1.4"
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import { AppProps } from "next/app";
|
||||
import "../styles/index.css";
|
||||
|
||||
export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
import Container from "../components/container";
|
||||
import MoreStories from "../components/more-stories";
|
||||
import HeroPost from "../components/hero-post";
|
||||
import Intro from "../components/intro";
|
||||
import Layout from "../components/layout";
|
||||
import { getAllPosts } from "../lib/api";
|
||||
import Head from "next/head";
|
||||
import { CMS_NAME } from "../lib/constants";
|
||||
import Post from "../interfaces/post";
|
||||
|
||||
type Props = {
|
||||
allPosts: Post[];
|
||||
};
|
||||
|
||||
export default function Index({ allPosts }: Props) {
|
||||
const heroPost = allPosts[0];
|
||||
const morePosts = allPosts.slice(1);
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>{`Next.js Blog Example with ${CMS_NAME}`}</title>
|
||||
</Head>
|
||||
<Container>
|
||||
<Intro />
|
||||
{heroPost && (
|
||||
<HeroPost
|
||||
title={heroPost.title}
|
||||
coverImage={heroPost.coverImage}
|
||||
date={heroPost.date}
|
||||
author={heroPost.author}
|
||||
slug={heroPost.slug}
|
||||
excerpt={heroPost.excerpt}
|
||||
/>
|
||||
)}
|
||||
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
|
||||
</Container>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const allPosts = getAllPosts([
|
||||
"title",
|
||||
"date",
|
||||
"slug",
|
||||
"author",
|
||||
"coverImage",
|
||||
"excerpt",
|
||||
]);
|
||||
|
||||
return {
|
||||
props: { allPosts },
|
||||
};
|
||||
};
|
|
@ -1,96 +0,0 @@
|
|||
import { useRouter } from "next/router";
|
||||
import ErrorPage from "next/error";
|
||||
import Container from "../../components/container";
|
||||
import PostBody from "../../components/post-body";
|
||||
import Header from "../../components/header";
|
||||
import PostHeader from "../../components/post-header";
|
||||
import Layout from "../../components/layout";
|
||||
import { getPostBySlug, getAllPosts } from "../../lib/api";
|
||||
import PostTitle from "../../components/post-title";
|
||||
import Head from "next/head";
|
||||
import { CMS_NAME } from "../../lib/constants";
|
||||
import markdownToHtml from "../../lib/markdownToHtml";
|
||||
import type PostType from "../../interfaces/post";
|
||||
|
||||
type Props = {
|
||||
post: PostType;
|
||||
morePosts: PostType[];
|
||||
preview?: boolean;
|
||||
};
|
||||
|
||||
export default function Post({ post, morePosts, preview }: Props) {
|
||||
const router = useRouter();
|
||||
const title = `${post.title} | Next.js Blog Example with ${CMS_NAME}`;
|
||||
if (!router.isFallback && !post?.slug) {
|
||||
return <ErrorPage statusCode={404} />;
|
||||
}
|
||||
return (
|
||||
<Layout preview={preview}>
|
||||
<Container>
|
||||
<Header />
|
||||
{router.isFallback ? (
|
||||
<PostTitle>Loading…</PostTitle>
|
||||
) : (
|
||||
<>
|
||||
<article className="mb-32">
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta property="og:image" content={post.ogImage.url} />
|
||||
</Head>
|
||||
<PostHeader
|
||||
title={post.title}
|
||||
coverImage={post.coverImage}
|
||||
date={post.date}
|
||||
author={post.author}
|
||||
/>
|
||||
<PostBody content={post.content} />
|
||||
</article>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
type Params = {
|
||||
params: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getStaticProps({ params }: Params) {
|
||||
const post = getPostBySlug(params.slug, [
|
||||
"title",
|
||||
"date",
|
||||
"slug",
|
||||
"author",
|
||||
"content",
|
||||
"ogImage",
|
||||
"coverImage",
|
||||
]);
|
||||
const content = await markdownToHtml(post.content || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
post: {
|
||||
...post,
|
||||
content,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = getAllPosts(["slug"]);
|
||||
|
||||
return {
|
||||
paths: posts.map((post) => {
|
||||
return {
|
||||
params: {
|
||||
slug: post.slug,
|
||||
},
|
||||
};
|
||||
}),
|
||||
fallback: false,
|
||||
};
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Container from "./container";
|
||||
import Container from "@/app/_components/container";
|
||||
import { EXAMPLE_PATH } from "@/lib/constants";
|
||||
import cn from "classnames";
|
||||
import { EXAMPLE_PATH } from "../lib/constants";
|
||||
|
||||
type Props = {
|
||||
preview?: boolean;
|
|
@ -1,7 +1,7 @@
|
|||
import Container from "./container";
|
||||
import { EXAMPLE_PATH } from "../lib/constants";
|
||||
import Container from "@/app/_components/container";
|
||||
import { EXAMPLE_PATH } from "@/lib/constants";
|
||||
|
||||
const Footer = () => {
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-neutral-50 border-t border-neutral-200">
|
||||
<Container>
|
||||
|
@ -11,7 +11,7 @@ const Footer = () => {
|
|||
</h3>
|
||||
<div className="flex flex-col lg:flex-row justify-center items-center lg:pl-4 lg:w-1/2">
|
||||
<a
|
||||
href="https://nextjs.org/docs/basic-features/pages"
|
||||
href="https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts"
|
||||
className="mx-3 bg-black hover:bg-white hover:text-black border border-black text-white font-bold py-3 px-12 lg:px-8 duration-200 transition-colors mb-6 lg:mb-0"
|
||||
>
|
||||
Read Documentation
|
||||
|
@ -27,6 +27,6 @@ const Footer = () => {
|
|||
</Container>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Footer;
|
|
@ -1,8 +1,8 @@
|
|||
import Avatar from "./avatar";
|
||||
import DateFormatter from "./date-formatter";
|
||||
import CoverImage from "./cover-image";
|
||||
import Avatar from "@/app/_components/avatar";
|
||||
import CoverImage from "@/app/_components/cover-image";
|
||||
import { type Author } from "@/interfaces/author";
|
||||
import Link from "next/link";
|
||||
import type Author from "../interfaces/author";
|
||||
import DateFormatter from "./date-formatter";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
@ -13,14 +13,14 @@ type Props = {
|
|||
slug: string;
|
||||
};
|
||||
|
||||
const HeroPost = ({
|
||||
export function HeroPost({
|
||||
title,
|
||||
coverImage,
|
||||
date,
|
||||
excerpt,
|
||||
author,
|
||||
slug,
|
||||
}: Props) => {
|
||||
}: Props) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-8 md:mb-16">
|
||||
|
@ -48,6 +48,4 @@ const HeroPost = ({
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroPost;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { CMS_NAME } from "../lib/constants";
|
||||
import { CMS_NAME } from "@/lib/constants";
|
||||
|
||||
const Intro = () => {
|
||||
export function Intro() {
|
||||
return (
|
||||
<section className="flex-col md:flex-row flex items-center md:justify-between mt-16 mb-16 md:mb-12">
|
||||
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter leading-tight md:pr-8">
|
||||
|
@ -18,6 +18,4 @@ const Intro = () => {
|
|||
</h4>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Intro;
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
import PostPreview from "./post-preview";
|
||||
import type Post from "../interfaces/post";
|
||||
import { Post } from "@/interfaces/post";
|
||||
import { PostPreview } from "./post-preview";
|
||||
|
||||
type Props = {
|
||||
posts: Post[];
|
||||
};
|
||||
|
||||
const MoreStories = ({ posts }: Props) => {
|
||||
export function MoreStories({ posts }: Props) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-8 text-5xl md:text-7xl font-bold tracking-tighter leading-tight">
|
||||
|
@ -26,6 +26,4 @@ const MoreStories = ({ posts }: Props) => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoreStories;
|
||||
}
|
|
@ -4,7 +4,7 @@ type Props = {
|
|||
content: string;
|
||||
};
|
||||
|
||||
const PostBody = ({ content }: Props) => {
|
||||
export function PostBody({ content }: Props) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div
|
||||
|
@ -13,6 +13,4 @@ const PostBody = ({ content }: Props) => {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostBody;
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import Avatar from "./avatar";
|
||||
import DateFormatter from "./date-formatter";
|
||||
import CoverImage from "./cover-image";
|
||||
import PostTitle from "./post-title";
|
||||
import type Author from "../interfaces/author";
|
||||
import DateFormatter from "./date-formatter";
|
||||
import { PostTitle } from "@/app/_components/post-title";
|
||||
import { type Author } from "@/interfaces/author";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
@ -11,7 +11,7 @@ type Props = {
|
|||
author: Author;
|
||||
};
|
||||
|
||||
const PostHeader = ({ title, coverImage, date, author }: Props) => {
|
||||
export function PostHeader({ title, coverImage, date, author }: Props) {
|
||||
return (
|
||||
<>
|
||||
<PostTitle>{title}</PostTitle>
|
||||
|
@ -31,6 +31,4 @@ const PostHeader = ({ title, coverImage, date, author }: Props) => {
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostHeader;
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import Avatar from "./avatar";
|
||||
import DateFormatter from "./date-formatter";
|
||||
import CoverImage from "./cover-image";
|
||||
import { type Author } from "@/interfaces/author";
|
||||
import Link from "next/link";
|
||||
import type Author from "../interfaces/author";
|
||||
import Avatar from "./avatar";
|
||||
import CoverImage from "./cover-image";
|
||||
import DateFormatter from "./date-formatter";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
@ -13,14 +13,14 @@ type Props = {
|
|||
slug: string;
|
||||
};
|
||||
|
||||
const PostPreview = ({
|
||||
export function PostPreview({
|
||||
title,
|
||||
coverImage,
|
||||
date,
|
||||
excerpt,
|
||||
author,
|
||||
slug,
|
||||
}: Props) => {
|
||||
}: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
|
@ -42,6 +42,4 @@ const PostPreview = ({
|
|||
<Avatar name={author.name} picture={author.picture} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostPreview;
|
||||
}
|
|
@ -4,12 +4,10 @@ type Props = {
|
|||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const PostTitle = ({ children }: Props) => {
|
||||
export function PostTitle({ children }: Props) {
|
||||
return (
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-bold tracking-tighter leading-tight md:leading-none mb-12 text-center md:text-left">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostTitle;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export function SectionSeparator() {
|
||||
return <hr className="border-neutral-200 mt-28 mb-24" />;
|
||||
}
|
64
examples/blog-starter/src/app/layout.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import Footer from "@/app/_components/footer";
|
||||
import { CMS_NAME, HOME_OG_IMAGE_URL } from "@/lib/constants";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Next.js Blog Example with ${CMS_NAME}`,
|
||||
description: `A statically generated blog example using Next.js and ${CMS_NAME}.`,
|
||||
openGraph: {
|
||||
images: [HOME_OG_IMAGE_URL],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/favicon/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="/favicon/safari-pinned-tab.svg"
|
||||
color="#000000"
|
||||
/>
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta
|
||||
name="msapplication-config"
|
||||
content="/favicon/browserconfig.xml"
|
||||
/>
|
||||
<meta name="theme-color" content="#000" />
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<div className="min-h-screen">{children}</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
30
examples/blog-starter/src/app/page.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import Container from "@/app/_components/container";
|
||||
import { HeroPost } from "@/app/_components/hero-post";
|
||||
import { Intro } from "@/app/_components/intro";
|
||||
import { MoreStories } from "@/app/_components/more-stories";
|
||||
import { getAllPosts } from "../lib/api";
|
||||
|
||||
export default function Index() {
|
||||
const allPosts = getAllPosts();
|
||||
|
||||
const heroPost = allPosts[0];
|
||||
|
||||
const morePosts = allPosts.slice(1);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Container>
|
||||
<Intro />
|
||||
<HeroPost
|
||||
title={heroPost.title}
|
||||
coverImage={heroPost.coverImage}
|
||||
date={heroPost.date}
|
||||
author={heroPost.author}
|
||||
slug={heroPost.slug}
|
||||
excerpt={heroPost.excerpt}
|
||||
/>
|
||||
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
69
examples/blog-starter/src/app/posts/[slug]/page.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAllPosts, getPostBySlug } from "../../../lib/api";
|
||||
import { CMS_NAME } from "../../../lib/constants";
|
||||
import markdownToHtml from "../../../lib/markdownToHtml";
|
||||
import Alert from "../../_components/alert";
|
||||
import Container from "../../_components/container";
|
||||
import Header from "../../_components/header";
|
||||
import { PostBody } from "../../_components/post-body";
|
||||
import { PostHeader } from "../../_components/post-header";
|
||||
|
||||
export default async function Post({ params }: Params) {
|
||||
const post = getPostBySlug(params.slug);
|
||||
|
||||
if (!post) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const content = await markdownToHtml(post.content || "");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Alert preview={post.preview} />
|
||||
<Container>
|
||||
<Header />
|
||||
<article className="mb-32">
|
||||
<PostHeader
|
||||
title={post.title}
|
||||
coverImage={post.coverImage}
|
||||
date={post.date}
|
||||
author={post.author}
|
||||
/>
|
||||
<PostBody content={content} />
|
||||
</article>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type Params = {
|
||||
params: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function generateMetadata({ params }: Params): Metadata {
|
||||
const post = getPostBySlug(params.slug);
|
||||
|
||||
if (!post) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const title = `${post.title} | Next.js Blog Example with ${CMS_NAME}`;
|
||||
|
||||
return {
|
||||
openGraph: {
|
||||
title,
|
||||
images: [post.ogImage.url],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = getAllPosts();
|
||||
|
||||
return posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
4
examples/blog-starter/src/interfaces/author.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type Author = {
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import type Author from "./author";
|
||||
import { type Author } from "./author";
|
||||
|
||||
type PostType = {
|
||||
export type Post = {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
|
@ -11,6 +11,5 @@ type PostType = {
|
|||
url: string;
|
||||
};
|
||||
content: string;
|
||||
preview?: boolean;
|
||||
};
|
||||
|
||||
export default PostType;
|
|
@ -1,6 +1,7 @@
|
|||
import { Post } from "@/interfaces/post";
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
import matter from "gray-matter";
|
||||
import { join } from "path";
|
||||
|
||||
const postsDirectory = join(process.cwd(), "_posts");
|
||||
|
||||
|
@ -8,39 +9,19 @@ export function getPostSlugs() {
|
|||
return fs.readdirSync(postsDirectory);
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string, fields: string[] = []) {
|
||||
export function getPostBySlug(slug: string) {
|
||||
const realSlug = slug.replace(/\.md$/, "");
|
||||
const fullPath = join(postsDirectory, `${realSlug}.md`);
|
||||
const fileContents = fs.readFileSync(fullPath, "utf8");
|
||||
const { data, content } = matter(fileContents);
|
||||
|
||||
type Items = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
const items: Items = {};
|
||||
|
||||
// Ensure only the minimal needed data is exposed
|
||||
fields.forEach((field) => {
|
||||
if (field === "slug") {
|
||||
items[field] = realSlug;
|
||||
}
|
||||
if (field === "content") {
|
||||
items[field] = content;
|
||||
}
|
||||
|
||||
if (typeof data[field] !== "undefined") {
|
||||
items[field] = data[field];
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
return { ...data, slug: realSlug, content } as Post;
|
||||
}
|
||||
|
||||
export function getAllPosts(fields: string[] = []) {
|
||||
export function getAllPosts(): Post[] {
|
||||
const slugs = getPostSlugs();
|
||||
const posts = slugs
|
||||
.map((slug) => getPostBySlug(slug, fields))
|
||||
.map((slug) => getPostBySlug(slug))
|
||||
// sort posts by date in descending order
|
||||
.sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
|
||||
return posts;
|
BIN
examples/blog-starter/src/public/assets/blog/authors/jj.jpeg
Normal file
After Width: | Height: | Size: 6 KiB |
BIN
examples/blog-starter/src/public/assets/blog/authors/joe.jpeg
Normal file
After Width: | Height: | Size: 7 KiB |
BIN
examples/blog-starter/src/public/assets/blog/authors/tim.jpeg
Normal file
After Width: | Height: | Size: 6 KiB |
After Width: | Height: | Size: 115 KiB |
After Width: | Height: | Size: 103 KiB |
BIN
examples/blog-starter/src/public/assets/blog/preview/cover.jpg
Normal file
After Width: | Height: | Size: 43 KiB |
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 14 KiB |
BIN
examples/blog-starter/src/public/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/favicons/mstile-150x150.png"/>
|
||||
<TileColor>#000000</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
examples/blog-starter/src/public/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 595 B |
BIN
examples/blog-starter/src/public/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 880 B |
BIN
examples/blog-starter/src/public/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
examples/blog-starter/src/public/favicon/mstile-150x150.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M4785 10234 c-22 -2 -92 -9 -155 -14 -1453 -131 -2814 -915 -3676
|
||||
-2120 -480 -670 -787 -1430 -903 -2235 -41 -281 -46 -364 -46 -745 0 -381 5
|
||||
-464 46 -745 278 -1921 1645 -3535 3499 -4133 332 -107 682 -180 1080 -224
|
||||
155 -17 825 -17 980 0 687 76 1269 246 1843 539 88 45 105 57 93 67 -8 6 -383
|
||||
509 -833 1117 l-818 1105 -1025 1517 c-564 834 -1028 1516 -1032 1516 -4 1 -8
|
||||
-673 -10 -1496 -3 -1441 -4 -1499 -22 -1533 -26 -49 -46 -69 -88 -91 -32 -16
|
||||
-60 -19 -211 -19 l-173 0 -46 29 c-30 19 -52 44 -67 73 l-21 45 2 2005 3 2006
|
||||
31 39 c16 21 50 48 74 61 41 20 57 22 230 22 204 0 238 -8 291 -66 15 -16 570
|
||||
-852 1234 -1859 664 -1007 1572 -2382 2018 -3057 l810 -1227 41 27 c363 236
|
||||
747 572 1051 922 647 743 1064 1649 1204 2615 41 281 46 364 46 745 0 381 -5
|
||||
464 -46 745 -278 1921 -1645 3535 -3499 4133 -327 106 -675 179 -1065 223 -96
|
||||
10 -757 21 -840 13z m2094 -3094 c48 -24 87 -70 101 -118 8 -26 10 -582 8
|
||||
-1835 l-3 -1798 -317 486 -318 486 0 1307 c0 845 4 1320 10 1343 16 56 51 100
|
||||
99 126 41 21 56 23 213 23 148 0 174 -2 207 -20z"/>
|
||||
<path d="M7843 789 c-35 -22 -46 -37 -15 -20 22 13 58 40 52 41 -3 0 -20 -10
|
||||
-37 -21z"/>
|
||||
<path d="M7774 744 c-18 -14 -18 -15 4 -4 12 6 22 13 22 15 0 8 -5 6 -26 -11z"/>
|
||||
<path d="M7724 714 c-18 -14 -18 -15 4 -4 12 6 22 13 22 15 0 8 -5 6 -26 -11z"/>
|
||||
<path d="M7674 684 c-18 -14 -18 -15 4 -4 12 6 22 13 22 15 0 8 -5 6 -26 -11z"/>
|
||||
<path d="M7598 644 c-38 -20 -36 -28 2 -9 17 9 30 18 30 20 0 7 -1 6 -32 -11z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
19
examples/blog-starter/src/public/favicon/site.webmanifest
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Next.js",
|
||||
"short_name": "Next.js",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"display": "standalone"
|
||||
}
|
|
@ -1,8 +1,18 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./components/**/*.tsx", "./pages/**/*.tsx"],
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
colors: {
|
||||
"accent-1": "#FAFAFA",
|
||||
"accent-2": "#EAEAEA",
|
||||
|
@ -16,9 +26,6 @@ module.exports = {
|
|||
letterSpacing: {
|
||||
tighter: "-.04em",
|
||||
},
|
||||
lineHeight: {
|
||||
tight: 1.2,
|
||||
},
|
||||
fontSize: {
|
||||
"5xl": "2.5rem",
|
||||
"6xl": "2.75rem",
|
||||
|
@ -33,3 +40,4 @@ module.exports = {
|
|||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
|
@ -1,20 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"jsx": "preserve",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|