]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12137 Introduce a new comment syntax for rendering plugin data
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Thu, 23 May 2019 07:07:00 +0000 (09:07 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 28 Jun 2019 06:45:57 +0000 (08:45 +0200)
We can now fetch plugin data from updates.sonarsource.com, and
dynamically render this information on the page.

server/sonar-docs/README.md
server/sonar-docs/src/@types/types.d.ts
server/sonar-docs/src/components/PluginMetaData.tsx [new file with mode: 0644]
server/sonar-docs/src/components/PluginVersionMetaData.tsx [new file with mode: 0644]
server/sonar-docs/src/components/__tests__/PluginMetaData-test.tsx [new file with mode: 0644]
server/sonar-docs/src/components/__tests__/PluginVersionMetaData-test.tsx [new file with mode: 0644]
server/sonar-docs/src/components/__tests__/__snapshots__/PluginMetaData-test.tsx.snap [new file with mode: 0644]
server/sonar-docs/src/components/__tests__/__snapshots__/PluginVersionMetaData-test.tsx.snap [new file with mode: 0644]
server/sonar-docs/src/components/utils.tsx
server/sonar-docs/src/layouts/index.tsx
server/sonar-docs/src/layouts/layout.css

index 89646417296c074fd748ac7ee5abce31291b62ed..11400efc5c2d4454d2f3c07834a48d91aab9c1d4 100644 (file)
@@ -307,3 +307,21 @@ Note that an iframe is **not** a self-closing tag. This means that the following
 <iframe src="http://www.sonarsource.com" />
 
 ```
+
+#### Dynamic Plugin Version Info
+
+_Note: at this time, this is only supported for the static documentation, and will be stripped from the embedded documentation._
+
+You can dynamically include a plugin version block to any page, using the following special tag:
+
+```html
+<!-- update_center:PLUGIN_KEY -->
+```
+
+For example, for Sonar Java, use:
+
+```html
+<!-- update_center:java -->
+```
+
+You can include multiple boxes per page, if needed.
index 67db6d2ad17e36e0e7bb77da250c9c3f83c902a1..289ad620c2af383bc83cba5c779d99f15ad7a060 100644 (file)
@@ -18,6 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
+export type Dict<T> = { [key: string]: T };
+
 export interface DocVersion {
   current: boolean;
   value: string;
@@ -35,6 +37,31 @@ export interface DocsNavigationExternalLink {
   url: string;
 }
 
+export type PluginMetaDataInfo = {
+  category?: string;
+  isSonarSourceCommercial: boolean;
+  issueTrackerURL?: string;
+  key?: string;
+  license?: string;
+  name: string;
+  organization?: {
+    name: string;
+    url?: string;
+  };
+  sourcesURL?: string;
+  versions?: PluginVersionInfo[];
+};
+
+export type PluginVersionInfo = {
+  archived?: boolean;
+  changeLogUrl?: string;
+  compatibility?: string;
+  date?: string;
+  description?: string;
+  downloadURL?: string;
+  version: string;
+};
+
 export interface SearchResult {
   exactMatch?: boolean;
   highlights: { [field: string]: [number, number][] };
diff --git a/server/sonar-docs/src/components/PluginMetaData.tsx b/server/sonar-docs/src/components/PluginMetaData.tsx
new file mode 100644 (file)
index 0000000..80fd723
--- /dev/null
@@ -0,0 +1,162 @@
+/*
+ * 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 * as React from 'react';
+import { createPortal } from 'react-dom';
+import PluginVersionMetaData from './PluginVersionMetaData';
+import { getPluginMetaData } from './utils';
+import { Dict, PluginMetaDataInfo } from '../@types/types';
+
+interface Props {
+  location: Pick<Location, 'pathname'>;
+}
+
+interface State {
+  data: Dict<PluginMetaDataInfo>;
+  wrappers: Dict<HTMLDivElement>;
+}
+
+export default class PluginMetaData extends React.Component<Props, State> {
+  state: State = {
+    data: {},
+    wrappers: {}
+  };
+
+  componentDidMount() {
+    this.searchForCommentNodes();
+  }
+
+  componentDidUpdate({ location }: Props) {
+    if (location.pathname !== this.props.location.pathname) {
+      this.clearWrapperNodes();
+      this.searchForCommentNodes();
+    }
+  }
+
+  clearWrapperNodes = () => {
+    const { wrappers } = this.state;
+
+    Object.keys(wrappers).forEach(key => {
+      const node = wrappers[key];
+      const { parentNode } = node;
+      if (parentNode) {
+        parentNode.removeChild(node);
+      }
+
+      delete wrappers[key];
+    });
+
+    this.setState({ data: {}, wrappers: {} });
+  };
+
+  fetchAndRender = () => {
+    const { wrappers } = this.state;
+
+    Object.keys(wrappers).forEach(key => {
+      getPluginMetaData(key).then(
+        (payload: PluginMetaDataInfo) => {
+          this.setState(({ data }) => ({ data: { ...data, [key]: payload } }));
+        },
+        () => {}
+      );
+    });
+  };
+
+  searchForCommentNodes = () => {
+    const pageContainer = document.querySelector('.page-container');
+
+    if (pageContainer) {
+      const iterator = document.createNodeIterator(pageContainer, NodeFilter.SHOW_COMMENT, {
+        acceptNode: (_: Node) => NodeFilter.FILTER_ACCEPT
+      });
+
+      let node;
+      const wrappers: Dict<HTMLDivElement> = {};
+      while ((node = iterator.nextNode())) {
+        if (node.nodeValue && /update_center\s*:/.test(node.nodeValue)) {
+          let [, key] = node.nodeValue.split(':');
+          key = key.trim();
+
+          const wrapper = document.createElement('div');
+          wrapper.className = 'plugin-meta-data-wrapper';
+          wrappers[key] = wrapper;
+
+          node.parentNode!.insertBefore(wrapper, node);
+        }
+      }
+      this.setState({ wrappers }, this.fetchAndRender);
+    }
+  };
+
+  renderMetaData = ({
+    isSonarSourceCommercial,
+    issueTrackerURL,
+    license,
+    organization,
+    versions
+  }: PluginMetaDataInfo) => {
+    let vendor;
+    if (organization) {
+      vendor = organization.name;
+      if (organization.url) {
+        vendor = (
+          <a href={organization.url} rel="noopener noreferrer" target="_blank">
+            {vendor}
+          </a>
+        );
+      }
+    }
+    return (
+      <div className="plugin-meta-data">
+        <div className="plugin-meta-data-header">
+          {vendor && <span className="plugin-meta-data-vendor">By {vendor}</span>}
+          {license && <span className="plugin-meta-data-license">{license}</span>}
+          {issueTrackerURL && (
+            <span className="plugin-meta-data-issue-tracker">
+              <a href={issueTrackerURL} rel="noopener noreferrer" target="_blank">
+                Issue Tracker
+              </a>
+            </span>
+          )}
+          {isSonarSourceCommercial && (
+            <span className="plugin-meta-data-supported">Supported by SonarSource</span>
+          )}
+        </div>
+        {versions && versions.length > 0 && <PluginVersionMetaData versions={versions} />}
+      </div>
+    );
+  };
+
+  render() {
+    const { data, wrappers } = this.state;
+    const keys = Object.keys(data);
+
+    if (keys.length === 0) {
+      return null;
+    }
+
+    return keys.map(key => {
+      if (wrappers[key] !== undefined && data[key] !== undefined) {
+        return createPortal(this.renderMetaData(data[key]), wrappers[key]);
+      } else {
+        return null;
+      }
+    });
+  }
+}
diff --git a/server/sonar-docs/src/components/PluginVersionMetaData.tsx b/server/sonar-docs/src/components/PluginVersionMetaData.tsx
new file mode 100644 (file)
index 0000000..e71fae7
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * 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 * as React from 'react';
+import classNames from 'classnames';
+import { PluginVersionInfo } from '../@types/types';
+
+interface Props {
+  versions: PluginVersionInfo[];
+}
+
+interface State {
+  collapsed: boolean;
+}
+
+export default class PluginVersionMetaData extends React.Component<Props, State> {
+  state: State = {
+    collapsed: true
+  };
+
+  handleClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState(({ collapsed }) => ({ collapsed: !collapsed }));
+  };
+
+  renderVersion = ({
+    archived,
+    changeLogUrl,
+    compatibility,
+    date,
+    description,
+    downloadURL,
+    version
+  }: PluginVersionInfo) => {
+    return (
+      <div
+        className={classNames('plugin-meta-data-version', {
+          'plugin-meta-data-version-archived': archived
+        })}
+        key={version}>
+        <div className="plugin-meta-data-version-version">{version}</div>
+
+        <div className="plugin-meta-data-version-release-info">
+          {date && <time className="plugin-meta-data-version-release-date">{date}</time>}
+
+          {compatibility && (
+            <span className="plugin-meta-data-version-compatibility">{compatibility}</span>
+          )}
+        </div>
+
+        {description && (
+          <div className="plugin-meta-data-version-release-description">{description}</div>
+        )}
+
+        {(downloadURL || changeLogUrl) && (
+          <div className="plugin-meta-data-version-release-links">
+            {downloadURL && (
+              <span className="plugin-meta-data-version-download">
+                <a href={downloadURL} rel="noopener noreferrer" target="_blank">
+                  Download
+                </a>
+              </span>
+            )}
+
+            {changeLogUrl && (
+              <span className="plugin-meta-data-version-release-notes">
+                <a href={changeLogUrl} rel="noopener noreferrer" target="_blank">
+                  Release notes
+                </a>
+              </span>
+            )}
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  render() {
+    const { versions } = this.props;
+    const { collapsed } = this.state;
+
+    const archivedVersions = versions.filter(version => version.archived);
+    const currentVersions = versions.filter(version => !version.archived);
+    return (
+      <div className="plugin-meta-data-versions">
+        {archivedVersions.length > 0 && (
+          <button
+            className="plugin-meta-data-versions-show-more"
+            onClick={this.handleClick}
+            type="button">
+            {collapsed ? 'Show more versions' : 'Show fewer version'}
+          </button>
+        )}
+
+        {currentVersions.map(version => this.renderVersion(version))}
+
+        {!collapsed && archivedVersions.map(version => this.renderVersion(version))}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-docs/src/components/__tests__/PluginMetaData-test.tsx b/server/sonar-docs/src/components/__tests__/PluginMetaData-test.tsx
new file mode 100644 (file)
index 0000000..78c9ca9
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * 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 * as React from 'react';
+import { mount } from 'enzyme';
+import PluginMetaData from '../PluginMetaData';
+import { getPluginMetaData } from '../utils';
+
+jest.mock('../utils', () => ({
+  getPluginMetaData: jest.fn().mockResolvedValue({
+    name: 'SonarJava',
+    key: 'java',
+    isSonarSourceCommercial: true,
+    organization: {
+      name: 'SonarSource',
+      url: 'http://www.sonarsource.com/'
+    },
+    category: 'Languages',
+    license: 'SonarSource',
+    issueTrackerURL: 'https://jira.sonarsource.com/browse/SONARJAVA',
+    sourcesURL: 'https://github.com/SonarSource/sonar-java',
+    versions: [
+      {
+        version: '4.2',
+        compatibilityRange: { minimum: '6.0', maximum: '6.6' },
+        archived: false,
+        downloadURL: 'https://example.com/sonar-java-plugin-5.13.0.18197.jar'
+      },
+      {
+        version: '3.2',
+        date: '2015-04-30',
+        compatibilityRange: { maximum: '6.0' },
+        archived: true,
+        changeLogUrl: 'https://example.com/sonar-java-plugin/release',
+        downloadURL: 'https://example.com/sonar-java-plugin-3.2.jar'
+      }
+    ]
+  })
+}));
+
+beforeAll(() => {
+  (global as any).document.body.innerHTML = `
+<div class="page-container">
+  <p>Lorem ipsum</p>
+  <!-- update_center:java -->
+  <p>Dolor sit amet</p>
+  <!-- update_center : python -->
+  <p>Foo Bar</p>
+  <!--update_center       :       abap-->
+</div>
+`;
+});
+
+it('should render correctly', async () => {
+  const wrapper = shallowRender();
+  await new Promise(setImmediate);
+  expect(wrapper).toMatchSnapshot();
+  expect(getPluginMetaData).toBeCalledWith('java');
+  expect(getPluginMetaData).toBeCalledWith('python');
+  expect(getPluginMetaData).toBeCalledWith('abap');
+});
+
+function shallowRender(props: Partial<PluginMetaData['props']> = {}) {
+  return mount(<PluginMetaData location={{ pathname: 'foo' }} {...props} />);
+}
diff --git a/server/sonar-docs/src/components/__tests__/PluginVersionMetaData-test.tsx b/server/sonar-docs/src/components/__tests__/PluginVersionMetaData-test.tsx
new file mode 100644 (file)
index 0000000..699599a
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * 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 * as React from 'react';
+import { shallow } from 'enzyme';
+import PluginVersionMetaData from '../PluginVersionMetaData';
+
+it('should render correctly', () => {
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should correctly show all versions', () => {
+  const wrapper = shallowRender();
+  expect(wrapper.find('.plugin-meta-data-version').length).toBe(2);
+  wrapper.instance().setState({ collapsed: false });
+  expect(wrapper.find('.plugin-meta-data-version').length).toBe(5);
+});
+
+function shallowRender(props: Partial<PluginVersionMetaData['props']> = {}) {
+  return shallow(
+    <PluginVersionMetaData
+      versions={[
+        {
+          version: '5.13',
+          date: '2019-05-31',
+          compatibility: '6.7',
+          archived: false,
+          downloadURL: 'https://example.com/sonar-java-plugin-5.13.0.18197.jar',
+          changeLogUrl: 'https://example.com/sonar-java-plugin/release'
+        },
+        {
+          version: '4.2',
+          archived: false,
+          downloadURL: 'https://example.com/sonar-java-plugin-5.13.0.18197.jar'
+        },
+        {
+          version: '3.2',
+          date: '2015-04-30',
+          compatibility: '6.0 to 7.1',
+          archived: true,
+          changeLogUrl: 'https://example.com/sonar-java-plugin/release',
+          downloadURL: 'https://example.com/sonar-java-plugin-3.2.jar'
+        },
+        {
+          version: '3.1',
+          description: 'Lorem ipsum dolor sit amet',
+          archived: true,
+          changeLogUrl: 'https://example.com/sonar-java-plugin/release',
+          downloadURL: 'https://example.com/sonar-java-plugin-3.1.jar'
+        },
+        {
+          version: '2.1',
+          archived: true,
+          downloadURL: 'https://example.com/sonar-java-plugin-2.1.jar'
+        }
+      ]}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-docs/src/components/__tests__/__snapshots__/PluginMetaData-test.tsx.snap b/server/sonar-docs/src/components/__tests__/__snapshots__/PluginMetaData-test.tsx.snap
new file mode 100644 (file)
index 0000000..e98eab2
--- /dev/null
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<PluginMetaData
+  location={
+    Object {
+      "pathname": "foo",
+    }
+  }
+/>
+`;
diff --git a/server/sonar-docs/src/components/__tests__/__snapshots__/PluginVersionMetaData-test.tsx.snap b/server/sonar-docs/src/components/__tests__/__snapshots__/PluginVersionMetaData-test.tsx.snap
new file mode 100644 (file)
index 0000000..e8c0a12
--- /dev/null
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+  className="plugin-meta-data-versions"
+>
+  <button
+    className="plugin-meta-data-versions-show-more"
+    onClick={[Function]}
+    type="button"
+  >
+    Show more versions
+  </button>
+  <div
+    className="plugin-meta-data-version"
+    key="5.13"
+  >
+    <div
+      className="plugin-meta-data-version-version"
+    >
+      5.13
+    </div>
+    <div
+      className="plugin-meta-data-version-release-info"
+    >
+      <time
+        className="plugin-meta-data-version-release-date"
+      >
+        2019-05-31
+      </time>
+      <span
+        className="plugin-meta-data-version-compatibility"
+      >
+        6.7
+      </span>
+    </div>
+    <div
+      className="plugin-meta-data-version-release-links"
+    >
+      <span
+        className="plugin-meta-data-version-download"
+      >
+        <a
+          href="https://example.com/sonar-java-plugin-5.13.0.18197.jar"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
+          Download
+        </a>
+      </span>
+      <span
+        className="plugin-meta-data-version-release-notes"
+      >
+        <a
+          href="https://example.com/sonar-java-plugin/release"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
+          Release notes
+        </a>
+      </span>
+    </div>
+  </div>
+  <div
+    className="plugin-meta-data-version"
+    key="4.2"
+  >
+    <div
+      className="plugin-meta-data-version-version"
+    >
+      4.2
+    </div>
+    <div
+      className="plugin-meta-data-version-release-info"
+    />
+    <div
+      className="plugin-meta-data-version-release-links"
+    >
+      <span
+        className="plugin-meta-data-version-download"
+      >
+        <a
+          href="https://example.com/sonar-java-plugin-5.13.0.18197.jar"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
+          Download
+        </a>
+      </span>
+    </div>
+  </div>
+</div>
+`;
index f39e3c331d45ad6d85cc11377b5497c235b4a397..471e35fa48ba7aec13f41790094bcab793b049cd 100644 (file)
@@ -19,6 +19,7 @@
  */
 import { sortBy } from 'lodash';
 import { MarkdownRemark } from '../@types/graphql-types';
+import { PluginMetaDataInfo } from '../@types/types';
 
 const WORDS = 6;
 
@@ -126,3 +127,18 @@ export function highlightMarks(str: string, marks: Array<{ from: number; to: num
 export function isDefined<T>(x: T | undefined | null): x is T {
   return x !== undefined && x !== null;
 }
+
+export function getPluginMetaData(key: string): Promise<PluginMetaDataInfo> {
+  return (
+    window
+      .fetch(`https://update.sonarsource.org/${key}.json`)
+      .then((response: Response) => {
+        if (response.status >= 200 && response.status < 300) {
+          return response.json();
+        }
+        return Promise.reject(response);
+      })
+      /* eslint-disable no-console */
+      .catch(console.error)
+  );
+}
index e5c2dbf86bbd5b3ac5580beb8edd2eccad68322a..f6d0923d4f86272ec3739565763d17a35de80c41 100644 (file)
@@ -22,6 +22,7 @@ import { StaticQuery, graphql } from 'gatsby';
 import Footer from '../components/Footer';
 import HeaderListProvider from '../components/HeaderListProvider';
 import HeadingsLink from '../components/HeadingsLink';
