/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import fs from 'fs'; import _ from 'lodash'; import webpack from 'webpack'; import unified from 'unified'; import visit from 'unist-util-visit'; import Node from 'unist'; // @ts-ignore import remarkStringify from 'remark-stringify'; // @ts-ignore import headingRange from 'mdast-util-heading-range'; import toMarkdown from 'mdast-util-to-markdown'; import toString from 'mdast-util-to-string'; import remarkParse from 'remark-parse'; import dedent from 'dedent'; import path from 'path'; import { encodePath, fileToPath, aliasedSitePath, docuHash, getPluginI18nPath, getFolderContainingFile, addTrailingPathSeparator, Globby, createAbsoluteFilePathMatcher, normalizeUrl, } from '@docusaurus/utils'; import { LoadContext, Plugin, OptionValidationContext, ValidationResult, ConfigureWebpackUtils, } from '@docusaurus/types'; import {Configuration} from 'webpack'; import admonitions from 'remark-admonitions'; import {PluginOptionSchema} from './pluginOptionSchema'; import { DEFAULT_PLUGIN_ID, STATIC_DIR_NAME, } from '@docusaurus/core/lib/constants'; import { PluginOptions, LoadedContent, Metadata, PagesContentPaths, } from './types'; import {flatten} from 'lodash'; import {generateCourseLessons} from './courseUtils'; const PLUGIN_NAME = 'docusaurus-plugin-content-light-course-page'; export function getContentPathList(contentPaths: PagesContentPaths): string[] { return [contentPaths.contentPathLocalized, contentPaths.contentPath]; } const isMarkdownSource = (source: string) => source.endsWith('.md') || source.endsWith('.mdx'); type ContentPluginOptions = PluginOptions & { lightCourseComponent: string; }; type ContentPluginMetadata = Metadata & { sections: Record; }; const defaultOptions: Partial = { lightCourseComponent: '@theme/LightCoursePage', }; export default function pluginContentPages( context: LoadContext, opts: ContentPluginOptions, ): Plugin { const options = {...defaultOptions, ...opts}; if (options.admonitions) { options.remarkPlugins = options.remarkPlugins.concat([ [admonitions, options.admonitions || {}], ]); } const { siteConfig, siteDir, generatedFilesDir, i18n: {currentLocale}, } = context; const contentPaths: PagesContentPaths = { contentPath: path.resolve(siteDir, options.path), contentPathLocalized: getPluginI18nPath({ siteDir, locale: currentLocale, pluginName: PLUGIN_NAME, pluginId: options.id, }), }; const courseConfigPath = path.resolve(siteDir, 'course/config.js'); const pluginDataDirRoot = path.join(generatedFilesDir, PLUGIN_NAME); const dataDir = path.join(pluginDataDirRoot, options.id ?? DEFAULT_PLUGIN_ID); return { name: PLUGIN_NAME, getPathsToWatch() { const {include = []} = options; return flatten( getContentPathList(contentPaths).map((contentPath) => { return include.map((pattern) => `${contentPath}/${pattern}`); }), ); }, async loadContent() { const {include} = options; if (!fs.existsSync(contentPaths.contentPath)) { return null; } const {baseUrl} = siteConfig; const pagesFiles = await Globby(include, { cwd: contentPaths.contentPath, ignore: options.exclude, }); async function toMetadata( relativeSource: string, ): Promise { // Lookup in localized folder in priority const contentPath = await getFolderContainingFile( getContentPathList(contentPaths), relativeSource, ); const source = path.join(contentPath, relativeSource); const aliasedSourcePath = aliasedSitePath(source, siteDir); const permalink = normalizeUrl([ baseUrl, options.routeBasePath, encodePath(fileToPath(relativeSource)), ]); function sectionSerializerPlugin(options = {}) { return function transformer(ast: any, file: any) { const visitor: visit.Visitor = (node) => { const nodeTitle = toString(node); headingRange( ast, nodeTitle, (_start: any, nodes: any, _end: any) => { const data = file.data || {}; data.sections = _.assign(data.sections, { [nodeTitle]: { nodes, mdx: toMarkdown({ type: 'root', children: nodes, }), }, }); }, ); }; visit(ast, 'heading', visitor); }; } const courseConfig = require(courseConfigPath)({dedent}); const courseLessons = await generateCourseLessons( courseConfig, context, ); const parsed = unified() .use(remarkStringify) .use(remarkParse as unified.Attacher) .use(sectionSerializerPlugin as unified.Attacher) .processSync(fs.readFileSync(source)); const metadata = { sections: (parsed.data as Record).sections, permalink, source: aliasedSourcePath, courseConfig, courseLessons, }; if (isMarkdownSource(relativeSource)) { return { type: 'mdx', ...metadata, }; } else { return { type: 'jsx', ...metadata, }; } } return Promise.all(pagesFiles.map(toMetadata)); }, async contentLoaded({content, actions}) { if (!content) { return; } const {addRoute, createData} = actions; await Promise.all( content.map(async (metadata) => { const {permalink, source} = metadata; await createData( // Note that this created data path must be in sync with // metadataPath provided to mdx-loader. `${docuHash(metadata.source)}.json`, JSON.stringify(metadata, null, 2), ); addRoute({ path: permalink, component: options.lightCourseComponent, exact: true, modules: { content: source, }, }); }), ); }, configureWebpack( _config: Configuration, isServer: boolean, {getJSLoader}: ConfigureWebpackUtils, ) { const { rehypePlugins, remarkPlugins, beforeDefaultRehypePlugins, beforeDefaultRemarkPlugins, } = options; const contentDirs = getContentPathList(contentPaths); return { resolve: { alias: { '~pages': pluginDataDirRoot, }, fallback: { path: false, }, }, plugins: [ // for remark new webpack.ProvidePlugin({ process: 'process/browser', }), ], module: { rules: [ { test: /(\.mdx?)$/, include: contentDirs // Trailing slash is important, see https://github.com/facebook/docusaurus/pull/3970 .map(addTrailingPathSeparator), use: [ getJSLoader({isServer}), { loader: require.resolve('@docusaurus/mdx-loader'), options: { remarkPlugins, rehypePlugins, beforeDefaultRehypePlugins, beforeDefaultRemarkPlugins, staticDir: path.join(siteDir, STATIC_DIR_NAME), isMDXPartial: createAbsoluteFilePathMatcher( options.exclude, contentDirs, ), metadataPath: (mdxPath: string) => { // Note that metadataPath must be the same/in-sync as // the path from createData for each MDX. const aliasedSource = aliasedSitePath(mdxPath, siteDir); return path.join( dataDir, `${docuHash(aliasedSource)}.json`, ); }, }, }, { loader: path.resolve(__dirname, './markdownLoader.js'), options: { // siteDir, // contentPath, }, }, ].filter(Boolean), }, ], }, }; }, }; } export function validateOptions({ validate, options, }: OptionValidationContext): ValidationResult { const validatedOptions = validate(PluginOptionSchema, options); return validatedOptions; }