import growiDirective from '@growi/remark-growi-directive'; import type { Schema as SanitizeOption } from 'hast-util-sanitize'; import katex from 'rehype-katex'; import raw from 'rehype-raw'; import sanitize, { defaultSchema as rehypeSanitizeDefaultSchema } from 'rehype-sanitize'; import slug from 'rehype-slug'; import breaks from 'remark-breaks'; import emoji from 'remark-emoji'; import remarkFrontmatter from 'remark-frontmatter'; import gfm from 'remark-gfm'; import math from 'remark-math'; import toc from 'remark-toc'; import deepmerge from 'ts-deepmerge'; import type { Pluggable, PluginTuple } from 'unified'; import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock'; import { NextLink } from '~/components/ReactMarkdownComponents/NextLink'; import * as frontmatterHider from '~/features/frontmatter-hider'; import { RehypeSanitizeOption } from '~/interfaces/rehype'; import type { RendererOptions } from '~/interfaces/renderer-options'; import type { RendererConfig } from '~/interfaces/services/renderer'; import loggerFactory from '~/utils/logger'; import * as addClass from './rehype-plugins/add-class'; import { relativeLinks } from './rehype-plugins/relative-links'; import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker'; import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker'; import * as xsvToTable from './remark-plugins/xsv-to-table'; // import EasyGrid from './PreProcessor/EasyGrid'; const logger = loggerFactory('growi:services:renderer'); type SanitizePlugin = PluginTuple<[SanitizeOption]>; const baseSanitizeSchema = { tagNames: ['iframe', 'section', 'video'], attributes: { iframe: ['allow', 'referrerpolicy', 'sandbox', 'src', 'srcdoc'], video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'], // The special value 'data*' as a property name can be used to allow all data properties. // see: https://github.com/syntax-tree/hast-util-sanitize/ '*': ['key', 'class', 'className', 'style', 'data*'], }, }; export const commonSanitizeOption: SanitizeOption = deepmerge( rehypeSanitizeDefaultSchema, baseSanitizeSchema, { clobberPrefix: '', // remove clobber prefix }, ); let isInjectedCustomSanitaizeOption = false; export const injectCustomSanitizeOption = (config: RendererConfig): void => { if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.xssOption === RehypeSanitizeOption.CUSTOM) { commonSanitizeOption.tagNames = baseSanitizeSchema.tagNames.concat(config.tagWhitelist ?? []); commonSanitizeOption.attributes = deepmerge(baseSanitizeSchema.attributes, config.attrWhitelist ?? {}); isInjectedCustomSanitaizeOption = true; } }; const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => { if (!Array.isArray(pluggable) || pluggable.length < 2) { return false; } const sanitizeOption = pluggable[1]; return 'tagNames' in sanitizeOption && 'attributes' in sanitizeOption; }; const hasSanitizePlugin = (options: RendererOptions, shouldBeTheLastItem: boolean): boolean => { const { rehypePlugins } = options; if (rehypePlugins == null || rehypePlugins.length === 0) { return false; } return shouldBeTheLastItem ? isSanitizePlugin(rehypePlugins.slice(-1)[0]) // evaluate the last one : rehypePlugins.some(rehypePlugin => isSanitizePlugin(rehypePlugin)); }; export const verifySanitizePlugin = (options: RendererOptions, shouldBeTheLastItem = true): void => { if (hasSanitizePlugin(options, shouldBeTheLastItem)) { return; } throw new Error('The specified options does not have sanitize plugin in \'rehypePlugins\''); }; export const generateCommonOptions = (pagePath: string|undefined): RendererOptions => { return { remarkPlugins: [ [toc, { maxDepth: 3, tight: true }], gfm, emoji, pukiwikiLikeLinker, growiDirective, remarkFrontmatter, // frontmatterHider.remarkPlugin, ], remarkRehypeOptions: { clobberPrefix: '', // remove clobber prefix allowDangerousHtml: true, }, rehypePlugins: [ [relativeLinksByPukiwikiLikeLinker, { pagePath }], [relativeLinks, { pagePath }], raw, [addClass.rehypePlugin, { table: 'table table-bordered', }], // frontmatterHider.rehypePlugin, ], components: { a: NextLink, code: CodeBlock, }, }; }; export const generateSSRViewOptions = ( config: RendererConfig, pagePath: string, ): RendererOptions => { const options = generateCommonOptions(pagePath); const { remarkPlugins, rehypePlugins, components } = options; // add remark plugins remarkPlugins.push( math, xsvToTable.remarkPlugin, ); const isEnabledLinebreaks = config.isEnabledLinebreaks; if (isEnabledLinebreaks) { remarkPlugins.push(breaks); } if (config.xssOption === RehypeSanitizeOption.CUSTOM) { injectCustomSanitizeOption(config); } const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention ? [sanitize, deepmerge( commonSanitizeOption, )] : () => {}; // add rehype plugins rehypePlugins.push( slug, rehypeSanitizePlugin, katex, ); // add components // if (components != null) { // } if (config.isEnabledXssPrevention) { verifySanitizePlugin(options, false); } return options; };