diff --git a/.changeset/purple-melons-begin.md b/.changeset/purple-melons-begin.md new file mode 100644 index 0000000000..d6ddef56d1 --- /dev/null +++ b/.changeset/purple-melons-begin.md @@ -0,0 +1,6 @@ +--- +'nextra': patch +'nextra-theme-blog': patch +--- + +add `nextraConfig.unstable_readingTime` option for blog theme diff --git a/examples/blog/next.config.mjs b/examples/blog/next.config.mjs index aecd923d57..57fb0549a9 100644 --- a/examples/blog/next.config.mjs +++ b/examples/blog/next.config.mjs @@ -4,7 +4,8 @@ const withNextra = nextra({ theme: 'nextra-theme-blog', themeConfig: './theme.config.jsx', unstable_staticImage: true, - unstable_defaultShowCopyCode: true + unstable_defaultShowCopyCode: true, + unstable_readingTime: true }) export default withNextra({ diff --git a/examples/blog/pages/posts/code-blocks.mdx b/examples/blog/pages/posts/code-blocks.mdx index 0f39519f24..24eccf161d 100644 --- a/examples/blog/pages/posts/code-blocks.mdx +++ b/examples/blog/pages/posts/code-blocks.mdx @@ -2,7 +2,7 @@ title: Code blocks date: 2022/7/29 description: En example of using code blocks in your blog. -tag: web development +tag: web development,JavaScript,GraphQL,C++,Java,React,Next.js,The Guild,MacBook Pro author: Dimitri POSTOLOV --- diff --git a/packages/nextra-theme-blog/src/article-layout.tsx b/packages/nextra-theme-blog/src/article-layout.tsx index 66c267c4c1..71d5ae2683 100644 --- a/packages/nextra-theme-blog/src/article-layout.tsx +++ b/packages/nextra-theme-blog/src/article-layout.tsx @@ -3,14 +3,12 @@ import Meta from './meta' import MDXTheme from './mdx-theme' import { useBlogContext } from './blog-context' import { BasicLayout } from './basic-layout' -import { getParent } from './utils/parent' export const ArticleLayout = ({ children }: { children: ReactNode }) => { - const { opts, config } = useBlogContext() - const { back } = getParent({ opts, config }) + const { config } = useBlogContext() return ( - + {children} {config.postFooter} diff --git a/packages/nextra-theme-blog/src/mdx-theme.tsx b/packages/nextra-theme-blog/src/mdx-theme.tsx index a340141bac..9fc97fc5c1 100644 --- a/packages/nextra-theme-blog/src/mdx-theme.tsx +++ b/packages/nextra-theme-blog/src/mdx-theme.tsx @@ -1,4 +1,6 @@ import React, { + RefObject, + createRef, ComponentProps, createContext, ReactElement, @@ -9,34 +11,26 @@ import React, { } from 'react' import { MDXProvider } from '@mdx-js/react' import Link from 'next/link' -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { Code, Pre, Td, Th, Tr } from 'nextra/components' import { useBlogContext } from './blog-context' export const HeadingContext = createContext< - React.RefObject ->(React.createRef()) - -const useHeadingRef = () => { - const ref = useContext(HeadingContext) - return ref -} + RefObject +>(createRef()) const H1 = ({ children }: { children?: ReactNode }): ReactElement => { - const ref = useHeadingRef() - const { - opts: { hasJsxInH1 } - } = useBlogContext() + const ref = useContext(HeadingContext) + const { opts } = useBlogContext() const [showHeading, setShowHeading] = useState(false) useEffect(() => { - if (ref.current && hasJsxInH1) { + if (ref.current && opts.hasJsxInH1) { setShowHeading(true) } }, []) - return ( - <>{showHeading ? ReactDOM.createPortal(children, ref.current!) : null} - ) + return <>{showHeading && createPortal(children, ref.current!)} } + const createHeaderLink = (Tag: `h${2 | 3 | 4 | 5 | 6}`) => ({ children, id, ...props }: ComponentProps<'h2'>): ReactElement => { @@ -48,13 +42,7 @@ const createHeaderLink = ) } -const A = ({ - children, - ...props -}: { - children?: React.ReactNode - href?: string -}) => { +const A = ({ children, ...props }: ComponentProps<'a'>) => { const isExternal = props.href && props.href.startsWith('https://') if (isExternal) { return ( diff --git a/packages/nextra-theme-blog/src/meta.tsx b/packages/nextra-theme-blog/src/meta.tsx index c3668108fa..185c838eae 100644 --- a/packages/nextra-theme-blog/src/meta.tsx +++ b/packages/nextra-theme-blog/src/meta.tsx @@ -1,67 +1,72 @@ import React, { ReactElement } from 'react' import Link from 'next/link' import ThemeSwitch from './theme-switch' -import type { NextraBlogTheme } from './types' import { split } from './utils/get-tags' +import { useBlogContext } from './blog-context' +import { getParent } from './utils/parent' -interface MetaProps { - author?: string - date?: string - tag?: string | string[] - back?: string | null - config: NextraBlogTheme -} - -export default function Meta({ - author, - date, - tag, - back, - config -}: MetaProps): ReactElement { +export default function Meta(): ReactElement { + const { opts, config } = useBlogContext() + const { author, date, tag } = opts.frontMatter + const { back } = getParent({ opts, config }) const tags = tag ? split(tag) : [] + const tagsEl = tags.map(t => ( + + + {t} + + + )) + + const readingTime = opts.readingTime?.text + return ( -
-
- {author} - {author && date && ','} - {date && ( - +
+
+
+ {author} + {author && date && ','} + {date && ( + + )} + {(author || date) && tags.length > 0 && ' • '} + {readingTime || tagsEl} +
+ {readingTime && ( +
{tagsEl}
)} - {(author || date) && tags.length > 0 && '•'} - {tags.map(t => ( - - - {t} - +
+
+ {back && ( + + Back - ))} + )} + {config.darkMode && }
- {back && ( - - Back - - )} - {config.darkMode && }
) } diff --git a/packages/nextra/package.json b/packages/nextra/package.json index 319e9a9f47..2c2ff29f1e 100644 --- a/packages/nextra/package.json +++ b/packages/nextra/package.json @@ -90,6 +90,7 @@ "rehype-mdx-title": "^1.0.0", "rehype-pretty-code": "0.2.4", "remark-gfm": "^3.0.1", + "remark-reading-time": "^2.0.1", "shiki": "0.10.1", "slash": "^3.0.0" }, diff --git a/packages/nextra/src/compile.ts b/packages/nextra/src/compile.ts index a22c830ef3..7178a5e986 100644 --- a/packages/nextra/src/compile.ts +++ b/packages/nextra/src/compile.ts @@ -3,9 +3,10 @@ import { Processor } from '@mdx-js/mdx/lib/core' import remarkGfm from 'remark-gfm' import rehypePrettyCode from 'rehype-pretty-code' import { rehypeMdxTitle } from 'rehype-mdx-title' +import readingTime from 'remark-reading-time' import { remarkStaticImage } from './mdx-plugins/static-image' import { remarkHeadings } from './mdx-plugins/remark' -import { LoaderOptions, PageOpts } from './types' +import { LoaderOptions, PageOpts, ReadingTime } from './types' import structurize from './mdx-plugins/structurize' import { parseMeta, attachMeta } from './mdx-plugins/rehype-handler' import theme from './theme.json' @@ -45,6 +46,7 @@ export async function compileMdx( | 'unstable_staticImage' | 'unstable_flexsearch' | 'unstable_defaultShowCopyCode' + | 'unstable_readingTime' > = {}, filePath = '' ) { @@ -59,7 +61,8 @@ export async function compileMdx( remarkHeadings, nextraOptions.unstable_staticImage && remarkStaticImage, nextraOptions.unstable_flexsearch && - structurize(structurizedData, nextraOptions.unstable_flexsearch) + structurize(structurizedData, nextraOptions.unstable_flexsearch), + nextraOptions.unstable_readingTime && readingTime ].filter(truthy), rehypePlugins: [ ...(mdxOptions.rehypePlugins || []), @@ -76,16 +79,18 @@ export async function compileMdx( ] }) try { - const result = String(await compiler.process(source)) + const vFile = await compiler.process(source) + const result = String(vFile) .replace('export const __nextra_title__', 'const __nextra_title__') .replace('export default MDXContent;', '') - + const readingTime = vFile.data.readingTime as ReadingTime | undefined return { result, ...(compiler.data('headingMeta') as Pick< PageOpts, 'headings' | 'hasJsxInH1' >), + ...(readingTime && { readingTime }), structurizedData } } catch (err) { diff --git a/packages/nextra/src/loader.ts b/packages/nextra/src/loader.ts index ef3184c46d..ddee40c0a1 100644 --- a/packages/nextra/src/loader.ts +++ b/packages/nextra/src/loader.ts @@ -66,6 +66,7 @@ async function loader( unstable_defaultShowCopyCode, unstable_flexsearch, unstable_staticImage, + unstable_readingTime, mdxOptions, pageMapCache, newNextLinkBehavior @@ -111,16 +112,18 @@ async function loader( // Extract frontMatter information if it exists const { data: frontMatter, content } = grayMatter(source) - const { result, headings, structurizedData, hasJsxInH1 } = await compileMdx( - content, - mdxOptions, - { - unstable_defaultShowCopyCode, - unstable_staticImage, - unstable_flexsearch - }, - mdxPath - ) + const { result, headings, structurizedData, hasJsxInH1, readingTime } = + await compileMdx( + content, + mdxOptions, + { + unstable_readingTime, + unstable_defaultShowCopyCode, + unstable_staticImage, + unstable_flexsearch + }, + mdxPath + ) // @ts-expect-error const cssImport = OFFICIAL_THEMES.includes(theme) ? `import '${theme}/style.css'` @@ -184,7 +187,8 @@ export default MDXContent`.trimStart() hasJsxInH1, timestamp, unstable_flexsearch, - newNextLinkBehavior + newNextLinkBehavior, + readingTime } const pageNextRoute = diff --git a/packages/nextra/src/types.ts b/packages/nextra/src/types.ts index 874910c248..1c63d88ec9 100644 --- a/packages/nextra/src/types.ts +++ b/packages/nextra/src/types.ts @@ -71,6 +71,14 @@ export type PageOpts = { timestamp?: number unstable_flexsearch?: Flexsearch newNextLinkBehavior?: boolean + readingTime?: ReadingTime +} + +export type ReadingTime = { + text: string + minutes: number + time: number + words: number } type Theme = string @@ -82,6 +90,7 @@ export type NextraConfig = { unstable_defaultShowCopyCode?: boolean unstable_flexsearch?: Flexsearch unstable_staticImage?: boolean + unstable_readingTime?: boolean mdxOptions?: Pick & { rehypePrettyCodeOptions?: Partial } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36decd8a7..3c9dfcd445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,7 @@ importers: rehype-mdx-title: ^1.0.0 rehype-pretty-code: 0.2.4 remark-gfm: ^3.0.1 + remark-reading-time: ^2.0.1 shiki: 0.10.1 slash: ^3.0.0 tsup: ^6.2.1 @@ -155,6 +156,7 @@ importers: rehype-mdx-title: 1.0.0 rehype-pretty-code: 0.2.4_shiki@0.10.1 remark-gfm: 3.0.1 + remark-reading-time: 2.0.1 shiki: 0.10.1 slash: 3.0.0 devDependencies: @@ -2660,6 +2662,13 @@ packages: resolution: {integrity: sha512-rxZj1GkQhY4x1j/CSnybK9cGuMFQYFPLq0iNyopqf14aOVLFtMv7Esika+ObJWPWiOHuMOAHz3YkWoLYYRnzWQ==} dev: false + /estree-util-value-to-estree/1.3.0: + resolution: {integrity: sha512-Y+ughcF9jSUJvncXwqRageavjrNPAI+1M/L3BI3PyLp1nmgYTGUXU6t5z1Y7OWuThoDdhPME07bQU+d5LxdJqw==} + engines: {node: '>=12.0.0'} + dependencies: + is-plain-obj: 3.0.0 + dev: false + /estree-util-visit/1.2.0: resolution: {integrity: sha512-wdsoqhWueuJKsh5hqLw3j8lwFqNStm92VcwtAOAny8g/KS/l5Y8RISjR4k5W6skCj3Nirag/WUCMS0Nfy3sgsg==} dependencies: @@ -3266,6 +3275,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-plain-obj/3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + dev: false + /is-plain-obj/4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -5121,6 +5135,10 @@ packages: picomatch: 2.3.1 dev: true + /reading-time/1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + dev: false + /redent/3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5195,6 +5213,15 @@ packages: - supports-color dev: false + /remark-reading-time/2.0.1: + resolution: {integrity: sha512-fy4BKy9SRhtYbEHvp6AItbRTnrhiDGbqLQTSYVbQPGuRCncU1ubSsh9p/W5QZSxtYcUXv8KGL0xBgPLyNJA1xw==} + dependencies: + estree-util-is-identifier-name: 2.0.1 + estree-util-value-to-estree: 1.3.0 + reading-time: 1.5.0 + unist-util-visit: 3.1.0 + dev: false + /remark-rehype/10.1.0: resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} dependencies: @@ -6145,6 +6172,13 @@ packages: unist-util-is: 4.1.0 dev: false + /unist-util-visit-parents/4.1.1: + resolution: {integrity: sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==} + dependencies: + '@types/unist': 2.0.6 + unist-util-is: 5.1.1 + dev: false + /unist-util-visit-parents/5.1.0: resolution: {integrity: sha512-y+QVLcY5eR/YVpqDsLf/xh9R3Q2Y4HxkZTp7ViLDU6WtJCEcPmRzW1gpdWDCDIqIlhuPDXOgttqPlykrHYDekg==} dependencies: @@ -6160,6 +6194,14 @@ packages: unist-util-visit-parents: 3.1.1 dev: false + /unist-util-visit/3.1.0: + resolution: {integrity: sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==} + dependencies: + '@types/unist': 2.0.6 + unist-util-is: 5.1.1 + unist-util-visit-parents: 4.1.1 + dev: false + /unist-util-visit/4.1.0: resolution: {integrity: sha512-n7lyhFKJfVZ9MnKtqbsqkQEk5P1KShj0+//V7mAcoI6bpbUjh3C/OG8HVD+pBihfh6Ovl01m8dkcv9HNqYajmQ==} dependencies: