diff --git a/README.md b/README.md index c9c2573..1d5a544 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ server entry); those marked _explicit_ provide a `src/waku.server.tsx` that call | [`tanstack-router`](fs-router/tanstack-router) | Replace the client router with TanStack Router _(explicit)_ | | [`no-ssr`](fs-router/no-ssr) | Disable SSR for client-only rendering _(explicit)_ | | [`cloudflare`](fs-router/cloudflare) | Deploy to Cloudflare Workers _(explicit)_ | +| [`nextjs-blog-starter`](fs-router/nextjs-blog-starter) | Next.js blog-starter, migrated: static generation, metadata, markdown | +| [`nextjs-dashboard`](fs-router/nextjs-dashboard) | Next.js Learn dashboard, migrated: auth, a database, server actions, search | +| [`nextjs-commerce`](fs-router/nextjs-commerce) | Next.js Commerce, migrated: cookies, optimistic UI, sitemap and OG images | +| [`nextjs-photo-modal`](fs-router/nextjs-photo-modal) | Nextgram, reimplemented: parallel and intercepting routes | ### create-pages @@ -94,6 +98,7 @@ The same examples, grouped by what they teach. | Integrations | [`create-pages/react-tweet`](create-pages/react-tweet), [`fs-router/tanstack-router`](fs-router/tanstack-router) | | Deployment | [`fs-router/cloudflare`](fs-router/cloudflare) | | Tooling & config | [`minimal-api/path-alias`](minimal-api/path-alias) | +| Migrating from Next.js | [`fs-router/nextjs-blog-starter`](fs-router/nextjs-blog-starter), [`fs-router/nextjs-dashboard`](fs-router/nextjs-dashboard), [`fs-router/nextjs-commerce`](fs-router/nextjs-commerce), [`fs-router/nextjs-photo-modal`](fs-router/nextjs-photo-modal) | ## Running an example diff --git a/e2e/examples-smoke.spec.ts b/e2e/examples-smoke.spec.ts index 681bdbb..29231dd 100644 --- a/e2e/examples-smoke.spec.ts +++ b/e2e/examples-smoke.spec.ts @@ -2,8 +2,8 @@ * Smoke tests for all examples. * * Boots every example in both DEV and PRD modes and checks that the home page - * renders with a "Waku" title. Examples are discovered by scanning the API - * group directories at the repo root. + * renders with a "Waku" title, or the one listed in `expectedTitles`. Examples + * are discovered by scanning the API group directories at the repo root. */ import type { ChildProcess } from 'node:child_process'; import { exec } from 'node:child_process'; @@ -36,6 +36,14 @@ const examples = groups.flatMap((group) => { })); }); +// The Next.js migrations keep the titles of the apps they were migrated from. +const expectedTitles: Record = { + 'fs-router/nextjs-blog-starter': /^Next\.js Blog Example/, + 'fs-router/nextjs-commerce': /^Acme Store/, + 'fs-router/nextjs-dashboard': /^Acme Dashboard/, + 'fs-router/nextjs-photo-modal': /^NextGram/, +}; + const modes = ['DEV', 'PRD'] as const; test.describe.configure({ mode: 'parallel' }); @@ -90,7 +98,9 @@ for (const { name, cwd } of examples) { await page.goto(`http://localhost:${port}/`); // title may not be ready yet await page.waitForLoadState('load'); - await expect.poll(() => page.title()).toMatch(/^Waku/); + await expect + .poll(() => page.title()) + .toMatch(expectedTitles[name] ?? /^Waku/); }); }); } diff --git a/fs-router/nextjs-blog-starter/LICENSE b/fs-router/nextjs-blog-starter/LICENSE new file mode 100644 index 0000000..5948ee9 --- /dev/null +++ b/fs-router/nextjs-blog-starter/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fs-router/nextjs-blog-starter/README.md b/fs-router/nextjs-blog-starter/README.md new file mode 100644 index 0000000..7e33c16 --- /dev/null +++ b/fs-router/nextjs-blog-starter/README.md @@ -0,0 +1,44 @@ +# Next.js `blog-starter` → Waku + +A migration of [`vercel/next.js/examples/blog-starter`](https://github.com/vercel/next.js/tree/canary/examples/blog-starter) +to Waku. Markdown files in `_posts/` are read at build time and every page is +prerendered, which is what the original does too. + +Code and images taken from the original are © Vercel, Inc., under the MIT +license in [`LICENSE`](LICENSE). + +```sh +pnpm install +pnpm typegen # generates src/pages.gen.ts for typed routes +pnpm dev +pnpm build && pnpm start +``` + +## What changed + +| Next.js | Waku | +| --- | --- | +| `app/layout.tsx` renders ``/`` | `src/pages/_root.tsx` renders the document shell, `src/pages/_layout.tsx` renders inside it | +| `export const metadata` | ``/`<meta>`/`<link>` rendered in the component; Waku hoists them, and a page's override its layout's | +| `generateMetadata()` | same — render the tags with the page, using data you already fetched | +| `generateStaticParams()` | `staticPaths` in the page's `getConfig()` | +| (implicitly static) | `render: 'static'` in `getConfig()`, stated per page and layout | +| `params: Promise<{ slug }>` | `slug` arrives as a plain prop, typed by `PageProps<'/posts/[slug]'>` | +| `notFound()` from `next/navigation` | `unstable_notFound()` from `waku/router/server` | +| `next/link` `<Link href>` | `<Link to>` from `waku` | +| `next/image` | plain `<img>` — Waku has no image component | +| `next/font/google` | `@fontsource/inter`, imported from `globals.css` | +| `app/_components/` | `src/components/` | +| tsconfig `paths` resolve automatically | the same alias is declared in `waku.config.ts` for Vite | +| `postcss.config.js` with `module.exports` | ESM `export default` — Waku packages are `"type": "module"` | + +Tailwind v3, PostCSS, CSS modules and `dangerouslySetInnerHTML` all work unchanged. + +## Gotcha: `og:image` does not merge + +The Next.js original sets a default `og:image` in `app/layout.tsx` and overrides it +in `app/posts/[slug]/page.tsx`. Waku merges a page's `title`, `description` and +single-value `og:*` tags over its layout's, but `og:image` can repeat, so it is +emitted as written and a layout default would come first on every post. The home +page and each post therefore declare their own `og:image`, and the layout declares +none. diff --git a/fs-router/nextjs-blog-starter/_posts/dynamic-routing.md b/fs-router/nextjs-blog-starter/_posts/dynamic-routing.md new file mode 100644 index 0000000..44b0b31 --- /dev/null +++ b/fs-router/nextjs-blog-starter/_posts/dynamic-routing.md @@ -0,0 +1,19 @@ +--- +title: "Dynamic Routing and Static Generation" +excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus." +coverImage: "/assets/blog/dynamic-routing/cover.jpg" +date: "2020-03-16T05:35:07.322Z" +author: + name: JJ Kasper + picture: "/assets/blog/authors/jj.jpeg" +ogImage: + url: "/assets/blog/dynamic-routing/cover.jpg" +--- + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus. Praesent elementum facilisis leo vel fringilla. Congue mauris rhoncus aenean vel. Egestas sed tempus urna et pharetra pharetra massa massa ultricies. + +Venenatis cras sed felis eget velit. Consectetur libero id faucibus nisl tincidunt. Gravida in fermentum et sollicitudin ac orci phasellus egestas tellus. Volutpat consequat mauris nunc congue nisi vitae. Id aliquet risus feugiat in ante metus dictum at tempor. Sed blandit libero volutpat sed cras. Sed odio morbi quis commodo odio aenean sed adipiscing. Velit euismod in pellentesque massa placerat. Mi bibendum neque egestas congue quisque egestas diam in arcu. Nisi lacus sed viverra tellus in. Nibh cras pulvinar mattis nunc sed. Luctus accumsan tortor posuere ac ut consequat semper viverra. Fringilla ut morbi tincidunt augue interdum velit euismod. + +## Lorem Ipsum + +Tristique senectus et netus et malesuada fames ac turpis. Ridiculous mus mauris vitae ultricies leo integer malesuada nunc vel. In mollis nunc sed id semper. Egestas tellus rutrum tellus pellentesque. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Quis blandit turpis cursus in hac habitasse platea dictumst quisque. Eros donec ac odio tempor orci dapibus ultrices. Aliquam sem et tortor consequat id porta nibh. Adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla. Diam vulputate ut pharetra sit amet. Ut tellus elementum sagittis vitae et leo. Arcu non odio euismod lacinia at quis risus sed vulputate. diff --git a/fs-router/nextjs-blog-starter/_posts/hello-world.md b/fs-router/nextjs-blog-starter/_posts/hello-world.md new file mode 100644 index 0000000..e3fe9fe --- /dev/null +++ b/fs-router/nextjs-blog-starter/_posts/hello-world.md @@ -0,0 +1,19 @@ +--- +title: "Learn How to Pre-render Pages Using Static Generation with Next.js" +excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus." +coverImage: "/assets/blog/hello-world/cover.jpg" +date: "2020-03-16T05:35:07.322Z" +author: + name: Tim Neutkens + picture: "/assets/blog/authors/tim.jpeg" +ogImage: + url: "/assets/blog/hello-world/cover.jpg" +--- + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus. Praesent elementum facilisis leo vel fringilla. Congue mauris rhoncus aenean vel. Egestas sed tempus urna et pharetra pharetra massa massa ultricies. + +Venenatis cras sed felis eget velit. Consectetur libero id faucibus nisl tincidunt. Gravida in fermentum et sollicitudin ac orci phasellus egestas tellus. Volutpat consequat mauris nunc congue nisi vitae. Id aliquet risus feugiat in ante metus dictum at tempor. Sed blandit libero volutpat sed cras. Sed odio morbi quis commodo odio aenean sed adipiscing. Velit euismod in pellentesque massa placerat. Mi bibendum neque egestas congue quisque egestas diam in arcu. Nisi lacus sed viverra tellus in. Nibh cras pulvinar mattis nunc sed. Luctus accumsan tortor posuere ac ut consequat semper viverra. Fringilla ut morbi tincidunt augue interdum velit euismod. + +## Lorem Ipsum + +Tristique senectus et netus et malesuada fames ac turpis. Ridiculous mus mauris vitae ultricies leo integer malesuada nunc vel. In mollis nunc sed id semper. Egestas tellus rutrum tellus pellentesque. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Quis blandit turpis cursus in hac habitasse platea dictumst quisque. Eros donec ac odio tempor orci dapibus ultrices. Aliquam sem et tortor consequat id porta nibh. Adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla. Diam vulputate ut pharetra sit amet. Ut tellus elementum sagittis vitae et leo. Arcu non odio euismod lacinia at quis risus sed vulputate. diff --git a/fs-router/nextjs-blog-starter/_posts/preview.md b/fs-router/nextjs-blog-starter/_posts/preview.md new file mode 100644 index 0000000..588bf9d --- /dev/null +++ b/fs-router/nextjs-blog-starter/_posts/preview.md @@ -0,0 +1,19 @@ +--- +title: "Preview Mode for Static Generation" +excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus." +coverImage: "/assets/blog/preview/cover.jpg" +date: "2020-03-16T05:35:07.322Z" +author: + name: Joe Haddad + picture: "/assets/blog/authors/joe.jpeg" +ogImage: + url: "/assets/blog/preview/cover.jpg" +--- + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus. Praesent elementum facilisis leo vel fringilla. Congue mauris rhoncus aenean vel. Egestas sed tempus urna et pharetra pharetra massa massa ultricies. + +Venenatis cras sed felis eget velit. Consectetur libero id faucibus nisl tincidunt. Gravida in fermentum et sollicitudin ac orci phasellus egestas tellus. Volutpat consequat mauris nunc congue nisi vitae. Id aliquet risus feugiat in ante metus dictum at tempor. Sed blandit libero volutpat sed cras. Sed odio morbi quis commodo odio aenean sed adipiscing. Velit euismod in pellentesque massa placerat. Mi bibendum neque egestas congue quisque egestas diam in arcu. Nisi lacus sed viverra tellus in. Nibh cras pulvinar mattis nunc sed. Luctus accumsan tortor posuere ac ut consequat semper viverra. Fringilla ut morbi tincidunt augue interdum velit euismod. + +## Lorem Ipsum + +Tristique senectus et netus et malesuada fames ac turpis. Ridiculous mus mauris vitae ultricies leo integer malesuada nunc vel. In mollis nunc sed id semper. Egestas tellus rutrum tellus pellentesque. Phasellus vestibulum lorem sed risus ultricies tristique nulla. Quis blandit turpis cursus in hac habitasse platea dictumst quisque. Eros donec ac odio tempor orci dapibus ultrices. Aliquam sem et tortor consequat id porta nibh. Adipiscing elit duis tristique sollicitudin nibh sit amet commodo nulla. Diam vulputate ut pharetra sit amet. Ut tellus elementum sagittis vitae et leo. Arcu non odio euismod lacinia at quis risus sed vulputate. diff --git a/fs-router/nextjs-blog-starter/package.json b/fs-router/nextjs-blog-starter/package.json new file mode 100644 index 0000000..74ff06c --- /dev/null +++ b/fs-router/nextjs-blog-starter/package.json @@ -0,0 +1,32 @@ +{ + "name": "fs-router-nextjs-blog-starter", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "dev": "waku dev", + "build": "waku build", + "start": "waku start", + "typegen": "waku router typegen" + }, + "dependencies": { + "@fontsource/inter": "^5.2.11", + "classnames": "^2.5.1", + "date-fns": "^3.6.0", + "gray-matter": "^4.0.3", + "react": "~19.3.0", + "react-dom": "~19.3.0", + "react-server-dom-webpack": "~19.3.0", + "remark": "^15.0.1", + "remark-html": "^16.0.1", + "waku": "1.0.0-rc.2" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^7.0.2" + } +} diff --git a/fs-router/nextjs-blog-starter/postcss.config.js b/fs-router/nextjs-blog-starter/postcss.config.js new file mode 100644 index 0000000..4eb92e7 --- /dev/null +++ b/fs-router/nextjs-blog-starter/postcss.config.js @@ -0,0 +1,8 @@ +// Waku projects are ESM ("type": "module" in package.json), so this file uses +// `export default` where the Next.js original used `module.exports`. +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/fs-router/nextjs-blog-starter/public/assets/blog/authors/jj.jpeg b/fs-router/nextjs-blog-starter/public/assets/blog/authors/jj.jpeg new file mode 100644 index 0000000..e3d5214 Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/assets/blog/authors/jj.jpeg differ diff --git a/fs-router/nextjs-blog-starter/public/assets/blog/authors/joe.jpeg b/fs-router/nextjs-blog-starter/public/assets/blog/authors/joe.jpeg new file mode 100644 index 0000000..d9677ad Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/assets/blog/authors/joe.jpeg differ diff --git a/fs-router/nextjs-blog-starter/public/assets/blog/authors/tim.jpeg b/fs-router/nextjs-blog-starter/public/assets/blog/authors/tim.jpeg new file mode 100644 index 0000000..cc49257 Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/assets/blog/authors/tim.jpeg differ diff --git a/fs-router/nextjs-blog-starter/public/assets/blog/dynamic-routing/cover.jpg b/fs-router/nextjs-blog-starter/public/assets/blog/dynamic-routing/cover.jpg new file mode 100644 index 0000000..c660c92 Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/assets/blog/dynamic-routing/cover.jpg differ diff --git a/fs-router/nextjs-blog-starter/public/assets/blog/hello-world/cover.jpg b/fs-router/nextjs-blog-starter/public/assets/blog/hello-world/cover.jpg new file mode 100644 index 0000000..33b7dc4 Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/assets/blog/hello-world/cover.jpg differ diff --git a/fs-router/nextjs-blog-starter/public/assets/blog/preview/cover.jpg b/fs-router/nextjs-blog-starter/public/assets/blog/preview/cover.jpg new file mode 100644 index 0000000..6a975fb Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/assets/blog/preview/cover.jpg differ diff --git a/fs-router/nextjs-blog-starter/public/images/favicon.png b/fs-router/nextjs-blog-starter/public/images/favicon.png new file mode 100644 index 0000000..cd90d79 Binary files /dev/null and b/fs-router/nextjs-blog-starter/public/images/favicon.png differ diff --git a/fs-router/nextjs-blog-starter/src/components/alert.tsx b/fs-router/nextjs-blog-starter/src/components/alert.tsx new file mode 100644 index 0000000..7a394e6 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/alert.tsx @@ -0,0 +1,48 @@ +import Container from "@/components/container"; +import { EXAMPLE_PATH } from "@/lib/constants"; +import cn from "classnames"; + +type Props = { + preview?: boolean; +}; + +const Alert = ({ preview }: Props) => { + return ( + <div + className={cn("border-b dark:bg-slate-800", { + "bg-neutral-800 border-neutral-800 text-white": preview, + "bg-neutral-50 border-neutral-200": !preview, + })} + > + <Container> + <div className="py-2 text-center text-sm"> + {preview ? ( + <> + This page is a preview.{" "} + <a + href="/api/exit-preview" + className="underline hover:text-teal-300 duration-200 transition-colors" + > + Click here + </a>{" "} + to exit preview mode. + </> + ) : ( + <> + The source code for this blog is{" "} + <a + href={`https://github.com/vercel/next.js/tree/canary/examples/${EXAMPLE_PATH}`} + className="underline hover:text-blue-600 duration-200 transition-colors" + > + available on GitHub + </a> + . + </> + )} + </div> + </Container> + </div> + ); +}; + +export default Alert; diff --git a/fs-router/nextjs-blog-starter/src/components/avatar.tsx b/fs-router/nextjs-blog-starter/src/components/avatar.tsx new file mode 100644 index 0000000..9c2ea4d --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/avatar.tsx @@ -0,0 +1,15 @@ +type Props = { + name: string; + picture: string; +}; + +const Avatar = ({ name, picture }: Props) => { + return ( + <div className="flex items-center"> + <img src={picture} className="w-12 h-12 rounded-full mr-4" alt={name} /> + <div className="text-xl font-bold">{name}</div> + </div> + ); +}; + +export default Avatar; diff --git a/fs-router/nextjs-blog-starter/src/components/container.tsx b/fs-router/nextjs-blog-starter/src/components/container.tsx new file mode 100644 index 0000000..17458eb --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/container.tsx @@ -0,0 +1,9 @@ +type Props = { + children?: React.ReactNode; +}; + +const Container = ({ children }: Props) => { + return <div className="container mx-auto px-5">{children}</div>; +}; + +export default Container; diff --git a/fs-router/nextjs-blog-starter/src/components/cover-image.tsx b/fs-router/nextjs-blog-starter/src/components/cover-image.tsx new file mode 100644 index 0000000..20b6220 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/cover-image.tsx @@ -0,0 +1,35 @@ +import cn from "classnames"; +import { Link } from "waku"; + +type Props = { + title: string; + src: string; + slug?: string; +}; + +const CoverImage = ({ title, src, slug }: Props) => { + const image = ( + <img + src={src} + alt={`Cover Image for ${title}`} + className={cn("shadow-sm w-full", { + "hover:shadow-lg transition-shadow duration-200": slug, + })} + width={1300} + height={630} + /> + ); + return ( + <div className="sm:mx-0"> + {slug ? ( + <Link to={{ to: "/posts/[slug]", params: { slug } }} aria-label={title}> + {image} + </Link> + ) : ( + image + )} + </div> + ); +}; + +export default CoverImage; diff --git a/fs-router/nextjs-blog-starter/src/components/date-formatter.tsx b/fs-router/nextjs-blog-starter/src/components/date-formatter.tsx new file mode 100644 index 0000000..b092f5e --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/date-formatter.tsx @@ -0,0 +1,12 @@ +import { parseISO, format } from "date-fns"; + +type Props = { + dateString: string; +}; + +const DateFormatter = ({ dateString }: Props) => { + const date = parseISO(dateString); + return <time dateTime={dateString}>{format(date, "LLLL d, yyyy")}</time>; +}; + +export default DateFormatter; diff --git a/fs-router/nextjs-blog-starter/src/components/footer.tsx b/fs-router/nextjs-blog-starter/src/components/footer.tsx new file mode 100644 index 0000000..34b8855 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/footer.tsx @@ -0,0 +1,32 @@ +import Container from "@/components/container"; +import { EXAMPLE_PATH } from "@/lib/constants"; + +export function Footer() { + return ( + <footer className="bg-neutral-50 border-t border-neutral-200 dark:bg-slate-800"> + <Container> + <div className="py-28 flex flex-col lg:flex-row items-center"> + <h3 className="text-4xl lg:text-[2.5rem] font-bold tracking-tighter leading-tight text-center lg:text-left mb-10 lg:mb-0 lg:pr-4 lg:w-1/2"> + Statically Generated with Next.js. + </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/app/building-your-application/routing/layouts-and-templates" + 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 + </a> + <a + href={`https://github.com/vercel/next.js/tree/canary/examples/${EXAMPLE_PATH}`} + className="mx-3 font-bold hover:underline" + > + View on GitHub + </a> + </div> + </div> + </Container> + </footer> + ); +} + +export default Footer; diff --git a/fs-router/nextjs-blog-starter/src/components/header.tsx b/fs-router/nextjs-blog-starter/src/components/header.tsx new file mode 100644 index 0000000..9bdf650 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/header.tsx @@ -0,0 +1,14 @@ +import { Link } from "waku"; + +const Header = () => { + return ( + <h2 className="text-2xl md:text-4xl font-bold tracking-tight md:tracking-tighter leading-tight mb-20 mt-8 flex items-center"> + <Link to="/" className="hover:underline"> + Blog + </Link> + . + </h2> + ); +}; + +export default Header; diff --git a/fs-router/nextjs-blog-starter/src/components/hero-post.tsx b/fs-router/nextjs-blog-starter/src/components/hero-post.tsx new file mode 100644 index 0000000..2bfeab2 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/hero-post.tsx @@ -0,0 +1,47 @@ +import Avatar from "@/components/avatar"; +import CoverImage from "@/components/cover-image"; +import { type Author } from "@/interfaces/author"; +import { Link } from "waku"; +import DateFormatter from "./date-formatter"; + +type Props = { + title: string; + coverImage: string; + date: string; + excerpt: string; + author: Author; + slug: string; +}; + +export function HeroPost({ + title, + coverImage, + date, + excerpt, + author, + slug, +}: Props) { + return ( + <section> + <div className="mb-8 md:mb-16"> + <CoverImage title={title} src={coverImage} slug={slug} /> + </div> + <div className="md:grid md:grid-cols-2 md:gap-x-16 lg:gap-x-8 mb-20 md:mb-28"> + <div> + <h3 className="mb-4 text-4xl lg:text-5xl leading-tight"> + <Link to={{ to: "/posts/[slug]", params: { slug } }} className="hover:underline"> + {title} + </Link> + </h3> + <div className="mb-4 md:mb-0 text-lg"> + <DateFormatter dateString={date} /> + </div> + </div> + <div> + <p className="text-lg leading-relaxed mb-4">{excerpt}</p> + <Avatar name={author.name} picture={author.picture} /> + </div> + </div> + </section> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/intro.tsx b/fs-router/nextjs-blog-starter/src/components/intro.tsx new file mode 100644 index 0000000..1364d14 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/intro.tsx @@ -0,0 +1,21 @@ +import { CMS_NAME } from "@/lib/constants"; + +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"> + Blog. + </h1> + <h4 className="text-center md:text-left text-lg mt-5 md:pl-8"> + A statically generated blog example using{" "} + <a + href="https://nextjs.org/" + className="underline hover:text-blue-600 duration-200 transition-colors" + > + Next.js + </a>{" "} + and {CMS_NAME}. + </h4> + </section> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/markdown-styles.module.css b/fs-router/nextjs-blog-starter/src/components/markdown-styles.module.css new file mode 100644 index 0000000..95d4f8b --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/markdown-styles.module.css @@ -0,0 +1,18 @@ +.markdown { + @apply text-lg leading-relaxed; +} + +.markdown p, +.markdown ul, +.markdown ol, +.markdown blockquote { + @apply my-6; +} + +.markdown h2 { + @apply text-3xl mt-12 mb-4 leading-snug; +} + +.markdown h3 { + @apply text-2xl mt-8 mb-4 leading-snug; +} diff --git a/fs-router/nextjs-blog-starter/src/components/more-stories.tsx b/fs-router/nextjs-blog-starter/src/components/more-stories.tsx new file mode 100644 index 0000000..17191da --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/more-stories.tsx @@ -0,0 +1,29 @@ +import { Post } from "@/interfaces/post"; +import { PostPreview } from "./post-preview"; + +type Props = { + posts: Post[]; +}; + +export function MoreStories({ posts }: Props) { + return ( + <section> + <h2 className="mb-8 text-5xl md:text-7xl font-bold tracking-tighter leading-tight"> + More Stories + </h2> + <div className="grid grid-cols-1 md:grid-cols-2 md:gap-x-16 lg:gap-x-32 gap-y-20 md:gap-y-32 mb-32"> + {posts.map((post) => ( + <PostPreview + key={post.slug} + title={post.title} + coverImage={post.coverImage} + date={post.date} + author={post.author} + slug={post.slug} + excerpt={post.excerpt} + /> + ))} + </div> + </section> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/post-body.tsx b/fs-router/nextjs-blog-starter/src/components/post-body.tsx new file mode 100644 index 0000000..065883c --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/post-body.tsx @@ -0,0 +1,16 @@ +import markdownStyles from "./markdown-styles.module.css"; + +type Props = { + content: string; +}; + +export function PostBody({ content }: Props) { + return ( + <div className="max-w-2xl mx-auto"> + <div + className={markdownStyles["markdown"]} + dangerouslySetInnerHTML={{ __html: content }} + /> + </div> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/post-header.tsx b/fs-router/nextjs-blog-starter/src/components/post-header.tsx new file mode 100644 index 0000000..12d701e --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/post-header.tsx @@ -0,0 +1,34 @@ +import Avatar from "./avatar"; +import CoverImage from "./cover-image"; +import DateFormatter from "./date-formatter"; +import { PostTitle } from "@/components/post-title"; +import { type Author } from "@/interfaces/author"; + +type Props = { + title: string; + coverImage: string; + date: string; + author: Author; +}; + +export function PostHeader({ title, coverImage, date, author }: Props) { + return ( + <> + <PostTitle>{title}</PostTitle> + <div className="hidden md:block md:mb-12"> + <Avatar name={author.name} picture={author.picture} /> + </div> + <div className="mb-8 md:mb-16 sm:mx-0"> + <CoverImage title={title} src={coverImage} /> + </div> + <div className="max-w-2xl mx-auto"> + <div className="block md:hidden mb-6"> + <Avatar name={author.name} picture={author.picture} /> + </div> + <div className="mb-6 text-lg"> + <DateFormatter dateString={date} /> + </div> + </div> + </> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/post-preview.tsx b/fs-router/nextjs-blog-starter/src/components/post-preview.tsx new file mode 100644 index 0000000..c635171 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/post-preview.tsx @@ -0,0 +1,41 @@ +import { type Author } from "@/interfaces/author"; +import { Link } from "waku"; +import Avatar from "./avatar"; +import CoverImage from "./cover-image"; +import DateFormatter from "./date-formatter"; + +type Props = { + title: string; + coverImage: string; + date: string; + excerpt: string; + author: Author; + slug: string; +}; + +export function PostPreview({ + title, + coverImage, + date, + excerpt, + author, + slug, +}: Props) { + return ( + <div> + <div className="mb-5"> + <CoverImage slug={slug} title={title} src={coverImage} /> + </div> + <h3 className="text-3xl mb-3 leading-snug"> + <Link to={{ to: "/posts/[slug]", params: { slug } }} className="hover:underline"> + {title} + </Link> + </h3> + <div className="text-lg mb-4"> + <DateFormatter dateString={date} /> + </div> + <p className="text-lg leading-relaxed mb-4">{excerpt}</p> + <Avatar name={author.name} picture={author.picture} /> + </div> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/post-title.tsx b/fs-router/nextjs-blog-starter/src/components/post-title.tsx new file mode 100644 index 0000000..5d2dc0b --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/post-title.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +type Props = { + children?: ReactNode; +}; + +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> + ); +} diff --git a/fs-router/nextjs-blog-starter/src/components/section-separator.tsx b/fs-router/nextjs-blog-starter/src/components/section-separator.tsx new file mode 100644 index 0000000..62412c0 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/section-separator.tsx @@ -0,0 +1,3 @@ +export function SectionSeparator() { + return <hr className="border-neutral-200 mt-28 mb-24" />; +} diff --git a/fs-router/nextjs-blog-starter/src/components/switch.module.css b/fs-router/nextjs-blog-starter/src/components/switch.module.css new file mode 100644 index 0000000..9ab3692 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/switch.module.css @@ -0,0 +1,56 @@ +.switch { + all: unset; + position: absolute; + right: 20px; + top: 70px; + display: inline-block; + color: currentColor; + border-radius: 50%; + border: 1px dashed currentColor; + cursor: pointer; + --size: 24px; + height: var(--size); + width: var(--size); + transition: all 0.3s ease-in-out 0s !important; +} + +[data-mode="system"] .switch::after { + position: absolute; + height: 100%; + width: 100%; + top: 0; + left: 0; + font-weight: 600; + font-size: calc(var(--size) / 2); + display: flex; + align-items: center; + justify-content: center; + content: "A"; +} + +[data-mode="light"] .switch { + box-shadow: 0 0 50px 10px yellow; + background-color: yellow; + border: 1px solid orangered; +} + +[data-mode="dark"] .switch { + box-shadow: calc(var(--size) / 4) calc(var(--size) / -4) calc(var(--size) / 8) + inset #fff; + border: none; + background: transparent; + animation: n linear 0.5s; +} + +@keyframes n { + 40% { + transform: rotate(-15deg); + } + 80% { + transform: rotate(10deg); + } + 0%, + 100% { + transform: rotate(0deg); + } +} diff --git a/fs-router/nextjs-blog-starter/src/components/theme-switcher.tsx b/fs-router/nextjs-blog-starter/src/components/theme-switcher.tsx new file mode 100644 index 0000000..c02678f --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/components/theme-switcher.tsx @@ -0,0 +1,113 @@ +"use client"; + +import styles from "./switch.module.css"; +import { memo, useEffect, useState } from "react"; + +declare global { + var updateDOM: () => void; +} + +type ColorSchemePreference = "system" | "dark" | "light"; + +const STORAGE_KEY = "nextjs-blog-starter-theme"; +const modes: ColorSchemePreference[] = ["system", "dark", "light"]; + +/** to reuse updateDOM function defined inside injected script */ + +/** function to be injected in script tag for avoiding FOUC (Flash of Unstyled Content) */ +export const NoFOUCScript = (storageKey: string) => { + /* can not use outside constants or function as this script will be injected in a different context */ + const [SYSTEM, DARK, LIGHT] = ["system", "dark", "light"]; + + /** Modify transition globally to avoid patched transitions */ + const modifyTransition = () => { + const css = document.createElement("style"); + css.textContent = "*,*:after,*:before{transition:none !important;}"; + document.head.appendChild(css); + + return () => { + /* Force restyle */ + getComputedStyle(document.body); + /* Wait for next tick before removing */ + setTimeout(() => document.head.removeChild(css), 1); + }; + }; + + const media = matchMedia(`(prefers-color-scheme: ${DARK})`); + + /** function to add remove dark class */ + window.updateDOM = () => { + const restoreTransitions = modifyTransition(); + const mode = localStorage.getItem(storageKey) ?? SYSTEM; + const systemMode = media.matches ? DARK : LIGHT; + const resolvedMode = mode === SYSTEM ? systemMode : mode; + const classList = document.documentElement.classList; + if (resolvedMode === DARK) classList.add(DARK); + else classList.remove(DARK); + document.documentElement.setAttribute("data-mode", mode); + restoreTransitions(); + }; + window.updateDOM(); + media.addEventListener("change", window.updateDOM); +}; + +let updateDOM: () => void; + +/** + * Switch button to quickly toggle user preference. + */ +const Switch = () => { + const [mode, setMode] = useState<ColorSchemePreference>( + () => + ((typeof localStorage !== "undefined" && + localStorage.getItem(STORAGE_KEY)) ?? + "system") as ColorSchemePreference, + ); + + useEffect(() => { + // store global functions to local variables to avoid any interference + updateDOM = window.updateDOM; + /** Sync the tabs */ + addEventListener("storage", (e: StorageEvent): void => { + e.key === STORAGE_KEY && setMode(e.newValue as ColorSchemePreference); + }); + }, []); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, mode); + updateDOM(); + }, [mode]); + + /** toggle mode */ + const handleModeSwitch = () => { + const index = modes.indexOf(mode); + setMode(modes[(index + 1) % modes.length]); + }; + return ( + <button + suppressHydrationWarning + className={styles.switch} + onClick={handleModeSwitch} + /> + ); +}; + +const Script = memo(() => ( + <script + dangerouslySetInnerHTML={{ + __html: `(${NoFOUCScript.toString()})('${STORAGE_KEY}')`, + }} + /> +)); + +/** + * This component applies classes and transitions. + */ +export const ThemeSwitcher = () => { + return ( + <> + <Script /> + <Switch /> + </> + ); +}; diff --git a/fs-router/nextjs-blog-starter/src/global.d.ts b/fs-router/nextjs-blog-starter/src/global.d.ts new file mode 100644 index 0000000..35306c6 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/global.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/fs-router/nextjs-blog-starter/src/globals.css b/fs-router/nextjs-blog-starter/src/globals.css new file mode 100644 index 0000000..ddafc83 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/globals.css @@ -0,0 +1,12 @@ +/* next/font/google self-hosts and preloads Inter. The equivalent without a + framework font loader is the @fontsource package for the same family. */ +@import "@fontsource/inter/400.css"; +@import "@fontsource/inter/700.css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: "Inter", ui-sans-serif, system-ui, sans-serif; +} diff --git a/fs-router/nextjs-blog-starter/src/interfaces/author.ts b/fs-router/nextjs-blog-starter/src/interfaces/author.ts new file mode 100644 index 0000000..352ff4c --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/interfaces/author.ts @@ -0,0 +1,4 @@ +export type Author = { + name: string; + picture: string; +}; diff --git a/fs-router/nextjs-blog-starter/src/interfaces/post.ts b/fs-router/nextjs-blog-starter/src/interfaces/post.ts new file mode 100644 index 0000000..e2da990 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/interfaces/post.ts @@ -0,0 +1,15 @@ +import { type Author } from "./author"; + +export type Post = { + slug: string; + title: string; + date: string; + coverImage: string; + author: Author; + excerpt: string; + ogImage: { + url: string; + }; + content: string; + preview?: boolean; +}; diff --git a/fs-router/nextjs-blog-starter/src/lib/api.ts b/fs-router/nextjs-blog-starter/src/lib/api.ts new file mode 100644 index 0000000..c4a8183 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/lib/api.ts @@ -0,0 +1,28 @@ +import { Post } from "@/interfaces/post"; +import fs from "fs"; +import matter from "gray-matter"; +import { join } from "path"; + +const postsDirectory = join(process.cwd(), "_posts"); + +export function getPostSlugs() { + return fs.readdirSync(postsDirectory); +} + +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); + + return { ...data, slug: realSlug, content } as Post; +} + +export function getAllPosts(): Post[] { + const slugs = getPostSlugs(); + const posts = slugs + .map((slug) => getPostBySlug(slug)) + // sort posts by date in descending order + .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); + return posts; +} diff --git a/fs-router/nextjs-blog-starter/src/lib/constants.ts b/fs-router/nextjs-blog-starter/src/lib/constants.ts new file mode 100644 index 0000000..8f89c20 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/lib/constants.ts @@ -0,0 +1,4 @@ +export const EXAMPLE_PATH = "blog-starter"; +export const CMS_NAME = "Markdown"; +export const HOME_OG_IMAGE_URL = + "https://og-image.vercel.app/Next.js%20Blog%20Starter%20Example.png?theme=light&md=1&fontSize=100px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg"; diff --git a/fs-router/nextjs-blog-starter/src/lib/markdownToHtml.ts b/fs-router/nextjs-blog-starter/src/lib/markdownToHtml.ts new file mode 100644 index 0000000..51e3169 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/lib/markdownToHtml.ts @@ -0,0 +1,7 @@ +import { remark } from "remark"; +import html from "remark-html"; + +export default async function markdownToHtml(markdown: string) { + const result = await remark().use(html).process(markdown); + return result.toString(); +} diff --git a/fs-router/nextjs-blog-starter/src/pages/_layout.tsx b/fs-router/nextjs-blog-starter/src/pages/_layout.tsx new file mode 100644 index 0000000..db99954 --- /dev/null +++ b/fs-router/nextjs-blog-starter/src/pages/_layout.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import Footer from "@/components/footer"; +import { ThemeSwitcher } from "@/components/theme-switcher"; +import { CMS_NAME } from "@/lib/constants"; +import "@/globals.css"; + +// The `metadata` export of app/layout.tsx becomes plain tags: Waku hoists any +// <title>, <meta> and <link> it finds in a page or layout into the document head, +// and a page's title and description override the defaults here. og:image can +// repeat, so Waku does not merge it; it stays on the pages that set one. +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + <> + <title>{`Next.js Blog Example with ${CMS_NAME}`} + + + + +
{children}
+