export interface DocsNavigationBlock {
title: string;
- children: (DocNavigationItem | string)[];
+ children: DocNavigationItem[];
}
export interface DocsNavigationExternalLink {
}
export interface PluginInstalled extends PluginPending {
+ documentationPath?: string;
filename: string;
hash: string;
sonarLintSupported: boolean;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+import { checkStatus, request } from 'sonar-ui-common/helpers/request';
+
+export function getPluginStaticFileContent(pluginKey: string, staticFilePath: string) {
+ return request(`/static/${pluginKey}/${staticFilePath}`)
+ .submit()
+ .then(checkStatus)
+ .then(response => response.text());
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+import { mockDocumentationMarkdown } from '../../../helpers/testMocks';
+
+jest.mock('remark', () => ({
+ default: () => ({
+ parse: jest.fn().mockReturnValue({})
+ })
+}));
+
+jest.mock('unist-util-visit', () => ({
+ default: (_: any, cb: (node: { type: string; value: string }) => void) => {
+ cb({ type: 'text', value: 'Text content' });
+ }
+}));
+
+const lorem = {
+ url: 'analysis/languages/lorem',
+ title: 'toto doc',
+ key: 'toto',
+ content: 'TOTO CONTENT'
+};
+const foo = {
+ url: `analysis/languages/foo`,
+ title: 'foo doc',
+ key: 'foo'
+};
+const loremDoc = mockDocumentationMarkdown(lorem);
+const fooDoc = mockDocumentationMarkdown(foo);
+
+jest.mock('../documentation.directory-loader', () => [
+ { content: loremDoc, path: lorem.url },
+ { content: fooDoc, path: foo.url }
+]);
+
+it('should correctly parse files', () => {
+ const pages = getPages();
+ expect(pages.length).toBe(2);
+
+ expect(pages[0]).toMatchObject({
+ relativeName: lorem.url,
+ url: `/${lorem.url}/`,
+ title: lorem.title,
+ navTitle: undefined,
+ order: -1,
+ scope: undefined,
+ content: lorem.content
+ });
+
+ expect(pages[1]).toMatchObject({
+ relativeName: foo.url,
+ url: `/${foo.url}/`,
+ title: foo.title,
+ navTitle: undefined,
+ order: -1,
+ scope: undefined
+ });
+});
+
+it('should correctly handle overrides (replace & add)', () => {
+ const overrideFooDoc = {
+ content: 'OVERRIDE_FOO',
+ title: 'OVERRIDE_TITLE_FOO',
+ key: foo.key
+ };
+ const newDoc = {
+ content: 'TATA',
+ title: 'TATA_TITLE',
+ key: 'tata'
+ };
+
+ const overrides: string[] = [
+ mockDocumentationMarkdown(overrideFooDoc),
+ mockDocumentationMarkdown(newDoc)
+ ];
+ const pages = getPages(overrides);
+
+ expect(pages.length).toBe(3);
+ expect(pages[1].content).toBe(overrideFooDoc.content);
+ expect(pages[1].title).toBe(overrideFooDoc.title);
+ expect(pages[2].content).toBe(newDoc.content);
+ expect(pages[2].title).toBe(newDoc.title);
+});
+
+function getPages(overrides: string[] = []) {
+ // This allows the use of out-of-scope data inside jest.mock
+ // Usually, it is impossible as jest.mock'ed module is hoisted on the top of the file
+ return require.requireActual('../pages').default(overrides);
+}
import * as React from 'react';
import Helmet from 'react-helmet';
import { Link } from 'react-router';
+import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { addSideBarClass, removeSideBarClass } from 'sonar-ui-common/helpers/pages';
+import { isDefined } from 'sonar-ui-common/helpers/types';
+import { getInstalledPlugins } from '../../../api/plugins';
+import { getPluginStaticFileContent } from '../../../api/static';
import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget';
import NotFound from '../../../app/components/NotFound';
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
import { isSonarCloud } from '../../../helpers/system';
import getPages from '../pages';
import '../styles.css';
+import { DocumentationEntry } from '../utils';
import Sidebar from './Sidebar';
interface Props {
params: { splat?: string };
}
-export default class App extends React.PureComponent<Props> {
+interface State {
+ loading: boolean;
+ pages: DocumentationEntry[];
+ tree: DocNavigationItem[];
+}
+
+export default class App extends React.PureComponent<Props, State> {
mounted = false;
- pages = getPages();
+ state: State = {
+ loading: false,
+ pages: [],
+ tree: []
+ };
componentDidMount() {
+ this.mounted = true;
addSideBarClass();
+
+ this.setState({ loading: true });
+
+ const tree = isSonarCloud()
+ ? ((navigationTreeSonarCloud as any).default as DocNavigationItem[])
+ : ((navigationTreeSonarQube as any).default as DocNavigationItem[]);
+
+ this.getLanguagesOverrides().then(
+ overrides => {
+ if (this.mounted) {
+ this.setState({
+ loading: false,
+ pages: getPages(overrides),
+ tree
+ });
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({
+ loading: false
+ });
+ }
+ }
+ );
}
componentWillUnmount() {
+ this.mounted = false;
removeSideBarClass();
}
+ getLanguagesOverrides = () => {
+ const pluginStaticFileNameRegEx = new RegExp(`^static/(.*)`);
+
+ return getInstalledPlugins()
+ .then(plugins =>
+ Promise.all(
+ plugins.map(plugin => {
+ if (plugin.documentationPath) {
+ const matchArray = pluginStaticFileNameRegEx.exec(plugin.documentationPath);
+
+ if (matchArray && matchArray.length > 1) {
+ // eslint-disable-next-line promise/no-nesting
+ return getPluginStaticFileContent(plugin.key, matchArray[1]).then(
+ content => content,
+ () => undefined
+ );
+ }
+ }
+ return undefined;
+ })
+ )
+ )
+ .then(contents => contents.filter(isDefined));
+ };
+
render() {
- const tree = isSonarCloud()
- ? ((navigationTreeSonarCloud as any).default as DocNavigationItem[])
- : ((navigationTreeSonarQube as any).default as DocNavigationItem[]);
+ const { loading, pages, tree } = this.state;
const { splat = '' } = this.props.params;
- const page = this.pages.find(p => p.url === '/' + splat);
+
+ if (loading) {
+ return (
+ <div className="page page-limited">
+ <DeferredSpinner />
+ </div>
+ );
+ }
+
+ const page = pages.find(p => p.url === '/' + splat);
const mainTitle = translate(
'documentation.page_title',
isSonarCloud() ? 'sonarcloud' : 'sonarqube'
);
+ const isIndex = splat === 'index';
if (!page) {
return (
);
}
- const isIndex = splat === 'index';
-
return (
<div className="layout-page">
<Helmet title={isIndex || !page.title ? mainTitle : `${page.title} | ${mainTitle}`}>
<h1>{translate('documentation.page')}</h1>
</Link>
</div>
- <Sidebar navigation={tree} pages={this.pages} splat={splat} />
+ <Sidebar navigation={tree} pages={pages} splat={splat} />
</div>
</div>
</div>
import { shallow } from 'enzyme';
import * as React from 'react';
import { addSideBarClass, removeSideBarClass } from 'sonar-ui-common/helpers/pages';
+import { request } from 'sonar-ui-common/helpers/request';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { isSonarCloud } from '../../../../helpers/system';
import App from '../App';
isSonarCloud: jest.fn().mockReturnValue(false)
}));
-jest.mock(
- 'Docs/../static/SonarQubeNavigationTree.json',
- () => [
+jest.mock('Docs/../static/SonarQubeNavigationTree.json', () => ({
+ default: [
{
title: 'SonarQube',
children: [
'/lorem/ipsum/',
+ '/analysis/languages/csharp/',
{
title: 'Child category',
children: [
}
]
}
- ],
- { virtual: true }
-);
+ ]
+}));
-jest.mock(
- 'Docs/../static/SonarCloudNavigationTree.json',
- () => [
+jest.mock('Docs/../static/SonarCloudNavigationTree.json', () => ({
+ default: [
{
title: 'SonarCloud',
children: [
}
]
}
- ],
- { virtual: true }
-);
+ ]
+}));
jest.mock('sonar-ui-common/helpers/pages', () => ({
addSideBarClass: jest.fn(),
removeSideBarClass: jest.fn()
}));
+jest.mock('sonar-ui-common/helpers/request', () => ({
+ request: jest.fn(() => ({
+ submit: jest.fn().mockResolvedValue({ status: 200, text: jest.fn().mockReturnValue('TEST') })
+ }))
+}));
+
jest.mock('../../pages', () => {
const { mockDocumentationEntry } = require.requireActual('../../../../helpers/testMocks');
return {
- default: () => [mockDocumentationEntry()]
+ default: () => [
+ mockDocumentationEntry(),
+ mockDocumentationEntry({ url: '/analysis/languages/csharp/' })
+ ]
};
});
-it('should render correctly for SonarQube', () => {
- const wrapper = shallowRender();
+jest.mock('../../../../api/plugins', () => ({
+ getInstalledPlugins: jest
+ .fn()
+ .mockResolvedValue([
+ { key: 'csharp', documentationPath: 'static/documentation.md' },
+ { key: 'vbnet', documentationPath: 'Sstatic/documentation.md' },
+ { key: 'vbnett', documentationPath: undefined }
+ ])
+}));
- expect(wrapper).toMatchSnapshot();
+it('should render correctly for SonarQube', async () => {
+ const wrapper = shallowRender();
+ expect(wrapper.find('DeferredSpinner').exists()).toBe(true);
expect(addSideBarClass).toBeCalled();
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
expect(wrapper.find('ScreenPositionHelper').dive()).toMatchSnapshot();
wrapper.unmount();
expect(removeSideBarClass).toBeCalled();
});
-it('should render correctly for SonarCloud', () => {
+it('should render correctly for SonarCloud', async () => {
(isSonarCloud as jest.Mock).mockReturnValue(true);
- expect(shallowRender()).toMatchSnapshot();
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
});
-it("should show a 404 if the page doesn't exist", () => {
+it("should show a 404 if the page doesn't exist", async () => {
const wrapper = shallowRender({ params: { splat: 'unknown' } });
+ await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});
+it('should try to fetch language plugin documentation if documentationPath matches', async () => {
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+
+ expect(request).toHaveBeenCalledWith('/static/csharp/documentation.md');
+ expect(request).not.toHaveBeenCalledWith('/static/vbnet/documentation.md');
+ expect(request).not.toHaveBeenCalledWith('/static/vbnett/documentation.md');
+});
+
function shallowRender(props: Partial<App['props']> = {}) {
return shallow(<App params={{ splat: 'lorem/ipsum' }} {...props} />);
}
</Link>
</div>
<Sidebar
+ navigation={
+ Array [
+ Object {
+ "children": Array [
+ "/lorem/ipsum/",
+ "/analysis/languages/csharp/",
+ Object {
+ "children": Array [
+ "/lorem/ipsum/dolor",
+ Object {
+ "children": Array [
+ "/lorem/ipsum/sit",
+ ],
+ "title": "Grandchild category",
+ },
+ "/lorem/ipsum/amet",
+ ],
+ "title": "Child category",
+ },
+ ],
+ "title": "SonarQube",
+ },
+ ]
+ }
pages={
Array [
Object {
"title": "Lorem",
"url": "/lorem/ipsum",
},
+ Object {
+ "content": "Lorem ipsum dolor sit amet fredum",
+ "navTitle": undefined,
+ "relativeName": "Lorem",
+ "text": "Lorem ipsum dolor sit amet fredum",
+ "title": "Lorem",
+ "url": "/analysis/languages/csharp/",
+ },
]
}
splat="lorem/ipsum"
*/
import remark from 'remark';
import visit from 'unist-util-visit';
-import { filterContent, separateFrontMatter } from '../../helpers/markdown';
+import { filterContent, ParsedContent, separateFrontMatter } from '../../helpers/markdown';
import * as Docs from './documentation.directory-loader';
import { DocumentationEntry, DocumentationEntryScope } from './utils';
-export default function getPages(): DocumentationEntry[] {
- return ((Docs as unknown) as Array<{ content: string; path: string }>).map(file => {
- const parsed = separateFrontMatter(file.content);
+const LANGUAGES_BASE_URL = 'analysis/languages';
+
+export default function getPages(overrides: string[] = []): DocumentationEntry[] {
+ const parsedOverrides: T.Dict<ParsedContent> = {};
+ overrides.forEach(override => {
+ const parsedOverride = separateFrontMatter(override);
+ if (parsedOverride && parsedOverride.frontmatter && parsedOverride.frontmatter.key) {
+ parsedOverrides[`${LANGUAGES_BASE_URL}/${parsedOverride.frontmatter.key}`] = parsedOverride;
+ }
+ });
+
+ // Merge with existing entries.
+ const pages = ((Docs as unknown) as Array<{ content: string; path: string }>).map(file => {
+ let parsed = separateFrontMatter(file.content);
+
+ if (parsedOverrides[file.path]) {
+ const parsedOverride = parsedOverrides[file.path];
+ parsed = {
+ content: parsedOverride.content,
+ frontmatter: { ...parsed.frontmatter, ...parsedOverride.frontmatter }
+ };
+ delete parsedOverrides[file.path];
+ }
+
+ return { parsed, file };
+ });
+
+ // Add new entries.
+ Object.keys(parsedOverrides).forEach(path => {
+ const parsed = parsedOverrides[path];
+ pages.push({
+ parsed,
+ file: { content: parsed.content, path }
+ });
+ });
+
+ return pages.map(({ parsed, file }) => {
const content = filterContent(parsed.content);
const text = getText(content);
return {
relativeName: file.path,
- url: parsed.frontmatter.url || `/${file.path}`,
+ url: parsed.frontmatter.url || `/${file.path}/`,
title: parsed.frontmatter.title,
navTitle: parsed.frontmatter.nav || undefined,
order: Number(parsed.frontmatter.order || -1),
[x: string]: string;
}
+interface ParsedContent {
+ content: string;
+ frontmatter: FrontMatter;
+}
+
export function getFrontMatter(content: string): FrontMatter;
-export function separateFrontMatter(content: string): { content: string; frontmatter: FrontMatter };
+export function separateFrontMatter(content: string): ParsedContent;
/** Removes SonarQube/SonarCloud only content */
export function filterContent(content: string): string;
};
}
+export function mockDocumentationMarkdown(
+ overrides: Partial<{ content: string; title: string; key: string }> = {}
+): string {
+ const content =
+ overrides.content ||
+ `
+## Lorem Ipsum
+
+Donec at est elit. In finibus justo ut augue rhoncus, vitae consequat mauris mattis.
+Nunc ante est, volutpat ac volutpat ac, pharetra in libero.
+`;
+
+ const frontMatter = `
+---
+${overrides.title ? 'title: ' + overrides.title : ''}
+${overrides.key ? 'key: ' + overrides.key : ''}
+---`;
+
+ return `${frontMatter}
+${content}`;
+}
+
export function mockDocumentationEntry(
overrides: Partial<DocumentationEntry> = {}
): DocumentationEntry {