refactor(blog) remove content-collections & add blog source
This commit is contained in:
parent
21bc0b1293
commit
0a2d081b07
@ -30,7 +30,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Content Management
|
## Content Management
|
||||||
- `content/`: MDX content files
|
- `content/`: MDX content files
|
||||||
- `content-collections.ts`: Content collection configuration
|
- `source.config.ts`: Fumadocs source configuration
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
- `env.example`: Environment variables template
|
- `env.example`: Environment variables template
|
||||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -22,6 +22,8 @@
|
|||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/node_modules": true,
|
"**/node_modules": true,
|
||||||
".next": true,
|
".next": true,
|
||||||
".content-collections": true,
|
".source": true,
|
||||||
|
".wrangler": true,
|
||||||
|
".open-next": true
|
||||||
}
|
}
|
||||||
}
|
}
|
10
biome.json
10
biome.json
@ -11,18 +11,17 @@
|
|||||||
".next/**",
|
".next/**",
|
||||||
".cursor/**",
|
".cursor/**",
|
||||||
".vscode/**",
|
".vscode/**",
|
||||||
".content-collections/**",
|
".source/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"drizzle/**",
|
"src/db/**",
|
||||||
"tailwind.config.ts",
|
"tailwind.config.ts",
|
||||||
"src/components/ui/*.tsx",
|
"src/components/ui/*.tsx",
|
||||||
"src/components/magicui/*.tsx",
|
"src/components/magicui/*.tsx",
|
||||||
"src/components/animate-ui/*.tsx",
|
"src/components/animate-ui/*.tsx",
|
||||||
"src/components/tailark/*.tsx",
|
"src/components/tailark/*.tsx",
|
||||||
"src/app/[[]locale]/preview/**",
|
"src/app/[[]locale]/preview/**",
|
||||||
"src/db/schema.ts",
|
|
||||||
"src/payment/types.ts",
|
"src/payment/types.ts",
|
||||||
"src/types/index.d.ts",
|
"src/types/index.d.ts",
|
||||||
"public/sw.js"
|
"public/sw.js"
|
||||||
@ -70,18 +69,17 @@
|
|||||||
".next/**",
|
".next/**",
|
||||||
".cursor/**",
|
".cursor/**",
|
||||||
".vscode/**",
|
".vscode/**",
|
||||||
".content-collections/**",
|
".source/**",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"drizzle/**",
|
"src/db/**",
|
||||||
"tailwind.config.ts",
|
"tailwind.config.ts",
|
||||||
"src/components/ui/*.tsx",
|
"src/components/ui/*.tsx",
|
||||||
"src/components/magicui/*.tsx",
|
"src/components/magicui/*.tsx",
|
||||||
"src/components/animate-ui/*.tsx",
|
"src/components/animate-ui/*.tsx",
|
||||||
"src/components/tailark/*.tsx",
|
"src/components/tailark/*.tsx",
|
||||||
"src/app/[[]locale]/preview/**",
|
"src/app/[[]locale]/preview/**",
|
||||||
"src/db/schema.ts",
|
|
||||||
"src/payment/types.ts",
|
"src/payment/types.ts",
|
||||||
"src/types/index.d.ts",
|
"src/types/index.d.ts",
|
||||||
"public/sw.js"
|
"public/sw.js"
|
||||||
|
@ -1,324 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import { DEFAULT_LOCALE, LOCALES } from '@/i18n/routing';
|
|
||||||
import { defineCollection, defineConfig } from '@content-collections/core';
|
|
||||||
import { transformMDX } from '@fumadocs/content-collections/configuration';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. Content Collections documentation
|
|
||||||
* https://www.content-collections.dev/docs/quickstart/next
|
|
||||||
* https://www.content-collections.dev/docs/configuration
|
|
||||||
* https://www.content-collections.dev/docs/transform#join-collections
|
|
||||||
*
|
|
||||||
* 2. Use Content Collections for Fumadocs
|
|
||||||
* https://fumadocs.dev/docs/headless/content-collections
|
|
||||||
*/
|
|
||||||
// const docs = defineCollection({
|
|
||||||
// name: 'docs',
|
|
||||||
// directory: 'content/docs',
|
|
||||||
// include: '**/*.mdx',
|
|
||||||
// schema: (z) => ({
|
|
||||||
// ...createDocSchema(z),
|
|
||||||
// preview: z.string().optional(),
|
|
||||||
// index: z.boolean().default(false),
|
|
||||||
// }),
|
|
||||||
// transform: transformMDX,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const metas = defineCollection({
|
|
||||||
// name: 'meta',
|
|
||||||
// directory: 'content/docs',
|
|
||||||
// include: '**/meta**.json',
|
|
||||||
// parser: 'json',
|
|
||||||
// schema: createMetaSchema,
|
|
||||||
// });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blog Author collection
|
|
||||||
*
|
|
||||||
* Authors are identified by their slug across all languages
|
|
||||||
* New format: content/author/authorname.{locale}.mdx
|
|
||||||
* Example: content/author/mksaas.mdx (default locale) and content/author/mksaas.zh.mdx (Chinese)
|
|
||||||
*
|
|
||||||
* For author, slug is slugAsParams
|
|
||||||
*/
|
|
||||||
export const authors = defineCollection({
|
|
||||||
name: 'author',
|
|
||||||
directory: 'content/author',
|
|
||||||
include: '**/*.mdx',
|
|
||||||
schema: (z) => ({
|
|
||||||
slug: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
avatar: z.string(),
|
|
||||||
locale: z.string().optional().default(DEFAULT_LOCALE),
|
|
||||||
}),
|
|
||||||
transform: async (data, context) => {
|
|
||||||
// Get the filename from the path
|
|
||||||
const filePath = data._meta.path;
|
|
||||||
const fileName = filePath.split(path.sep).pop() || '';
|
|
||||||
|
|
||||||
// Extract locale and base from filename
|
|
||||||
const { locale, base } = extractLocaleAndBase(fileName);
|
|
||||||
// console.log(`author processed: ${fileName}, locale=${locale}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
locale,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blog Category collection
|
|
||||||
*
|
|
||||||
* Categories are identified by their slug across all languages
|
|
||||||
* New format: content/category/categoryname.{locale}.mdx
|
|
||||||
* Example: content/category/tutorial.mdx (default locale) and content/category/tutorial.zh.mdx (Chinese)
|
|
||||||
*
|
|
||||||
* For category, slug is slugAsParams
|
|
||||||
*/
|
|
||||||
export const categories = defineCollection({
|
|
||||||
name: 'category',
|
|
||||||
directory: 'content/category',
|
|
||||||
include: '**/*.mdx',
|
|
||||||
schema: (z) => ({
|
|
||||||
slug: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
locale: z.string().optional().default(DEFAULT_LOCALE),
|
|
||||||
}),
|
|
||||||
transform: async (data, context) => {
|
|
||||||
// Get the filename from the path
|
|
||||||
const filePath = data._meta.path;
|
|
||||||
const fileName = filePath.split(path.sep).pop() || '';
|
|
||||||
|
|
||||||
// Extract locale and base from filename
|
|
||||||
const { locale, base } = extractLocaleAndBase(fileName);
|
|
||||||
// console.log(`category processed: ${fileName}, locale=${locale}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
locale,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blog Post collection
|
|
||||||
*
|
|
||||||
* New format: content/blog/post-slug.{locale}.mdx
|
|
||||||
*
|
|
||||||
* slug: /blog/first-post, used in URL or sitemap
|
|
||||||
* slugAsParams: first-post, used in route params
|
|
||||||
*
|
|
||||||
* 1. For a blog post at content/blog/first-post.mdx (default locale):
|
|
||||||
* locale: en
|
|
||||||
* slug: /blog/first-post
|
|
||||||
* slugAsParams: first-post
|
|
||||||
*
|
|
||||||
* 2. For a blog post at content/blog/first-post.zh.mdx (Chinese locale):
|
|
||||||
* locale: zh
|
|
||||||
* slug: /blog/first-post
|
|
||||||
* slugAsParams: first-post
|
|
||||||
*/
|
|
||||||
export const posts = defineCollection({
|
|
||||||
name: 'post',
|
|
||||||
directory: 'content/blog',
|
|
||||||
include: '**/*.mdx',
|
|
||||||
schema: (z) => ({
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
image: z.string(),
|
|
||||||
date: z.string().datetime(),
|
|
||||||
published: z.boolean().default(true),
|
|
||||||
categories: z.array(z.string()),
|
|
||||||
author: z.string(),
|
|
||||||
estimatedTime: z.number().optional(), // Reading time in minutes
|
|
||||||
}),
|
|
||||||
transform: async (data, context) => {
|
|
||||||
// Use Fumadocs transformMDX for consistent MDX processing
|
|
||||||
const transformedData = await transformMDX(data, context);
|
|
||||||
|
|
||||||
// Get the filename from the path
|
|
||||||
const filePath = data._meta.path;
|
|
||||||
const fileName = filePath.split(path.sep).pop() || '';
|
|
||||||
|
|
||||||
// Extract locale and base from filename
|
|
||||||
const { locale, base } = extractLocaleAndBase(fileName);
|
|
||||||
// console.log(`post processed: ${fileName}, base=${base}, locale=${locale}`);
|
|
||||||
|
|
||||||
// Find the author by matching slug and locale
|
|
||||||
const blogAuthor = context
|
|
||||||
.documents(authors)
|
|
||||||
.find((a) => a.slug === data.author && a.locale === locale);
|
|
||||||
|
|
||||||
// Find categories by matching slug and locale
|
|
||||||
const blogCategories = data.categories
|
|
||||||
.map((categorySlug) => {
|
|
||||||
const category = context
|
|
||||||
.documents(categories)
|
|
||||||
.find((c) => c.slug === categorySlug && c.locale === locale);
|
|
||||||
|
|
||||||
return category;
|
|
||||||
})
|
|
||||||
.filter(Boolean); // Remove null values
|
|
||||||
|
|
||||||
// Create the slug and slugAsParams
|
|
||||||
const slug = `/blog/${base}`;
|
|
||||||
const slugAsParams = base;
|
|
||||||
|
|
||||||
// Calculate estimated reading time
|
|
||||||
const wordCount = data.content.split(/\s+/).length;
|
|
||||||
const wordsPerMinute = 200; // average reading speed: 200 words per minute
|
|
||||||
const estimatedTime = Math.max(Math.ceil(wordCount / wordsPerMinute), 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
locale,
|
|
||||||
author: blogAuthor,
|
|
||||||
categories: blogCategories,
|
|
||||||
slug,
|
|
||||||
slugAsParams,
|
|
||||||
estimatedTime,
|
|
||||||
body: transformedData.body,
|
|
||||||
toc: transformedData.toc,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pages collection for policy pages like privacy-policy, terms-of-service, etc.
|
|
||||||
*
|
|
||||||
* New format: content/pages/page-slug.{locale}.mdx
|
|
||||||
*
|
|
||||||
* 1. For a page at content/pages/privacy-policy.mdx (default locale):
|
|
||||||
* locale: en
|
|
||||||
* slug: /pages/privacy-policy
|
|
||||||
* slugAsParams: privacy-policy
|
|
||||||
*
|
|
||||||
* 2. For a page at content/pages/privacy-policy.zh.mdx (Chinese locale):
|
|
||||||
* locale: zh
|
|
||||||
* slug: /pages/privacy-policy
|
|
||||||
* slugAsParams: privacy-policy
|
|
||||||
*/
|
|
||||||
// export const pages = defineCollection({
|
|
||||||
// name: 'page',
|
|
||||||
// directory: 'content/pages',
|
|
||||||
// include: '**/*.mdx',
|
|
||||||
// schema: (z) => ({
|
|
||||||
// title: z.string(),
|
|
||||||
// description: z.string(),
|
|
||||||
// date: z.string().datetime(),
|
|
||||||
// published: z.boolean().default(true),
|
|
||||||
// }),
|
|
||||||
// transform: async (data, context) => {
|
|
||||||
// // Use Fumadocs transformMDX for consistent MDX processing
|
|
||||||
// const transformedData = await transformMDX(data, context);
|
|
||||||
|
|
||||||
// // Get the filename from the path
|
|
||||||
// const filePath = data._meta.path;
|
|
||||||
// const fileName = filePath.split(path.sep).pop() || '';
|
|
||||||
|
|
||||||
// // Extract locale and base from filename
|
|
||||||
// const { locale, base } = extractLocaleAndBase(fileName);
|
|
||||||
// // console.log(`page processed: ${fileName}, base=${base}, locale=${locale}`);
|
|
||||||
|
|
||||||
// // Create the slug and slugAsParams
|
|
||||||
// const slug = `/pages/${base}`;
|
|
||||||
// const slugAsParams = base;
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// ...data,
|
|
||||||
// locale,
|
|
||||||
// slug,
|
|
||||||
// slugAsParams,
|
|
||||||
// body: transformedData.body,
|
|
||||||
// toc: transformedData.toc,
|
|
||||||
// };
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Releases collection for changelog
|
|
||||||
*
|
|
||||||
* New format: content/release/version-slug.{locale}.mdx
|
|
||||||
*
|
|
||||||
* 1. For a release at content/release/v1-0-0.mdx (default locale):
|
|
||||||
* locale: en
|
|
||||||
* slug: /release/v1-0-0
|
|
||||||
* slugAsParams: v1-0-0
|
|
||||||
*
|
|
||||||
* 2. For a release at content/release/v1-0-0.zh.mdx (Chinese locale):
|
|
||||||
* locale: zh
|
|
||||||
* slug: /release/v1-0-0
|
|
||||||
* slugAsParams: v1-0-0
|
|
||||||
*/
|
|
||||||
// export const releases = defineCollection({
|
|
||||||
// name: 'release',
|
|
||||||
// directory: 'content/release',
|
|
||||||
// include: '**/*.mdx',
|
|
||||||
// schema: (z) => ({
|
|
||||||
// title: z.string(),
|
|
||||||
// description: z.string(),
|
|
||||||
// date: z.string().datetime(),
|
|
||||||
// version: z.string(),
|
|
||||||
// published: z.boolean().default(true),
|
|
||||||
// }),
|
|
||||||
// transform: async (data, context) => {
|
|
||||||
// // Use Fumadocs transformMDX for consistent MDX processing
|
|
||||||
// const transformedData = await transformMDX(data, context);
|
|
||||||
|
|
||||||
// // Get the filename from the path
|
|
||||||
// const filePath = data._meta.path;
|
|
||||||
// const fileName = filePath.split(path.sep).pop() || '';
|
|
||||||
|
|
||||||
// // Extract locale and base from filename
|
|
||||||
// const { locale, base } = extractLocaleAndBase(fileName);
|
|
||||||
// // console.log(`release processed: ${fileName}, base=${base}, locale=${locale}`);
|
|
||||||
|
|
||||||
// // Create the slug and slugAsParams
|
|
||||||
// const slug = `/release/${base}`;
|
|
||||||
// const slugAsParams = base;
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// ...data,
|
|
||||||
// locale,
|
|
||||||
// slug,
|
|
||||||
// slugAsParams,
|
|
||||||
// body: transformedData.body,
|
|
||||||
// toc: transformedData.toc,
|
|
||||||
// };
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to extract locale and base name from filename
|
|
||||||
* Handles filename formats:
|
|
||||||
* - name -> locale: DEFAULT_LOCALE, base: name
|
|
||||||
* - name.zh -> locale: zh, base: name
|
|
||||||
*
|
|
||||||
* @param fileName Filename without extension (already has .mdx removed)
|
|
||||||
* @returns Object with locale and base name
|
|
||||||
*/
|
|
||||||
function extractLocaleAndBase(fileName: string): {
|
|
||||||
locale: string;
|
|
||||||
base: string;
|
|
||||||
} {
|
|
||||||
// Split filename into parts
|
|
||||||
const parts = fileName.split('.');
|
|
||||||
|
|
||||||
if (parts.length === 1) {
|
|
||||||
// Simple filename without locale: xxx
|
|
||||||
return { locale: DEFAULT_LOCALE, base: parts[0] };
|
|
||||||
}
|
|
||||||
if (parts.length === 2 && LOCALES.includes(parts[1])) {
|
|
||||||
// Filename with locale: xxx.zh
|
|
||||||
return { locale: parts[1], base: parts[0] };
|
|
||||||
}
|
|
||||||
// Unexpected format, use first part as base and default locale
|
|
||||||
console.warn(`Unexpected filename format: ${fileName}`);
|
|
||||||
return { locale: DEFAULT_LOCALE, base: parts[0] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
collections: [authors, categories, posts, pages],
|
|
||||||
});
|
|
12
package.json
12
package.json
@ -3,8 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"content-collections watch\" \"next dev\"",
|
"dev": "next dev",
|
||||||
"build": "content-collections build && next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"postinstall": "fumadocs-mdx",
|
"postinstall": "fumadocs-mdx",
|
||||||
"lint": "biome check --write .",
|
"lint": "biome check --write .",
|
||||||
@ -15,8 +15,12 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"list-contacts": "tsx scripts/list-contacts.ts",
|
"list-contacts": "tsx scripts/list-contacts.ts",
|
||||||
"docs": "content-collections build",
|
"content": "fumadocs-mdx",
|
||||||
"email": "email dev --dir src/mail/templates --port 3333"
|
"email": "email dev --dir src/mail/templates --port 3333",
|
||||||
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
|
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||||
|
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^1.1.13",
|
"@ai-sdk/openai": "^1.1.13",
|
||||||
|
@ -6,25 +6,21 @@ import {
|
|||||||
} from 'fumadocs-mdx/config';
|
} from 'fumadocs-mdx/config';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const customDocsSchema = frontmatterSchema.extend({
|
|
||||||
preview: z.string().optional(),
|
|
||||||
index: z.boolean().default(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
const customMetaSchema = metaSchema.extend({
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://fumadocs.dev/docs/mdx/collections#schema-1
|
* https://fumadocs.dev/docs/mdx/collections#schema-1
|
||||||
*/
|
*/
|
||||||
export const docs = defineDocs({
|
export const docs = defineDocs({
|
||||||
dir: 'content/docs',
|
dir: 'content/docs',
|
||||||
docs: {
|
docs: {
|
||||||
schema: customDocsSchema,
|
schema: frontmatterSchema.extend({
|
||||||
|
preview: z.string().optional(),
|
||||||
|
index: z.boolean().default(false),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
schema: customMetaSchema,
|
schema: metaSchema.extend({
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,25 +30,61 @@ export const docs = defineDocs({
|
|||||||
export const changelog = defineCollections({
|
export const changelog = defineCollections({
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
dir: 'content/changelog',
|
dir: 'content/changelog',
|
||||||
schema: z.object({
|
schema: frontmatterSchema.extend({
|
||||||
title: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
date: z.string().datetime(),
|
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
|
date: z.string().date(),
|
||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pages
|
* Pages, like privacy policy, terms of service, etc.
|
||||||
*/
|
*/
|
||||||
export const pages = defineCollections({
|
export const pages = defineCollections({
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
dir: 'content/pages',
|
dir: 'content/pages',
|
||||||
schema: z.object({
|
schema: frontmatterSchema.extend({
|
||||||
title: z.string(),
|
date: z.string().date(),
|
||||||
description: z.string(),
|
|
||||||
date: z.string().datetime(),
|
|
||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog authors
|
||||||
|
*
|
||||||
|
* description is optional, but we must add it to the schema
|
||||||
|
*/
|
||||||
|
export const author = defineCollections({
|
||||||
|
type: 'doc',
|
||||||
|
dir: 'content/author',
|
||||||
|
schema: frontmatterSchema.extend({
|
||||||
|
name: z.string(),
|
||||||
|
avatar: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog categories
|
||||||
|
*/
|
||||||
|
export const category = defineCollections({
|
||||||
|
type: 'doc',
|
||||||
|
dir: 'content/category',
|
||||||
|
schema: frontmatterSchema.extend({
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog posts
|
||||||
|
*/
|
||||||
|
export const blog = defineCollections({
|
||||||
|
type: 'doc',
|
||||||
|
dir: 'content/blog',
|
||||||
|
schema: frontmatterSchema.extend({
|
||||||
|
image: z.string(),
|
||||||
|
date: z.string().date(),
|
||||||
|
published: z.boolean().default(true),
|
||||||
|
categories: z.array(z.string()),
|
||||||
|
author: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import { websiteConfig } from '@/config/website';
|
|
||||||
import { getLocalePathname } from '@/i18n/navigation';
|
import { getLocalePathname } from '@/i18n/navigation';
|
||||||
import { routing } from '@/i18n/routing';
|
import { routing } from '@/i18n/routing';
|
||||||
import { source } from '@/lib/docs/source';
|
import { source } from '@/lib/docs/source';
|
||||||
import { allCategories, allPosts } from 'content-collections';
|
|
||||||
import type { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
import type { Locale } from 'next-intl';
|
import type { Locale } from 'next-intl';
|
||||||
import { getBaseUrl } from '../lib/urls/urls';
|
import { getBaseUrl } from '../lib/urls/urls';
|
||||||
@ -50,86 +48,86 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// add categories
|
// add categories
|
||||||
sitemapList.push(
|
// sitemapList.push(
|
||||||
...allCategories.flatMap((category: { slug: string }) =>
|
// ...allCategories.flatMap((category: { slug: string }) =>
|
||||||
routing.locales.map((locale) => ({
|
// routing.locales.map((locale) => ({
|
||||||
url: getUrl(`/blog/category/${category.slug}`, locale),
|
// url: getUrl(`/blog/category/${category.slug}`, locale),
|
||||||
lastModified: new Date(),
|
// lastModified: new Date(),
|
||||||
priority: 0.8,
|
// priority: 0.8,
|
||||||
changeFrequency: 'weekly' as const,
|
// changeFrequency: 'weekly' as const,
|
||||||
}))
|
// }))
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
|
|
||||||
// add paginated blog list pages
|
// add paginated blog list pages
|
||||||
routing.locales.forEach((locale) => {
|
// routing.locales.forEach((locale) => {
|
||||||
const posts = allPosts.filter(
|
// const posts = allPosts.filter(
|
||||||
(post) => post.locale === locale && post.published
|
// (post) => post.locale === locale && post.published
|
||||||
);
|
// );
|
||||||
const totalPages = Math.max(
|
// const totalPages = Math.max(
|
||||||
1,
|
// 1,
|
||||||
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
|
// Math.ceil(posts.length / websiteConfig.blog.paginationSize)
|
||||||
);
|
// );
|
||||||
// /blog/page/[page] (from 2)
|
// // /blog/page/[page] (from 2)
|
||||||
for (let page = 2; page <= totalPages; page++) {
|
// for (let page = 2; page <= totalPages; page++) {
|
||||||
sitemapList.push({
|
// sitemapList.push({
|
||||||
url: getUrl(`/blog/page/${page}`, locale),
|
// url: getUrl(`/blog/page/${page}`, locale),
|
||||||
lastModified: new Date(),
|
// lastModified: new Date(),
|
||||||
priority: 0.8,
|
// priority: 0.8,
|
||||||
changeFrequency: 'weekly' as const,
|
// changeFrequency: 'weekly' as const,
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
|
||||||
// add paginated category pages
|
// add paginated category pages
|
||||||
routing.locales.forEach((locale) => {
|
// routing.locales.forEach((locale) => {
|
||||||
const localeCategories = allCategories.filter(
|
// const localeCategories = allCategories.filter(
|
||||||
(category) => category.locale === locale
|
// (category) => category.locale === locale
|
||||||
);
|
// );
|
||||||
localeCategories.forEach((category) => {
|
// localeCategories.forEach((category) => {
|
||||||
// posts in this category and locale
|
// // posts in this category and locale
|
||||||
const postsInCategory = allPosts.filter(
|
// const postsInCategory = allPosts.filter(
|
||||||
(post) =>
|
// (post) =>
|
||||||
post.locale === locale &&
|
// post.locale === locale &&
|
||||||
post.published &&
|
// post.published &&
|
||||||
post.categories.some((cat) => cat && cat.slug === category.slug)
|
// post.categories.some((cat) => cat && cat.slug === category.slug)
|
||||||
);
|
// );
|
||||||
const totalPages = Math.max(
|
// const totalPages = Math.max(
|
||||||
1,
|
// 1,
|
||||||
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
|
// Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
|
||||||
);
|
// );
|
||||||
// /blog/category/[slug] (first page)
|
// // /blog/category/[slug] (first page)
|
||||||
sitemapList.push({
|
// sitemapList.push({
|
||||||
url: getUrl(`/blog/category/${category.slug}`, locale),
|
// url: getUrl(`/blog/category/${category.slug}`, locale),
|
||||||
lastModified: new Date(),
|
// lastModified: new Date(),
|
||||||
priority: 0.8,
|
// priority: 0.8,
|
||||||
changeFrequency: 'weekly' as const,
|
// changeFrequency: 'weekly' as const,
|
||||||
});
|
// });
|
||||||
// /blog/category/[slug]/page/[page] (from 2)
|
// // /blog/category/[slug]/page/[page] (from 2)
|
||||||
for (let page = 2; page <= totalPages; page++) {
|
// for (let page = 2; page <= totalPages; page++) {
|
||||||
sitemapList.push({
|
// sitemapList.push({
|
||||||
url: getUrl(`/blog/category/${category.slug}/page/${page}`, locale),
|
// url: getUrl(`/blog/category/${category.slug}/page/${page}`, locale),
|
||||||
lastModified: new Date(),
|
// lastModified: new Date(),
|
||||||
priority: 0.8,
|
// priority: 0.8,
|
||||||
changeFrequency: 'weekly' as const,
|
// changeFrequency: 'weekly' as const,
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
});
|
// });
|
||||||
|
|
||||||
// add posts (single post pages)
|
// // add posts (single post pages)
|
||||||
sitemapList.push(
|
// sitemapList.push(
|
||||||
...allPosts.flatMap((post: { slugAsParams: string; locale: string }) =>
|
// ...allPosts.flatMap((post: { slugAsParams: string; locale: string }) =>
|
||||||
routing.locales
|
// routing.locales
|
||||||
.filter((locale) => post.locale === locale)
|
// .filter((locale) => post.locale === locale)
|
||||||
.map((locale) => ({
|
// .map((locale) => ({
|
||||||
url: getUrl(`/blog/${post.slugAsParams}`, locale),
|
// url: getUrl(`/blog/${post.slugAsParams}`, locale),
|
||||||
lastModified: new Date(),
|
// lastModified: new Date(),
|
||||||
priority: 0.8,
|
// priority: 0.8,
|
||||||
changeFrequency: 'weekly' as const,
|
// changeFrequency: 'weekly' as const,
|
||||||
}))
|
// }))
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
|
|
||||||
// add docs
|
// add docs
|
||||||
const docsParams = source.generateParams();
|
const docsParams = source.generateParams();
|
||||||
|
@ -1,22 +1,19 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import type { changelogSource } from '@/lib/docs/source';
|
import type { ChangelogType } from '@/lib/docs/source';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import type { InferPageType } from 'fumadocs-core/source';
|
|
||||||
import { CalendarIcon, TagIcon } from 'lucide-react';
|
import { CalendarIcon, TagIcon } from 'lucide-react';
|
||||||
import { getMDXComponents } from '../custom/mdx-components';
|
import { getMDXComponents } from '../custom/mdx-components';
|
||||||
|
|
||||||
type ChangelogRelease = InferPageType<typeof changelogSource>;
|
|
||||||
|
|
||||||
interface ReleaseCardProps {
|
interface ReleaseCardProps {
|
||||||
releaseItem: ChangelogRelease;
|
releaseItem: ChangelogType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseCard({ releaseItem }: ReleaseCardProps) {
|
export function ReleaseCard({ releaseItem }: ReleaseCardProps) {
|
||||||
const { title, description, date, version } = releaseItem.data;
|
const { title, description, date, version } = releaseItem.data;
|
||||||
const MDX = releaseItem.data.body;
|
|
||||||
const formattedDate = formatDate(new Date(date));
|
const formattedDate = formatDate(new Date(date));
|
||||||
|
const MDX = releaseItem.data.body;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
import type { pagesSource } from '@/lib/docs/source';
|
import type { PagesType } from '@/lib/docs/source';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import type { InferPageType } from 'fumadocs-core/source';
|
|
||||||
import { CalendarIcon } from 'lucide-react';
|
import { CalendarIcon } from 'lucide-react';
|
||||||
import { getMDXComponents } from '../custom/mdx-components';
|
import { getMDXComponents } from '../custom/mdx-components';
|
||||||
import { Card, CardContent } from '../ui/card';
|
import { Card, CardContent } from '../ui/card';
|
||||||
|
|
||||||
type Page = InferPageType<typeof pagesSource>;
|
|
||||||
|
|
||||||
interface CustomPageProps {
|
interface CustomPageProps {
|
||||||
page: Page;
|
page: PagesType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomPage({ page }: CustomPageProps) {
|
export function CustomPage({ page }: CustomPageProps) {
|
||||||
|
@ -1,54 +0,0 @@
|
|||||||
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
|
||||||
import { Wrapper } from '@/components/docs/wrapper';
|
|
||||||
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
|
||||||
import { MDXContent } from '@content-collections/mdx/react';
|
|
||||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
|
||||||
import { File, Files, Folder } from 'fumadocs-ui/components/files';
|
|
||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
|
||||||
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
|
||||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
|
||||||
import * as LucideIcons from 'lucide-react';
|
|
||||||
import type { MDXComponents } from 'mdx/types';
|
|
||||||
import type { ComponentProps, FC } from 'react';
|
|
||||||
|
|
||||||
interface CustomMDXContentProps {
|
|
||||||
code: string;
|
|
||||||
customComponents?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enhanced MDX Content component that includes commonly used MDX components
|
|
||||||
* It can be used for blog posts, documentation, and custom pages
|
|
||||||
*/
|
|
||||||
export async function CustomMDXContent({
|
|
||||||
code,
|
|
||||||
customComponents = {},
|
|
||||||
}: CustomMDXContentProps) {
|
|
||||||
// Start with default components
|
|
||||||
const baseComponents: Record<string, any> = {
|
|
||||||
...defaultMdxComponents,
|
|
||||||
...LucideIcons,
|
|
||||||
...((await import('lucide-react')) as unknown as MDXComponents),
|
|
||||||
YoutubeVideo,
|
|
||||||
Tabs,
|
|
||||||
Tab,
|
|
||||||
TypeTable,
|
|
||||||
Accordion,
|
|
||||||
Accordions,
|
|
||||||
Steps,
|
|
||||||
Step,
|
|
||||||
Wrapper,
|
|
||||||
File,
|
|
||||||
Folder,
|
|
||||||
Files,
|
|
||||||
blockquote: Callout as unknown as FC<ComponentProps<'blockquote'>>,
|
|
||||||
img: ImageWrapper,
|
|
||||||
...customComponents,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MDXContent code={code} components={baseComponents as MDXComponents} />
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
import { websiteConfig } from '@/config/website';
|
|
||||||
import { allPosts } from 'content-collections';
|
|
||||||
|
|
||||||
export function getPaginatedBlogPosts({
|
|
||||||
locale,
|
|
||||||
page = 1,
|
|
||||||
category,
|
|
||||||
}: {
|
|
||||||
locale: string;
|
|
||||||
page?: number;
|
|
||||||
category?: string;
|
|
||||||
}) {
|
|
||||||
// Filter posts by locale
|
|
||||||
let filteredPosts = allPosts.filter(
|
|
||||||
(post) => post.locale === locale && post.published
|
|
||||||
);
|
|
||||||
// If no posts found for the current locale, show all published posts
|
|
||||||
if (filteredPosts.length === 0) {
|
|
||||||
filteredPosts = allPosts.filter((post) => post.published);
|
|
||||||
}
|
|
||||||
// Filter by category if category is provided
|
|
||||||
if (category) {
|
|
||||||
filteredPosts = filteredPosts.filter((post) =>
|
|
||||||
post.categories.some((cat) => cat && cat.slug === category)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Sort posts by date (newest first)
|
|
||||||
const sortedPosts = [...filteredPosts].sort(
|
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
|
||||||
);
|
|
||||||
// Paginate posts
|
|
||||||
const paginationSize = websiteConfig.blog.paginationSize;
|
|
||||||
const startIndex = (page - 1) * paginationSize;
|
|
||||||
const endIndex = startIndex + paginationSize;
|
|
||||||
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
|
|
||||||
const totalPages = Math.ceil(filteredPosts.length / paginationSize);
|
|
||||||
return {
|
|
||||||
paginatedPosts,
|
|
||||||
totalPages,
|
|
||||||
filteredPostsCount: filteredPosts.length,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,8 +1,15 @@
|
|||||||
import { loader } from 'fumadocs-core/source';
|
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||||
import { createMDXSource } from 'fumadocs-mdx';
|
import { createMDXSource } from 'fumadocs-mdx';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
import { changelog, docs, pages } from '../../../.source';
|
import {
|
||||||
|
author,
|
||||||
|
blog,
|
||||||
|
category,
|
||||||
|
changelog,
|
||||||
|
docs,
|
||||||
|
pages,
|
||||||
|
} from '../../../.source';
|
||||||
import { docsI18nConfig } from './i18n';
|
import { docsI18nConfig } from './i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,7 +17,6 @@ import { docsI18nConfig } from './i18n';
|
|||||||
* .source folder is generated by `fumadocs-mdx`
|
* .source folder is generated by `fumadocs-mdx`
|
||||||
*
|
*
|
||||||
* https://fumadocs.dev/docs/headless/source-api
|
* https://fumadocs.dev/docs/headless/source-api
|
||||||
* https://fumadocs.dev/docs/headless/content-collections
|
|
||||||
*/
|
*/
|
||||||
export const source = loader({
|
export const source = loader({
|
||||||
baseUrl: '/docs',
|
baseUrl: '/docs',
|
||||||
@ -42,9 +48,44 @@ export const changelogSource = loader({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pages source
|
* Pages source
|
||||||
|
*
|
||||||
|
* TODO: how to set the baseUrl for pages?
|
||||||
*/
|
*/
|
||||||
export const pagesSource = loader({
|
export const pagesSource = loader({
|
||||||
baseUrl: '/pages',
|
baseUrl: '/pages',
|
||||||
i18n: docsI18nConfig,
|
i18n: docsI18nConfig,
|
||||||
source: createMDXSource(pages),
|
source: createMDXSource(pages),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog authors source
|
||||||
|
*/
|
||||||
|
export const authorSource = loader({
|
||||||
|
baseUrl: '/author',
|
||||||
|
i18n: docsI18nConfig,
|
||||||
|
source: createMDXSource(author),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog categories source
|
||||||
|
*/
|
||||||
|
export const categorySource = loader({
|
||||||
|
baseUrl: '/category',
|
||||||
|
i18n: docsI18nConfig,
|
||||||
|
source: createMDXSource(category),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blog posts source
|
||||||
|
*/
|
||||||
|
export const blogSource = loader({
|
||||||
|
baseUrl: '/blog',
|
||||||
|
i18n: docsI18nConfig,
|
||||||
|
source: createMDXSource(blog),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangelogType = InferPageType<typeof changelogSource>;
|
||||||
|
export type PagesType = InferPageType<typeof pagesSource>;
|
||||||
|
export type AuthorType = InferPageType<typeof authorSource>;
|
||||||
|
export type CategoryType = InferPageType<typeof categorySource>;
|
||||||
|
export type BlogType = InferPageType<typeof blogSource>;
|
||||||
|
@ -21,8 +21,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@/content/*": ["./content/*"],
|
"@/content/*": ["./content/*"],
|
||||||
"@/public/*": ["./public/*"],
|
"@/public/*": ["./public/*"]
|
||||||
"content-collections": ["./.content-collections/generated"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
Loading…
Reference in New Issue
Block a user