Pull markdown headers from description into side menu

This commit is contained in:
Roman Hotsiy 2016-07-21 13:35:27 +03:00
parent 82f1be1d11
commit 205aa6211c
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
23 changed files with 213 additions and 78 deletions

View File

@ -4,7 +4,28 @@ schemes:
host: petstore.swagger.io
basePath: /v2
info:
description: 'This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.'
description: |
This is a sample server Petstore server.
You can find out more about Swagger at
[http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/).
For this sample, you can use the api key `special-key` to test the authorization filters.
# Introduction
This API is documented in **OpenAPI format** and is based on
[Pestore sample](http://petstore.swagger.io/) provided by [swagger.io](http://swagger.io) team.
It was **extended** to illustrate features of [generator-openapi-repo](https://github.com/Rebilly/generator-openapi-repo)
tool and [ReDoc](https://github.com/Rebilly/ReDoc) documentation. In addition to standard
OpenAPI syntax we use a few [vendor extensions](https://github.com/Rebilly/ReDoc/blob/master/docs/redoc-vendor-extensions.md).
# OpenAPI Specification
This API is documented in **OpenAPI format** and is based on
[Pestore sample](http://petstore.swagger.io/) provided by [swagger.io](http://swagger.io) team.
It was **extended** to illustrate features of [generator-openapi-repo](https://github.com/Rebilly/generator-openapi-repo)
tool and [ReDoc](https://github.com/Rebilly/ReDoc) documentation. In addition to standard
OpenAPI syntax we use a few [vendor extensions](https://github.com/Rebilly/ReDoc/blob/master/docs/redoc-vendor-extensions.md).
# Cross-Origin Resource Sharing
This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with [W3C spec](https://www.w3.org/TR/cors/).
And that allows cross-domain communication from the browser.
All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site.
version: 1.0.0
title: Swagger Petstore
termsOfService: 'http://swagger.io/terms/'
@ -19,19 +40,6 @@ externalDocs:
description: Find out how to create Github repo for your OpenAPI spec.
url: 'https://github.com/Rebilly/generator-openapi-repo'
tags:
- name: Introduction
x-traitTag: true
description: 'This API is documented in **OpenAPI format** and is based on [Pestore sample](http://petstore.swagger.io/) provided by [swagger.io](http://swagger.io) team. It was **extended** to illustrate features of [generator-openapi-repo](https://github.com/Rebilly/generator-openapi-repo) tool and [ReDoc](https://github.com/Rebilly/ReDoc) documentation. In addition to standard OpenAPI syntax we use a few [vendor extensions](https://github.com/Rebilly/ReDoc/blob/master/docs/redoc-vendor-extensions.md).'
- name: OpenAPI Specification
description: 'The goal of The OpenAPI Specification is to define a standard, language-agnostic interface to REST APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection. When properly defined via OpenAPI, a consumer can understand and interact with the remote service with a minimal amount of implementation logic. Similar to what interfaces have done for lower-level programming, OpenAPI removes the guesswork in calling the service.'
externalDocs:
description: Find out more
url: 'https://openapis.org/'
- name: Cross-Origin Resource Sharing
description: |
This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with [W3C spec](https://www.w3.org/TR/cors/).
And that allows cross-domain communication from the browser.
All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site.
- name: pet
description: Everything about your Pets
- name: store

View File

@ -1,6 +1,6 @@
<div>
<h1 class="api-info-header">{{info.title}} ({{info.version}})</h1>
<p *ngIf="info.description" [innerHtml]="info.description | marked"> </p>
<p *ngIf="info.description" [innerHtml]="info['x-redoc-html-description'] | safe"> </p>
<p>
<!-- TODO: create separate components for contact and license ? -->
<span *ngIf="info.contact"> Contact:

View File

@ -6,6 +6,8 @@
:host > div {
width: 60%;
padding: 40px;
box-sizing: border-box;
}
a.openapi-button {
@ -15,3 +17,7 @@ a.openapi-button {
margin-left: 0.5em;
font-weight: normal;
}
:host [section] {
padding-top: 80px;
}

View File

@ -2,6 +2,7 @@
import { getChildDebugElement } from '../../../tests/helpers';
import { Component } from '@angular/core';
import { OptionsService } from '../../services/index';
import {
inject,
@ -17,8 +18,14 @@ describe('Redoc components', () => {
let builder;
let component;
let fixture;
let opts;
beforeEach(async(inject([TestComponentBuilder, SpecManager], (tcb, specMgr) => {
beforeEach(async(inject([TestComponentBuilder, SpecManager, OptionsService], (tcb, specMgr, _opts) => {
opts = _opts;
opts.options = {
scrollYOffset: () => 0,
$scrollParent: window
};
builder = tcb;
return specMgr.load('/tests/schemas/api-info-test.json');
})));

View File

@ -1,7 +1,7 @@
'use strict';
import { SpecManager, RedocComponent, BaseComponent } from '../base';
import { OptionsService } from '../../services/index';
import { OptionsService, MenuService } from '../../services/index';
@RedocComponent({
selector: 'api-info',
@ -11,7 +11,7 @@ import { OptionsService } from '../../services/index';
export class ApiInfo extends BaseComponent {
info: any;
specUrl: String;
constructor(specMgr:SpecManager, private optionsService:OptionsService) {
constructor(specMgr:SpecManager, private optionsService:OptionsService, private menuServ: MenuService) {
super(specMgr);
}

View File

@ -1,5 +1,5 @@
@import '../../shared/styles/variables';
@import '../../shared/styles/share-link';
:host {
padding-bottom: 100px;

View File

@ -1,10 +1,10 @@
<div class="methods">
<div class="tag" *ngFor="let tag of tags;trackBy:trackByTagName">
<div class="tag-info" [attr.tag]="tag.name" *ngIf="!tag.empty">
<h1 class="sharable-header"> <a class="share-link" href="#tag/{{tag.name | encodeURIComponent}}"></a>{{tag.name}} </h1>
<div class="tag-info" [attr.section]="tag.name" *ngIf="!tag.virtual">
<h1 class="sharable-header"> <a class="share-link" href="#section/{{tag.name | encodeURIComponent}}"></a>{{tag.name}} </h1>
<p *ngIf="tag.description" [innerHtml]="tag.description | marked"> </p>
</div>
<method *ngFor="let method of tag.methods;trackBy:trackByPointer" [pointer]="method.pointer" [attr.pointer]="method.pointer"
[attr.tag]="method.tag" [tag]="method.tag" [attr.operation-id]="method.operationId"></method>
[attr.section]="method.tag" [tag]="method.tag" [attr.operation-id]="method.operationId"></method>
</div>
</div>

View File

@ -1,5 +1,4 @@
@import '../../shared/styles/variables';
@import '../../shared/styles/share-link';
.tag-info {
padding: 40px;
@ -21,6 +20,7 @@
color: $headers-color;
text-transform: capitalize;
font-weight: normal;
margin-top: 0;
}
.methods {

View File

@ -15,25 +15,21 @@ import { SchemaHelper } from '../../services/index';
detect: true
})
export class MethodsList extends BaseComponent {
tags:Array<any>;
tags:Array<any> = [];
constructor(specMgr:SpecManager) {
super(specMgr);
}
init() {
this.tags = [];
// follow SwaggerUI behavior for cases when one method has more than one tag:
// duplicate methods
let tags = SchemaHelper.buildMenuTree(this.specMgr.schema);
tags.forEach(tagInfo => {
this.tags = tags.filter(tagInfo => !tagInfo.virtual);
this.tags.forEach(tagInfo => {
// inject tag name into method info
tagInfo.methods = tagInfo.methods || [];
tagInfo.methods.forEach(method => {
method.tag = tagInfo.name;
});
});
this.tags = tags;
}
trackByPointer(idx, el) {

View File

@ -51,10 +51,6 @@ api-info, .side-bar {
padding: 10px 0;
}
api-info {
padding: 40px;
}
api-logo {
display: block;
text-align: center;
@ -150,6 +146,8 @@ api-logo {
color: #383838;
}
}
@import '../../shared/styles/share-link';
}
footer {

View File

@ -15,7 +15,8 @@ import { SideMenu } from '../SideMenu/side-menu';
import { StickySidebar } from '../../shared/components/index';
import {SpecManager} from '../../utils/SpecManager';
import { OptionsService, RedocEventsService } from '../../services/index';
import { OptionsService, RedocEventsService, MenuService,
ScrollService, Hash } from '../../services/index';
var dom = new BrowserDomAdapter();
var _modeLocked = false;
@ -25,7 +26,10 @@ var _modeLocked = false;
providers: [
SpecManager,
BrowserDomAdapter,
RedocEventsService
RedocEventsService,
ScrollService,
Hash,
MenuService,
],
templateUrl: './redoc.html',
styleUrls: ['./redoc.css'],

View File

@ -6,13 +6,13 @@
</span>
</div>
<div #desktop id="resources-nav">
<h5 class="menu-header"> API reference </h5>
<h5 class="menu-header" (click)="onclk()"> API reference </h5>
<div *ngFor="let cat of categories; let idx = index" class="menu-cat">
<label class="menu-cat-header" (click)="activateAndScroll(idx, -1)" [hidden]="cat.empty"
<label class="menu-cat-header" (click)="activateAndScroll(idx, -1)" [hidden]="cat.headless"
[ngClass]="{active: cat.active}"> {{cat.name}}</label>
<ul class="menu-subitems" @itemAnimation="cat.active ? 'expanded' : 'collapsed'">
<li *ngFor="let method of cat.methods; let methIdx = index"
<li *ngFor="let method of cat.methods; trackBy:summary; let methIdx = index"
[ngClass]="{active: method.active}"
(click)="activateAndScroll(idx, methIdx)">
{{method.summary}}

View File

@ -29,7 +29,7 @@ describe('Redoc components', () => {
testOptions = opts;
testOptions.options = {
scrollYOffset: () => 0,
scrollParent: window
$scrollParent: window
};
return specMgr.load('/tests/schemas/extended-petstore.yml');
})));

View File

@ -1,16 +1,18 @@
'use strict';
import { ElementRef, ChangeDetectorRef } from '@angular/core';
import { BrowserDomAdapter } from '@angular/platform-browser/src/browser/browser_adapter';
import { global } from '@angular/core/src/facade/lang';
import { trigger, state, animate, transition, style } from '@angular/core';
import { RedocComponent, BaseComponent, SpecManager } from '../base';
import { ScrollService, Hash, MenuService, OptionsService } from '../../services/index';
import { MenuCategory } from '../../services/schema-helper.service';
@RedocComponent({
selector: 'side-menu',
templateUrl: './side-menu.html',
providers: [ScrollService, MenuService, Hash],
styleUrls: ['./side-menu.css'],
detect: true,
onPushOnly: false,
@ -29,7 +31,7 @@ import { ScrollService, Hash, MenuService, OptionsService } from '../../services
export class SideMenu extends BaseComponent {
activeCatCaption: string;
activeItemCaption: string;
categories: any;
categories: Array<MenuCategory>;
private options: any;
private $element: any;
@ -87,7 +89,7 @@ export class SideMenu extends BaseComponent {
toggleMobileNav() {
let dom = this.dom;
let $overflowParent = (this.options.$scrollParent === global) ? dom.defaultDoc().body
: this.$scrollParent.$scrollParent;
: this.$scrollParent;
if (dom.hasStyle(this.$resourcesNav, 'height')) {
dom.removeStyle(this.$resourcesNav, 'height');
dom.removeStyle($overflowParent, 'overflow-y');

View File

@ -2,7 +2,7 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core';
import { CORE_DIRECTIVES, JsonPipe, AsyncPipe } from '@angular/common';
import { SpecManager } from '../utils/SpecManager';
import { MarkedPipe, JsonPointerEscapePipe } from '../utils/pipes';
import { MarkedPipe, JsonPointerEscapePipe, SafePipe } from '../utils/pipes';
export { SpecManager };
@ -50,7 +50,7 @@ function snapshot(obj) {
export function RedocComponent(options) {
let inputs = safeConcat(options.inputs, commonInputs);
let directives = safeConcat(options.directives, CORE_DIRECTIVES);
let pipes = safeConcat(options.pipes, [JsonPointerEscapePipe, MarkedPipe, JsonPipe, AsyncPipe]);
let pipes = safeConcat(options.pipes, [JsonPointerEscapePipe, MarkedPipe, JsonPipe, AsyncPipe, SafePipe]);
if (options.onPushOnly === undefined) options.onPushOnly = true;
return function decorator(target) {

View File

@ -43,7 +43,7 @@ describe('Menu service', () => {
});
it('should scroll to method when location hash is present [jp]', (done) => {
let hash = '#tag/pet/paths/~1pet~1findByStatus/get';
let hash = '#section/pet/paths/~1pet~1findByStatus/get';
spyOn(menu, 'hashScroll').and.callThrough();
spyOn(window, 'scrollTo').and.stub();
hashService.changed.subscribe(() => {

View File

@ -3,7 +3,7 @@ import { Injectable, EventEmitter } from '@angular/core';
import { ScrollService, INVIEW_POSITION } from './scroll.service';
import { Hash } from './hash.service';
import { SpecManager } from '../utils/SpecManager';
import { SchemaHelper } from './schema-helper.service';
import { SchemaHelper, MenuCategory } from './schema-helper.service';
const CHANGE = {
NEXT : 1,
@ -14,7 +14,7 @@ const CHANGE = {
@Injectable()
export class MenuService {
changed: EventEmitter<any> = new EventEmitter();
categories: any;
categories: Array<MenuCategory>;
activeCatIdx: number = 0;
activeMethodIdx: number = -1;
@ -58,8 +58,8 @@ export class MenuService {
this.categories[this.activeCatIdx].name);
}
getMethodElByPtr(ptr, tag) {
let selector = ptr ? `[pointer="${ptr}"][tag="${tag}"]` : `[tag="${tag}"]`;
getMethodElByPtr(ptr, section) {
let selector = ptr ? `[pointer="${ptr}"][section="${section}"]` : `[section="${section}"]`;
return document.querySelector(selector);
}
@ -95,6 +95,7 @@ export class MenuService {
_calcActiveIndexes(offset) {
let menu = this.categories;
let catCount = menu.length;
if (!catCount) return [0, -1];
let catLength = menu[this.activeCatIdx].methods.length;
let resMethodIdx = this.activeMethodIdx + offset;
@ -140,7 +141,7 @@ export class MenuService {
let ptr = decodeURIComponent(hash.substr(namespace.length + 1));
if (namespace === 'operation') {
$el = this.getMethodElByOperId(ptr);
} else if (namespace === 'tag') {
} else if (namespace === 'section') {
let tag = ptr.split('/')[0];
ptr = ptr.substr(tag.length);
$el = this.getMethodElByPtr(ptr, tag);

View File

@ -59,9 +59,13 @@ describe('Spec Helper', () => {
info.methods[0].summary.should.be.equal('test post');
});
it('should map x-traitTag to empty section', () => {
let info = menuTree[0];
info.empty.should.be.true();
});
it('should map x-traitTag to empty methods list', () => {
let info = menuTree[0];
info['x-traitTag'].should.be.true();
info.methods.should.be.empty();
});

View File

@ -8,6 +8,22 @@ interface PropertyPreprocessOptions {
skipReadOnly?: boolean;
}
export interface MenuMethod {
active: boolean;
summary: string;
tag: string;
}
export interface MenuCategory {
name: string;
active?: boolean;
methods?: Array<MenuMethod>;
description?: string;
empty?: string;
virtual?: boolean;
}
const injectors = {
general: {
check: () => true,
@ -225,17 +241,22 @@ export class SchemaHelper {
(method.description && method.description.substring(0, 50)) || '<no description>';
}
static buildMenuTree(schema) {
static buildMenuTree(schema):Array<MenuCategory> {
let tag2MethodMapping = {};
let definedTags = schema.tags || [];
// add tags into map to preserve order
for (let tag of definedTags) {
for (let header of (<Array<string>>(schema.info && schema.info['x-redoc-markdown-headers'] || []))) {
tag2MethodMapping[header] = {
name: header, virtual: true, methods: []
};
}
for (let tag of schema.tags || []) {
tag2MethodMapping[tag.name] = {
'description': tag.description,
'name': tag.name,
'x-traitTag': tag['x-traitTag'],
'methods': []
name: tag.name,
description: tag.description,
headless: tag.name === '',
empty: !!tag['x-traitTag'],
methods: [],
};
}
@ -256,11 +277,11 @@ export class SchemaHelper {
if (!tag2MethodMapping[tag]) {
tagDetails = {
name: tag,
empty: tag === ''
headless: tag === ''
};
tag2MethodMapping[tag] = tagDetails;
}
if (tagDetails['x-traitTag']) continue;
if (tagDetails.empty) continue;
if (!tagDetails.methods) tagDetails.methods = [];
tagDetails.methods.push({
pointer: methodPointer,

View File

@ -2,6 +2,7 @@
import JsonSchemaRefParser from 'json-schema-ref-parser';
import JsonPointer from './JsonPointer';
import { renderMd, safePush } from './helpers';
export class SpecManager {
public _schema:any = {};
@ -42,6 +43,22 @@ export class SpecManager {
if (this.apiUrl.endsWith('/')) {
this.apiUrl = this.apiUrl.substr(0, this.apiUrl.length - 1);
}
this.preprocess();
}
preprocess() {
this._schema.info['x-redoc-html-description'] = renderMd( this._schema.info.description, {
open: (tokens, idx) => {
let content = tokens[idx + 1].content;
safePush(this._schema.info, 'x-redoc-markdown-headers', content);
return `<h${tokens[idx].hLevel} section="${content}">` +
`<a class="share-link" href="#section/${content}"></a>`;
},
close: (tokens, idx) => {
return `</h${tokens[idx].hLevel}>`;
}
});
}
get schema() {

View File

@ -1,4 +1,59 @@
'use strict';
import Remarkable from 'remarkable';
declare var Prism: any;
const md = new Remarkable({
html: true,
linkify: true,
breaks: false,
typographer: false,
highlight: (str, lang) => {
if (lang === 'json') lang = 'js';
let grammar = Prism.languages[lang];
//fallback to clike
if (!grammar) return str;
return Prism.highlight(str, grammar);
}
});
interface HeadersHandler {
open: Function;
close: Function;
}
export function renderMd(rawText:string, headersHandler?:HeadersHandler) {
let _origRule;
if (headersHandler) {
_origRule = {
open: md.renderer.rules.heading_open,
close: md.renderer.rules.heading_close
};
md.renderer.rules.heading_open = (tokens, idx) => {
if (tokens[idx].hLevel !== 1 ) {
return _origRule.open(tokens, idx);
} else {
return headersHandler.open(tokens, idx);
}
};
md.renderer.rules.heading_close = (tokens, idx) => {
if (tokens[idx].hLevel !== 1 ) {
return _origRule.close(tokens, idx);
} else {
return headersHandler.close(tokens, idx);
}
};
}
let res = md.render(rawText);
if (headersHandler) {
md.renderer.rules.heading_open = _origRule.open;
md.renderer.rules.heading_close = _origRule.close;
}
return res;
}
export function statusCodeType(statusCode) {
if (statusCode < 100 || statusCode > 599) {
@ -29,3 +84,8 @@ export function defaults(target, src) {
}
return target;
}
export function safePush(obj, prop, val) {
if (!obj[prop]) obj[prop] = [];
obj[prop].push(val);
}

View File

@ -5,24 +5,11 @@ import { DomSanitizationService } from '@angular/platform-browser';
import { isString, stringify, isBlank } from '@angular/core/src/facade/lang';
import { BaseException } from '@angular/core/src/facade/exceptions';
import JsonPointer from './JsonPointer';
import { renderMd } from './helpers';
declare var Prism: any;
import Remarkable from 'remarkable';
const md = new Remarkable({
html: true,
linkify: true,
breaks: false,
typographer: false,
highlight: (str, lang) => {
if (lang === 'json') lang = 'js';
let grammar = Prism.languages[lang];
//fallback to clike
if (!grammar) return str;
return Prism.highlight(str, grammar);
}
});
class InvalidPipeArgumentException extends BaseException {
constructor(type, value) {
@ -73,11 +60,24 @@ export class MarkedPipe implements PipeTransform {
}
return this.sanitizer.bypassSecurityTrustHtml(
`<span class="redoc-markdown-block">${md.render(value)}</span>`
`<span class="redoc-markdown-block">${renderMd(value)}</span>`
);
}
}
@Pipe({ name: 'safe' })
export class SafePipe implements PipeTransform {
constructor(private sanitizer: DomSanitizationService) {}
transform(value) {
if (isBlank(value)) return value;
if (!isString(value)) {
throw new InvalidPipeArgumentException(JsonPointerEscapePipe, value);
}
return this.sanitizer.bypassSecurityTrustHtml(value);
}
}
const langMap = {
'c++': 'cpp',
'c#': 'csharp',

View File

@ -16,5 +16,16 @@
"host": "petstore.swagger.io",
"basePath": "/v2/",
"schemes": ["http"],
"paths": {}
"paths": {
"/pet": {
"post": {
"tags": ["tag1"],
"summary": "tag1"
},
"put": {
"tags": ["tag1", "traitTag"],
"summary": "two tags",
}
}
}
}