From: Pascal Mugnier Date: Tue, 10 Jul 2018 09:39:57 +0000 (+0200) Subject: SONAR-11017 Add ToC component to markdowns X-Git-Tag: 7.5~735 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=4423587a87475044fb3eea1229eca5f52177db95;p=sonarqube.git SONAR-11017 Add ToC component to markdowns --- diff --git a/server/sonar-docs/gatsby-node.js b/server/sonar-docs/gatsby-node.js index c9cd2fd4837..0c39ec3c7ba 100644 --- a/server/sonar-docs/gatsby-node.js +++ b/server/sonar-docs/gatsby-node.js @@ -44,6 +44,10 @@ exports.createPages = ({ graphql, boundActionCreators }) => { frontmatter { scope } + headings { + depth + value + } fields { slug } diff --git a/server/sonar-docs/src/pages/branches/index.md b/server/sonar-docs/src/pages/branches/index.md index d248125a626..0bf242db08d 100644 --- a/server/sonar-docs/src/pages/branches/index.md +++ b/server/sonar-docs/src/pages/branches/index.md @@ -2,6 +2,8 @@ title: Branches --- +## Table of Contents + _Branch analysis is available as part of [Developer Edition](https://redirect.sonarsource.com/editions/developer.html)_ diff --git a/server/sonar-docs/src/templates/page.js b/server/sonar-docs/src/templates/page.js index de57fc1a2a4..37edc9dbc77 100644 --- a/server/sonar-docs/src/templates/page.js +++ b/server/sonar-docs/src/templates/page.js @@ -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( /\@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(/Table Of Contents<\/h[1-9]>/i) + ) { + htmlWithInclusions = generateTableOfContents(htmlWithInclusions, page.headings); + } + return (
@@ -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 = '

Table Of Contents

'; + let depth = headings[0].depth - 1; + for (let i = 1; i < headings.length; i++) { + while (headings[i].depth > depth) { + html += '
    '; + depth++; + } + while (headings[i].depth < depth) { + html += '
'; + depth--; + } + html += `
  • ${headings[i].value}
  • `; + content = content.replace( + new RegExp(`${headings[i].value}`, 'gi'), + `${headings[i].value}` + ); + } + return content.replace(/Table Of Contents<\/h[1-9]>/, html); +} + function cutSonarCloudContent(content) { const beginning = ''; const ending = ''; diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index 9d91aa58460..6c5cdc30674 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -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 index 00000000000..990735d9ec0 --- /dev/null +++ b/server/sonar-web/src/main/js/@types/remark-toc.d.ts @@ -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; +} diff --git a/server/sonar-web/src/main/js/apps/documentation/components/Menu.tsx b/server/sonar-web/src/main/js/apps/documentation/components/Menu.tsx index fbd4ee1a753..a350ef15bae 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/Menu.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/Menu.tsx @@ -46,7 +46,7 @@ export default class Menu extends React.PureComponent { 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 => { diff --git a/server/sonar-web/src/main/js/components/docs/DocLink.tsx b/server/sonar-web/src/main/js/components/docs/DocLink.tsx index 33c6125f89e..5a0eb5de4b8 100644 --- a/server/sonar-web/src/main/js/components/docs/DocLink.tsx +++ b/server/sonar-web/src/main/js/components/docs/DocLink.tsx @@ -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; + const SONARCLOUD_LINK = '/#sonarcloud#/'; -export default function DocLink(props: React.AnchorHTMLAttributes) { - 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 { + handleClickOnAnchor = (event: React.MouseEvent) => { + 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 ( + + {children} + + ); } + + if (href && href.startsWith('/')) { + let url = `/documentation/${href.substr(1)}`; + if (href.startsWith(SONARCLOUD_LINK)) { + url = `/${href.substr(SONARCLOUD_LINK.length)}`; + } + return ( + + {children} + + ); + } + return ( - - {children} - + <> + + {children} + + + ); } - - return ( - <> - - {children} - - - - ); } diff --git a/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx b/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx index 8e9c594a4fd..0bc5350b297 100644 --- a/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx +++ b/server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx @@ -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 ( -
    - {displayH1 &&

    {parsed.frontmatter.title}

    } - { - remark() - // .use(remarkInclude) - .use(reactRenderer, { - remarkReactComponents: { - // do not render outer
    - 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 { + node: HTMLElement | null = null; + + handleAnchorClick = (href: string, event: React.MouseEvent) => { + if (this.node) { + const element = this.node.querySelector(`#user-content-${href.substr(1)}`); + if (element) { + event.preventDefault(); + scrollToElement(element, { bottomOffset: window.innerHeight - 80 }); } -
    - ); + } + }; + + render() { + const { childProps, content, className, displayH1, isTooltip } = this.props; + const parsed = separateFrontMatter(content || ''); + return ( +
    (this.node = ref)}> + {displayH1 &&

    {parsed.frontmatter.title}

    } + { + remark() + // .use(remarkInclude) + .use(remarkToc, { maxDepth: 3 }) + .use(reactRenderer, { + remarkReactComponents: { + // do not render outer
    + 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 + } +
    + ); + } } function withChildProps

    ( - WrappedComponent: React.ComponentType

    , - childProps?: { [k: string]: string } + WrappedComponent: React.ComponentType

    , + childProps?: { [k: string]: any } ) { return function withChildProps(props: P) { return ; diff --git a/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx b/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx index 16deb2d6838..1dcb52eaaa2 100644 --- a/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx +++ b/server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx @@ -39,9 +39,9 @@ it('should render simple markdown', () => { expect(shallow()).toMatchSnapshot(); }); -it('should render use custom component for links', () => { +it('should use custom component for links', () => { expect( - shallow().find('DocLink') + shallow().find('withChildProps') ).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap b/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap index 9e7a8b437fb..c47b4d7fe4b 100644 --- a/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap @@ -96,13 +96,13 @@ exports[`should render simple markdown 1`] = `

    `; -exports[`should render use custom component for links 1`] = ` - link - + `; exports[`should render with custom props for links 1`] = ` diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index eba63961f78..9abe63c34b2 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -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"