MDX: Support intelligent schema types

This commit is contained in:
SonMooSans
2023-12-11 20:30:55 +08:00
parent 5a4a8bbcd3
commit 1c187b9035
15 changed files with 223 additions and 144 deletions
+5 -5
View File
@@ -10,11 +10,11 @@ You can now generate slugs automatically depending on the root directory you hav
```ts
const utils = fromMap(map, {
rootDir: 'ui',
validate: {
frontmatter: frontmatterSchema
}
})
rootDir: 'ui',
schema: {
frontmatter: frontmatterSchema
}
})
```
The configuration above will generate `/hello` slugs for a file named `/content/ui/hello.mdx`, while the previous one generates `/ui/hello`.
+23
View File
@@ -0,0 +1,23 @@
---
'next-docs-mdx': major
---
**Support intelligent schema types**
The `validate` options is now renamed to `schema`.
```ts
import { defaultSchemas, fromMap } from 'next-docs-mdx/map';
const utils = fromMap(map, {
rootDir: 'docs/ui',
baseUrl: '/docs/ui',
schema: {
frontmatter: defaultSchemas.frontmatter.extend({
preview: z.string().optional(),
}),
},
})
```
The `frontmatter` field on pages should be automatically inferred to your Zod schema type.
+1
View File
@@ -7,5 +7,6 @@
"access": "restricted",
"baseBranch": "dev",
"updateInternalDependencies": "patch",
"privatePackages": { "version": false },
"ignore": ["simple-app", "docs", "advanced-app", "simple-app-mdx"]
}
@@ -1,12 +1,12 @@
import { resolve } from 'node:url';
import { ExternalLinkIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Utils } from 'next-docs-mdx/map';
import type { Page } from 'next-docs-mdx/types';
import { Card, Cards, MDXContent } from 'next-docs-ui/mdx';
import { DocsPage } from 'next-docs-ui/page';
import { getGithubLastEdit } from 'next-docs-zeta/server';
import { notFound } from 'next/navigation';
import type { DocsUtils } from '@/utils/source';
import { getUtils, tabs } from '@/utils/source';
import { createMetadata } from '@/utils/metadata';
import Preview from '@/components/preview';
@@ -60,7 +60,7 @@ export default async function Page({
>
<MDXContent>
<div className="not-prose mb-12">
<h1 className="mb-4 text-3xl font-semibold text-foreground sm:text-4xl">
<h1 className="mb-4 text-3xl font-bold text-foreground sm:text-4xl">
{page.matter.title}
</h1>
<p className="text-lg text-muted-foreground">
@@ -78,7 +78,7 @@ export default async function Page({
);
}
function Category({ page, tab }: { page: Page; tab: Utils }): JSX.Element {
function Category({ page, tab }: { page: Page; tab: DocsUtils }): JSX.Element {
const filtered = tab.pages.filter(
(docs) =>
docs.file.dirname === page.file.dirname && docs.file.name !== 'index',
+23 -17
View File
@@ -1,55 +1,61 @@
import { map } from '@/_map';
import { writeFileSync } from 'fs';
import { defaultValidators, fromMap, type Utils } from 'next-docs-mdx/map';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import type { Utils } from 'next-docs-mdx/map';
import { defaultSchemas, fromMap } from 'next-docs-mdx/map';
import type { StructuredData } from 'next-docs-zeta/mdx-plugins';
import { PHASE_PRODUCTION_BUILD } from 'next/constants';
import path from 'path';
import { z } from 'zod';
import type { DefaultMetaData } from 'next-docs-mdx/types';
import { map } from '@/_map';
const frontmatterSchema = defaultValidators.frontmatter.extend({
const frontmatterSchema = defaultSchemas.frontmatter.extend({
preview: z.string().optional(),
index: z.boolean().default(false),
});
export const tabs: Record<string, Utils> = {
export type DocsUtils = Utils<{
languages: undefined;
schema: {
frontmatter: z.infer<typeof frontmatterSchema>;
meta: DefaultMetaData;
};
}>;
export const tabs: Record<string, DocsUtils> = {
ui: fromMap(map, {
rootDir: 'docs/ui',
baseUrl: '/docs/ui',
validate: {
schema: {
frontmatter: frontmatterSchema,
},
}),
headless: fromMap(map, {
rootDir: 'docs/headless',
baseUrl: '/docs/headless',
validate: {
schema: {
frontmatter: frontmatterSchema,
},
}),
mdx: fromMap(map, {
rootDir: 'docs/mdx',
baseUrl: '/docs/mdx',
validate: {
schema: {
frontmatter: frontmatterSchema,
},
}),
};
export function getUtils(mode: 'ui' | 'headless' | 'mdx' | string): Utils {
return tabs[mode] ?? tabs['headless'];
export function getUtils(mode: string): DocsUtils {
return mode in tabs ? tabs[mode] : tabs.headless;
}
declare module 'next-docs-mdx/types' {
interface Frontmatter extends z.infer<typeof frontmatterSchema> {}
}
export type Index = {
export interface Index {
id: string;
title: string;
description?: string;
url: string;
structuredData: StructuredData;
};
}
// Access and export MDX pages data to json file
// So that we can update search indexes after the build
+2 -1
View File
@@ -4,13 +4,14 @@ import {
} from 'next-docs-zeta/server';
import type { ReactElement } from 'react';
import type { ResolvedFiles } from './resolve-files';
import type { DefaultFrontmatter, DefaultMetaData } from './types';
export interface BuilderOptions {
resolveIcon?: (icon: string) => ReactElement | undefined;
}
export function getPageTreeBuilder(
{ metas, pages }: ResolvedFiles,
{ metas, pages }: ResolvedFiles<DefaultFrontmatter, DefaultMetaData>,
{ resolveIcon = () => undefined }: BuilderOptions,
): PageTreeBuilder {
return createPageTreeBuilder({
+1
View File
@@ -86,6 +86,7 @@ const createNextDocs =
const valueToExport = [
'structuredData',
'toc',
'frontmatter',
...(mdxOptions.valueToExport ?? []),
];
const _mapPath = path.resolve(cwd, rootMapPath);
+29 -20
View File
@@ -18,6 +18,11 @@ export interface NextDocsBuildInfo {
const cache = new Map<string, Processor>();
/**
* Load MDX/markdown files
*
* it supports frontmatter by parsing and injecting the data in `vfile.data.frontmatter`
*/
export default function loader(
this: LoaderContext<Options>,
source: string,
@@ -27,7 +32,7 @@ export default function loader(
const context = this.context;
const filePath = this.resourcePath;
const options = this.getOptions();
const { content, data } = grayMatter(source);
const { content, data: frontmatter } = grayMatter(source);
const config = {
development: this.mode === 'development',
...options,
@@ -41,25 +46,29 @@ export default function loader(
cache.set(format, processor);
}
processor.process({ value: content, path: filePath }).then(
(file) => {
const result = String(file.value);
const info = this._module?.buildInfo as NextDocsBuildInfo;
processor
.process({
value: content,
path: filePath,
data: {
frontmatter,
},
})
.then(
(file) => {
const info = this._module?.buildInfo as NextDocsBuildInfo;
info.__next_docs = {
path: this.resourcePath,
data: file.data,
};
info.__next_docs = {
path: filePath,
data: file.data,
};
const final = `export const frontmatter = ${JSON.stringify(
data,
)};\n${result}`;
callback(undefined, final, file.map || undefined);
},
(error: Error) => {
const fpath = path.relative(context, filePath);
error.message = `${fpath}:${error.name}: ${error.message}`;
callback(error);
},
);
callback(undefined, String(file.value), file.map || undefined);
},
(error: Error) => {
const fpath = path.relative(context, filePath);
error.message = `${fpath}:${error.name}: ${error.message}`;
callback(error);
},
);
}
+74 -26
View File
@@ -3,40 +3,85 @@ import {
type BuildPageTreeOptions,
type PageTree,
} from 'next-docs-zeta/server';
import type { AnyZodObject, z } from 'zod';
import { getPageTreeBuilder, type BuilderOptions } from './build-tree';
import { createPageUtils, type PageUtils } from './page-utils';
import {
defaultValidators,
resolveFiles,
type ResolvedFiles,
type SchemaOptions,
type ResolveOptions,
} from './resolve-files';
import type { Meta, Page } from './types';
import type { DefaultFrontmatter, DefaultMetaData, Meta, Page } from './types';
import { defaultSchemas } from './validate/schema';
type UtilsOptions<Langs extends string[] | undefined> = {
languages: Langs;
interface UtilsOptions extends BuilderOptions {
languages: string[] | undefined;
/**
* @defaultValue `'/'`
*/
baseUrl: string;
schema: Partial<SchemaOptions>;
getUrl: ResolveOptions['getUrl'];
getSlugs: ResolveOptions['getSlugs'];
rootDir: ResolveOptions['rootDir'];
pageTreeOptions: BuildPageTreeOptions;
} & BuilderOptions &
Pick<ResolveOptions, 'getSlugs' | 'getUrl' | 'validate' | 'rootDir'>;
}
export type Utils = PageUtils & {
tree: PageTree;
pages: Page[];
metas: Meta[];
interface RootConfig {
languages: string[] | undefined;
schema: {
frontmatter: unknown;
meta: unknown;
};
}
export type Utils<TTypes extends RootConfig> = PageUtils<
TTypes['schema']['frontmatter']
> & {
tree: TTypes['languages'] extends string[]
? Record<string, PageTree>
: PageTree;
pages: Page<TTypes['schema']['frontmatter']>[];
metas: Meta<TTypes['schema']['meta']>[];
};
type I18nUtils = Omit<Utils, 'tree'> & {
tree: Record<string, PageTree>;
};
type GetSchemaType<Schema, DefaultValue> = Schema extends AnyZodObject
? z.infer<Schema>
: DefaultValue;
function fromMap<Langs extends string[] | undefined = undefined>(
/**
* Get accurate options type from partial options
*/
interface TransformPartialOptions<TOptions extends Partial<UtilsOptions>> {
languages: TOptions['languages'] extends string[] ? string[] : undefined;
schema: TOptions['schema'] extends Partial<SchemaOptions>
? {
frontmatter: GetSchemaType<
TOptions['schema']['frontmatter'],
DefaultFrontmatter
>;
meta: GetSchemaType<TOptions['schema']['meta'], DefaultMetaData>;
}
: {
frontmatter: DefaultFrontmatter;
meta: DefaultMetaData;
};
}
function fromMap<TOptions extends Partial<UtilsOptions>>(
map: Record<string, unknown>,
{
options?: TOptions,
): Utils<TransformPartialOptions<TOptions>> {
type $Options = TransformPartialOptions<TOptions>;
type $Frontmatter = $Options['schema']['frontmatter'];
type $MetaData = $Options['schema']['meta'];
type $Utils = Utils<$Options>;
const {
baseUrl = '/',
rootDir = '',
getSlugs,
@@ -44,22 +89,25 @@ function fromMap<Langs extends string[] | undefined = undefined>(
resolveIcon,
pageTreeOptions = { root: '' },
languages,
validate,
}: Partial<UtilsOptions<Langs>> = {},
): Langs extends string[] ? I18nUtils : Utils {
const resolved = resolveFiles({
schema,
} = options ?? {};
const resolved = resolveFiles<$Frontmatter, $MetaData>({
map,
rootDir,
getSlugs,
getUrl,
validate,
schema,
});
const pageUtils = createPageUtils(resolved, languages ?? []);
const builder = getPageTreeBuilder(resolved, {
resolveIcon,
});
const builder = getPageTreeBuilder(
resolved as ResolvedFiles<DefaultFrontmatter, DefaultMetaData>,
{
resolveIcon,
},
);
const tree =
languages === undefined
@@ -71,9 +119,9 @@ function fromMap<Langs extends string[] | undefined = undefined>(
return {
...resolved,
tree: tree as $Utils['tree'],
...pageUtils,
tree,
} as Langs extends string[] ? I18nUtils : Utils;
};
}
export {
@@ -81,5 +129,5 @@ export {
resolveFiles,
createPageUtils,
getPageTreeBuilder,
defaultValidators,
defaultSchemas,
};
+5
View File
@@ -1,5 +1,10 @@
import { valueToEstree } from 'estree-util-value-to-estree';
/**
* MDX.js first converts javascript (with esm support) into mdast nodes with remark-mdx, then handle the other remark plugins
*
* Therefore, if we want to inject an export, we must convert the object into AST, then add the mdast node
*/
export function getMdastExport(name: string, value: unknown): object {
return {
type: 'mdxjsEsm',
+23 -27
View File
@@ -1,22 +1,26 @@
import type { ResolvedFiles } from './resolve-files';
import type { Page } from './types';
export interface PageUtils {
getPages: (locale?: string) => Page[];
getPage: (slugs: string[] | undefined, locale?: string) => Page | null;
export interface PageUtils<Frontmatter> {
getPages: (locale?: string) => Page<Frontmatter>[];
getPage: (
slugs: string[] | undefined,
locale?: string,
) => Page<Frontmatter> | null;
}
export function createPageUtils(
{ pages }: ResolvedFiles,
export function createPageUtils<Frontmatter>(
{ pages }: ResolvedFiles<Frontmatter, unknown>,
languages: string[],
): PageUtils {
const pageMap = new Map<string, Page>();
): PageUtils<Frontmatter> {
type $Page = Page<Frontmatter>;
const pageMap = new Map<string, $Page>();
for (const page of pages) {
pageMap.set(page.file.flattenedPath, page);
}
const i18nMap = new Map<string, Page[]>();
const i18nMap = new Map<string, $Page[]>();
i18nMap.set('', []);
@@ -41,25 +45,17 @@ export function createPageUtils(
return i18nMap.get(locale) ?? [];
},
getPage(slugs, locale) {
return getPage(pages, slugs, locale);
const path = (slugs ?? []).join('/');
let def: $Page | null = null;
for (const page of pages) {
if (page.slugs.join('/') === path) {
if (!page.file.locale) def = page;
if (page.file.locale === locale) return page;
}
}
return def;
},
};
}
function getPage(
pages: Page[],
slugs: string[] | undefined = [],
locale?: string,
): Page | null {
const path = slugs.join('/');
let def: Page | null = null;
for (const page of pages) {
if (page.slugs.join('/') === path) {
if (!page.file.locale) def = page;
if (page.file.locale === locale) return page;
}
}
return def;
}
+22 -33
View File
@@ -2,19 +2,14 @@ import path from 'node:path';
import { joinPaths, splitPath } from 'next-docs-zeta/server';
import type { AnyZodObject } from 'zod';
import type { FileInfo, MDXExport, Meta, Page } from './types';
import {
frontmatterSchema,
metaSchema,
type Frontmatter,
type MetaExport,
} from './validate/schema';
import { defaultSchemas } from './validate/schema';
const pageTypes = ['.md', '.mdx'];
const metaTypes = ['.json'];
export interface ResolvedFiles {
pages: Page[];
metas: Meta[];
export interface ResolvedFiles<Frontmatter, MetaData> {
pages: Page<Frontmatter>[];
metas: Meta<MetaData>[];
}
export interface ResolveOptions {
@@ -33,9 +28,9 @@ export interface ResolveOptions {
getSlugs?: (file: FileInfo) => string[];
/**
* Check frontmatter/meta objects, transform allowed
* Zod schema for frontmatter/meta objects, transform allowed
*/
validate?: ValidateOptions;
schema?: Partial<SchemaOptions>;
/**
* Get url from slugs and locale, override the default getUrl function
@@ -43,15 +38,10 @@ export interface ResolveOptions {
getUrl: (slugs: string[], locale?: string) => string;
}
type ValidateOptions = Partial<{
export interface SchemaOptions {
frontmatter: AnyZodObject;
meta: AnyZodObject;
}>;
export const defaultValidators = {
frontmatter: frontmatterSchema,
meta: metaSchema,
};
}
function parsePath(p: string, root = ''): FileInfo | false {
if (!p.startsWith(root)) return false;
@@ -90,28 +80,27 @@ function parse<T>(schema: AnyZodObject, object: unknown, errorName: string): T {
return result.data as T;
}
export function resolveFiles({
export function resolveFiles<Frontmatter, MetaData>({
map,
getSlugs = pathToSlugs,
getUrl,
rootDir = '',
validate = defaultValidators,
}: ResolveOptions): ResolvedFiles {
const metas: Meta[] = [];
const pages: Page[] = [];
schema = defaultSchemas,
}: ResolveOptions): ResolvedFiles<Frontmatter, MetaData> {
type $Meta = Meta<MetaData>;
type $Page = Page<Frontmatter>;
const metas: $Meta[] = [];
const pages: $Page[] = [];
for (const [key, value] of Object.entries(map)) {
const file = parsePath(key, rootDir);
if (file === false) continue;
if (metaTypes.includes(file.type)) {
const meta: Meta = {
const meta: $Meta = {
file,
data: parse<MetaExport>(
validate.meta ?? defaultValidators.meta,
value,
file.path,
),
data: parse(schema.meta ?? defaultSchemas.meta, value, file.path),
};
metas.push(meta);
@@ -120,15 +109,15 @@ export function resolveFiles({
}
if (pageTypes.includes(file.type)) {
const data = value as MDXExport;
const data = value as MDXExport<Frontmatter>;
const slugs = getSlugs(file);
const page: Page = {
const page: $Page = {
file,
slugs,
url: getUrl(slugs, file.locale),
matter: parse<Frontmatter>(
validate.frontmatter ?? defaultValidators.frontmatter,
matter: parse(
schema.frontmatter ?? defaultSchemas.frontmatter,
data.frontmatter,
file.path,
),
+8 -7
View File
@@ -1,6 +1,7 @@
import type { MDXProps } from 'mdx/types';
import type { StructuredData } from 'next-docs-zeta/mdx-plugins';
import type { TableOfContents } from 'next-docs-zeta/server';
import type { z } from 'zod';
import type * as Default from './validate/schema';
export interface FileInfo {
@@ -47,26 +48,26 @@ export interface FileInfo {
/**
* Defalt MDX properties, feel free to extend
*/
export interface MDXExport {
export interface MDXExport<Frontmatter = unknown> {
default: (props: MDXProps) => JSX.Element;
frontmatter: Frontmatter;
toc: TableOfContents;
structuredData: StructuredData;
}
export type Frontmatter = Default.Frontmatter;
export type DefaultFrontmatter = z.infer<typeof Default.frontmatterSchema>;
export type JsonExport = Default.MetaExport;
export type DefaultMetaData = z.infer<typeof Default.metaSchema>;
export interface Meta {
export interface Meta<Data = unknown> {
file: FileInfo;
data: JsonExport;
data: Data;
}
export interface Page {
export interface Page<Frontmatter = unknown> {
file: FileInfo;
url: string;
slugs: string[];
data: MDXExport;
data: MDXExport<Frontmatter>;
matter: Frontmatter;
}
+4 -2
View File
@@ -12,5 +12,7 @@ export const frontmatterSchema = z.object({
icon: z.string().optional(),
});
export type MetaExport = z.infer<typeof metaSchema>;
export type Frontmatter = z.infer<typeof frontmatterSchema>;
export const defaultSchemas = {
frontmatter: frontmatterSchema,
meta: metaSchema,
};
-3
View File
@@ -91,14 +91,11 @@ export const docsUi = plugin.withOptions<NextDocsUIOptions>(
'& [data-line]': {
'padding-left': `theme('spacing.4')`,
'padding-right': `theme('spacing.4')`,
'border-left-width': `theme('borderWidth.2')`,
'border-color': 'transparent',
'& span': {
color: 'var(--shiki-light)',
},
},
'& [data-highlighted-line]': {
'border-color': `theme('colors.primary.DEFAULT')`,
'background-color': `theme('colors.primary.DEFAULT / 10%')`,
},
'& [data-highlighted-chars]': {