renderer.tsx 15 KB

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