+import PluginMetaData from '../components/PluginMetaData';
 import Sidebar from '../components/Sidebar';
 import { MarkdownRemarkConnection, MarkdownRemark } from '../@types/graphql-types';
 import './layout.css';
@@ -94,6 +95,7 @@ export default function Layout({ children, location }: Props) {
                 <div className="markdown-container">{children}</div>
               </div>
               <Footer />
+              <PluginMetaData location={location} />
             </div>
           </div>
         )}
index 0fa37ad5a93b8aa9e5395439b5d6386e8f3997a8..8a8f25594c27623970bc8e98906689db084ebd84 100644 (file)
@@ -708,3 +708,86 @@ img[src$='/images/info.svg'] {
   margin-bottom: 0;
   top: 0 !important;
 }
+
+.plugin-meta-data {
+  margin: 16px 0;
+  padding: 16px 16px 8px 16px;
+  background: #f9f9fb;
+  border: 1px solid #e6e6e6;
+  border-radius: 3px;
+}
+
+.plugin-meta-data a svg {
+  margin-right: 8px;
+}
+
+.plugin-meta-data-header {
+  border-bottom: 1px solid #cfd3d7;
+  padding-bottom: 16px;
+}
+
+.plugin-meta-data-header,
+.plugin-meta-data-version-release-info,
+.plugin-meta-data-version-links {
+  display: flex;
+}
+
+.plugin-meta-data-header > * + *,
+.plugin-meta-data-version-release-info > * + *,
+.plugin-meta-data-version-release-links > * + * {
+  margin-left: 16px;
+}
+
+.plugin-meta-data-header > * + * {
+  padding-left: 16px;
+  border-left: 1px solid #cfd3d7;
+}
+
+.plugin-meta-data-versions {
+  margin-top: 16px;
+}
+
+.plugin-meta-data-versions-show-more {
+  font-size: 14px;
+  float: right;
+  color: #51575a;
+  border-color: #7b8184;
+  border-width: 0 0 1px 0;
+  padding-left: 0;
+  padding-right: 0;
+  background: transparent;
+  cursor: pointer;
+}
+
+.plugin-meta-data-versions-show-more:hover {
+  color: #2d3032;
+  border-color: #2d3032;
+}
+
+.plugin-meta-data-version {
+  margin-bottom: 16px;
+}
+
+.plugin-meta-data-version + .plugin-meta-data-version {
+  padding-top: 8px;
+  padding-top: 8px;
+  border-top: 1px dashed #cfd3d7;
+}
+
+.plugin-meta-data-version-version {
+  font-weight: bold;
+  font-size: 18px;
+}
+
+.plugin-meta-data-version-release-info {
+  margin-top: 8px;
+  font-style: italic;
+}
+
+.plugin-meta-data-version-release-description {
+  margin-top: 8px;
+}
+
+.plugin-meta-data-version-release-links {
+  margin-top: 8px;
+}