mirror of
https://github.com/shadcn-ui/taxonomy
synced 2026-05-24 09:48:32 +00:00
feat: implement blog using next-mdx-remote
This commit is contained in:
parent
a82ef82973
commit
4c6d66fa67
15 changed files with 1571 additions and 94 deletions
42
app/(marketing)/blog/[...slug]/page.tsx
Normal file
42
app/(marketing)/blog/[...slug]/page.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Blog } from "@/lib/mdx/sources"
|
||||
import { MdxContent } from "@/components/mdx-content"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
|
||||
// TODO: Properly type this file once the following fix lands.
|
||||
// @see https://github.com/vercel/next.js/pull/42019
|
||||
interface PostPageProps {
|
||||
params: {
|
||||
slug: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const files = await Blog.getMdxFiles()
|
||||
|
||||
return files?.map((file) => ({
|
||||
slug: file.slug.split("/"),
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }) {
|
||||
const post = await Blog.getMdxNode(params?.slug?.join("/"))
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-2xl py-12">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="max-w-[90%] text-4xl font-bold leading-normal">
|
||||
{post.frontMatter.title}
|
||||
</h1>
|
||||
{post.frontMatter.date && (
|
||||
<p className="text-sm text-slate-600">
|
||||
{formatDate(post.frontMatter.date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<hr className="my-6" />
|
||||
<div className="prose max-w-none">
|
||||
<MdxContent source={post.mdx} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
45
app/(marketing)/blog/page.tsx
Normal file
45
app/(marketing)/blog/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import Link from "next/link"
|
||||
|
||||
import { Blog } from "@/lib/mdx/sources"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { Icons } from "@/components/icons"
|
||||
|
||||
export default async function BlogPage() {
|
||||
const posts = await Blog.getAllMdxNodes()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-6 py-12 xl:px-8">
|
||||
<h1 className="text-3xl font-bold leading-tight sm:text-4xl md:text-5xl">
|
||||
Blog
|
||||
</h1>
|
||||
<p className="mt-4 text-gray-700">
|
||||
A blog built using MDX content. I copied some sample blog posts from my{" "}
|
||||
<Link href="https://shadcn.com" target="_blank" className="underline">
|
||||
personal site
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<hr className="mt-6 py-6" />
|
||||
{posts.map((post) => (
|
||||
<article key={post.slug} className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Link href={post.url}>
|
||||
<h2 className="max-w-[90%] text-2xl font-bold leading-normal sm:text-3xl md:text-3xl">
|
||||
{post.frontMatter.title}
|
||||
</h2>
|
||||
</Link>
|
||||
{post.frontMatter.date && (
|
||||
<p className="text-sm text-slate-600">
|
||||
{formatDate(post.frontMatter.date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{post.frontMatter.excerpt && (
|
||||
<p className="text-slate-600">{post.frontMatter.excerpt}</p>
|
||||
)}
|
||||
<hr className="mt-6 py-6" />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,10 +9,17 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||
return (
|
||||
<div className="mx-auto w-full px-4">
|
||||
<header className="mx-auto flex max-w-[1440px] items-center justify-between py-4">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Icons.logo />
|
||||
<span className="font-bold">Taxonomy</span>
|
||||
</Link>
|
||||
<div className="flex gap-10">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Icons.logo />
|
||||
<span className="font-bold">Taxonomy</span>
|
||||
</Link>
|
||||
<nav>
|
||||
<Link href="/blog" className="hover:underline">
|
||||
Blog
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="/login">Login</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ export default function Head() {
|
|||
<title>Taxonomy</title>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta
|
||||
name="description"
|
||||
content="An open source application built using the new router, server components and everything new in Next.js 13."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ interface RootLayoutProps {
|
|||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body className="min-h-screen">
|
||||
{children}
|
||||
<Toaster position="bottom-right" />
|
||||
|
|
|
|||
7
components/mdx-content.tsx
Normal file
7
components/mdx-content.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { MDXRemote } from "next-mdx-remote"
|
||||
|
||||
export function MdxContent({ source }) {
|
||||
return <MDXRemote {...source} />
|
||||
}
|
||||
64
content/blog/next-auth-postmark.mdx
Normal file
64
content/blog/next-auth-postmark.mdx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
title: Using Postmark with NextAuth.js
|
||||
excerpt: Send NextAuth emails using Postmark email templates.
|
||||
date: "2023-03-04"
|
||||
---
|
||||
|
||||
Install Postmark
|
||||
|
||||
```bash
|
||||
yarn add postmark
|
||||
```
|
||||
|
||||
Next add the following environment variables:
|
||||
|
||||
```
|
||||
POSTMARK_API_TOKEN=
|
||||
SMTP_HOST=smtp.postmarkapp.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=name <no-reply@name.com>
|
||||
```
|
||||
|
||||
You can find the values for `POSTMARK_API_TOKEN`, `SMTP_USER` and `SMTP_PASSWORD` by visiting Servers → API Tokens from the Postmark dashboard.
|
||||
|
||||
To send next-auth emails using Postmark, update the `NextAuth` callback as follows:
|
||||
|
||||
```ts {3,5,19-32} title="pages/api/auth/[...nextauth].ts"
|
||||
import NextAuth from "next-auth"
|
||||
import Providers from "next-auth/providers"
|
||||
import { Client } from "postmark"
|
||||
|
||||
const postmarkClient = new Client(process.env.POSTMARK_API_TOKEN)
|
||||
|
||||
export default NextAuth({
|
||||
providers: [
|
||||
Providers.Email({
|
||||
server: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: process.env.SMTP_FROM,
|
||||
sendVerificationRequest: async ({ identifier, url, provider }) => {
|
||||
const result = await postmarkClient.sendEmailWithTemplate({
|
||||
TemplateId: "TEMPLATE-ID",
|
||||
To: identifier,
|
||||
From: provider.from,
|
||||
TemplateModel: {
|
||||
// variable: value,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.ErrorCode) {
|
||||
throw new Error(result.Message)
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
83
content/blog/next-single-page-pagination.mdx
Normal file
83
content/blog/next-single-page-pagination.mdx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
title: Single-page Pagination in Next.js
|
||||
excerpt: Implement pagination with route parameters and rewrites.
|
||||
date: "2023-04-09"
|
||||
---
|
||||
|
||||
Let's say:
|
||||
|
||||
1. We have an API (local or from a headless CMS) returning **150 posts**.
|
||||
2. We want to display **10 posts per page**.
|
||||
|
||||
We want to implement paginated static routes as follows: `/blog/1`, `blog/2` up to `blog/15`.
|
||||
|
||||
You can do this in a single page using [route parameters](https://nextjs.org/docs/routing/dynamic-routes) and [rewrites](https://nextjs.org/docs/api-reference/next.config.js/rewrites):
|
||||
|
||||
```tsx title="pages/blog/[page].tsx"
|
||||
interface BlogPageProps {
|
||||
posts: Post[]
|
||||
}
|
||||
|
||||
export default function BlogPage({ posts }: BlogPageProps) {
|
||||
// Loop and render posts.
|
||||
}
|
||||
|
||||
export async function getStaticPaths(context): Promise<GetStaticPathsResult> {
|
||||
// Get total number of posts from API.
|
||||
const totalPages = await getTotalPagesFromAPI()
|
||||
const numberOfPages = Math.ceil(totalPages / 10)
|
||||
|
||||
// Build paths `blog/0`, `blog/1` ...etc.
|
||||
const paths = Array(numberOfPages)
|
||||
.fill(0)
|
||||
.map((_, page) => ({
|
||||
params: {
|
||||
page: `${page + 1}`,
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: "false",
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticProps(
|
||||
context
|
||||
): Promise<GetStaticPropsResult<BlogPageProps>> {
|
||||
// Call your API and get the posts for the current page.
|
||||
const posts = await getPostsFromAPI({
|
||||
limit: 10,
|
||||
offset: context.params.page ? 10 * context.params.page : 0,
|
||||
})
|
||||
|
||||
if (!posts.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
posts,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If we visit `/blog/0` we should see the first 10 posts, `/blog/1` the next 10 posts and so on.
|
||||
|
||||
We can use a rewrite to map `/blog` to `/blog/0`.
|
||||
|
||||
```js title="next.config.js"
|
||||
module.exports = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/blog",
|
||||
destination: "/blog/0",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
```
|
||||
40
content/blog/prisma-jest-reset-database.mdx
Normal file
40
content/blog/prisma-jest-reset-database.mdx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
title: Reset Prisma database before running tests with Jest
|
||||
date: "2023-01-02"
|
||||
---
|
||||
|
||||
Add a custom setup file to your `jest.config.ts`:
|
||||
|
||||
```ts title="jest.config.ts"
|
||||
import type { Config } from "@jest/types"
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
// ...
|
||||
setupFilesAfterEnv: ["./src/tests/setup.ts"], // highlight-line
|
||||
// ...
|
||||
}
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
Then in `setup.ts`
|
||||
|
||||
```ts title="setup.ts"
|
||||
import { Prisma } from ".prisma/client"
|
||||
|
||||
export async function resetDatabase() {
|
||||
const tables = Prisma.dmmf.datamodel.models.map((model) => model.dbName)
|
||||
|
||||
for (const table of tables) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table} CASCADE`)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
prisma.$disconnect()
|
||||
})
|
||||
```
|
||||
93
content/blog/validation-middleware.mdx
Normal file
93
content/blog/validation-middleware.mdx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
title: Next.js API Routes validation using Middlewares and Yup
|
||||
date: "2023-01-08"
|
||||
---
|
||||
|
||||
## Middlewares
|
||||
|
||||
### withMethods
|
||||
|
||||
Let's start with a simple middleware to validate `request.method`.
|
||||
|
||||
```ts
|
||||
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
export function withMethods(methods: string[], handler: NextApiHandler) {
|
||||
return async function (request: NextApiRequest, response: NextApiResponse) {
|
||||
if (!methods.includes(request.method)) {
|
||||
return response.status(405).json("")
|
||||
}
|
||||
|
||||
return handler(request, response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To use this middleware, we call our API handler by wrapping it inside `withMethods`:
|
||||
|
||||
```ts
|
||||
async function handler(request: NextApiRequest, response: NextApiResponse) {}
|
||||
|
||||
export default withMethods(["POST"], handler)
|
||||
```
|
||||
|
||||
### withValidation
|
||||
|
||||
Next, we're going to create a middleware to validate `request.body`
|
||||
|
||||
```ts
|
||||
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
|
||||
import type { ObjectShape, OptionalObjectSchema } from "yup/lib/object"
|
||||
|
||||
export function withValidation<T extends OptionalObjectSchema<ObjectShape>>(
|
||||
schema: T,
|
||||
handler: NextApiHandler
|
||||
) {
|
||||
return async function (request: NextApiRequest, response: NextApiResponse) {
|
||||
try {
|
||||
request.body = await schema.validate(request.body)
|
||||
|
||||
return handler(request, response)
|
||||
} catch (error) {
|
||||
if (error instanceof yup.ValidationError) {
|
||||
return response.status(422).json(error.message)
|
||||
}
|
||||
|
||||
return response.status(422).json("")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This middleware accepts a schema to validate `request.body` against. We use it as follows:
|
||||
|
||||
```ts
|
||||
const postSchema = yup.object({
|
||||
title: yup.string().required("`Title` field missing."),
|
||||
body: yup.string().required("`Body` field missing"),
|
||||
})
|
||||
|
||||
interface Post extends yup.TypeOf<typeof postSchema> {}
|
||||
|
||||
async function handler(request: NextApiRequest, response: NextApiResponse) {
|
||||
const post: Post = request.body
|
||||
|
||||
// TODO: Save post.
|
||||
|
||||
response.status(201).json("")
|
||||
}
|
||||
|
||||
export default withValidation(postSchema, handler)
|
||||
```
|
||||
|
||||
## Chaining
|
||||
|
||||
We can call mutiple middlewares by chaining:
|
||||
|
||||
```ts title="pages/api/posts"
|
||||
export default withMethods(["POST"], withValidation(postSchema, handler))
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
You can check out the code here: [https://github.com/arshad/nextjs-middleware-validation](https://github.com/arshad/nextjs-middleware-validation).
|
||||
143
lib/mdx/index.ts
Normal file
143
lib/mdx/index.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { promises as fs } from "fs"
|
||||
import hasha from "hasha"
|
||||
import glob from "fast-glob"
|
||||
import path from "path"
|
||||
import NodeCache from "node-cache"
|
||||
import { serialize } from "next-mdx-remote/serialize"
|
||||
import {
|
||||
SerializeOptions,
|
||||
MDXRemoteSerializeResult,
|
||||
} from "next-mdx-remote/dist/types"
|
||||
import * as z from "zod"
|
||||
|
||||
const mdxCache = new NodeCache()
|
||||
|
||||
export interface Source<T> {
|
||||
contentPath: string
|
||||
basePath: string
|
||||
sortBy?: string
|
||||
sortOrder?: "asc" | "desc"
|
||||
frontMatter: T
|
||||
}
|
||||
|
||||
interface MdxFile {
|
||||
filepath: string
|
||||
slug: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface MdxFileData<TFrontmatter> {
|
||||
raw: string
|
||||
hash: string
|
||||
frontMatter: TFrontmatter
|
||||
mdx: MDXRemoteSerializeResult
|
||||
}
|
||||
|
||||
export function createSource<T extends z.ZodType>(source: Source<T>) {
|
||||
const { contentPath, basePath, sortBy, sortOrder } = source
|
||||
|
||||
async function getMdxFiles() {
|
||||
const files = await glob(`${contentPath}/**/*.{md,mdx}`)
|
||||
|
||||
if (!files.length) return []
|
||||
|
||||
return files.map((filepath) => {
|
||||
let slug = filepath
|
||||
.replace(contentPath, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(new RegExp(path.extname(filepath) + "$"), "")
|
||||
|
||||
slug = slug.replace(/\/?index$/, "")
|
||||
|
||||
return {
|
||||
filepath,
|
||||
slug,
|
||||
url: `${basePath?.replace(/\/$/, "")}/${slug}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getFileData(
|
||||
file: MdxFile,
|
||||
mdxOptions?: SerializeOptions
|
||||
): Promise<MdxFileData<z.infer<T>>> {
|
||||
const raw = await fs.readFile(file.filepath, "utf-8")
|
||||
const hash = hasha(raw.toString())
|
||||
|
||||
const cachedContent = mdxCache.get<MdxFileData<z.infer<T>>>(hash)
|
||||
if (cachedContent?.hash === hash) {
|
||||
return cachedContent
|
||||
}
|
||||
|
||||
const mdx = await serialize(raw, {
|
||||
parseFrontmatter: true,
|
||||
...mdxOptions,
|
||||
})
|
||||
const frontMatter = mdx.frontmatter
|
||||
? (source.frontMatter.parse(mdx.frontmatter) as z.infer<T>)
|
||||
: null
|
||||
|
||||
const fileData = {
|
||||
raw,
|
||||
frontMatter,
|
||||
hash,
|
||||
mdx,
|
||||
}
|
||||
|
||||
mdxCache.set(hash, fileData)
|
||||
|
||||
return fileData
|
||||
}
|
||||
|
||||
async function getMdxNode(
|
||||
slug: string | string[],
|
||||
mdxOptions?: SerializeOptions
|
||||
) {
|
||||
const _slug = Array.isArray(slug) ? slug.join("/") : slug
|
||||
|
||||
const files = await getMdxFiles()
|
||||
|
||||
if (!files?.length) return null
|
||||
|
||||
const [file] = files.filter((file) => file.slug === _slug)
|
||||
|
||||
if (!file) return null
|
||||
|
||||
const data = await getFileData(file, mdxOptions)
|
||||
|
||||
return {
|
||||
...file,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllMdxNodes() {
|
||||
const files = await getMdxFiles()
|
||||
|
||||
if (!files.length) return []
|
||||
|
||||
const nodes = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return await getMdxNode(file.slug)
|
||||
})
|
||||
)
|
||||
|
||||
const adjust = sortOrder === "desc" ? -1 : 1
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.frontMatter[sortBy] < b.frontMatter[sortBy]) {
|
||||
return -1 * adjust
|
||||
}
|
||||
if (a.frontMatter[sortBy] > b.frontMatter[sortBy]) {
|
||||
return 1 * adjust
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getMdxFiles,
|
||||
getFileData,
|
||||
getMdxNode,
|
||||
getAllMdxNodes,
|
||||
}
|
||||
}
|
||||
14
lib/mdx/sources.ts
Normal file
14
lib/mdx/sources.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import * as z from "zod"
|
||||
import { createSource } from "."
|
||||
|
||||
export const Blog = createSource({
|
||||
contentPath: "content/blog",
|
||||
basePath: "/blog",
|
||||
sortBy: "date",
|
||||
sortOrder: "desc",
|
||||
frontMatter: z.object({
|
||||
title: z.string(),
|
||||
date: z.string(),
|
||||
excerpt: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
"lint": "next lint",
|
||||
"preview": "next build && next start"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19",
|
||||
"dependencies": {
|
||||
"@editorjs/code": "^2.7.0",
|
||||
"@editorjs/editorjs": "^2.25.0",
|
||||
|
|
@ -33,9 +32,13 @@
|
|||
"@radix-ui/react-popover": "1.0.0",
|
||||
"@radix-ui/react-toggle": "^1.0.0",
|
||||
"clsx": "^1.2.1",
|
||||
"fast-glob": "^3.2.12",
|
||||
"hasha": "^5.2.2",
|
||||
"lucide-react": "^0.92.0",
|
||||
"next": "^13.0.0",
|
||||
"next-auth": "^4.15.0",
|
||||
"next-mdx-remote": "^4.1.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"nodemailer": "^6.8.0",
|
||||
"postmark": "^3.0.14",
|
||||
"prop-types": "^15.8.1",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
|
|
@ -20,15 +16,10 @@
|
|||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/components/*": [
|
||||
"components/*"
|
||||
],
|
||||
"@/lib/*": [
|
||||
"lib/*"
|
||||
],
|
||||
"@/styles/*": [
|
||||
"styles/*"
|
||||
]
|
||||
"@/components/*": ["components/*"],
|
||||
"@/lib/*": ["lib/*"],
|
||||
"@/styles/*": ["styles/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
|
@ -40,9 +31,8 @@
|
|||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue