renderer.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. // allow only types to import from react
  2. import { ComponentType } from 'react';
  3. import { isClient } from '@growi/core';
  4. import * as drawioPlugin from '@growi/remark-drawio-plugin';
  5. import growiPlugin from '@growi/remark-growi-plugin';
  6. import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
  7. import * as lsxGrowiPlugin from '@growi/remark-lsx/services/renderer';
  8. import { Schema as SanitizeOption } from 'hast-util-sanitize';
  9. import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
  10. import { NormalComponents } from 'react-markdown/lib/complex-types';
  11. import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
  12. import katex from 'rehype-katex';
  13. import raw from 'rehype-raw';
  14. import sanitize, { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
  15. import slug from 'rehype-slug';
  16. import { HtmlElementNode } from 'rehype-toc';
  17. import breaks from 'remark-breaks';
  18. import emoji from 'remark-emoji';
  19. import gfm from 'remark-gfm';
  20. import math from 'remark-math';
  21. import deepmerge from 'ts-deepmerge';
  22. import { PluggableList, Pluggable, PluginTuple } from 'unified';
  23. import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
  24. import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
  25. import { Header } from '~/components/ReactMarkdownComponents/Header';
  26. import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
  27. import { Table } from '~/components/ReactMarkdownComponents/Table';
  28. import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
  29. import { RendererConfig } from '~/interfaces/services/renderer';
  30. import { registerGrowiFacade } from '~/utils/growi-facade';
  31. import loggerFactory from '~/utils/logger';
  32. import * as addClass from './rehype-plugins/add-class';
  33. import * as addLineNumberAttribute from './rehype-plugins/add-line-number-attribute';
  34. import * as keywordHighlighter from './rehype-plugins/keyword-highlighter';
  35. import { relativeLinks } from './rehype-plugins/relative-links';
  36. import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
  37. import * as toc from './rehype-plugins/relocate-toc';
  38. import * as plantuml from './remark-plugins/plantuml';
  39. import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
  40. import * as table from './remark-plugins/table';
  41. import * as xsvToTable from './remark-plugins/xsv-to-table';
  42. // import CsvToTable from './PreProcessor/CsvToTable';
  43. // import EasyGrid from './PreProcessor/EasyGrid';
  44. // import Linker from './PreProcessor/Linker';
  45. // import XssFilter from './PreProcessor/XssFilter';
  46. // import BlockdiagConfigurer from './markdown-it/blockdiag';
  47. // import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
  48. // import EmojiConfigurer from './markdown-it/emoji';
  49. // import FooternoteConfigurer from './markdown-it/footernote';
  50. // import HeaderConfigurer from './markdown-it/header';
  51. // import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
  52. // import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
  53. // import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
  54. // import MathJaxConfigurer from './markdown-it/mathjax';
  55. // import PlantUMLConfigurer from './markdown-it/plantuml';
  56. // import TableConfigurer from './markdown-it/table';
  57. // import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
  58. // import TaskListsConfigurer from './markdown-it/task-lists';
  59. // import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
  60. const logger = loggerFactory('growi:util:GrowiRenderer');
  61. // declare const hljs;
  62. // type MarkdownSettings = {
  63. // breaks?: boolean,
  64. // };
  65. // export default class GrowiRenderer {
  66. // RendererConfig: RendererConfig;
  67. // constructor(RendererConfig: RendererConfig, pagePath?: Nullable<string>) {
  68. // this.RendererConfig = RendererConfig;
  69. // this.pagePath = pagePath;
  70. // if (isClient() && (window as CustomWindow).growiRenderer != null) {
  71. // this.preProcessors = (window as CustomWindow).growiRenderer.preProcessors;
  72. // this.postProcessors = (window as CustomWindow).growiRenderer.postProcessors;
  73. // }
  74. // else {
  75. // this.preProcessors = [
  76. // new EasyGrid(),
  77. // new Linker(),
  78. // new CsvToTable(),
  79. // new XssFilter({
  80. // isEnabledXssPrevention: this.RendererConfig.isEnabledXssPrevention,
  81. // tagWhiteList: this.RendererConfig.tagWhiteList,
  82. // attrWhiteList: this.RendererConfig.attrWhiteList,
  83. // }),
  84. // ];
  85. // this.postProcessors = [
  86. // ];
  87. // }
  88. // this.init = this.init.bind(this);
  89. // this.addConfigurers = this.addConfigurers.bind(this);
  90. // this.setMarkdownSettings = this.setMarkdownSettings.bind(this);
  91. // this.configure = this.configure.bind(this);
  92. // this.process = this.process.bind(this);
  93. // this.codeRenderer = this.codeRenderer.bind(this);
  94. // }
  95. // init() {
  96. // let parser: Processor = unified().use(parse);
  97. // this.remarkPlugins.forEach((item) => {
  98. // parser = applyPlugin(parser, item);
  99. // });
  100. // let rehype: Processor = parser.use(remark2rehype);
  101. // this.rehypePlugins.forEach((item) => {
  102. // rehype = applyPlugin(rehype, item);
  103. // });
  104. // this.processor = rehype.use(rehype2react, {
  105. // createElement: React.createElement,
  106. // components: {
  107. // // a: NextLink,
  108. // },
  109. // });
  110. // }
  111. // init() {
  112. // // init markdown-it
  113. // this.md = new MarkdownIt({
  114. // html: true,
  115. // linkify: true,
  116. // highlight: this.codeRenderer,
  117. // });
  118. // this.isMarkdownItConfigured = false;
  119. // this.markdownItConfigurers = [
  120. // new TaskListsConfigurer(),
  121. // new HeaderConfigurer(),
  122. // new EmojiConfigurer(),
  123. // new MathJaxConfigurer(),
  124. // new DrawioViewerConfigurer(),
  125. // new PlantUMLConfigurer(this.RendererConfig),
  126. // new BlockdiagConfigurer(this.RendererConfig),
  127. // ];
  128. // if (this.pagePath != null) {
  129. // this.markdownItConfigurers.push(
  130. // new LinkerByRelativePathConfigurer(this.pagePath),
  131. // );
  132. // }
  133. // }
  134. // addConfigurers(configurers: any[]): void {
  135. // this.markdownItConfigurers.push(...configurers);
  136. // }
  137. // setMarkdownSettings(settings: MarkdownSettings): void {
  138. // this.md.set(settings);
  139. // }
  140. // configure(): void {
  141. // if (!this.isMarkdownItConfigured) {
  142. // this.markdownItConfigurers.forEach((configurer) => {
  143. // configurer.configure(this.md);
  144. // });
  145. // }
  146. // }
  147. // preProcess(markdown, context) {
  148. // let processed = markdown;
  149. // for (let i = 0; i < this.preProcessors.length; i++) {
  150. // if (!this.preProcessors[i].process) {
  151. // continue;
  152. // }
  153. // processed = this.preProcessors[i].process(processed, context);
  154. // }
  155. // return processed;
  156. // }
  157. // process(markdown, context) {
  158. // return this.md.render(markdown, context);
  159. // }
  160. // postProcess(html, context) {
  161. // let processed = html;
  162. // for (let i = 0; i < this.postProcessors.length; i++) {
  163. // if (!this.postProcessors[i].process) {
  164. // continue;
  165. // }
  166. // processed = this.postProcessors[i].process(processed, context);
  167. // }
  168. // return processed;
  169. // }
  170. // codeRenderer(code, langExt) {
  171. // const noborder = (!this.RendererConfig.highlightJsStyleBorder) ? 'hljs-no-border' : '';
  172. // let citeTag = '';
  173. // let hljsLang = 'plaintext';
  174. // let showLinenumbers = false;
  175. // if (langExt) {
  176. // // https://regex101.com/r/qGs7eZ/3
  177. // const match = langExt.match(/^([^:=\n]+)?(=([^:=\n]*))?(:([^:=\n]*))?(=([^:=\n]*))?$/);
  178. // const lang = match[1];
  179. // const fileName = match[5] || null;
  180. // showLinenumbers = (match[2] != null) || (match[6] != null);
  181. // if (fileName != null) {
  182. // citeTag = `<cite>${fileName}</cite>`;
  183. // }
  184. // if (hljs.getLanguage(lang)) {
  185. // hljsLang = lang;
  186. // }
  187. // }
  188. // let highlightCode = code;
  189. // try {
  190. // highlightCode = hljs.highlight(hljsLang, code, true).value;
  191. // // add line numbers
  192. // if (showLinenumbers) {
  193. // highlightCode = hljs.lineNumbersValue((highlightCode));
  194. // }
  195. // }
  196. // catch (err) {
  197. // logger.error(err);
  198. // }
  199. // return `<pre class="hljs ${noborder}">${citeTag}<code>${highlightCode}</code></pre>`;
  200. // }
  201. // }
  202. type SanitizePlugin = PluginTuple<[SanitizeOption]>;
  203. export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'> & {
  204. remarkPlugins: PluggableList,
  205. rehypePlugins: PluggableList,
  206. components?:
  207. | Partial<
  208. Omit<NormalComponents, keyof SpecialComponents>
  209. & SpecialComponents
  210. & {
  211. [elem: string]: ComponentType<any>,
  212. }
  213. >
  214. | undefined
  215. };
  216. const commonSanitizeOption: SanitizeOption = deepmerge(
  217. sanitizeDefaultSchema,
  218. {
  219. clobberPrefix: 'mdcont-',
  220. attributes: {
  221. '*': ['class', 'className', 'style'],
  222. },
  223. },
  224. );
  225. const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
  226. if (!Array.isArray(pluggable) || pluggable.length < 2) {
  227. return false;
  228. }
  229. const sanitizeOption = pluggable[1];
  230. return 'tagNames' in sanitizeOption && 'attributes' in sanitizeOption;
  231. };
  232. const hasSanitizePlugin = (options: RendererOptions, shouldBeTheLastItem: boolean): boolean => {
  233. const { rehypePlugins } = options;
  234. if (rehypePlugins == null || rehypePlugins.length === 0) {
  235. return false;
  236. }
  237. return shouldBeTheLastItem
  238. ? isSanitizePlugin(rehypePlugins.slice(-1)[0]) // evaluate the last one
  239. : rehypePlugins.some(rehypePlugin => isSanitizePlugin(rehypePlugin));
  240. };
  241. const verifySanitizePlugin = (options: RendererOptions, shouldBeTheLastItem = true): void => {
  242. if (hasSanitizePlugin(options, shouldBeTheLastItem)) {
  243. return;
  244. }
  245. throw new Error('The specified options does not have sanitize plugin in \'rehypePlugins\'');
  246. };
  247. const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
  248. return {
  249. remarkPlugins: [
  250. gfm,
  251. emoji,
  252. pukiwikiLikeLinker,
  253. growiPlugin,
  254. ],
  255. rehypePlugins: [
  256. [relativeLinksByPukiwikiLikeLinker, { pagePath }],
  257. [relativeLinks, { pagePath }],
  258. raw,
  259. [addClass.rehypePlugin, {
  260. table: 'table table-bordered',
  261. }],
  262. ],
  263. components: {
  264. a: NextLink,
  265. code: CodeBlock,
  266. },
  267. };
  268. };
  269. export const generateViewOptions = (
  270. pagePath: string,
  271. config: RendererConfig,
  272. storeTocNode: (toc: HtmlElementNode) => void,
  273. ): RendererOptions => {
  274. const options = generateCommonOptions(pagePath, config);
  275. const { remarkPlugins, rehypePlugins, components } = options;
  276. // add remark plugins
  277. remarkPlugins.push(
  278. math,
  279. [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
  280. drawioPlugin.remarkPlugin,
  281. xsvToTable.remarkPlugin,
  282. lsxGrowiPlugin.remarkPlugin,
  283. );
  284. if (config.isEnabledLinebreaks) {
  285. remarkPlugins.push(breaks);
  286. }
  287. // add rehype plugins
  288. rehypePlugins.push(
  289. slug,
  290. [lsxGrowiPlugin.rehypePlugin, { pagePath }],
  291. [sanitize, deepmerge(
  292. commonSanitizeOption,
  293. drawioPlugin.sanitizeOption,
  294. lsxGrowiPlugin.sanitizeOption,
  295. )],
  296. katex,
  297. [toc.rehypePluginStore, { storeTocNode }],
  298. // [autoLinkHeadings, {
  299. // behavior: 'append',
  300. // }]
  301. );
  302. // add components
  303. if (components != null) {
  304. components.h1 = Header;
  305. components.h2 = Header;
  306. components.h3 = Header;
  307. components.lsx = Lsx;
  308. components.drawio = DrawioViewerWithEditButton;
  309. components.table = TableWithEditButton;
  310. }
  311. // // Add configurers for viewer
  312. // renderer.addConfigurers([
  313. // new FooternoteConfigurer(),
  314. // new TocAndAnchorConfigurer(),
  315. // new HeaderLineNumberConfigurer(),
  316. // new HeaderWithEditLinkConfigurer(),
  317. // new TableWithHandsontableButtonConfigurer(),
  318. // ]);
  319. // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
  320. // renderer.configure();
  321. verifySanitizePlugin(options, false);
  322. return options;
  323. };
  324. export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementNode | undefined): RendererOptions => {
  325. const options = generateCommonOptions(undefined, config);
  326. const { rehypePlugins } = options;
  327. // add remark plugins
  328. // remarkPlugins.push();
  329. // add rehype plugins
  330. rehypePlugins.push(
  331. [toc.rehypePluginRestore, { tocNode }],
  332. [sanitize, commonSanitizeOption],
  333. );
  334. // renderer.rehypePlugins.push([autoLinkHeadings, {
  335. // behavior: 'append',
  336. // }]);
  337. verifySanitizePlugin(options);
  338. return options;
  339. };
  340. export const generateSimpleViewOptions = (config: RendererConfig, pagePath: string, highlightKeywords?: string | string[]): RendererOptions => {
  341. const options = generateCommonOptions(pagePath, config);
  342. const { remarkPlugins, rehypePlugins, components } = options;
  343. // add remark plugins
  344. remarkPlugins.push(
  345. math,
  346. [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
  347. drawioPlugin.remarkPlugin,
  348. xsvToTable.remarkPlugin,
  349. lsxGrowiPlugin.remarkPlugin,
  350. table.remarkPlugin,
  351. );
  352. if (config.isEnabledLinebreaks) {
  353. remarkPlugins.push(breaks);
  354. }
  355. // add rehype plugins
  356. rehypePlugins.push(
  357. [lsxGrowiPlugin.rehypePlugin, { pagePath }],
  358. [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
  359. [sanitize, deepmerge(
  360. commonSanitizeOption,
  361. drawioPlugin.sanitizeOption,
  362. lsxGrowiPlugin.sanitizeOption,
  363. )],
  364. katex,
  365. );
  366. // add components
  367. if (components != null) {
  368. components.lsx = LsxImmutable;
  369. components.drawio = drawioPlugin.DrawioViewer;
  370. components.table = Table;
  371. }
  372. verifySanitizePlugin(options, false);
  373. return options;
  374. };
  375. export const generatePreviewOptions = (config: RendererConfig, pagePath: string): RendererOptions => {
  376. const options = generateCommonOptions(pagePath, config);
  377. const { remarkPlugins, rehypePlugins, components } = options;
  378. // add remark plugins
  379. remarkPlugins.push(
  380. math,
  381. [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
  382. drawioPlugin.remarkPlugin,
  383. xsvToTable.remarkPlugin,
  384. lsxGrowiPlugin.remarkPlugin,
  385. table.remarkPlugin,
  386. );
  387. if (config.isEnabledLinebreaks) {
  388. remarkPlugins.push(breaks);
  389. }
  390. // add rehype plugins
  391. rehypePlugins.push(
  392. [lsxGrowiPlugin.rehypePlugin, { pagePath }],
  393. addLineNumberAttribute.rehypePlugin,
  394. [sanitize, deepmerge(
  395. commonSanitizeOption,
  396. lsxGrowiPlugin.sanitizeOption,
  397. drawioPlugin.sanitizeOption,
  398. addLineNumberAttribute.sanitizeOption,
  399. )],
  400. katex,
  401. );
  402. // add components
  403. if (components != null) {
  404. components.lsx = LsxImmutable;
  405. components.drawio = drawioPlugin.DrawioViewer;
  406. components.table = Table;
  407. }
  408. // verifySanitizePlugin(options, false);
  409. return options;
  410. };
  411. export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
  412. const options = generateCommonOptions(undefined, config);
  413. const { rehypePlugins } = options;
  414. // renderer.addConfigurers([
  415. // new TableConfigurer(),
  416. // ]);
  417. // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
  418. // renderer.configure();
  419. // add rehype plugins
  420. rehypePlugins.push(
  421. [sanitize, commonSanitizeOption],
  422. );
  423. verifySanitizePlugin(options);
  424. return options;
  425. };
  426. // register to facade
  427. if (isClient()) {
  428. registerGrowiFacade({
  429. markdownRenderer: {
  430. optionsGenerators: {
  431. generateViewOptions,
  432. generatePreviewOptions,
  433. },
  434. },
  435. });
  436. }