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
This commit is contained in:
Sviatoslav 2018-10-25 07:01:39 +03:00 committed by Yangshun Tay
parent edde297504
commit a2d3f26722
8 changed files with 466 additions and 139 deletions

View file

@ -1,52 +1,66 @@
// build the docs meta such as next, previous, category and sidebar // build the docs meta such as next, previous, category and sidebar
module.exports = function createOrder(allSidebars = {}) { module.exports = function createOrder(allSidebars = {}) {
const order = {}; 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 categoryOrder = [];
const subCategoryOrder = []; const subCategoryOrder = [];
Object.keys(categories).forEach(category => { const indexItems = ({items, categoryLabel, subCategoryLabel}) => {
if (Array.isArray(categories[category])) { items.forEach(item => {
ids = ids.concat(categories[category]); switch (item.type) {
case 'category':
// eslint-disable-next-line indexItems({
for (let i = 0; i < categories[category].length; i++) { items: item.items,
categoryOrder.push(category); categoryLabel: categoryLabel || item.label,
subCategoryOrder.push(undefined); 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 indexItems({items: sidebar});
for (let i = 0; i < categories[category][subCategory].length; i++) {
categoryOrder.push(category);
subCategoryOrder.push(subCategory);
}
});
}
});
// eslint-disable-next-line // eslint-disable-next-line
for (let i = 0; i < ids.length; i++) { for (let i = 0; i < ids.length; i++) {
const id = ids[i]; const id = ids[i];
let previous; let previous;
let next; 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] = { order[id] = {
previous, previous,
next, next,
sidebar, sidebar: sidebarId,
category: categoryOrder[i], category: categoryOrder[i],
subCategory: subCategoryOrder[i], subCategory: subCategoryOrder[i],
}; };
} }
}); });
return order; return order;
}; };

View file

@ -2,6 +2,110 @@ const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const {idx} = require('../utils'); const {idx} = require('../utils');
/**
* Check that item contains only allowed keys
*
* @param {Object} item
* @param {Array<string>} 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<Object>} category
* @param {number} [level=0]
*
* @return {Array<Object>}
*/
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<Object>}}
*/
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) { module.exports = function loadSidebars({siteDir, env}, deleteCache = true) {
let allSidebars = {}; let allSidebars = {};
@ -34,5 +138,6 @@ module.exports = function loadSidebars({siteDir, env}, deleteCache = true) {
}); });
} }
} }
return allSidebars;
return normalizeSidebar(allSidebars);
}; };

View file

@ -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 (
<div
className={classnames(styles.sidebarGroup, {
[styles.sidebarSubGroup]: subCategory,
})}
key={label}>
<Heading
className={classnames(
styles.sidebarItem,
styles.sidebarGroupTitle,
styles.sidebarGroupCategoryTitle,
)}>
{label}
</Heading>
<ul className={styles.sidebarList}>{items.map(renderItem)}</ul>
</div>
);
}

View file

@ -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}) => <a {...linkProps} href={to} />
: NavLink;
return (
<li className={styles.sidebarListItem}>
<Link
activeClassName={styles.sidebarLinkActive}
className={classnames(styles.sidebarLink, styles.sidebarItem)}
to={href}>
{label}
</Link>
</li>
);
}

View file

@ -1,80 +1,63 @@
import React from 'react'; 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'; import styles from './styles.css';
function Sidebar(props) { function Sidebar(props) {
const {metadata, docsSidebars, docsMetadatas} = props; const {metadata, docsSidebars, docsMetadatas} = props;
const {sidebar, language} = metadata; const {sidebar, language} = metadata;
if (!sidebar || !docsSidebars) {
if (!sidebar) {
return null; return null;
} }
const thisSidebar = docsSidebars[sidebar]; const thisSidebar = docsSidebars[sidebar];
const renderItemLink = rawLinkID => { if (!thisSidebar) {
const linkID = (language ? `${language}-` : '') + rawLinkID; throw new Error(`Can not find ${sidebar} config`);
}
const convertDocLink = item => {
const linkID = (language ? `${language}-` : '') + item.id;
const linkMetadata = docsMetadatas[linkID]; const linkMetadata = docsMetadatas[linkID];
if (!linkMetadata) { if (!linkMetadata) {
throw new Error( throw new Error(
`Improper sidebars.json file, document with id '${linkID}' not found.`, `Improper sidebars.json file, document with id '${linkID}' not found.`,
); );
} }
return ( return {
<li className={styles.sidebarListItem} key={linkID}> type: 'link',
<NavLink label: linkMetadata.sidebar_label || linkMetadata.title,
activeClassName={styles.sidebarLinkActive} href: linkMetadata.permalink,
className={classnames(styles.sidebarLink, styles.sidebarItem)} };
to={linkMetadata.permalink}>
{linkMetadata.sidebar_label || linkMetadata.title}
</NavLink>
</li>
);
}; };
const renderCategory = categoryName => { const renderItem = (item, {root} = {}) => {
const category = thisSidebar[categoryName]; switch (item.type) {
return ( case 'category':
<div className={styles.sidebarGroup} key={categoryName}> return (
<h3 <SidebarCategory
className={classnames( {...item}
styles.sidebarItem, key={item.label}
styles.sidebarGroupTitle, subCategory={!root}
styles.sidebarGroupCategoryTitle, renderItem={renderItem}
)}> />
{categoryName} );
</h3> case 'link':
<ul className={styles.sidebarList}> return <SidebarLink {...item} key={item.href} />;
{Array.isArray(category) case 'ref':
? category.map(renderItemLink) default:
: Object.keys(category).map(subCategoryName => ( return renderItem(convertDocLink(item));
<div className={styles.sidebarSubGroup} key={subCategoryName}> }
<h4
className={classnames(
styles.sidebarItem,
styles.sidebarGroupTitle,
styles.sidebarGroupSubcategorytitle,
)}>
{subCategoryName}
</h4>
<ul className={styles.sidebarList}>
{category[subCategoryName].map(renderItemLink)}
</ul>
</div>
))}
</ul>
</div>
);
}; };
return ( return (
thisSidebar && ( <div className={styles.sidebar}>
<div className={styles.sidebar}> {thisSidebar.map(item => renderItem(item, {root: true}))}
{Object.keys(thisSidebar).map(renderCategory)} </div>
</div>
)
); );
} }

