chore: refactor, unify markdown headings

This commit is contained in:
Roman Hotsiy 2018-07-30 13:04:27 +03:00
parent d731518c8c
commit 35ba10f759
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
7 changed files with 72 additions and 67 deletions

View File

@ -2,36 +2,19 @@ import * as React from 'react';
import { MiddlePanel, Row } from '../../common-elements/'; import { MiddlePanel, Row } from '../../common-elements/';
import { AppStore } from '../../services/AppStore';
import { Markdown } from '../Markdown/Markdown'; import { Markdown } from '../Markdown/Markdown';
import { SecurityDefs } from '../SecuritySchemes/SecuritySchemes';
export interface ApiDescriptionProps { export interface ApiDescriptionProps {
store: AppStore; description: string;
} }
const ALLOWED_COMPONENTS = {
'security-definitions': {
component: SecurityDefs,
propsSelector: _store => ({
securitySchemes: _store!.spec.securitySchemes,
}),
},
};
export class ApiDescription extends React.PureComponent<ApiDescriptionProps> { export class ApiDescription extends React.PureComponent<ApiDescriptionProps> {
render() { render() {
const { store } = this.props; const { description } = this.props;
const description = store.spec.info.description;
return ( return (
<Row> <Row>
<MiddlePanel> <MiddlePanel>
<Markdown <Markdown source={description} />
source={description || ''}
raw={false}
allowedComponents={ALLOWED_COMPONENTS}
store={store}
/>
</MiddlePanel> </MiddlePanel>
</Row> </Row>
); );

View File

@ -5,25 +5,43 @@ import { SECTION_ATTR } from '../../services/MenuStore';
import { Markdown } from '../Markdown/Markdown'; import { Markdown } from '../Markdown/Markdown';
import { H1, MiddlePanel, Row, ShareLink } from '../../common-elements'; import { H1, MiddlePanel, Row, ShareLink } from '../../common-elements';
import { MDXComponentMeta } from '../../services/MarkdownRenderer';
import { ContentItemModel } from '../../services/MenuBuilder'; import { ContentItemModel } from '../../services/MenuBuilder';
import { OperationModel } from '../../services/models'; import { OperationModel } from '../../services/models';
import { Operation } from '../Operation/Operation'; import { Operation } from '../Operation/Operation';
import { SecurityDefs } from '../SecuritySchemes/SecuritySchemes';
import { StoreConsumer } from '../StoreBuilder';
@observer @observer
export class ContentItems extends React.Component<{ export class ContentItems extends React.Component<{
items: ContentItemModel[]; items: ContentItemModel[];
allowedMdComponents?: Dict<MDXComponentMeta>;
}> { }> {
static defaultProps = {
allowedMdComponents: {
'security-definitions': {
component: SecurityDefs,
propsSelector: _store => ({
securitySchemes: _store!.spec.securitySchemes,
}),
},
},
};
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 => <ContentItem item={item} key={item.id} />); return items.map(item => (
<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
@ -37,10 +55,11 @@ export class ContentItem extends React.Component<ContentItemProps> {
content = null; content = null;
break; break;
case 'tag': case 'tag':
content = <TagItem item={item} />; content = <SectionItem {...this.props} />;
break; break;
case 'section': case 'section':
return null; content = <SectionItem {...this.props} />;
break;
case 'operation': case 'operation':
content = <OperationItem item={item as any} />; content = <OperationItem item={item as any} />;
break; break;
@ -58,9 +77,10 @@ export class ContentItem extends React.Component<ContentItemProps> {
} }
@observer @observer
export class TagItem extends React.Component<ContentItemProps> { export class SectionItem extends React.Component<ContentItemProps> {
render() { render() {
const { name, description } = this.props.item; const { name, description } = this.props.item;
const components = this.props.allowedMdComponents;
return ( return (
<Row> <Row>
<MiddlePanel> <MiddlePanel>
@ -68,7 +88,15 @@ export class TagItem extends React.Component<ContentItemProps> {
<ShareLink href={'#' + this.props.item.id} /> <ShareLink href={'#' + this.props.item.id} />
{name} {name}
</H1> </H1>
{description !== undefined && <Markdown source={description} />} {components ? (
<Markdown source={description || ''} />
) : (
<StoreConsumer>
{store => (
<Markdown source={description || ''} allowedComponents={components} store={store} />
)}
</StoreConsumer>
)}
</MiddlePanel> </MiddlePanel>
</Row> </Row>
); );

View File

@ -14,7 +14,6 @@ export interface StylingMarkdownProps {
} }
export interface BaseMarkdownProps extends StylingMarkdownProps { export interface BaseMarkdownProps extends StylingMarkdownProps {
raw?: boolean;
sanitize?: boolean; sanitize?: boolean;
store?: AppStore; store?: AppStore;
} }
@ -54,7 +53,7 @@ export class Markdown extends React.Component<MarkdownProps> {
} }
render() { render() {
const { source, raw, allowedComponents, store, inline, dense } = this.props; const { source, allowedComponents, store, inline, dense } = this.props;
if (allowedComponents && !store) { if (allowedComponents && !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');
@ -64,17 +63,13 @@ export class Markdown extends React.Component<MarkdownProps> {
if (allowedComponents) { if (allowedComponents) {
return ( return (
<AdvancedMarkdown <AdvancedMarkdown
parts={renderer.renderMdWithComponents(source, allowedComponents, raw)} parts={renderer.renderMdWithComponents(source, allowedComponents)}
{...this.props} {...this.props}
/> />
); );
} else { } else {
return ( return (
<SanitizedMarkdownHTML <SanitizedMarkdownHTML html={renderer.renderMd(source)} inline={inline} dense={dense} />
html={renderer.renderMd(source, raw)}
inline={inline}
dense={dense}
/>
); );
} }
} }

View File

@ -1,8 +1,7 @@
import * as marked from 'marked'; import * as marked from 'marked';
import { highlight, html2Str, safeSlugify } from '../utils'; import { highlight, safeSlugify } from '../utils';
import { AppStore } from './AppStore'; import { AppStore } from './AppStore';
import { SECTION_ATTR } from './MenuStore';
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
@ -76,54 +75,49 @@ export class MarkdownRenderer {
} }
attachHeadingsDescriptions(rawText: string) { attachHeadingsDescriptions(rawText: string) {
const buildRegexp = heading => const buildRegexp = heading => new RegExp(`##?\\s+${heading.name}`);
new RegExp(`<h\\d ${SECTION_ATTR}="${heading.id}" id="${heading.id}">`);
const flatHeadings = this.flattenHeadings(this.headings); const flatHeadings = this.flattenHeadings(this.headings);
if (flatHeadings.length < 1) { if (flatHeadings.length < 1) {
return; return;
} }
let prevHeading = flatHeadings[0]; let prevHeading = flatHeadings[0];
let prevRegexp = buildRegexp(prevHeading);
let prevPos = rawText.search(buildRegexp(prevHeading)); let prevPos = rawText.search(prevRegexp);
for (let i = 1; i < flatHeadings.length; i++) { for (let i = 1; i < flatHeadings.length; i++) {
const heading = flatHeadings[i]; const heading = flatHeadings[i];
const currentPos = rawText.substr(prevPos + 1).search(buildRegexp(heading)) + prevPos + 1; const regexp = buildRegexp(heading);
prevHeading.description = html2Str(rawText.substring(prevPos, currentPos)); const currentPos = rawText.substr(prevPos + 1).search(regexp) + prevPos + 1;
prevHeading.description = rawText
.substring(prevPos, currentPos)
.replace(prevRegexp, '')
.trim();
prevHeading = heading; prevHeading = heading;
prevRegexp = regexp;
prevPos = currentPos; prevPos = currentPos;
} }
prevHeading.description = html2Str(rawText.substring(prevPos)); prevHeading.description = rawText
.substring(prevPos)
.replace(prevRegexp, '')
.trim();
} }
headingRule = (text: string, level: number, raw: string) => { headingRule = (text: string, level: number, raw: string) => {
if (level === 1) { if (level === 1) {
this.currentTopHeading = this.saveHeading(text); this.currentTopHeading = this.saveHeading(text);
const id = this.currentTopHeading.id;
return (
`<a name="${id}"></a>` +
`<h${level} ${SECTION_ATTR}="${id}" id="${id}">` +
`<a class="share-link" href="#${id}"></a>${text}</h${level}>`
);
} else if (level === 2) { } else if (level === 2) {
const { id } = this.saveHeading( this.saveHeading(
text, text,
this.currentTopHeading && this.currentTopHeading.items, this.currentTopHeading && this.currentTopHeading.items,
this.currentTopHeading && this.currentTopHeading.id, this.currentTopHeading && this.currentTopHeading.id,
); );
return (
`<a name="${id}"></a>` +
`<h${level} ${SECTION_ATTR}="${id}" id="${id}">` +
`<a class="share-link" href="#${id}"></a>${text}</h${level}>`
);
} else {
return this.originalHeadingRule(text, level, raw);
} }
return this.originalHeadingRule(text, level, raw);
}; };
renderMd(rawText: string, raw: boolean = true): string { renderMd(rawText: string, extractHeadings: boolean = false): string {
const opts = raw ? undefined : { renderer: this.headingEnhanceRenderer }; const opts = extractHeadings ? { renderer: this.headingEnhanceRenderer } : undefined;
const res = marked(rawText.toString(), opts); const res = marked(rawText.toString(), opts);
@ -131,8 +125,8 @@ export class MarkdownRenderer {
} }
extractHeadings(rawText: string): MarkdownHeading[] { extractHeadings(rawText: string): MarkdownHeading[] {
const text = this.renderMd(rawText, false); this.renderMd(rawText, true);
this.attachHeadingsDescriptions(text); this.attachHeadingsDescriptions(rawText);
const res = this.headings; const res = this.headings;
this.headings = []; this.headings = [];
return res; return res;
@ -143,7 +137,6 @@ export class MarkdownRenderer {
renderMdWithComponents( renderMdWithComponents(
rawText: string, rawText: string,
components: Dict<MDXComponentMeta>, components: Dict<MDXComponentMeta>,
raw: boolean = true,
): Array<string | MDXComponentMeta> { ): Array<string | MDXComponentMeta> {
const componentDefs: string[] = []; const componentDefs: string[] = [];
const names = '(?:' + Object.keys(components).join('|') + ')'; const names = '(?:' + Object.keys(components).join('|') + ')';
@ -167,7 +160,7 @@ export class MarkdownRenderer {
for (let i = 0; i < htmlParts.length; i++) { for (let i = 0; i < htmlParts.length; i++) {
const htmlPart = htmlParts[i]; const htmlPart = htmlParts[i];
if (htmlPart) { if (htmlPart) {
res.push(this.renderMd(htmlPart, raw)); res.push(this.renderMd(htmlPart));
} }
if (componentDefs[i]) { if (componentDefs[i]) {
const { componentName, attrs } = parseComponent(componentDefs[i]); const { componentName, attrs } = parseComponent(componentDefs[i]);

View File

@ -5,7 +5,7 @@ import { OpenAPIRef, OpenAPISchema, OpenAPISpec, Referenced } from '../types';
import { appendToMdHeading, IS_BROWSER } from '../utils/'; import { appendToMdHeading, IS_BROWSER } from '../utils/';
import { JsonPointer } from '../utils/JsonPointer'; import { JsonPointer } from '../utils/JsonPointer';
import { isNamedDefinition } from '../utils/openapi'; import { isNamedDefinition } from '../utils/openapi';
import { buildComponentComment, COMPONENT_REGEXP } from './MarkdownRenderer'; import { buildComponentComment, COMPONENT_REGEXP, MDX_COMPONENT_REGEXP } from './MarkdownRenderer';
import { RedocNormalizedOptions } from './RedocNormalizedOptions'; import { RedocNormalizedOptions } from './RedocNormalizedOptions';
export type MergedOpenAPISchema = OpenAPISchema & { parentRefs?: string[] }; export type MergedOpenAPISchema = OpenAPISchema & { parentRefs?: string[] };
@ -74,11 +74,15 @@ export class OpenAPIParser {
) { ) {
// Automatically inject Authentication section with SecurityDefinitions component // Automatically inject Authentication section with SecurityDefinitions component
const description = spec.info.description || ''; const description = spec.info.description || '';
const securityRegexp = new RegExp( const legacySecurityRegexp = new RegExp(
COMPONENT_REGEXP.replace('{component}', '<security-definitions>'), COMPONENT_REGEXP.replace('{component}', '<security-definitions>'),
'gmi', 'gmi',
); );
if (!securityRegexp.test(description)) { const securityRegexp = new RegExp(
MDX_COMPONENT_REGEXP.replace('{component}', 'security-definitions'),
'gmi',
);
if (!legacySecurityRegexp.test(description) && !securityRegexp.test(description)) {
const comment = buildComponentComment('security-definitions'); const comment = buildComponentComment('security-definitions');
spec.info.description = appendToMdHeading(description, 'Authentication', comment); spec.info.description = appendToMdHeading(description, 'Authentication', comment);
} }

View File

@ -7,7 +7,7 @@ describe('Markdown renderer', () => {
}); });
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', () => {
renderer.renderMd('## Sub Intro', false); renderer.renderMd('## Sub Intro', true);
expect(Object.keys(renderer.headings)).toHaveLength(1); expect(Object.keys(renderer.headings)).toHaveLength(1);
expect(renderer.headings[0].name).toEqual('Sub Intro'); expect(renderer.headings[0].name).toEqual('Sub Intro');
}); });

View File

@ -6,13 +6,15 @@ export class ApiInfoModel implements OpenAPIInfo {
title: string; title: string;
version: string; version: string;
description?: string; description: string;
termsOfService?: string; termsOfService?: string;
contact?: OpenAPIContact; contact?: OpenAPIContact;
license?: OpenAPILicense; license?: OpenAPILicense;
constructor(private parser: OpenAPIParser) { constructor(private parser: OpenAPIParser) {
Object.assign(this, parser.spec.info); Object.assign(this, parser.spec.info);
this.description = parser.spec.info.description || '';
this.description = this.description.substring(0, this.description.search(/^##?\s+/m));
} }
get downloadLink(): string | undefined { get downloadLink(): string | undefined {