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
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;
};

View file

@ -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<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) {
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 {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 (
<li className={styles.sidebarListItem} key={linkID}>
<NavLink
activeClassName={styles.sidebarLinkActive}
className={classnames(styles.sidebarLink, styles.sidebarItem)}
to={linkMetadata.permalink}>
{linkMetadata.sidebar_label || linkMetadata.title}
</NavLink>
</li>
);
return {
type: 'link',
label: linkMetadata.sidebar_label || linkMetadata.title,
href: linkMetadata.permalink,
};
};
const renderCategory = categoryName => {
const category = thisSidebar[categoryName];
return (
<div className={styles.sidebarGroup} key={categoryName}>
<h3
className={classnames(
styles.sidebarItem,
styles.sidebarGroupTitle,
styles.sidebarGroupCategoryTitle,
)}>
{categoryName}
</h3>
<ul className={styles.sidebarList}>
{Array.isArray(category)
? category.map(renderItemLink)
: Object.keys(category).map(subCategoryName => (
<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>
);
const renderItem = (item, {root} = {}) => {
switch (item.type) {
case 'category':
return (
<SidebarCategory
{...item}
key={item.label}
subCategory={!root}
renderItem={renderItem}
/>
);
case 'link':
return <SidebarLink {...item} key={item.href} />;
case 'ref':
default:
return renderItem(convertDocLink(item));
}
};
return (
thisSidebar && (
<div className={styles.sidebar}>
{Object.keys(thisSidebar).map(renderCategory)}
</div>
)
<div className={styles.sidebar}>
{thisSidebar.map(item => renderItem(item, {root: true}))}
</div>
);
}

View file

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

View file

@ -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",
},
],
}
`;

View file

@ -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"`,
);
});
});