View file

@ -17,6 +17,10 @@
padding: 8px 12px; padding: 8px 12px;
} }
.sidebarSubGroup {
margin-left: 0.25em;
}
.sidebarGroupTitle { .sidebarGroupTitle {
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;

View file

@ -2,43 +2,99 @@
exports[`loadSidebars normal site with sidebars 1`] = ` exports[`loadSidebars normal site with sidebars 1`] = `
Object { Object {
"docs": Object { "docs": Array [
"Getting Started": Array [ Object {
"installation", "items": Array [
], Object {
"Guides": Array [ "id": "installation",
"blog", "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`] = ` exports[`loadSidebars site with sidebars & versioned sidebars 1`] = `
Object { Object {
"docs": Object { "docs": Array [
"Getting Started": Array [ Object {
"installation", "items": Array [
], Object {
"Guides": Array [ "id": "installation",
"blog", "type": "doc",
], },
}, ],
"version-1.0.0-docs": Object { "label": "Getting Started",
"Getting Started": Array [ "type": "category",
"version-1.0.0-installation", },
], Object {
"Guides": Array [ "items": Array [
"version-1.0.0-blog", Object {
], "id": "blog",
}, "type": "doc",
"version-1.0.1-docs": Object { },
"Getting Started": Array [ ],
"version-1.0.1-installation", "label": "Guides",
], "type": "category",
"Guides": Array [ },
"version-1.0.1-blog", ],
], "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",
},
],
} }
`; `;

View file

@ -3,16 +3,36 @@ import createOrder from '@lib/load/docs/order';
describe('createOrder', () => { describe('createOrder', () => {
test('multiple sidebars with subcategory', () => { test('multiple sidebars with subcategory', () => {
const result = createOrder({ const result = createOrder({
docs: { docs: [
Category1: { {
'Subcategory 1': ['doc1'], type: 'category',
'Subcategory 2': ['doc2'], 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'], {
}, type: 'category',
otherDocs: { label: 'Category2',
Category1: ['doc5'], items: [{type: 'doc', id: 'doc3'}, {type: 'doc', id: 'doc4'}],
}, },
],
otherDocs: [
{
type: 'category',
label: 'Category1',
items: [{type: 'doc', id: 'doc5'}],
},
],
}); });
expect(result).toEqual({ expect(result).toEqual({
doc1: { doc1: {
@ -54,13 +74,25 @@ describe('createOrder', () => {
}); });
test('multiple sidebars without subcategory', () => { test('multiple sidebars without subcategory', () => {
const result = createOrder({ const result = createOrder({
docs: { docs: [
Category1: ['doc1', 'doc2'], {
Category2: ['doc3', 'doc4'], type: 'category',
}, label: 'Category1',
otherDocs: { items: [{type: 'doc', id: 'doc1'}, {type: 'doc', id: 'doc2'}],
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({ expect(result).toEqual({
doc1: { doc1: {
@ -103,13 +135,25 @@ describe('createOrder', () => {
test('versioned sidebars', () => { test('versioned sidebars', () => {
const result = createOrder({ const result = createOrder({
docs: { docs: [
Category1: ['doc1'], {
}, type: 'category',
'version-1.2.3-docs': { label: 'Category1',
Category1: ['version-1.2.3-doc2'], items: [{type: 'doc', id: 'doc1'}],
Category2: ['version-1.2.3-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({ expect(result).toEqual({
doc1: { 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', () => { test('edge cases', () => {
expect(createOrder({})).toEqual({}); expect(createOrder({})).toEqual({});
expect(createOrder(null)).toEqual({});
expect(createOrder(undefined)).toEqual({}); expect(createOrder(undefined)).toEqual({});
expect(() => createOrder(null)).toThrowErrorMatchingInlineSnapshot(
`"Cannot convert undefined or null to object"`,
);
}); });
}); });