chore: refactor, move allowedMdComponents to options

This commit is contained in:
Roman Hotsiy 2018-08-17 14:41:22 +03:00
parent f903406c14
commit d3d35189f5
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
7 changed files with 93 additions and 52 deletions

View File

@ -4,48 +4,25 @@ import * as React from 'react';
import { AdvancedMarkdown } from '../Markdown/AdvancedMarkdown'; import { AdvancedMarkdown } from '../Markdown/AdvancedMarkdown';
import { H1, H2, MiddlePanel, Row, Section, ShareLink } from '../../common-elements'; import { H1, H2, MiddlePanel, Row, Section, ShareLink } from '../../common-elements';
import { MDXComponentMeta } from '../../services/MarkdownRenderer';
import { ContentItemModel } from '../../services/MenuBuilder'; import { ContentItemModel } from '../../services/MenuBuilder';
import { GroupModel, OperationModel } from '../../services/models'; import { GroupModel, OperationModel } from '../../services/models';
import { Operation } from '../Operation/Operation'; import { Operation } from '../Operation/Operation';
import { SecurityDefs } from '../SecuritySchemes/SecuritySchemes';
const DEFAULT_ALLOWED_COMPONENTS = {
'security-definitions': {
component: SecurityDefs,
propsSelector: _store => ({
securitySchemes: _store!.spec.securitySchemes,
}),
},
};
@observer @observer
export class ContentItems extends React.Component<{ export class ContentItems extends React.Component<{
items: ContentItemModel[]; items: ContentItemModel[];
allowedMdComponents?: Dict<MDXComponentMeta>;
}> { }> {
static defaultProps = {
allowedMdComponents: DEFAULT_ALLOWED_COMPONENTS,
};
render() { render() {
const items = this.props.items; const items = this.props.items;
if (items.length === 0) { if (items.length === 0) {
return null; return null;
} }
return items.map(item => ( return items.map(item => <ContentItem item={item} key={item.id} />);
<ContentItem
item={item}
key={item.id}
allowedMdComponents={this.props.allowedMdComponents!}
/>
));
} }
} }
export interface ContentItemProps { export interface ContentItemProps {
item: ContentItemModel; item: ContentItemModel;
allowedMdComponents: Dict<MDXComponentMeta>;
} }
@observer @observer
@ -76,9 +53,7 @@ export class ContentItem extends React.Component<ContentItemProps> {
<Section id={item.id} underlined={item.type === 'operation'}> <Section id={item.id} underlined={item.type === 'operation'}>
{content} {content}
</Section> </Section>
{item.items && ( {item.items && <ContentItems items={item.items} />}
<ContentItems items={item.items} allowedMdComponents={this.props.allowedMdComponents} />
)}
</> </>
); );
} }
@ -90,7 +65,6 @@ const middlePanelWrap = component => <MiddlePanel>{component}</MiddlePanel>;
export class SectionItem extends React.Component<ContentItemProps> { export class SectionItem extends React.Component<ContentItemProps> {
render() { render() {
const { name, description, level } = this.props.item as GroupModel; const { name, description, level } = this.props.item as GroupModel;
const { allowedMdComponents } = this.props;
const Header = level === 2 ? H2 : H1; const Header = level === 2 ? H2 : H1;
return ( return (
@ -103,11 +77,7 @@ export class SectionItem extends React.Component<ContentItemProps> {
</Header> </Header>
</MiddlePanel> </MiddlePanel>
</Row> </Row>
<AdvancedMarkdown <AdvancedMarkdown source={description || ''} htmlWrap={middlePanelWrap} />
allowedComponents={allowedMdComponents}
source={description || ''}
htmlWrap={middlePanelWrap}
/>
</> </>
); );
} }

View File

@ -1,29 +1,35 @@
import * as React from 'react'; import * as React from 'react';
import { AppStore, MarkdownRenderer, MDXComponentMeta } from '../../services'; import { AppStore, MarkdownRenderer, RedocNormalizedOptions } from '../../services';
import { BaseMarkdownProps } from './Markdown'; import { BaseMarkdownProps } from './Markdown';
import { SanitizedMarkdownHTML } from './SanitizedMdBlock'; import { SanitizedMarkdownHTML } from './SanitizedMdBlock';
import { OptionsConsumer } from '../OptionsProvider';
import { StoreConsumer } from '../StoreBuilder'; import { StoreConsumer } from '../StoreBuilder';
export interface AdvancedMarkdownProps extends BaseMarkdownProps { export interface AdvancedMarkdownProps extends BaseMarkdownProps {
allowedComponents: Dict<MDXComponentMeta>;
htmlWrap?: (part: JSX.Element) => JSX.Element; htmlWrap?: (part: JSX.Element) => JSX.Element;
} }
export class AdvancedMarkdown extends React.Component<AdvancedMarkdownProps> { export class AdvancedMarkdown extends React.Component<AdvancedMarkdownProps> {
render() { render() {
return <StoreConsumer>{store => this.renderWithStore(store)}</StoreConsumer>; return (
<OptionsConsumer>
{options => (
<StoreConsumer>{store => this.renderWithOptionsAndStore(options, store)}</StoreConsumer>
)}
</OptionsConsumer>
);
} }
renderWithStore(store?: AppStore) { renderWithOptionsAndStore(options: RedocNormalizedOptions, store?: AppStore) {
const { allowedComponents, source, htmlWrap = i => i } = this.props; const { source, htmlWrap = i => i } = this.props;
if (!store) { if (!store) {
throw new Error('When using componentes in markdown, store prop must be provided'); throw new Error('When using componentes in markdown, store prop must be provided');
} }
const renderer = new MarkdownRenderer(); const renderer = new MarkdownRenderer(options);
const parts = renderer.renderMdWithComponents(source, allowedComponents); const parts = renderer.renderMdWithComponents(source);
if (!parts.length) { if (!parts.length) {
return null; return null;

View File

@ -2,12 +2,12 @@ import * as PropTypes from 'prop-types';
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider } from '../../styled-components'; import { ThemeProvider } from '../../styled-components';
import { OptionsProvider } from '../OptionsProvider';
import { AppStore } from '../../services'; import { AppStore } from '../../services';
import { ApiInfo } from '../ApiInfo/'; import { ApiInfo } from '../ApiInfo/';
import { ApiLogo } from '../ApiLogo/ApiLogo'; import { ApiLogo } from '../ApiLogo/ApiLogo';
import { ContentItems } from '../ContentItems/ContentItems'; import { ContentItems } from '../ContentItems/ContentItems';
import { OptionsProvider } from '../OptionsProvider';
import { SideMenu } from '../SideMenu/SideMenu'; import { SideMenu } from '../SideMenu/SideMenu';
import { StickyResponsiveSidebar } from '../StickySidebar/StickyResponsiveSidebar'; import { StickyResponsiveSidebar } from '../StickySidebar/StickyResponsiveSidebar';
import { ApiContentWrap, BackgroundStub, RedocWrap } from './styled.elements'; import { ApiContentWrap, BackgroundStub, RedocWrap } from './styled.elements';

View File

@ -10,6 +10,9 @@ import { RedocNormalizedOptions, RedocRawOptions } from './RedocNormalizedOption
import { ScrollService } from './ScrollService'; import { ScrollService } from './ScrollService';
import { SearchStore } from './SearchStore'; import { SearchStore } from './SearchStore';
import { SecurityDefs } from '../components/SecuritySchemes/SecuritySchemes';
import { SECURITY_DEFINITIONS_COMPONENT_NAME } from '../utils/openapi';
export interface StoreState { export interface StoreState {
menu: { menu: {
activeItemIdx: number; activeItemIdx: number;
@ -64,7 +67,7 @@ export class AppStore {
createSearchIndex: boolean = true, createSearchIndex: boolean = true,
) { ) {
this.rawOptions = options; this.rawOptions = options;
this.options = new RedocNormalizedOptions(options); this.options = new RedocNormalizedOptions(options, DEFAULT_OPTIONS);
this.scroll = new ScrollService(this.options); this.scroll = new ScrollService(this.options);
// update position statically based on hash (in case of SSR) // update position statically based on hash (in case of SSR)
@ -137,3 +140,14 @@ export class AppStore {
this.marker.mark(); this.marker.mark();
} }
} }
const DEFAULT_OPTIONS: RedocRawOptions = {
allowedMdComponents: {
[SECURITY_DEFINITIONS_COMPONENT_NAME]: {
component: SecurityDefs,
propsSelector: (store: AppStore) => ({
securitySchemes: store.spec.securitySchemes,
}),
},
},
};

View File

@ -2,6 +2,7 @@ import * as marked from 'marked';
import { highlight, safeSlugify } from '../utils'; import { highlight, safeSlugify } from '../utils';
import { AppStore } from './AppStore'; import { AppStore } from './AppStore';
import { RedocNormalizedOptions } from './RedocNormalizedOptions';
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
@ -49,7 +50,7 @@ export class MarkdownRenderer {
private headingEnhanceRenderer: marked.Renderer; private headingEnhanceRenderer: marked.Renderer;
private originalHeadingRule: typeof marked.Renderer.prototype.heading; private originalHeadingRule: typeof marked.Renderer.prototype.heading;
constructor() { constructor(public options?: RedocNormalizedOptions) {
this.headingEnhanceRenderer = new marked.Renderer(); this.headingEnhanceRenderer = new marked.Renderer();
this.originalHeadingRule = this.headingEnhanceRenderer.heading.bind( this.originalHeadingRule = this.headingEnhanceRenderer.heading.bind(
this.headingEnhanceRenderer, this.headingEnhanceRenderer,
@ -148,10 +149,8 @@ export class MarkdownRenderer {
// TODO: rewrite this completelly! Regexp-based 👎 // TODO: rewrite this completelly! Regexp-based 👎
// Use marked ecosystem // Use marked ecosystem
renderMdWithComponents( renderMdWithComponents(rawText: string): Array<string | MDXComponentMeta> {
rawText: string, const components = this.options && this.options.allowedMdComponents;
components?: Dict<MDXComponentMeta>,
): Array<string | MDXComponentMeta> {
if (!components || Object.keys(components).length === 0) { if (!components || Object.keys(components).length === 0) {
return [this.renderMd(rawText)]; return [this.renderMd(rawText)];
} }
@ -160,7 +159,7 @@ export class MarkdownRenderer {
const names = '(?:' + Object.keys(components).join('|') + ')'; const names = '(?:' + Object.keys(components).join('|') + ')';
const anyCompRegexp = new RegExp( const anyCompRegexp = new RegExp(
COMPONENT_REGEXP.replace(/{component}/g, '(' + names + ')'), COMPONENT_REGEXP.replace(/{component}/g, '(' + names + '.*?)'),
'gmi', 'gmi',
); );
let match = anyCompRegexp.exec(rawText); let match = anyCompRegexp.exec(rawText);
@ -169,7 +168,10 @@ export class MarkdownRenderer {
match = anyCompRegexp.exec(rawText); match = anyCompRegexp.exec(rawText);
} }
const splitCompRegexp = new RegExp(COMPONENT_REGEXP.replace(/{component}/g, names), 'mi'); const splitCompRegexp = new RegExp(
COMPONENT_REGEXP.replace(/{component}/g, names + '.*?'),
'mi',
);
const htmlParts = rawText.split(splitCompRegexp); const htmlParts = rawText.split(splitCompRegexp);
const res: any[] = []; const res: any[] = [];
for (let i = 0; i < htmlParts.length; i++) { for (let i = 0; i < htmlParts.length; i++) {

View File

@ -2,6 +2,8 @@ import defaultTheme, { ResolvedThemeInterface, resolveTheme, ThemeInterface } fr
import { querySelector } from '../utils/dom'; import { querySelector } from '../utils/dom';
import { isNumeric, mergeObjects } from '../utils/helpers'; import { isNumeric, mergeObjects } from '../utils/helpers';
import { MDXComponentMeta } from './MarkdownRenderer';
export interface RedocRawOptions { export interface RedocRawOptions {
theme?: ThemeInterface; theme?: ThemeInterface;
scrollYOffset?: number | string | (() => number); scrollYOffset?: number | string | (() => number);
@ -17,6 +19,8 @@ export interface RedocRawOptions {
disableSearch?: boolean | string; disableSearch?: boolean | string;
unstable_ignoreMimeParameters?: boolean; unstable_ignoreMimeParameters?: boolean;
allowedMdComponents?: Dict<MDXComponentMeta>;
} }
function argValueToBoolean(val?: string | boolean): boolean { function argValueToBoolean(val?: string | boolean): boolean {
@ -98,9 +102,11 @@ export class RedocNormalizedOptions {
/* tslint:disable-next-line */ /* tslint:disable-next-line */
unstable_ignoreMimeParameters: boolean; unstable_ignoreMimeParameters: boolean;
allowedMdComponents: Dict<MDXComponentMeta>;
constructor(raw: RedocRawOptions) { constructor(raw: RedocRawOptions, defaults: RedocRawOptions = {}) {
let hook; let hook;
raw = { ...defaults, ...raw };
if (raw.theme && raw.theme.extensionsHook) { if (raw.theme && raw.theme.extensionsHook) {
hook = raw.theme.extensionsHook; hook = raw.theme.extensionsHook;
raw.theme.extensionsHook = undefined; raw.theme.extensionsHook = undefined;
@ -120,5 +126,7 @@ export class RedocNormalizedOptions {
this.disableSearch = argValueToBoolean(raw.disableSearch); this.disableSearch = argValueToBoolean(raw.disableSearch);
this.unstable_ignoreMimeParameters = argValueToBoolean(raw.unstable_ignoreMimeParameters); this.unstable_ignoreMimeParameters = argValueToBoolean(raw.unstable_ignoreMimeParameters);
this.allowedMdComponents = raw.allowedMdComponents || {};
} }
} }

View File

@ -1,9 +1,21 @@
import { MarkdownRenderer } from '../MarkdownRenderer'; import { MarkdownRenderer, MDXComponentMeta } from '../MarkdownRenderer';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
const TestComponent = () => null;
describe('Markdown renderer', () => { describe('Markdown renderer', () => {
let renderer: MarkdownRenderer; let renderer: MarkdownRenderer;
beforeEach(() => { beforeEach(() => {
renderer = new MarkdownRenderer(); renderer = new MarkdownRenderer(
new RedocNormalizedOptions({
allowedMdComponents: {
'security-definitions': {
component: TestComponent,
propsSelector: () => ({}),
},
},
}),
);
}); });
test('should return a level-1 heading even though only level-2 is present', () => { test('should return a level-1 heading even though only level-2 is present', () => {
@ -19,4 +31,33 @@ describe('Markdown renderer', () => {
expect(headings[0].items).toBeDefined(); expect(headings[0].items).toBeDefined();
expect(headings[0].items).toHaveLength(1); expect(headings[0].items).toHaveLength(1);
}); });
test('renderMdWithComponents should work with legacy syntax', () => {
const source = 'Hello!\n<!-- ReDoc-Inject: <security-definitions> -->\nBye';
const parts = renderer.renderMdWithComponents(source);
expect(parts).toHaveLength(3);
expect(parts[0]).toEqual('<p>Hello!</p>\n');
expect(typeof parts[1]).toEqual('object');
expect((parts[1] as MDXComponentMeta).component).toEqual(TestComponent);
expect(parts[2]).toEqual('<p>Bye</p>\n');
});
test('renderMdWithComponents should work with mdx-like syntax', () => {
const source = 'Hello!\n<security-definitions/>\nBye';
const parts = renderer.renderMdWithComponents(source);
expect(parts).toHaveLength(3);
expect(parts[0]).toEqual('<p>Hello!</p>\n');
expect(typeof parts[1]).toEqual('object');
expect((parts[1] as MDXComponentMeta).component).toBe(TestComponent);
expect(parts[2]).toEqual('<p>Bye</p>\n');
});
test('renderMdWithComponents should parse attribute names', () => {
const source = '<security-definitions pointer={"test"}/>';
const parts = renderer.renderMdWithComponents(source);
expect(parts).toHaveLength(1);
const part = parts[0] as MDXComponentMeta;
expect(part.component).toBe(TestComponent);
expect(part.attrs).toEqual({ pointer: 'test' });
});
}); });