MDX: Support last modified timestamp for Git

This commit is contained in:
SonMooSans
2024-01-01 20:32:44 +08:00
parent 9929c5b3ae
commit 8fd769fef0
22 changed files with 97 additions and 71 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'next-docs-mdx': minor
---
**Support last modified timestamp for Git**
+1 -3
View File
@@ -97,9 +97,7 @@ export function Previews(): JSX.Element {
>
@joulev
</a>
<p className="text-xs text-muted-foreground">
Moderator at Next.js Discord
</p>
<p className="text-xs text-muted-foreground">Next.js Expert</p>
</div>
);
}
+5 -21
View File
@@ -2,7 +2,6 @@ import { ExternalLinkIcon } from 'lucide-react';
import type { Metadata } from 'next';
import { Card, Cards } from 'next-docs-ui/mdx/card';
import { DocsPage, DocsBody } from 'next-docs-ui/page';
import { getGithubLastEdit } from 'next-docs-zeta/server';
import { notFound } from 'next/navigation';
import { utils, type Page } from '@/utils/source';
import { createMetadata } from '@/utils/metadata';
@@ -12,11 +11,7 @@ interface Param {
slug: string[];
}
export default async function Page({
params,
}: {
params: Param;
}): Promise<JSX.Element> {
export default function Page({ params }: { params: Param }): JSX.Element {
const page = utils.getPage(params.slug);
if (!page) {
@@ -24,21 +19,12 @@ export default async function Page({
}
const path = `apps/docs/content/docs/${page.file.path}`;
const time = await getGithubLastEdit({
owner: 'fuma-nama',
repo: 'next-docs',
path,
token: process.env.GIT_TOKEN
? `Bearer ${process.env.GIT_TOKEN}`
: undefined,
});
const preview = page.data.preview?.trim();
return (
<DocsPage
toc={page.data.exports.toc}
lastUpdate={time}
lastUpdate={page.data.exports.lastModified}
tableOfContent={{
footer: (
<a
@@ -125,9 +111,7 @@ export function generateMetadata({ params }: { params: Param }): Metadata {
}
export function generateStaticParams(): Param[] {
return (
utils.getPages()?.map<Param>((page) => ({
slug: page.slugs,
})) ?? []
);
return utils.getPages().map<Param>((page) => ({
slug: page.slugs,
}));
}
@@ -127,7 +127,7 @@ export async function generateStaticParams(): Promise<
{ lang: string; slug: string[] }[]
> {
return languages.flatMap((lang) =>
getPages(lang)!.map((docs) => ({
getPages(lang).map((docs) => ({
slug: docs.slug.split('/'),
lang: lang,
})),
+1
View File
@@ -22,6 +22,7 @@ const config = {
const withNextDocs = createNextDocs({
mdxOptions: {
lastModifiedTime: 'git',
remarkPlugins: [
remarkMath,
remarkDynamicContent,
@@ -26,7 +26,7 @@ export default async function Page({
}
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
return getPages()!.map((page) => ({
return getPages().map((page) => ({
slug: page.slugs,
}));
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { getPages } from '@/app/source';
import { createSearchAPI } from 'next-docs-zeta/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: getPages()!.map((page) => ({
indexes: getPages().map((page) => ({
title: page.data.title,
structuredData: page.data.exports.structuredData,
id: page.url,
@@ -27,7 +27,7 @@ export default async function Page({
}
export async function generateStaticParams() {
return getPages()!.map((page) => ({
return getPages().map((page) => ({
slug: page.slugs,
}));
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { getPages } from '@/app/source';
import { createSearchAPI } from 'next-docs-zeta/search/server';
export const { GET } = createSearchAPI('simple', {
indexes: getPages()!.map((page) => ({
indexes: getPages().map((page) => ({
title: page.data.title,
content: page.data.body.raw,
url: page.url,
@@ -26,11 +26,9 @@ export default async function Page({
}
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
return (
getPages()?.map((page) => ({
slug: page.slugs,
})) ?? []
);
return getPages().map((page) => ({
slug: page.slugs,
}));
}
export function generateMetadata({ params }: { params: { slug?: string[] } }) {
@@ -2,7 +2,7 @@ import { getPages } from '@/app/source';
import { createSearchAPI } from 'next-docs-zeta/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: getPages()!.map((page) => ({
indexes: getPages().map((page) => ({
id: page.data._id,
title: page.data.title,
url: page.url,
@@ -26,11 +26,9 @@ export default async function Page({
}
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
return (
getPages()?.map((page) => ({
slug: page.slugs,
})) ?? []
);
return getPages().map((page) => ({
slug: page.slugs,
}));
}
export function generateMetadata({ params }: { params: { slug?: string[] } }) {
@@ -2,7 +2,7 @@ import { getPages } from '@/app/source';
import { createSearchAPI } from 'next-docs-zeta/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: getPages()!.map((page) => ({
indexes: getPages().map((page) => ({
title: page.data.title,
structuredData: page.data.exports.structuredData,
id: page.url,
@@ -27,7 +27,7 @@ export default async function Page({
}
export async function generateStaticParams() {
return getPages()!.map((page) => ({
return getPages().map((page) => ({
slug: page.slugs,
}));
}
+3 -6
View File
@@ -50,14 +50,11 @@ export interface LoaderOutput<Config extends LoaderConfig> {
files: Node<Config['source']['metaData'], Config['source']['pageData']>[];
/**
* Get list of pages from language
* Get list of pages from language, empty if language hasn't specified
*
* @param language - If empty, the default language will be used
*/
getPages: (
language?: string,
) => Page<Config['source']['pageData']>[] | undefined;
getPages: (language?: string) => Page<Config['source']['pageData']>[];
/**
* @param language - If empty, the default language will be used
*/
@@ -155,7 +152,7 @@ function createOutput({
pageTree: pageTree as LoaderOutput<LoaderConfig>['pageTree'],
files: result.storage.list(),
getPages(language = '') {
return i18nMap.get(language);
return i18nMap.get(language) ?? [];
},
getPage(slugs_ = [], language = '') {
const path = slugs_.join('/');
+2
View File
@@ -45,6 +45,7 @@
},
"dependencies": {
"@mdx-js/mdx": "^3.0.0",
"cross-spawn": "^7.0.3",
"estree-util-value-to-estree": "^3.0.1",
"fast-glob": "^3.3.1",
"gray-matter": "^4.0.3",
@@ -52,6 +53,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.4",
"@types/mdast": "^4.0.3",
"@types/mdx": "^2.0.8",
"@types/react": "18.2.0",
+4 -11
View File
@@ -1,5 +1,4 @@
import path from 'node:path';
import type { ProcessorOptions } from '@mdx-js/mdx';
import type { NextConfig } from 'next';
import {
rehypeNextDocs,
@@ -13,12 +12,10 @@ import type { Configuration } from 'webpack';
import { NextDocsWebpackPlugin } from './webpack-plugins/next-docs';
import remarkMdxExport from './mdx-plugins/remark-exports';
import type { LoaderOptions } from './loader';
type WithMDX = (config: NextConfig) => NextConfig;
type Loader = (options: ProcessorOptions) => WithMDX;
import type { Options as MDXLoaderOptions } from './loader-mdx';
type MDXOptions = Omit<
NonNullable<ProcessorOptions>,
NonNullable<MDXLoaderOptions>,
'rehypePlugins' | 'remarkPlugins'
> & {
rehypePlugins?: ResolvePlugins;
@@ -42,11 +39,6 @@ interface NextDocsMDXOptions {
mdxOptions?: MDXOptions;
/**
* Custom MDX loader
*/
loader?: Loader;
/**
* Where the root map.ts should be, relative to cwd
*
@@ -87,6 +79,7 @@ const createNextDocs =
'structuredData',
'toc',
'frontmatter',
'lastModified',
...(mdxOptions.valueToExport ?? []),
];
const _mapPath = path.resolve(cwd, rootMapPath);
@@ -136,7 +129,7 @@ const createNextDocs =
...mdxOptions,
remarkPlugins,
rehypePlugins,
},
} satisfies MDXLoaderOptions,
},
],
},
+21 -10
View File
@@ -3,8 +3,15 @@ import { createProcessor, type ProcessorOptions } from '@mdx-js/mdx';
import type { Processor } from '@mdx-js/mdx/lib/core';
import grayMatter from 'gray-matter';
import { type LoaderContext } from 'webpack';
import { getGitTimestamp } from './utils/git-timestamp';
type Options = ProcessorOptions;
export interface Options extends ProcessorOptions {
/**
* Fetch last modified time with specified version control
* @defaultValue 'none'
*/
lastModifiedTime?: 'git' | 'none';
}
export interface NextDocsBuildInfo {
__next_docs?: {
@@ -23,27 +30,30 @@ const cache = new Map<string, Processor>();
*
* it supports frontmatter by parsing and injecting the data in `vfile.data.frontmatter`
*/
export default function loader(
export default async function loader(
this: LoaderContext<Options>,
source: string,
callback: LoaderContext<Options>['callback'],
): void {
): Promise<void> {
this.cacheable(true);
const context = this.context;
const filePath = this.resourcePath;
const options = this.getOptions();
const { content, data: frontmatter } = grayMatter(source);
const format = options.format ?? filePath.endsWith('.mdx') ? 'mdx' : 'md';
const config: ProcessorOptions = {
format,
development: this.mode === 'development',
...options,
};
let timestamp: number | undefined;
let processor = cache.get(format);
if (options.lastModifiedTime === 'git')
timestamp = (await getGitTimestamp(filePath))?.getTime();
if (!processor) {
processor = createProcessor(config);
processor = createProcessor({
format,
development: this.mode === 'development',
...options,
});
cache.set(format, processor);
}
@@ -52,6 +62,7 @@ export default function loader(
value: content,
path: filePath,
data: {
lastModified: timestamp,
frontmatter,
},
})
+1
View File
@@ -21,6 +21,7 @@ export type SourceFile<Meta extends MetaData, Fronmatter extends PageData> =
export interface MDXExport<Frontmatter = PageData> {
default: (props: MDXProps) => JSX.Element;
frontmatter: Frontmatter;
lastModified: number | undefined;
toc: TableOfContents;
structuredData: StructuredData;
}
+30
View File
@@ -0,0 +1,30 @@
import path from 'node:path';
import fs from 'node:fs';
import { spawn } from 'cross-spawn';
const cache = new Map<string, Date>();
export function getGitTimestamp(file: string): Promise<Date | undefined> {
const cachedTimestamp = cache.get(file);
if (cachedTimestamp) return Promise.resolve(cachedTimestamp);
return new Promise((resolve, reject) => {
const cwd = path.dirname(file);
if (!fs.existsSync(cwd)) {
resolve(undefined);
return;
}
const fileName = path.basename(file);
const child = spawn('git', ['log', '-1', '--pretty="%ai"', fileName], {
cwd,
});
let output: Date | undefined;
child.stdout.on('data', (d) => (output = new Date(String(d))));
child.on('close', () => {
if (output) cache.set(file, output);
resolve(output);
});
child.on('error', reject);
});
}
+4 -2
View File
@@ -37,7 +37,7 @@ export interface DocsPageProps {
items: NonNullable<FooterProps['items']>;
}>;
lastUpdate?: Date | null;
lastUpdate?: Date | string | number;
children: ReactNode;
}
@@ -53,7 +53,9 @@ export function DocsPage({
<article className="flex w-0 flex-1 flex-col gap-6 py-10">
{replaceOrDefault(breadcrumb, <Breadcrumb />)}
{props.children}
{props.lastUpdate ? <LastUpdate date={props.lastUpdate} /> : null}
{props.lastUpdate ? (
<LastUpdate date={new Date(props.lastUpdate)} />
) : null}
{replaceOrDefault(footer, <Footer items={footer.items} />)}
</article>
{replaceOrDefault(
+6
View File
@@ -454,6 +454,9 @@ importers:
'@mdx-js/mdx':
specifier: ^3.0.0
version: 3.0.0
cross-spawn:
specifier: ^7.0.3
version: 7.0.3
estree-util-value-to-estree:
specifier: ^3.0.1
version: 3.0.1
@@ -470,6 +473,9 @@ importers:
specifier: ^3.22.4
version: 3.22.4
devDependencies:
'@types/cross-spawn':
specifier: ^6.0.4
version: 6.0.4
'@types/mdast':
specifier: ^4.0.3
version: 4.0.3