Update blog CTA and blog content handling
This commit is contained in:
parent
dbf50e68d5
commit
5dcdf9a747
1
.github/workflows/build-images.yml
vendored
1
.github/workflows/build-images.yml
vendored
@ -73,7 +73,6 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
service:
|
service:
|
||||||
- { name: dashboard, workdir: ., dockerfile: Dockerfile }
|
- { name: dashboard, workdir: ., dockerfile: Dockerfile }
|
||||||
- { name: neurapress, workdir: ., dockerfile: packages/neurapress/docker/Dockerfile.prod }
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|||||||
4
Makefile
4
Makefile
@ -59,8 +59,8 @@ ensure-deps:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
dev: ensure-deps
|
dev: ensure-deps
|
||||||
@echo "🚀 Starting Next.js dev server (dashboard)..."
|
@echo "Starting Next.js dev server (dashboard)..."
|
||||||
@echo "ℹ️ /editor is proxied to an external NeuraPress front-end at http://localhost:4000."
|
@echo "/editor is proxied to an external NeuraPress front-end at http://localhost:4000."
|
||||||
yarn dev -p 3001
|
yarn dev -p 3001
|
||||||
|
|
||||||
start:
|
start:
|
||||||
|
|||||||
BIN
public/icons/webchat.jpg
Normal file
BIN
public/icons/webchat.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@ -6,6 +6,7 @@ import type { Metadata } from 'next'
|
|||||||
|
|
||||||
import { getBlogPostBySlug } from '@lib/blogContent'
|
import { getBlogPostBySlug } from '@lib/blogContent'
|
||||||
import { renderMarkdownContent } from '@server/render-markdown'
|
import { renderMarkdownContent } from '@server/render-markdown'
|
||||||
|
import BrandCTA from '@components/BrandCTA'
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { slug: string | string[] }
|
params: { slug: string | string[] }
|
||||||
@ -54,6 +55,7 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = renderMarkdownContent(post.content)
|
const html = renderMarkdownContent(post.content)
|
||||||
|
const language: 'zh' | 'en' = /[\u4e00-\u9fff]/.test(`${post.title} ${post.content}`) ? 'zh' : 'en'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||||
@ -86,10 +88,14 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
className="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline"
|
className="prose prose-slate max-w-none text-[15px] prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline"
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<BrandCTA lang={language} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer className="mt-16 border-t border-slate-200 pt-8">
|
<footer className="mt-16 border-t border-slate-200 pt-8">
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
|
|||||||
46
src/components/BrandCTA.tsx
Normal file
46
src/components/BrandCTA.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
type BrandCTAProps = {
|
||||||
|
lang?: 'zh' | 'en'
|
||||||
|
variant?: 'compact' | 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COPY = {
|
||||||
|
zh: {
|
||||||
|
main: '云原生实践 · 架构思考',
|
||||||
|
secondary: '获取更多信息,可通过右侧官方渠道',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
main: 'Cloud-native practice · Architecture thinking',
|
||||||
|
secondary: 'For more information, see the official channels on the right',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrandCTA({ lang = 'en', variant = 'default' }: BrandCTAProps) {
|
||||||
|
const content = COPY[lang]
|
||||||
|
const isCompact = variant === 'compact'
|
||||||
|
const imageSize = isCompact ? 160 : 180
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`flex items-center border-t border-slate-200 ${isCompact ? 'pt-3' : 'pt-4'}`}>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-sm font-medium text-slate-600">{content.main}</p>
|
||||||
|
{!isCompact && (
|
||||||
|
<>
|
||||||
|
<div className="h-3" aria-hidden="true" />
|
||||||
|
<p className="text-xs text-slate-500">{content.secondary}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-6 flex justify-end">
|
||||||
|
<Image
|
||||||
|
src="/icons/webchat.jpg"
|
||||||
|
alt={lang === 'zh' ? 'Cloud-Neutral 微信二维码' : 'Cloud-Neutral WeChat QR code'}
|
||||||
|
width={imageSize}
|
||||||
|
height={imageSize}
|
||||||
|
className="h-auto w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -232,7 +232,7 @@ export default function Navbar() {
|
|||||||
const labels = {
|
const labels = {
|
||||||
home: isChinese ? '首页' : 'Home',
|
home: isChinese ? '首页' : 'Home',
|
||||||
docs: isChinese ? '文档' : 'Docs',
|
docs: isChinese ? '文档' : 'Docs',
|
||||||
download: isChinese ? '下载' : 'Download',
|
download: isChinese ? '博客' : 'blog',
|
||||||
openSource: isChinese ? '开源项目' : 'Open source',
|
openSource: isChinese ? '开源项目' : 'Open source',
|
||||||
editor: isChinese ? '编辑器' : 'Editor',
|
editor: isChinese ? '编辑器' : 'Editor',
|
||||||
moreServices: isChinese ? '更多服务' : 'More services',
|
moreServices: isChinese ? '更多服务' : 'More services',
|
||||||
@ -273,7 +273,7 @@ export default function Navbar() {
|
|||||||
{ key: 'docs', label: labels.docs, href: '/docs' },
|
{ key: 'docs', label: labels.docs, href: '/docs' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const downloadLink = { key: 'download', label: labels.download, href: '/download' }
|
const downloadLink = { key: 'blog', label: labels.download, href: '/blog' }
|
||||||
|
|
||||||
const editorLink = {
|
const editorLink = {
|
||||||
key: 'editor',
|
key: 'editor',
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
|
import BrandCTA from '@components/BrandCTA'
|
||||||
import SearchComponent from '@components/search'
|
import SearchComponent from '@components/search'
|
||||||
import type { BlogPost } from '@lib/blogContent'
|
import type { BlogPost } from '@lib/blogContent'
|
||||||
|
|
||||||
@ -50,6 +51,15 @@ function buildCategoryCounts(posts: BlogPost[]) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectLanguage(posts: BlogPost[]): 'zh' | 'en' {
|
||||||
|
for (const post of posts) {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(`${post.title} ${post.excerpt}`)) {
|
||||||
|
return 'zh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
export default function BlogList({ posts }: BlogListProps) {
|
export default function BlogList({ posts }: BlogListProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const selectedCategory = searchParams.get('category')
|
const selectedCategory = searchParams.get('category')
|
||||||
@ -72,6 +82,7 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
const startIndex = (currentPage - 1) * postsPerPage
|
const startIndex = (currentPage - 1) * postsPerPage
|
||||||
const endIndex = startIndex + postsPerPage
|
const endIndex = startIndex + postsPerPage
|
||||||
const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
|
const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
|
||||||
|
const language = useMemo(() => detectLanguage(filteredPosts), [filteredPosts])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white text-slate-900">
|
<div className="bg-white text-slate-900">
|
||||||
@ -231,8 +242,13 @@ export default function BlogList({ posts }: BlogListProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<BrandCTA lang={language} variant="compact" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -73,14 +73,45 @@ function buildExcerpt(markdown: string): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHeadingText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[`*_]/g, '')
|
||||||
|
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitleFromContent(markdown: string): { title?: string; content: string } {
|
||||||
|
const lines = markdown.split(/\r?\n/)
|
||||||
|
let firstContentLine = 0
|
||||||
|
|
||||||
|
while (firstContentLine < lines.length && lines[firstContentLine].trim() === '') {
|
||||||
|
firstContentLine += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstContentLine < lines.length) {
|
||||||
|
const match = lines[firstContentLine].match(/^#{1,6}\s+(.*)$/)
|
||||||
|
if (match && match[1]?.trim()) {
|
||||||
|
const title = normalizeHeadingText(match[1])
|
||||||
|
const content = [...lines.slice(0, firstContentLine), ...lines.slice(firstContentLine + 1)].join('\n')
|
||||||
|
return { title, content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: markdown }
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePost(file: Awaited<ReturnType<typeof readBlogFiles>>[number]): BlogPost {
|
function normalizePost(file: Awaited<ReturnType<typeof readBlogFiles>>[number]): BlogPost {
|
||||||
const title = typeof file.metadata.title === 'string' ? file.metadata.title : file.slug
|
const metadataTitle = typeof file.metadata.title === 'string' ? file.metadata.title : undefined
|
||||||
|
const { title: derivedTitle, content } = metadataTitle
|
||||||
|
? { title: undefined, content: file.content }
|
||||||
|
: extractTitleFromContent(file.content)
|
||||||
|
const title = metadataTitle ?? derivedTitle ?? file.slug
|
||||||
const author = typeof file.metadata.author === 'string' ? file.metadata.author : undefined
|
const author = typeof file.metadata.author === 'string' ? file.metadata.author : undefined
|
||||||
const date = typeof file.metadata.date === 'string' ? file.metadata.date : undefined
|
const date = typeof file.metadata.date === 'string' ? file.metadata.date : undefined
|
||||||
const tags = Array.isArray(file.metadata.tags)
|
const tags = Array.isArray(file.metadata.tags)
|
||||||
? file.metadata.tags.filter((tag): tag is string => typeof tag === 'string')
|
? file.metadata.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||||
: []
|
: []
|
||||||
const excerpt = typeof file.metadata.excerpt === 'string' ? file.metadata.excerpt : buildExcerpt(file.content)
|
const excerpt = typeof file.metadata.excerpt === 'string' ? file.metadata.excerpt : buildExcerpt(content)
|
||||||
const categoryKey = typeof file.metadata.category === 'string' ? file.metadata.category : undefined
|
const categoryKey = typeof file.metadata.category === 'string' ? file.metadata.category : undefined
|
||||||
const categoryLabel = typeof file.metadata.categoryLabel === 'string' ? file.metadata.categoryLabel : categoryKey
|
const categoryLabel = typeof file.metadata.categoryLabel === 'string' ? file.metadata.categoryLabel : categoryKey
|
||||||
const category =
|
const category =
|
||||||
@ -95,7 +126,7 @@ function normalizePost(file: Awaited<ReturnType<typeof readBlogFiles>>[number]):
|
|||||||
date,
|
date,
|
||||||
tags,
|
tags,
|
||||||
excerpt,
|
excerpt,
|
||||||
content: file.content,
|
content,
|
||||||
category,
|
category,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,7 +134,7 @@ function normalizePost(file: Awaited<ReturnType<typeof readBlogFiles>>[number]):
|
|||||||
export const getBlogPosts = cache(async (): Promise<BlogPost[]> => {
|
export const getBlogPosts = cache(async (): Promise<BlogPost[]> => {
|
||||||
try {
|
try {
|
||||||
const files = await readBlogFiles()
|
const files = await readBlogFiles()
|
||||||
const posts = files.map(normalizePost)
|
const posts = files.map(normalizePost).filter((post) => post.content.trim().length > 0)
|
||||||
|
|
||||||
return posts
|
return posts
|
||||||
.map((post) => ({
|
.map((post) => ({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user