aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--server/sonar-docs/gatsby-node.js4
-rw-r--r--server/sonar-docs/src/pages/branches/index.md2
-rw-r--r--server/sonar-docs/src/templates/page.js35
-rw-r--r--server/sonar-web/package.json1
-rw-r--r--server/sonar-web/src/main/js/@types/remark-toc.d.ts31
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/Menu.tsx2
-rw-r--r--server/sonar-web/src/main/js/components/docs/DocLink.tsx67
-rw-r--r--server/sonar-web/src/main/js/components/docs/DocMarkdownBlock.tsx78
-rw-r--r--server/sonar-web/src/main/js/components/docs/__tests__/DocMarkdownBlock-test.tsx4
-rw-r--r--server/sonar-web/src/main/js/components/docs/__tests__/__snapshots__/DocMarkdownBlock-test.tsx.snap8
-rw-r--r--server/sonar-web/yarn.lock37
11 files changed, 208 insertions, 61 deletions
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
+
<!-- sonarqube -->
_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(
/\<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 -->';
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<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 => {
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<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}
- />
- </>
- );
}
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 (
- <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} />;
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(<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();
});
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`] = `
</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`] = `
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"