]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11017 Add ToC component to markdowns
authorPascal Mugnier <pascal.mugnier@sonarsource.com>
Tue, 10 Jul 2018 09:39:57 +0000 (11:39 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 25 Jul 2018 18:21:20 +0000 (20:21 +0200)
server/sonar-docs/gatsby-node.js
server/sonar-docs/src/pages/branches/index.md
server/sonar-docs/src/templates/page.js
server/sonar-web/package.json
server/sonar-web/src/main/js/@types/remark-toc.d.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/documentation/components/Menu.tsx
server/sonar-web/src/main/js/components/docs/DocLink.tsx
server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx
server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx
server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap
server/sonar-web/yarn.lock

index c9cd2fd483794082f0297080311143cf79f2dec3..0c39ec3c7ba71849bd8dda97584792f521e51bc0 100644 (file)
@@ -44,6 +44,10 @@ exports.createPages = ({ graphql, boundActionCreators }) => {
               frontmatter {
                 scope
               }
+              headings {
+                depth
+                value
+              }
               fields {
                 slug
               }
index d248125a626806f1bd4c96c5c17a11988ed24ba0..0bf242db08d117538fe65906a2ae8289fd7249fa 100644 (file)
@@ -2,6 +2,8 @@
 title: Branches
 ---
 
+## Table of Contents
+
 <!-- sonarqube -->
 
 _Branch analysis is available as part of [Developer Edition](https://redirect.sonarsource.com/editions/developer.html)_
index de57fc1a2a479ba91240a290c81e23c951e242c8..37edc9dbc7750aa46b83d50d2957607e0893e953 100644 (file)
@@ -22,7 +22,7 @@ import Helmet from 'react-helmet';
 
 export default ({ data }) => {
   const page = data.markdownRemark;
-  const htmlWithInclusions = cutSonarCloudContent(page.html).replace(
+  let htmlWithInclusions = cutSonarCloudContent(page.html).replace(
     /\<p\>@include (.*)\<\/p\>/,
     (_, path) => {
       const chunk = data.allMarkdownRemark.edges.find(edge => edge.node.fields.slug === path);
@@ -30,6 +30,14 @@ export default ({ data }) => {
     }
   );
 
+  if (
+    page.headings &&
+    page.headings.length > 0 &&
+    page.html.match(/<h[1-9]>Table Of Contents<\/h[1-9]>/i)
+  ) {
+    htmlWithInclusions = generateTableOfContents(htmlWithInclusions, page.headings);
+  }
+
   return (
     <div css={{ paddingTop: 24, paddingBottom: 24 }}>
       <Helmet title={page.frontmatter.title} />
@@ -53,6 +61,10 @@ export const query = graphql`
     }
     markdownRemark(fields: { slug: { eq: $slug } }) {
       html
+      headings {
+        depth
+        value
+      }
       frontmatter {
         title
       }
@@ -60,6 +72,27 @@ export const query = graphql`
   }
 `;
 
+function generateTableOfContents(content, headings) {
+  let html = '<h2>Table Of Contents</h2>';
+  let depth = headings[0].depth - 1;
+  for (let i = 1; i < headings.length; i++) {
+    while (headings[i].depth > depth) {
+      html += '<ul>';
+      depth++;
+    }
+    while (headings[i].depth < depth) {
+      html += '</ul>';
+      depth--;
+    }
+    html += `<li><a href="#header-${i}">${headings[i].value}</a></li>`;
+    content = content.replace(
+      new RegExp(`<h${headings[i].depth}>${headings[i].value}</h${headings[i].depth}>`, 'gi'),
+      `<h${headings[i].depth} id="header-${i}">${headings[i].value}</h${headings[i].depth}>`
+    );
+  }
+  return content.replace(/<h[1-9]>Table Of Contents<\/h[1-9]>/, html);
+}
+
 function cutSonarCloudContent(content) {
   const beginning = '<!-- sonarcloud -->';
   const ending = '<!-- /sonarcloud -->';
index 9d91aa584605324c553dca290a7581f3f03af6e0..6c5cdc306742288d8bbb786cfb403cbf07d44d84 100644 (file)
@@ -37,6 +37,7 @@
     "redux": "3.7.2",
     "redux-logger": "3.0.6",
     "redux-thunk": "2.2.0",
+    "remark-toc": "5.0.0",
     "whatwg-fetch": "2.0.4"
   },
   "devDependencies": {
diff --git a/server/sonar-web/src/main/js/@types/remark-toc.d.ts b/server/sonar-web/src/main/js/@types/remark-toc.d.ts
new file mode 100644 (file)
index 0000000..990735d
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+declare module 'remark-toc' {
+  interface Options {
+    /** Heading to look for */
+    heading?: string;
+    /** Maximum heading depth, inclusive */
+    maxDepth?: number;
+    /** Display list-item tightly */
+    tight?: boolean;
+  }
+
+  export default function remarkToc(options?: Options): JSX.Element;
+}
index fbd4ee1a753c93961fd700b8b9180c75f8a839bf..a350ef15baeeebf1d45c56d501cc8d6ac1b847f0 100644 (file)
@@ -46,7 +46,7 @@ export default class Menu extends React.PureComponent<Props> {
       const entryRoot = getEntryRoot(entry.relativeName);
       entry.children = entryRoot !== '' ? this.getMenuEntriesHierarchy(entryRoot) : [];
     });
-    return toplevelEntries;
+    return toplevelEntries.sort((a, b) => parseInt(a.order, 10) - parseInt(b.order, 10));
   };
 
   renderEntry = (entry: DocumentationEntry, depth: number): React.ReactNode => {
index 33c6125f89edbfdd4a4ad572c651228f41235287..5a0eb5de4b8e66cb7d0c188233b41e45bd9257fd 100644 (file)
@@ -21,31 +21,56 @@ import * as React from 'react';
 import { Link } from 'react-router';
 import DetachIcon from '../icons-components/DetachIcon';
 
+interface OwnProps {
+  customProps?: {
+    [k: string]: any;
+  };
+}
+
+type Props = OwnProps & React.AnchorHTMLAttributes<HTMLAnchorElement>;
+
 const SONARCLOUD_LINK = '/#sonarcloud#/';
 
-export default function DocLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
-  const { children, href, ...other } = props;
-  if (href && href.startsWith('/')) {
-    let url = `/documentation/${href.substr(1)}`;
-    if (href.startsWith(SONARCLOUD_LINK)) {
-      url = `/${href.substr(SONARCLOUD_LINK.length)}`;
+export default class DocLink extends React.PureComponent<Props> {
+  handleClickOnAnchor = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    const { customProps, href = '#' } = this.props;
+    if (customProps && customProps.onAnchorClick) {
+      customProps.onAnchorClick(href, event);
+    }
+  };
+
+  render() {
+    const { children, href, customProps, ...other } = this.props;
+    if (href && href.startsWith('#')) {
+      return (
+        <a href="#" onClick={this.handleClickOnAnchor}>
+          {children}
+        </a>
+      );
     }
+
+    if (href && href.startsWith('/')) {
+      let url = `/documentation/${href.substr(1)}`;
+      if (href.startsWith(SONARCLOUD_LINK)) {
+        url = `/${href.substr(SONARCLOUD_LINK.length)}`;
+      }
+      return (
+        <Link to={url} {...other}>
+          {children}
+        </Link>
+      );
+    }
+
     return (
-      <Link to={url} {...other}>
-        {children}
-      </Link>
+      <>
+        <a href={href} rel="noopener noreferrer" target="_blank" {...other}>
+          {children}
+        </a>
+        <DetachIcon
+          className="text-muted little-spacer-left little-spacer-right vertical-baseline"
+          size={12}
+        />
+      </>
     );
   }
-
-  return (
-    <>
-      <a href={href} rel="noopener noreferrer" target="_blank" {...other}>
-        {children}
-      </a>
-      <DetachIcon
-        className="text-muted little-spacer-left little-spacer-right vertical-baseline"
-        size={12}
-      />
-    </>
-  );
 }
index 8e9c594a4fde0cd826bd6a2238c17ac5cbbb549a..0bc5350b29784ace40979147b199704d3518df53 100644 (file)
@@ -21,12 +21,14 @@ import * as React from 'react';
 import * as classNames from 'classnames';
 import remark from 'remark';
 import reactRenderer from 'remark-react';
+import remarkToc from 'remark-toc';
 import DocLink from './DocLink';
 import DocParagraph from './DocParagraph';
 import DocImg from './DocImg';
 import DocTooltipLink from './DocTooltipLink';
 import { separateFrontMatter } from '../../helpers/markdown';
 import { isSonarCloud } from '../../helpers/system';
+import { scrollToElement } from '../../helpers/scrolling';
 
 interface Props {
   childProps?: { [k: string]: string };
@@ -36,42 +38,54 @@ interface Props {
   isTooltip?: boolean;
 }
 
-export default function DocMarkdownBlock({
-  childProps,
-  className,
-  content,
-  displayH1,
-  isTooltip
-}: Props) {
-  const parsed = separateFrontMatter(content || '');
-  return (
-    <div className={classNames('markdown', className)}>
-      {displayH1 && <h1>{parsed.frontmatter.title}</h1>}
-      {
-        remark()
-          // .use(remarkInclude)
-          .use(reactRenderer, {
-            remarkReactComponents: {
-              // do not render outer <div />
-              div: React.Fragment,
-              // use custom link to render documentation anchors
-              a: isTooltip ? withChildProps(DocTooltipLink, childProps) : DocLink,
-              // used to handle `@include`
-              p: DocParagraph,
-              // use custom img tag to render documentation images
-              img: DocImg
-            },
-            toHast: {}
-          })
-          .processSync(filterContent(parsed.content)).contents
+export default class DocMarkdownBlock extends React.PureComponent<Props> {
+  node: HTMLElement | null = null;
+
+  handleAnchorClick = (href: string, event: React.MouseEvent<HTMLAnchorElement>) => {
+    if (this.node) {
+      const element = this.node.querySelector(`#user-content-${href.substr(1)}`);
+      if (element) {
+        event.preventDefault();
+        scrollToElement(element, { bottomOffset: window.innerHeight - 80 });
       }
-    </div>
-  );
+    }
+  };
+
+  render() {
+    const { childProps, content, className, displayH1, isTooltip } = this.props;
+    const parsed = separateFrontMatter(content || '');
+    return (
+      <div className={classNames('markdown', className)} ref={ref => (this.node = ref)}>
+        {displayH1 && <h1>{parsed.frontmatter.title}</h1>}
+        {
+          remark()
+            // .use(remarkInclude)
+            .use(remarkToc, { maxDepth: 3 })
+            .use(reactRenderer, {
+              remarkReactComponents: {
+                // do not render outer <div />
+                div: React.Fragment,
+                // use custom link to render documentation anchors
+                a: isTooltip
+                  ? withChildProps(DocTooltipLink, childProps)
+                  : withChildProps(DocLink, { onAnchorClick: this.handleAnchorClick }),
+                // used to handle `@include`
+                p: DocParagraph,
+                // use custom img tag to render documentation images
+                img: DocImg
+              },
+              toHast: {}
+            })
+            .processSync(filterContent(parsed.content)).contents
+        }
+      </div>
+    );
+  }
 }
 
 function withChildProps<P>(
-  WrappedComponent: React.ComponentType<P & { customProps?: { [k: string]: string } }>,
-  childProps?: { [k: string]: string }
+  WrappedComponent: React.ComponentType<P & { customProps?: { [k: string]: any } }>,
+  childProps?: { [k: string]: any }
 ) {
   return function withChildProps(props: P) {
     return <WrappedComponent customProps={childProps} {...props} />;
index 16deb2d683898249f2eda983c2905fac41612ed1..1dcb52eaaa243f3ca7dc256e47c17cbdc0f34db6 100644 (file)
@@ -39,9 +39,9 @@ it('should render simple markdown', () => {
   expect(shallow(<DocMarkdownBlock content="this is *bold* text" />)).toMatchSnapshot();
 });
 
-it('should render use custom component for links', () => {
+it('should use custom component for links', () => {
   expect(
-    shallow(<DocMarkdownBlock content="some [link](#quality-profiles)" />).find('DocLink')
+    shallow(<DocMarkdownBlock content="some [link](/quality-profiles)" />).find('withChildProps')
   ).toMatchSnapshot();
 });
 
index 9e7a8b437fb573e06c2a5fc98fcdd12dcafc20c0..c47b4d7fe4b5edd99236671c74675db51e659a8d 100644 (file)
@@ -96,13 +96,13 @@ exports[`should render simple markdown 1`] = `
 </div>
 `;
 
-exports[`should render use custom component for links 1`] = `
-<DocLink
-  href="#quality-profiles"
+exports[`should use custom component for links 1`] = `
+<withChildProps
+  href="/quality-profiles"
   key="h-3"
 >
   link
-</DocLink>
+</withChildProps>
 `;
 
 exports[`should render with custom props for links 1`] = `
index eba63961f78bf600b7ad758c516c0cb03f150faa..9abe63c34b2d5abce5018e7d3793c46aa8bdb1f4 100644 (file)
@@ -2808,6 +2808,10 @@ elliptic@^6.0.0:
     minimalistic-assert "^1.0.0"
     minimalistic-crypto-utils "^1.0.0"
 
+"emoji-regex@>=6.0.0 <=6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e"
+
 emoji-regex@^6.1.0:
   version "6.5.1"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2"
@@ -3644,6 +3648,12 @@ getpass@^0.1.1:
   dependencies:
     assert-plus "^1.0.0"
 
+github-slugger@^1.0.0, github-slugger@^1.1.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.2.0.tgz#8ada3286fd046d8951c3c952a8d7854cfd90fd9a"
+  dependencies:
+    emoji-regex ">=6.0.0 <=6.1.1"
+
 glob-base@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
@@ -5585,6 +5595,18 @@ mdast-util-to-hast@^3.0.0:
     unist-util-visit "^1.1.0"
     xtend "^4.0.1"
 
+mdast-util-to-string@^1.0.0, mdast-util-to-string@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.0.4.tgz#5c455c878c9355f0c1e7f3e8b719cf583691acfb"
+
+mdast-util-toc@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/mdast-util-toc/-/mdast-util-toc-2.0.1.tgz#b1d2cb23bfb01f812fa7b55bffe8b0a8bedf6f21"
+  dependencies:
+    github-slugger "^1.1.1"
+    mdast-util-to-string "^1.0.2"
+    unist-util-visit "^1.1.0"
+
 mdurl@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
@@ -7364,6 +7386,14 @@ remark-react@4.0.3:
     hast-util-sanitize "^1.0.0"
     mdast-util-to-hast "^3.0.0"
 
+remark-slug@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/remark-slug/-/remark-slug-5.0.0.tgz#9de71fcdc2bfae33ebb4a41eb83035288a829980"
+  dependencies:
+    github-slugger "^1.0.0"
+    mdast-util-to-string "^1.0.0"
+    unist-util-visit "^1.0.0"
+
 remark-stringify@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba"
@@ -7383,6 +7413,13 @@ remark-stringify@^5.0.0:
     unherit "^1.0.4"
     xtend "^4.0.1"
 
+remark-toc@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/remark-toc/-/remark-toc-5.0.0.tgz#f1e13ed11062ad4d102b02e70168bd85015bf129"
+  dependencies:
+    mdast-util-toc "^2.0.0"
+    remark-slug "^5.0.0"
+
 remark@9.0.0:
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/remark/-/remark-9.0.0.tgz#c5cfa8ec535c73a67c4b0f12bfdbd3a67d8b2f60"