From a2d3f26722cb23c0e1fc08a73cab8adb3f4a0ee1 Mon Sep 17 00:00:00 2001 From: Sviatoslav Date: Thu, 25 Oct 2018 07:01:39 +0300 Subject: [PATCH] feat(v2): support external links and linking to docs from other sidebars (#1052) * feat(sidebar): support external links and linking to docs from other sidebars * Update styles.css --- v2/lib/load/docs/order.js | 70 ++++---- v2/lib/load/docs/sidebars.js | 107 +++++++++++- v2/lib/theme/Sidebar/SidebarCategory.js | 32 ++++ v2/lib/theme/Sidebar/SidebarLink.js | 24 +++ v2/lib/theme/Sidebar/index.js | 91 +++++----- v2/lib/theme/Sidebar/styles.css | 4 + .../docs/__snapshots__/sidebars.test.js.snap | 120 +++++++++---- v2/test/load/docs/order.test.js | 157 +++++++++++++++--- 8 files changed, 466 insertions(+), 139 deletions(-) create mode 100644 v2/lib/theme/Sidebar/SidebarCategory.js create mode 100644 v2/lib/theme/Sidebar/SidebarLink.js diff --git a/v2/lib/load/docs/order.js b/v2/lib/load/docs/order.js index ddf5757da1..fe033fa7f6 100644 --- a/v2/lib/load/docs/order.js +++ b/v2/lib/load/docs/order.js @@ -1,52 +1,66 @@ // build the docs meta such as next, previous, category and sidebar + module.exports = function createOrder(allSidebars = {}) { const order = {}; - if (!allSidebars) { - return order; - } - Object.keys(allSidebars).forEach(sidebar => { - const categories = allSidebars[sidebar]; - let ids = []; + Object.keys(allSidebars).forEach(sidebarId => { + const sidebar = allSidebars[sidebarId]; + + const ids = []; const categoryOrder = []; const subCategoryOrder = []; - Object.keys(categories).forEach(category => { - if (Array.isArray(categories[category])) { - ids = ids.concat(categories[category]); - - // eslint-disable-next-line - for (let i = 0; i < categories[category].length; i++) { - categoryOrder.push(category); - subCategoryOrder.push(undefined); + const indexItems = ({items, categoryLabel, subCategoryLabel}) => { + items.forEach(item => { + switch (item.type) { + case 'category': + indexItems({ + items: item.items, + categoryLabel: categoryLabel || item.label, + subCategoryLabel: categoryLabel && item.label, + }); + break; + case 'ref': + case 'link': + // refs and links should not be shown in navigation + break; + case 'doc': + ids.push(item.id); + categoryOrder.push(categoryLabel); + subCategoryOrder.push(subCategoryLabel); + break; + default: + throw new Error( + `Unknown item type: ${item.type}. Item: ${JSON.stringify(item)}`, + ); } - } else { - Object.keys(categories[category]).forEach(subCategory => { - ids = ids.concat(categories[category][subCategory]); + }); + }; - // eslint-disable-next-line - for (let i = 0; i < categories[category][subCategory].length; i++) { - categoryOrder.push(category); - subCategoryOrder.push(subCategory); - } - }); - } - }); + indexItems({items: sidebar}); // eslint-disable-next-line for (let i = 0; i < ids.length; i++) { const id = ids[i]; let previous; let next; - if (i > 0) previous = ids[i - 1]; - if (i < ids.length - 1) next = ids[i + 1]; + + if (i > 0) { + previous = ids[i - 1]; + } + + if (i < ids.length - 1) { + next = ids[i + 1]; + } + order[id] = { previous, next, - sidebar, + sidebar: sidebarId, category: categoryOrder[i], subCategory: subCategoryOrder[i], }; } }); + return order; }; diff --git a/v2/lib/load/docs/sidebars.js b/v2/lib/load/docs/sidebars.js index 0d8a97b024..1d941691d4 100644 --- a/v2/lib/load/docs/sidebars.js +++ b/v2/lib/load/docs/sidebars.js @@ -2,6 +2,110 @@ const fs = require('fs-extra'); const path = require('path'); const {idx} = require('../utils'); +/** + * Check that item contains only allowed keys + * + * @param {Object} item + * @param {Array} keys + */ +function assertItem(item, keys) { + const unknownKeys = Object.keys(item).filter( + key => !keys.includes(key) && key !== 'type', + ); + + if (unknownKeys.length) { + throw new Error( + `Unknown sidebar item keys: ${unknownKeys}. Item: ${JSON.stringify( + item, + )}`, + ); + } +} + +/** + * Normalizes recursively category and all its children. Ensures, that at the end + * each item will be an object with the corresponding type + * + * @param {Array} category + * @param {number} [level=0] + * + * @return {Array} + */ +function normalizeCategory(category, level = 0) { + if (level === 2) { + throw new Error( + `Can not process ${ + category.label + } category. Categories can be nested only one level deep.`, + ); + } + + assertItem(category, ['items', 'label']); + + if (!Array.isArray(category.items)) { + throw new Error( + `Error loading ${category.label} category. Category items must be array.`, + ); + } + + const items = category.items.map(item => { + switch (item.type) { + case 'category': + return normalizeCategory(item, level + 1); + case 'link': + assertItem(item, ['href', 'label']); + break; + case 'ref': + assertItem(item, ['id', 'label']); + break; + default: + if (typeof item === 'string') { + return { + type: 'doc', + id: item, + }; + } + + if (item.type !== 'doc') { + throw new Error(`Unknown sidebar item type: ${item.type}`); + } + + assertItem(item, ['id', 'label']); + break; + } + + return item; + }); + + return {...category, items}; +} + +/** + * Converts sidebars object to mapping to arrays of sidebar item objects + * + * @param {{[key: string]: Object}} sidebars + * + * @return {{[key: string]: Array}} + */ +function normalizeSidebar(sidebars) { + return Object.entries(sidebars).reduce((acc, [sidebarId, sidebar]) => { + let normalizedSidebar = sidebar; + + if (!Array.isArray(sidebar)) { + // convert sidebar to a more generic structure + normalizedSidebar = Object.entries(sidebar).map(([label, items]) => ({ + type: 'category', + label, + items, + })); + } + + acc[sidebarId] = normalizedSidebar.map(item => normalizeCategory(item)); + + return acc; + }, {}); +} + module.exports = function loadSidebars({siteDir, env}, deleteCache = true) { let allSidebars = {}; @@ -34,5 +138,6 @@ module.exports = function loadSidebars({siteDir, env}, deleteCache = true) { }); } } - return allSidebars; + + return normalizeSidebar(allSidebars); }; diff --git a/v2/lib/theme/Sidebar/SidebarCategory.js b/v2/lib/theme/Sidebar/SidebarCategory.js new file mode 100644 index 0000000000..379ff9053b --- /dev/null +++ b/v2/lib/theme/Sidebar/SidebarCategory.js @@ -0,0 +1,32 @@ +import React from 'react'; +import classnames from 'classnames'; + +import styles from './styles.css'; + +export default function SidebarCategory({ + label, + items, + subCategory, + renderItem, +}) { + const Heading = subCategory ? 'h4' : 'h3'; + + return ( +
+ + {label} + + +
    {items.map(renderItem)}
+
+ ); +} diff --git a/v2/lib/theme/Sidebar/SidebarLink.js b/v2/lib/theme/Sidebar/SidebarLink.js new file mode 100644 index 0000000000..ee7fd6d215 --- /dev/null +++ b/v2/lib/theme/Sidebar/SidebarLink.js @@ -0,0 +1,24 @@ +import React from 'react'; +import {NavLink} from 'react-router-dom'; +import classnames from 'classnames'; + +import styles from './styles.css'; + +export default function SidebarLink({href, label}) { + const isExternal = /^(https?:|\/\/)/.test(href); + const Link = isExternal + ? // eslint-disable-next-line jsx-a11y/anchor-has-content + ({to, activeClassName, ...linkProps}) => + : NavLink; + + return ( +
  • + + {label} + +
  • + ); +} diff --git a/v2/lib/theme/Sidebar/index.js b/v2/lib/theme/Sidebar/index.js index 9cd0ef21ae..92a450f661 100644 --- a/v2/lib/theme/Sidebar/index.js +++ b/v2/lib/theme/Sidebar/index.js @@ -1,80 +1,63 @@ import React from 'react'; -import {NavLink} from 'react-router-dom'; - -import classnames from 'classnames'; +import SidebarLink from './SidebarLink'; +import SidebarCategory from './SidebarCategory'; import styles from './styles.css'; function Sidebar(props) { const {metadata, docsSidebars, docsMetadatas} = props; const {sidebar, language} = metadata; - if (!sidebar || !docsSidebars) { + + if (!sidebar) { return null; } + const thisSidebar = docsSidebars[sidebar]; - const renderItemLink = rawLinkID => { - const linkID = (language ? `${language}-` : '') + rawLinkID; + if (!thisSidebar) { + throw new Error(`Can not find ${sidebar} config`); + } + + const convertDocLink = item => { + const linkID = (language ? `${language}-` : '') + item.id; const linkMetadata = docsMetadatas[linkID]; + if (!linkMetadata) { throw new Error( `Improper sidebars.json file, document with id '${linkID}' not found.`, ); } - return ( -
  • - - {linkMetadata.sidebar_label || linkMetadata.title} - -
  • - ); + return { + type: 'link', + label: linkMetadata.sidebar_label || linkMetadata.title, + href: linkMetadata.permalink, + }; }; - const renderCategory = categoryName => { - const category = thisSidebar[categoryName]; - return ( -
    -

    - {categoryName} -

    -
      - {Array.isArray(category) - ? category.map(renderItemLink) - : Object.keys(category).map(subCategoryName => ( -
      -

      - {subCategoryName} -

      -
        - {category[subCategoryName].map(renderItemLink)} -
      -
      - ))} -
    -
    - ); + const renderItem = (item, {root} = {}) => { + switch (item.type) { + case 'category': + return ( + + ); + case 'link': + return ; + case 'ref': + default: + return renderItem(convertDocLink(item)); + } }; return ( - thisSidebar && ( -
    - {Object.keys(thisSidebar).map(renderCategory)} -
    - ) +
    + {thisSidebar.map(item => renderItem(item, {root: true}))} +
    ); } diff --git a/v2/lib/theme/Sidebar/styles.css b/v2/lib/theme/Sidebar/styles.css index c0488dd080..6b186a67f4 100644 --- a/v2/lib/theme/Sidebar/styles.css +++ b/v2/lib/theme/Sidebar/styles.css @@ -17,6 +17,10 @@ padding: 8px 12px; } +.sidebarSubGroup { + margin-left: 0.25em; +} + .sidebarGroupTitle { font-size: 1em; font-weight: 500; diff --git a/v2/test/load/docs/__snapshots__/sidebars.test.js.snap b/v2/test/load/docs/__snapshots__/sidebars.test.js.snap index e96f753e75..1aa921c64e 100644 --- a/v2/test/load/docs/__snapshots__/sidebars.test.js.snap +++ b/v2/test/load/docs/__snapshots__/sidebars.test.js.snap @@ -2,43 +2,99 @@ exports[`loadSidebars normal site with sidebars 1`] = ` Object { - "docs": Object { - "Getting Started": Array [ - "installation", - ], - "Guides": Array [ - "blog", - ], - }, + "docs": Array [ + Object { + "items": Array [ + Object { + "id": "installation", + "type": "doc", + }, + ], + "label": "Getting Started", + "type": "category", + }, + Object { + "items": Array [ + Object { + "id": "blog", + "type": "doc", + }, + ], + "label": "Guides", + "type": "category", + }, + ], } `; exports[`loadSidebars site with sidebars & versioned sidebars 1`] = ` Object { - "docs": Object { - "Getting Started": Array [ - "installation", - ], - "Guides": Array [ - "blog", - ], - }, - "version-1.0.0-docs": Object { - "Getting Started": Array [ - "version-1.0.0-installation", - ], - "Guides": Array [ - "version-1.0.0-blog", - ], - }, - "version-1.0.1-docs": Object { - "Getting Started": Array [ - "version-1.0.1-installation", - ], - "Guides": Array [ - "version-1.0.1-blog", - ], - }, + "docs": Array [ + Object { + "items": Array [ + Object { + "id": "installation", + "type": "doc", + }, + ], + "label": "Getting Started", + "type": "category", + }, + Object { + "items": Array [ + Object { + "id": "blog", + "type": "doc", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + "version-1.0.0-docs": Array [ + Object { + "items": Array [ + Object { + "id": "version-1.0.0-installation", + "type": "doc", + }, + ], + "label": "Getting Started", + "type": "category", + }, + Object { + "items": Array [ + Object { + "id": "version-1.0.0-blog", + "type": "doc", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + "version-1.0.1-docs": Array [ + Object { + "items": Array [ + Object { + "id": "version-1.0.1-installation", + "type": "doc", + }, + ], + "label": "Getting Started", + "type": "category", + }, + Object { + "items": Array [ + Object { + "id": "version-1.0.1-blog", + "type": "doc", + }, + ], + "label": "Guides", + "type": "category", + }, + ], } `; diff --git a/v2/test/load/docs/order.test.js b/v2/test/load/docs/order.test.js index dd9cd8982f..7f0826386c 100644 --- a/v2/test/load/docs/order.test.js +++ b/v2/test/load/docs/order.test.js @@ -3,16 +3,36 @@ import createOrder from '@lib/load/docs/order'; describe('createOrder', () => { test('multiple sidebars with subcategory', () => { const result = createOrder({ - docs: { - Category1: { - 'Subcategory 1': ['doc1'], - 'Subcategory 2': ['doc2'], + docs: [ + { + type: 'category', + label: 'Category1', + items: [ + { + type: 'category', + label: 'Subcategory 1', + items: [{type: 'doc', id: 'doc1'}], + }, + { + type: 'category', + label: 'Subcategory 2', + items: [{type: 'doc', id: 'doc2'}], + }, + ], }, - Category2: ['doc3', 'doc4'], - }, - otherDocs: { - Category1: ['doc5'], - }, + { + type: 'category', + label: 'Category2', + items: [{type: 'doc', id: 'doc3'}, {type: 'doc', id: 'doc4'}], + }, + ], + otherDocs: [ + { + type: 'category', + label: 'Category1', + items: [{type: 'doc', id: 'doc5'}], + }, + ], }); expect(result).toEqual({ doc1: { @@ -54,13 +74,25 @@ describe('createOrder', () => { }); test('multiple sidebars without subcategory', () => { const result = createOrder({ - docs: { - Category1: ['doc1', 'doc2'], - Category2: ['doc3', 'doc4'], - }, - otherDocs: { - Category1: ['doc5'], - }, + docs: [ + { + type: 'category', + label: 'Category1', + items: [{type: 'doc', id: 'doc1'}, {type: 'doc', id: 'doc2'}], + }, + { + type: 'category', + label: 'Category2', + items: [{type: 'doc', id: 'doc3'}, {type: 'doc', id: 'doc4'}], + }, + ], + otherDocs: [ + { + type: 'category', + label: 'Category1', + items: [{type: 'doc', id: 'doc5'}], + }, + ], }); expect(result).toEqual({ doc1: { @@ -103,13 +135,25 @@ describe('createOrder', () => { test('versioned sidebars', () => { const result = createOrder({ - docs: { - Category1: ['doc1'], - }, - 'version-1.2.3-docs': { - Category1: ['version-1.2.3-doc2'], - Category2: ['version-1.2.3-doc1'], - }, + docs: [ + { + type: 'category', + label: 'Category1', + items: [{type: 'doc', id: 'doc1'}], + }, + ], + 'version-1.2.3-docs': [ + { + type: 'category', + label: 'Category1', + items: [{type: 'doc', id: 'version-1.2.3-doc2'}], + }, + { + type: 'category', + label: 'Category2', + items: [{type: 'doc', id: 'version-1.2.3-doc1'}], + }, + ], }); expect(result).toEqual({ doc1: { @@ -136,9 +180,74 @@ describe('createOrder', () => { }); }); + test('multiple sidebars with subcategories, refs and external links', () => { + const result = createOrder({ + docs: [ + { + type: 'category', + label: 'Category1', + items: [ + { + type: 'category', + label: 'Subcategory 1', + items: [{type: 'link', href: '//example.com', label: 'bar'}], + }, + { + type: 'category', + label: 'Subcategory 2', + items: [{type: 'doc', id: 'doc2'}], + }, + { + type: 'category', + label: 'Subcategory 1', + items: [{type: 'link', href: '//example2.com', label: 'baz'}], + }, + ], + }, + { + type: 'category', + label: 'Category2', + items: [{type: 'doc', id: 'doc3'}, {type: 'ref', id: 'doc4'}], + }, + ], + otherDocs: [ + { + type: 'category', + label: 'Category1', + items: [{type: 'doc', id: 'doc5'}], + }, + ], + }); + expect(result).toEqual({ + doc2: { + category: 'Category1', + subCategory: 'Subcategory 2', + next: 'doc3', + previous: undefined, + sidebar: 'docs', + }, + doc3: { + category: 'Category2', + subCategory: undefined, + next: undefined, + previous: 'doc2', + sidebar: 'docs', + }, + doc5: { + category: 'Category1', + subCategory: undefined, + next: undefined, + previous: undefined, + sidebar: 'otherDocs', + }, + }); + }); + test('edge cases', () => { expect(createOrder({})).toEqual({}); - expect(createOrder(null)).toEqual({}); expect(createOrder(undefined)).toEqual({}); + expect(() => createOrder(null)).toThrowErrorMatchingInlineSnapshot( + `"Cannot convert undefined or null to object"`, + ); }); });