diff --git a/.eslintrc.js b/.eslintrc.js index 6de5a39..e1f09fa 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,76 +1,16 @@ module.exports = { - env: { - browser: true, - es2021: true, - node: true, - }, - plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'], extends: [ 'eslint:recommended', - 'next', - 'next/core-web-vitals', 'plugin:@typescript-eslint/recommended', + 'next/core-web-vitals', 'prettier', ], - rules: { - 'no-unused-vars': 'off', - 'no-console': 'warn', - '@typescript-eslint/explicit-module-boundary-types': 'off', - 'react/no-unescaped-entities': 'off', - - 'react/display-name': 'off', - 'react/jsx-curly-brace-presence': [ - 'warn', - { props: 'never', children: 'never' }, - ], - - //#region //*=========== Unused Import =========== - '@typescript-eslint/no-unused-vars': 'off', - 'unused-imports/no-unused-imports': 'warn', - 'unused-imports/no-unused-vars': [ - 'warn', - { - vars: 'all', - varsIgnorePattern: '^_', - args: 'after-used', - argsIgnorePattern: '^_', - }, - ], - //#endregion //*======== Unused Import =========== - - //#region //*=========== Import Sort =========== - 'simple-import-sort/exports': 'warn', - 'simple-import-sort/imports': [ - 'warn', - { - groups: [ - // ext library & side effect imports - ['^@?\\w', '^\\u0000'], - // {s}css files - ['^.+\\.s?css$'], - // Other imports - ['^@/'], - // relative paths up until 3 level - [ - '^\\./?$', - '^\\.(?!/?$)', - '^\\.\\./?$', - '^\\.\\.(?!/?$)', - '^\\.\\./\\.\\./?$', - '^\\.\\./\\.\\.(?!/?$)', - '^\\.\\./\\.\\./\\.\\./?$', - '^\\.\\./\\.\\./\\.\\.(?!/?$)', - ], - ['^@/types'], - // other that didnt fit in - ['^'], - ], - }, - ], - //#endregion //*======== Import Sort =========== - }, globals: { React: true, JSX: true, }, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'no-relative-import-paths'], + reportUnusedDisableDirectives: true, + root: true, }; diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 139364d..cd36859 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,23 +15,23 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - name: 📥 Download deps - uses: u0reo/npm-install@fix/restore-failure + uses: u0reo/npm-install@v1 with: useRollingCache: true - name: 🔬 Lint - run: yarn lint:strict + run: yarn lint - # - name: 🔎 Type check - # run: yarn typecheck + - name: 🔎 Type check + run: yarn typecheck - name: 💅 Prettier check run: yarn format:check diff --git a/.prettierrc.js b/.prettierrc.js index 4a80dc6..f414f2a 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,6 +1,6 @@ module.exports = { singleQuote: true, - plugins: ['prettier-plugin-tailwindcss'], + plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'], tailwindConfig: './tailwind.config.ts', tailwindFunctions: ['clsx', 'cn'], }; diff --git a/.vscode/settings.json b/.vscode/settings.json index 4e500d4..800cb08 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,7 @@ "typescriptreact" ], "files.associations": { - "globals.scss": "tailwindcss" + "*.css": "tailwindcss" }, "tailwindCSS.classAttributes": [ "class", diff --git a/next.config.js b/next.config.ts similarity index 75% rename from next.config.js rename to next.config.ts index 7cf5eb7..5054d89 100644 --- a/next.config.js +++ b/next.config.ts @@ -1,27 +1,45 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -// @ts-check +import { flatten } from 'lodash'; +import type { NextConfig } from 'next'; -const { flatten } = require('lodash'); -const { withPlausibleProxy } = require('next-plausible'); - -/** @type {import('next').NextConfig} */ -module.exports = withPlausibleProxy()({ +const nextConfig: NextConfig = { eslint: { dirs: ['src'], }, reactStrictMode: true, - swcMinify: true, images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.hashnode.com' }, { protocol: 'https', hostname: 'i.scdn.co' }, { protocol: 'https', hostname: 'img.transistor.fm' }, - { protocol: 'https', hostname: 'a.storyblok.com' }, + { protocol: 'https', hostname: 'cdn.sanity.io' }, + { protocol: 'https', hostname: 'avatars.githubusercontent.com' }, ], }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + // { + // key: 'Strict-Transport-Security', + // value: 'max-age=31536000; includeSubDomains; preload', + // }, + ], + }, + ]; + }, + async redirects() { return [ { @@ -125,23 +143,24 @@ module.exports = withPlausibleProxy()({ async rewrites() { return [ { - source: '/api/analytics', - destination: 'https://user-analytics.hashnode.com/api/analytics', + source: '/js/script.js', + destination: 'https://plausible.io/js/script.outbound-links.js', }, { - source: '/api/ingest/static/:path*', - destination: 'https://eu-assets.i.posthog.com/static/:path*', + source: '/api/event', + destination: 'https://plausible.io/api/event', }, { - source: '/api/ingest/:path*', - destination: 'https://eu.i.posthog.com/:path*', + source: '/api/analytics', + destination: 'https://user-analytics.hashnode.com/api/analytics', }, ]; }, webpack(config) { // Grab the existing rule that handles SVG imports - const fileLoaderRule = config.module.rules.find((rule) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileLoaderRule = config.module.rules.find((rule: any) => rule.test?.test?.('.svg'), ); @@ -171,7 +190,21 @@ module.exports = withPlausibleProxy()({ return config; }, + compiler: + process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' + ? { + reactRemoveProperties: true, + removeConsole: true, + } + : undefined, + experimental: { + staleTimes: { + dynamic: 30, + }, + taint: true, webpackBuildWorker: true, }, -}); +}; + +export default nextConfig; diff --git a/package.json b/package.json index 577f365..0c33d56 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,9 @@ "scripts": { "dev": "next dev", "build": "next build", - "codegen": "graphql-codegen --config codegen.yml && yarn format", + "codegen": "graphql-codegen --config codegen.yml && prettier -w src/generated/hashnode", "start": "next start", "lint": "next lint", - "lint:fix": "eslint src --fix && yarn format", - "lint:strict": "eslint --max-warnings=0 src", "typecheck": "tsc --noEmit --incremental false", "format": "prettier -w .", "format:check": "prettier -c .", @@ -17,80 +15,73 @@ }, "dependencies": { "@graphql-typed-document-node/core": "3.2.0", - "@headlessui/react": "2.1.2", - "@hookform/resolvers": "3.9.0", - "@next/third-parties": "14.2.5", - "@storyblok/react": "^3.0.10", + "@headlessui/react": "2.2.0", + "@hookform/resolvers": "3.9.1", + "@next/third-parties": "15.0.2", "clsx": "2.1.1", "entities": "5.0.0", "feed": "4.2.2", "github-slugger": "2.0.0", "graphql": "16.9.0", "graphql-request": "7.1.0", - "html-react-parser": "5.1.12", + "html-react-parser": "5.1.18", "js-cookie": "3.0.5", "lodash": "4.17.21", - "next": "14.2.5", - "next-plausible": "3.12.0", + "next": "15.0.2", "next-share": "0.27.0", - "posthog-js": "1.154.2", "react": "18.3.1", "react-dom": "18.3.1", "react-google-recaptcha": "3.1.0", - "react-hook-form": "7.52.1", - "react-icons": "5.2.1", - "react-infinite-scroll-hook": "4.1.1", + "react-hook-form": "7.53.1", + "react-icons": "5.3.0", + "react-infinite-scroll-hook": "5.0.1", "react-markdown": "9.0.1", - "react-syntax-highlighter": "15.5.0", + "react-syntax-highlighter": "15.6.1", "rehype-raw": "7.0.0", "remark": "15.0.1", "remark-gfm": "4.0.0", "remark-smartypants": "3.0.2", - "sass": "^1.77.8", "server-only": "0.0.1", - "sharp": "0.33.4", - "storyblok-react": "^0.1.2", - "storyblok-rich-text-react-renderer": "^2.9.2", - "tailwind-merge": "2.4.0", + "sharp": "0.33.5", + "tailwind-merge": "2.5.4", "zod": "3.23.8", "zod-form-data": "2.0.2" }, "devDependencies": { - "@commitlint/cli": "19.3.0", - "@commitlint/config-conventional": "19.2.2", - "@graphql-codegen/cli": "5.0.2", - "@graphql-codegen/typed-document-node": "5.0.9", - "@graphql-codegen/typescript": "4.0.9", - "@graphql-codegen/typescript-operations": "4.2.3", + "@commitlint/cli": "19.5.0", + "@commitlint/config-conventional": "19.5.0", + "@graphql-codegen/cli": "5.0.3", + "@graphql-codegen/typed-document-node": "5.0.11", + "@graphql-codegen/typescript": "4.1.1", + "@graphql-codegen/typescript-operations": "4.3.1", "@svgr/webpack": "8.1.0", - "@tailwindcss/forms": "0.5.7", + "@tailwindcss/forms": "0.5.9", "@types/js-cookie": "3.0.6", - "@types/lodash": "4.17.7", - "@types/react": "18.3.3", + "@types/lodash": "4.17.13", + "@types/react": "18.3.12", "@types/react-google-recaptcha": "2.1.9", "@types/react-syntax-highlighter": "15.5.13", - "@typescript-eslint/eslint-plugin": "7.18.0", - "@typescript-eslint/parser": "7.18.0", + "@typescript-eslint/eslint-plugin": "8.12.2", + "@typescript-eslint/parser": "8.12.2", "autoprefixer": "10.4.20", "encoding": "0.1.13", - "eslint": "8.57.0", - "eslint-config-next": "14.2.5", + "eslint": "9.13.0", + "eslint-config-next": "15.0.2", "eslint-config-prettier": "9.1.0", - "eslint-plugin-simple-import-sort": "12.1.1", - "eslint-plugin-unused-imports": "4.0.1", - "husky": "9.1.4", - "lint-staged": "15.2.7", - "postcss": "8.4.40", + "eslint-plugin-no-relative-import-paths": "1.5.5", + "husky": "9.1.6", + "lint-staged": "15.2.10", + "postcss": "8.4.47", "prettier": "3.3.3", - "prettier-plugin-tailwindcss": "0.6.5", + "prettier-plugin-organize-imports": "4.1.0", + "prettier-plugin-tailwindcss": "0.6.8", "schema-dts": "1.1.2", "svgo": "3.3.2", - "tailwindcss": "3.4.7", - "typescript": "5.4.5" + "tailwindcss": "3.4.14", + "typescript": "5.6.3" }, "lint-staged": { "**/*.{js,jsx,ts,tsx}": [ - "eslint", "prettier -w" ], "**/*.{json,css,scss,md,webmanifest}": [ diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000..511b4c5 --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,10 @@ +/** @type {import("prettier").Config} */ +const config = { + plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'], + singleQuote: true, + tailwindConfig: './tailwind.config.ts', + tailwindAttributes: ['tw'], + tailwindFunctions: ['clsx', 'cn'], +}; + +export default config; diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png deleted file mode 100644 index beef963..0000000 Binary files a/public/android-chrome-192x192.png and /dev/null differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png deleted file mode 100644 index 31be2c7..0000000 Binary files a/public/android-chrome-512x512.png and /dev/null differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 10982d9..67b4a81 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png deleted file mode 100644 index 34a5028..0000000 Binary files a/public/favicon-16x16.png and /dev/null differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png deleted file mode 100644 index 9706399..0000000 Binary files a/public/favicon-32x32.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico index ef37fe8..ff29554 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..c1a0e65 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..26fe8aa Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..7aa5fd8 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/site.webmanifest b/public/site.webmanifest deleted file mode 100644 index bc19373..0000000 --- a/public/site.webmanifest +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Fix Security", - "short_name": "Fix", - "start_url": "./", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any maskable" - }, - { - "src": "/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ], - "theme_color": "#3d58d3", - "background_color": "#ffffff", - "display": "standalone" -} diff --git a/src/app/StoryblokRenderer.tsx b/src/app/StoryblokRenderer.tsx deleted file mode 100644 index 50115ee..0000000 --- a/src/app/StoryblokRenderer.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import { - ISbStoryData, - StoryblokComponent, - useStoryblokState, -} from '@storyblok/react'; -import React from 'react'; - -interface Blok { - _uid: string; - component: string; - [key: string]: unknown; -} - -// Define the content type used in ISbStoryData -interface StoryContent { - body: Blok[]; -} - -interface StoryblokRendererProps { - story: ISbStoryData; -} - -const StoryblokRenderer: React.FC = ({ story }) => { - // Enable live updates in the Storyblok Visual Editor - const liveStory = useStoryblokState(story); - - return ( -
- {liveStory?.content?.body.map((blok) => ( - - ))} -
- ); -}; - -export default StoryblokRenderer; diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx deleted file mode 100644 index b55ca88..0000000 --- a/src/app/[...slug]/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { getStoryblokApi, ISbStoriesParams } from '@storyblok/react'; -import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; - -import StoryblokRenderer from '@/app/StoryblokRenderer'; -import { generateMetadataFromStory } from '@/lib/storyblok'; - -async function fetchData( - slug: string, - version: 'published' | 'draft' | undefined, -) { - const cacheVersion = Math.floor(Date.now() / 1000); - const sbParams: ISbStoriesParams = { - version: version, - cv: cacheVersion, // Force bypass cache - }; - const storyblokApi = getStoryblokApi(); - - if (!storyblokApi) { - throw new Error('Storyblok API is not initialized'); - } - - return await storyblokApi.get(`cdn/stories/${slug}`, sbParams); -} - -export async function generateMetadata({ - params, -}: { - params: { slug: string[] }; -}): Promise { - const story = await fetchData(params.slug.join('/'), 'published'); - - return generateMetadataFromStory(story, false); -} - -export default async function Page({ - params, - searchParams, -}: { - params: { slug: string[] }; - searchParams: { _storyblok?: string }; -}) { - const slugPath = params.slug.join('/'); - let data; - try { - const version = searchParams._storyblok ? 'draft' : 'published'; - const response = await fetchData(slugPath, version); - data = response.data; - } catch (error) { - notFound(); - } - return ; -} diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx new file mode 100644 index 0000000..c2e1872 --- /dev/null +++ b/src/app/[slug]/page.tsx @@ -0,0 +1,107 @@ +import { metadata as rootMetadata } from '@/app/layout'; +import { metadata as notFoundMetadata } from '@/app/not-found'; +import HashnodePageView from '@/components/analytics/HashnodePageView'; +import MarkdownContent from '@/components/common/MarkdownContent'; +import { siteConfig } from '@/constants/config'; +import { isProd } from '@/constants/env'; +import { + getAllStaticPageSlugs, + getPublicationId, + getStaticPage, +} from '@/lib/hashnode'; +import { openGraph } from '@/utils/og'; +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +export const revalidate = 300; + +export async function generateStaticParams() { + const slugs = await getAllStaticPageSlugs(); + + return slugs + .filter((slug) => !slug.startsWith('fix-vs-')) + .map((slug) => ({ + slug, + })); +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await props.params; + const staticPage = await getStaticPage(slug); + + if (!staticPage) { + return notFoundMetadata; + } + + const url = `${siteConfig.url}/${staticPage.slug}`; + const title = staticPage.title; + const description = staticPage.seo?.description ?? undefined; + const ogImage = openGraph({ title, description }); + + return { + title, + description, + alternates: { + ...rootMetadata.alternates, + canonical: url, + }, + openGraph: { + ...rootMetadata.openGraph, + url, + title, + description, + images: [ogImage], + }, + twitter: { + ...rootMetadata.twitter, + title: `${title} | ${siteConfig.title}`, + description, + images: [ogImage], + }, + ...(staticPage.hidden ? { robots: notFoundMetadata.robots } : {}), + }; +} + +export default async function StaticPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + if (slug.startsWith('fix-vs-')) { + permanentRedirect(`/compare/${slug}`); + } + + const publicationIdData = getPublicationId(); + const staticPageData = getStaticPage(slug); + + const [publicationId, staticPage] = await Promise.all([ + publicationIdData, + staticPageData, + ]); + + if (!staticPage) { + notFound(); + } + + return ( + <> +
+
+

+ {staticPage.title} +

+ + {staticPage.content.markdown} + +
+
+ {isProd && publicationId ? ( + + ) : null} + + ); +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..409b04d --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,94 @@ +import { metadata as rootMetadata } from '@/app/layout'; +import { metadata as notFoundMetadata } from '@/app/not-found'; +import HashnodePageView from '@/components/analytics/HashnodePageView'; +import MarkdownContent from '@/components/common/MarkdownContent'; +import Faq from '@/components/sections/Faq'; +import Team from '@/components/sections/Team'; +import { siteConfig } from '@/constants/config'; +import { isProd } from '@/constants/env'; +import { getPublicationId, getStaticPage } from '@/lib/hashnode'; +import { openGraph } from '@/utils/og'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +export const revalidate = 300; + +export async function generateMetadata(): Promise { + const staticPage = await getStaticPage('about'); + + if (!staticPage) { + return notFoundMetadata; + } + + const url = `${siteConfig.url}/${staticPage.slug}`; + const title = staticPage.title; + const description = staticPage.seo?.description ?? undefined; + const ogImage = openGraph({ title, description }); + + return { + title, + description, + alternates: { + ...rootMetadata.alternates, + canonical: url, + }, + openGraph: { + ...rootMetadata.openGraph, + url, + title, + description, + images: [ogImage], + }, + twitter: { + ...rootMetadata.twitter, + title: `${staticPage.title} | ${siteConfig.title}`, + description, + images: [ogImage], + }, + ...(staticPage.hidden ? { robots: notFoundMetadata.robots } : {}), + }; +} + +export default async function AboutPage() { + const publicationIdData = getPublicationId(); + const staticPageData = getStaticPage('about'); + + const [publicationId, staticPage] = await Promise.all([ + publicationIdData, + staticPageData, + ]); + + if (!staticPage) { + notFound(); + } + + return ( + <> +
+
+

+ {staticPage.title} +

+

+ We don’t have a{' '} + + silver bullet for cloud security + + . +

+ + {staticPage.content.markdown} + +
+
+ + + {isProd && publicationId ? ( + + ) : null} + + ); +} diff --git a/src/app/api/blog/newsletter-signup/route.ts b/src/app/api/blog/newsletter-signup/route.ts index a3d1f8f..83781a6 100644 --- a/src/app/api/blog/newsletter-signup/route.ts +++ b/src/app/api/blog/newsletter-signup/route.ts @@ -1,10 +1,9 @@ -import type { NextRequest } from 'next/server'; -import { z } from 'zod'; -import { zfd } from 'zod-form-data'; - import { addPerson as addPersonToAttio } from '@/lib/attio'; import { validateCaptcha } from '@/lib/google/recaptcha'; import { subscribeToNewsletter } from '@/lib/hashnode'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; export async function POST(req: NextRequest) { const schema = zfd.formData({ @@ -22,7 +21,7 @@ export async function POST(req: NextRequest) { try { await addPersonToAttio(email); - } catch (e) { + } catch { // do nothing } diff --git a/src/app/api/blog/revalidate/route.ts b/src/app/api/blog/revalidate/route.ts index 99af563..86c62e9 100644 --- a/src/app/api/blog/revalidate/route.ts +++ b/src/app/api/blog/revalidate/route.ts @@ -1,12 +1,11 @@ +import { HASHNODE_WEBHOOK_SECRET } from '@/constants/hashnode'; +import { validateSignature } from '@/lib/hashnode/webhook'; import { revalidateTag } from 'next/cache'; import { headers } from 'next/headers'; import type { NextRequest } from 'next/server'; -import { HASHNODE_WEBHOOK_SECRET } from '@/constants/hashnode'; -import { validateSignature } from '@/lib/hashnode/webhook'; - export async function POST(req: NextRequest) { - const incomingSignatureHeader = headers().get('x-hashnode-signature'); + const incomingSignatureHeader = (await headers()).get('x-hashnode-signature'); const payload = await req.json(); const signatureResult = validateSignature({ diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index b81ec00..73e57f5 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,6 +1,4 @@ -import type { Metadata } from 'next'; -import { notFound, permanentRedirect } from 'next/navigation'; - +import { metadata as rootMetadata } from '@/app/layout'; import { metadata as notFoundMetadata } from '@/app/not-found'; import BlogPost from '@/components/blog/BlogPost'; import { siteConfig } from '@/constants/config'; @@ -11,8 +9,8 @@ import { getRedirectedPost, } from '@/lib/hashnode'; import { openGraph } from '@/utils/og'; - -import { metadata as rootMetadata } from '../../metadata'; +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; export async function generateStaticParams() { const slugs = await getAllPostSlugs(); @@ -22,12 +20,11 @@ export async function generateStaticParams() { })); } -export async function generateMetadata({ - params, -}: { - params: { slug: string }; +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; }): Promise { - const post = await getPost(params.slug); + const { slug } = await props.params; + const post = await getPost(slug); if (!post) { return {}; @@ -69,18 +66,17 @@ export async function generateMetadata({ }; } -export default async function BlogPostPage({ - params, -}: { - params: { slug: string }; +export default async function BlogPostPage(props: { + params: Promise<{ slug: string }>; }) { + const { slug } = await props.params; const publicationData = getPublication(); - const postData = getPost(params.slug); + const postData = getPost(slug); const [publication, post] = await Promise.all([publicationData, postData]); if (!publication || !post) { - const redirectedPost = await getRedirectedPost(params.slug); + const redirectedPost = await getRedirectedPost(slug); if (redirectedPost) { permanentRedirect(`/blog/${redirectedPost.slug}`); diff --git a/src/app/blog/dashboard/page.tsx b/src/app/blog/dashboard/page.tsx index 4781640..3e79f3a 100644 --- a/src/app/blog/dashboard/page.tsx +++ b/src/app/blog/dashboard/page.tsx @@ -1,6 +1,5 @@ -import { notFound, redirect } from 'next/navigation'; - import { getPublicationId } from '@/lib/hashnode'; +import { notFound, redirect } from 'next/navigation'; export default async function DashboardPage() { const publicationId = await getPublicationId(); diff --git a/src/app/blog/feed.json/route.ts b/src/app/blog/feed.json/route.ts index 3e43f1f..1b574e6 100644 --- a/src/app/blog/feed.json/route.ts +++ b/src/app/blog/feed.json/route.ts @@ -1,6 +1,5 @@ -import { NextResponse } from 'next/server'; - import { getFeed } from '@/lib/hashnode'; +import { NextResponse } from 'next/server'; export const revalidate = 300; diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index f151248..c3f648b 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,14 +1,12 @@ -import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; - +import { metadata as rootMetadata } from '@/app/layout'; import HashnodePageView from '@/components/analytics/HashnodePageView'; import BlogPostList from '@/components/blog/BlogPostList'; import { siteConfig } from '@/constants/config'; import { isProd } from '@/constants/env'; import { getPosts, getPublication } from '@/lib/hashnode'; import { openGraph } from '@/utils/og'; - -import { metadata as rootMetadata } from '../metadata'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; const url = `${siteConfig.url}/blog`; @@ -19,7 +17,7 @@ export async function generateMetadata(): Promise { return {}; } - const title = `${publication.title || `${siteConfig.title} Blog`} | ${siteConfig.title}`; + const title = publication.title || `${siteConfig.title} Blog`; const description = publication.about?.text; const ogImage = openGraph({ title, diff --git a/src/app/blog/preview/[id]/page.tsx b/src/app/blog/preview/[id]/page.tsx index 4cb8bd4..723dc4b 100644 --- a/src/app/blog/preview/[id]/page.tsx +++ b/src/app/blog/preview/[id]/page.tsx @@ -1,21 +1,18 @@ -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; - +import { metadata as rootMetadata } from '@/app/layout'; import BlogDraft from '@/components/blog/BlogDraft'; import { siteConfig } from '@/constants/config'; import { getDraft, getPublication } from '@/lib/hashnode'; import { openGraph } from '@/utils/og'; - -import { metadata as rootMetadata } from '../../../metadata'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; export const revalidate = 0; -export async function generateMetadata({ - params, -}: { - params: { id: string }; +export async function generateMetadata(props: { + params: Promise<{ id: string }>; }): Promise { - const draft = await getDraft(params.id); + const { id } = await props.params; + const draft = await getDraft(id); if (!draft) { return { @@ -58,13 +55,12 @@ export async function generateMetadata({ }; } -export default async function BlogPreviewPage({ - params, -}: { - params: { id: string }; +export default async function BlogPreviewPage(props: { + params: Promise<{ id: string }>; }) { + const { id } = await props.params; const publicationData = getPublication(); - const draftData = getDraft(params.id); + const draftData = getDraft(id); const [publication, draft] = await Promise.all([publicationData, draftData]); diff --git a/src/app/blog/series/[slug]/page.tsx b/src/app/blog/series/[slug]/page.tsx index 58c5b40..cb4d5e0 100644 --- a/src/app/blog/series/[slug]/page.tsx +++ b/src/app/blog/series/[slug]/page.tsx @@ -1,6 +1,4 @@ -import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; - +import { metadata as rootMetadata } from '@/app/layout'; import HashnodePageView from '@/components/analytics/HashnodePageView'; import BlogPostList from '@/components/blog/BlogPostList'; import { siteConfig } from '@/constants/config'; @@ -12,8 +10,8 @@ import { getSeries, } from '@/lib/hashnode'; import { openGraph } from '@/utils/og'; - -import { metadata as rootMetadata } from '../../../metadata'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; export async function generateStaticParams() { const slugs = await getAllSeriesSlugs(); @@ -23,18 +21,17 @@ export async function generateStaticParams() { })); } -export async function generateMetadata({ - params, -}: { - params: { slug: string }; +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; }): Promise { - const series = await getSeries(params.slug); + const { slug } = await props.params; + const series = await getSeries(slug); if (!series || !series.posts.totalDocuments) { return {}; } - const url = `${siteConfig.url}/blog/series/${params.slug}`; + const url = `${siteConfig.url}/blog/series/${slug}`; const title = `${series.name} | Blog`; const description = series.description?.text; const ogImage = openGraph({ @@ -65,14 +62,13 @@ export async function generateMetadata({ }; } -export default async function BlogSeriesPage({ - params, -}: { - params: { slug: string }; +export default async function BlogSeriesPage(props: { + params: Promise<{ slug: string }>; }) { + const { slug } = await props.params; const publicationData = getPublication(); - const seriesInfoData = getSeries(params.slug); - const postsData = getPostsBySeries({ seriesSlug: params.slug }); + const seriesInfoData = getSeries(slug); + const postsData = getPostsBySeries({ seriesSlug: slug }); const [publication, seriesInfo, posts] = await Promise.all([ publicationData, @@ -101,7 +97,7 @@ export default async function BlogSeriesPage({ > -

+

Blog series

@@ -119,7 +115,7 @@ export default async function BlogSeriesPage({ 'use server'; return await getPostsBySeries({ - seriesSlug: params.slug, + seriesSlug: slug, after, }); }} diff --git a/src/app/blog/tag/[slug]/page.tsx b/src/app/blog/tag/[slug]/page.tsx index c3b3e82..fa5a8c7 100644 --- a/src/app/blog/tag/[slug]/page.tsx +++ b/src/app/blog/tag/[slug]/page.tsx @@ -1,6 +1,4 @@ -import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; - +import { metadata as rootMetadata } from '@/app/layout'; import HashnodePageView from '@/components/analytics/HashnodePageView'; import BlogPostList from '@/components/blog/BlogPostList'; import { siteConfig } from '@/constants/config'; @@ -12,8 +10,8 @@ import { getTagName, } from '@/lib/hashnode'; import { openGraph } from '@/utils/og'; - -import { metadata as rootMetadata } from '../../../metadata'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; export async function generateStaticParams() { const slugs = await getAllTagSlugs(); @@ -23,18 +21,17 @@ export async function generateStaticParams() { })); } -export async function generateMetadata({ - params, -}: { - params: { slug: string }; +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; }): Promise { - const tag = await getTagName(params.slug); + const { slug } = await props.params; + const tag = await getTagName(slug); if (!tag) { return {}; } - const url = `${siteConfig.url}/blog/tag/${params.slug}`; + const url = `${siteConfig.url}/blog/tag/${slug}`; const title = `${tag.charAt(0).toUpperCase()}${tag.slice(1)} | Blog`; const description = `Guides, how-tos, and news from the Fix Security team.`; const ogImage = openGraph({ @@ -65,14 +62,13 @@ export async function generateMetadata({ }; } -export default async function BlogTagPage({ - params, -}: { - params: { slug: string }; +export default async function BlogTagPage(props: { + params: Promise<{ slug: string }>; }) { + const { slug } = await props.params; const publicationData = getPublication(); - const tagNameData = getTagName(params.slug); - const postsData = getPostsByTag({ tagSlug: params.slug }); + const tagNameData = getTagName(slug); + const postsData = getPostsByTag({ tagSlug: slug }); const [publication, tagName, posts] = await Promise.all([ publicationData, @@ -96,7 +92,7 @@ export default async function BlogTagPage({ > -

+

From the blog

@@ -113,7 +109,7 @@ export default async function BlogTagPage({ 'use server'; return await getPostsByTag({ - tagSlug: params.slug, + tagSlug: slug, after, }); }} diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx new file mode 100644 index 0000000..eaa8797 --- /dev/null +++ b/src/app/compare/[slug]/page.tsx @@ -0,0 +1,136 @@ +import { metadata as rootMetadata } from '@/app/layout'; +import { metadata as notFoundMetadata } from '@/app/not-found'; +import FixLogo from '@/assets/logo.svg'; +import HashnodePageView from '@/components/analytics/HashnodePageView'; +import MarkdownContent from '@/components/common/MarkdownContent'; +import CompetitorLogo, { hasLogo } from '@/components/compare/CompetitorLogo'; +import Customers from '@/components/sections/Customers'; +import Faq from '@/components/sections/Faq'; +import { siteConfig } from '@/constants/config'; +import { isProd } from '@/constants/env'; +import { + getAllStaticPageSlugs, + getPublicationId, + getStaticPage, +} from '@/lib/hashnode'; +import { openGraph } from '@/utils/og'; +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +export const revalidate = 300; + +export async function generateStaticParams() { + const slugs = await getAllStaticPageSlugs(); + + return slugs + .filter((slug) => slug.startsWith('fix-vs-')) + .map((slug) => ({ + slug, + })); +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await props.params; + const staticPage = await getStaticPage(slug); + + if (!staticPage) { + return notFoundMetadata; + } + + const url = `${siteConfig.url}/compare/${staticPage.slug}`; + const title = `Fix Security vs. ${staticPage.title}`; + const description = staticPage.seo?.description ?? undefined; + const ogImage = openGraph({ title, description }); + + return { + title, + description, + alternates: { + ...rootMetadata.alternates, + canonical: url, + }, + openGraph: { + ...rootMetadata.openGraph, + url, + title, + description, + images: [ogImage], + }, + twitter: { + ...rootMetadata.twitter, + title: `${title} | ${siteConfig.title}`, + description, + images: [ogImage], + }, + ...(staticPage.hidden ? { robots: notFoundMetadata.robots } : {}), + }; +} + +export default async function ComparisonPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + if (!slug.startsWith('fix-vs-')) { + permanentRedirect(`/${slug}`); + } + + const publicationIdData = getPublicationId(); + const staticPageData = getStaticPage(slug); + + const [publicationId, staticPage] = await Promise.all([ + publicationIdData, + staticPageData, + ]); + + if (!staticPage) { + notFound(); + } + + const title = `Fix Security vs. ${staticPage.title}`; + const subtitle = `Why engineers choose Fix Security over ${staticPage.title}`; + const competitorSlug = slug.replace('fix-vs-', ''); + + return ( + <> +
+
+ {hasLogo(competitorSlug) ? ( + <> +

{title}

+ + + ) : ( +

+ {title} +

+ )} +

+ {subtitle} +

+ + {staticPage.content.markdown} + +
+
+ + + {isProd && publicationId ? ( + + ) : null} + + ); +} diff --git a/src/app/compare/layout.tsx b/src/app/compare/layout.tsx new file mode 100644 index 0000000..7a3ddef --- /dev/null +++ b/src/app/compare/layout.tsx @@ -0,0 +1,9 @@ +export const revalidate = 300; + +export default function CompareLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx new file mode 100644 index 0000000..40cc3e8 --- /dev/null +++ b/src/app/compare/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default async function ComparePage() { + redirect('/'); +} diff --git a/src/app/fonts.ts b/src/app/fonts.ts deleted file mode 100644 index 51bf963..0000000 --- a/src/app/fonts.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Plus_Jakarta_Sans } from 'next/font/google'; - -export const plusJakartaSans = Plus_Jakarta_Sans({ - subsets: ['latin'], - display: 'swap', - variable: '--font-plus-jakarta-sans', -}); diff --git a/src/app/frequently-asked-questions/page.tsx b/src/app/frequently-asked-questions/page.tsx new file mode 100644 index 0000000..02beea7 --- /dev/null +++ b/src/app/frequently-asked-questions/page.tsx @@ -0,0 +1,39 @@ +import { metadata as rootMetadata } from '@/app/layout'; +import Faq from '@/components/sections/Faq'; +import { siteConfig } from '@/constants/config'; +import { openGraph } from '@/utils/og'; +import { Metadata } from 'next'; + +const url = `${siteConfig.url}/frequently-asked-questions`; +const title = 'Frequently asked questions'; +const description = siteConfig.description; +const ogImage = openGraph({ + title, + description, +}); + +export const metadata: Metadata = { + title, + description, + alternates: { + ...rootMetadata.alternates, + canonical: url, + }, + openGraph: { + ...rootMetadata.openGraph, + url, + title, + description, + images: [ogImage], + }, + twitter: { + ...rootMetadata.twitter, + title: `${title} | ${siteConfig.title}`, + description, + images: [ogImage], + }, +}; + +export default async function FaqPage() { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c86e9ec..a52a310 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,93 +1,148 @@ -import { apiPlugin, storyblokInit } from '@storyblok/react'; -import { StoryblokBridgeLoader } from '@storyblok/react/rsc'; -import { Viewport } from 'next'; -import { headers } from 'next/headers'; -import Script from 'next/script'; -import PlausibleProvider from 'next-plausible'; -import React, { Suspense } from 'react'; - -import '@/styles/main.scss'; +import '@/styles/cookiebot.css'; +import '@/styles/globals.css'; -import { plusJakartaSans } from '@/app/fonts'; -import PosthogPageView from '@/components/analytics/PosthogPageView'; import BlogNewsletterForm from '@/components/blog/BlogNewsletterForm'; import Footer from '@/components/layout/Footer'; import Header from '@/components/layout/Header'; -import PosthogProvider from '@/providers/posthog'; -import StoryblokProvider from '@/providers/StoryblokProvider'; +import { siteConfig } from '@/constants/config'; +import { COOKIEBOT_ID } from '@/constants/cookiebot'; +import { isProd } from '@/constants/env'; +import { GTM_CONTAINER_ID } from '@/constants/google'; +import { openGraph } from '@/utils/og'; +import { Metadata, Viewport } from 'next'; +import { Plus_Jakarta_Sans } from 'next/font/google'; +import { headers } from 'next/headers'; +import Script from 'next/script'; +import { Suspense } from 'react'; -import components from '../../storyblok'; +const { url, title, description } = siteConfig; +const ogImage = openGraph({ + title: siteConfig.tagline, + description, +}); -storyblokInit({ - accessToken: process.env.STORYBLOK_OAUTH_TOKEN, - use: [apiPlugin], - components, - apiOptions: { - cache: { type: 'none' }, +export const metadata: Metadata = { + title: { + default: `${title}: ${siteConfig.tagline}`, + template: `%s | ${title}`, }, -}); + description, + metadataBase: isProd ? new URL(url) : undefined, + robots: isProd + ? { index: true, follow: true } + : { index: false, follow: false }, + icons: [ + { + url: '/favicon.ico', + type: 'image/x-icon', + sizes: '16x16 32x32', + }, + { + url: '/icon.svg', + type: 'image/svg+xml', + sizes: 'any', + }, + { + rel: 'apple-touch-icon', + url: '/apple-touch-icon.png', + type: 'image/png', + sizes: '180x180', + }, + ], + manifest: '/site.webmanifest', + alternates: { + types: { + 'application/rss+xml': [ + { url: '/blog/rss.xml', title: 'Fix Security blog RSS feed' }, + ], + 'application/atom+xml': [ + { url: '/blog/atom.xml', title: 'Fix Security blog Atom feed' }, + ], + 'application/json': [ + { url: '/blog/feed.json', title: 'Fix Security blog JSON feed' }, + ], + }, + }, + openGraph: { + url, + title, + description, + siteName: title, + images: [ogImage], + type: 'website', + locale: 'en_US', + }, +}; export const viewport: Viewport = { - themeColor: '#3d58d3', + themeColor: '#7640eb', colorScheme: 'only light', }; -export default function RootLayout({ +const plusJakartaSans = Plus_Jakarta_Sans({ + subsets: ['latin'], + display: 'swap', + variable: '--font-plus-jakarta-sans', +}); + +const gtmScript = ` +(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= +'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','dataLayer','${GTM_CONTAINER_ID}'); +`; + +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { - const nonce = headers().get('x-nonce') ?? undefined; + const nonce = (await headers()).get('x-nonce') ?? undefined; return ( - -