renderer.ts 13 KB

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