build(aio): add version into navigation.json

The navigation.json is now passed through the dgeni pipeline.
The source file has been moved to `aio/content/navigation.json`
but the generated file will now appear where the original source file
was found, `aio/src/content/navigation.json`.

Everything inside `aio/src/content` is now generated and ignored by git.

The `processNavigationMap` processor in this commit adds the current version
information to the navigation.json file and verifies the relative urls in
the file map to real documents.

The navigationService exposes the versionInfo as an observable, which the
AppComponent renders at the top of the sidenav.
This commit is contained in:
Peter Bacon Darwin 2017-03-20 22:23:20 +00:00 committed by Miško Hevery
parent a0c6d44e18
commit 4e10faf1eb
10 changed files with 172 additions and 16 deletions

2
aio/.gitignore vendored
View File

@ -8,5 +8,5 @@ yarn-error.log
# Ignore generated content
/dist
/tmp
/src/content/docs
/src/content
/.sass-cache

View File

@ -8,6 +8,7 @@
<md-sidenav-container class="sidenav-container">
<md-sidenav #sidenav class="sidenav" [opened]="isSideBySide" [mode] = "isSideBySide ? 'side' : 'over'">
<div class="version-info vertical-menu-item">{{ (versionInfo | async)?.full }}</div>
<aio-top-menu *ngIf="!isSideBySide" class="small" [nodes]="(navigationViews | async)?.TopBar" [homeImageUrl]="homeImageUrl"></aio-top-menu>
<aio-nav-menu [nodes]="(navigationViews | async)?.SideNav" [selectedNodes]="selectedNodes | async"></aio-nav-menu>
</md-sidenav>
@ -16,5 +17,4 @@
<aio-search-results #searchResults></aio-search-results>
<aio-doc-viewer [doc]="currentDocument | async" (docRendered)="onDocRendered($event)"></aio-doc-viewer>
</section>
</md-sidenav-container>

View File

@ -5,7 +5,7 @@ import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { DocumentService, DocumentContents } from 'app/documents/document.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
import { NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
import { SearchService } from 'app/search/search.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
@ -24,6 +24,7 @@ export class AppComponent implements OnInit {
currentDocument: Observable<DocumentContents>;
navigationViews: Observable<NavigationViews>;
selectedNodes: Observable<NavigationNode[]>;
versionInfo: Observable<VersionInfo>;
@ViewChildren('searchBox, searchResults', { read: ElementRef })
searchElements: QueryList<ElementRef>;
@ -45,6 +46,7 @@ export class AppComponent implements OnInit {
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
this.navigationViews = navigationService.navigationViews;
this.selectedNodes = navigationService.selectedNodes;
this.versionInfo = navigationService.versionInfo;
}
ngOnInit() {

View File

@ -1,7 +1,7 @@
import { ReflectiveInjector } from '@angular/core';
import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend } from '@angular/http/testing';
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
import { NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service';
@ -125,4 +125,23 @@ describe('NavigationService', () => {
expect(currentNodes).toEqual([]);
});
});
describe('versionInfo', () => {
let service: NavigationService, versionInfo: VersionInfo;
beforeEach(() => {
service = injector.get(NavigationService);
service.versionInfo.subscribe(info => versionInfo = info);
const backend = injector.get(ConnectionBackend);
backend.connectionsArray[0].mockRespond(createResponse({
['__versionInfo']: { raw: '4.0.0' }
}));
});
it('should extract the version info', () => {
const backend = injector.get(ConnectionBackend);
expect(versionInfo).toEqual({ raw: '4.0.0' });
});
});
});

View File

@ -12,28 +12,62 @@ import { LocationService } from 'app/shared/location.service';
import { NavigationNode } from './navigation-node';
export { NavigationNode } from './navigation-node';
export type NavigationResponse = {'__versionInfo': VersionInfo } & { [name: string]: NavigationNode[] };
export interface NavigationViews {
[name: string]: NavigationNode[];
}
export interface NavigationMap {
[url: string]: NavigationNode;
}
export interface VersionInfo {
raw: string;
major: number;
minor: number;
patch: number;
prerelease: string[];
build: string;
version: string;
codeName: string;
isSnapshot: boolean;
full: string;
branch: string;
commitSHA: string;
}
const navigationPath = 'content/navigation.json';
@Injectable()
export class NavigationService {
/**
* An observable collection of NavigationNode trees, which can be used to render navigational menus
*/
navigationViews = this.fetchNavigationViews();
navigationViews: Observable<NavigationViews>;
/**
* The current version of doc-app that we are running
*/
versionInfo: Observable<VersionInfo>;
/**
* An observable array of nodes that indicate which nodes in the `navigationViews` match the current URL location
*/
selectedNodes = this.getSelectedNodes();
selectedNodes: Observable<NavigationNode[]>;
constructor(private http: Http, private location: LocationService, private logger: Logger) { }
constructor(private http: Http, private location: LocationService, private logger: Logger) {
const navigationInfo = this.fetchNavigationInfo();
// The version information is packaged inside the navigation response to save us an extra request.
this.versionInfo = this.getVersionInfo(navigationInfo);
this.navigationViews = this.getNavigationViews(navigationInfo);
this.selectedNodes = this.getSelectedNodes(this.navigationViews);
}
/**
* Get an observable that fetches the `NavigationViews` from the server.
* Get an observable that fetches the `NavigationResponse` from the server.
* We create an observable by calling `http.get` but then publish it to share the result
* among multiple subscribers, without triggering new requests.
* We use `publishLast` because once the http request is complete the request observable completes.
@ -43,10 +77,22 @@ export class NavigationService {
* another request to the server.
* We are not storing the subscription from connecting as we do not expect this service to be destroyed.
*/
private fetchNavigationViews(): Observable<NavigationViews> {
const navigationViews = this.http.get(navigationPath)
.map(res => res.json() as NavigationViews)
private fetchNavigationInfo(): Observable<NavigationResponse> {
const navigationInfo = this.http.get(navigationPath)
.map(res => res.json() as NavigationResponse)
.publishLast();
navigationInfo.connect();
return navigationInfo;
}
private getVersionInfo(navigationInfo: Observable<NavigationResponse>) {
const versionInfo = navigationInfo.map(response => response['__versionInfo']).publishReplay(1);
versionInfo.connect();
return versionInfo;
}
private getNavigationViews(navigationInfo: Observable<NavigationResponse>) {
const navigationViews = navigationInfo.map(response => unpluck(response, '__versionInfo')).publishReplay(1);
navigationViews.connect();
return navigationViews;
}
@ -57,9 +103,9 @@ export class NavigationService {
* URL change before they receive an emission.
* See above for discussion of using `connect`.
*/
private getSelectedNodes() {
private getSelectedNodes(navigationViews: Observable<NavigationViews>) {
const selectedNodes = combineLatest(
this.navigationViews.map(this.computeUrlToNodesMap),
navigationViews.map(this.computeUrlToNodesMap),
this.location.currentUrl,
(navMap, url) => navMap[url] || [])
.publishReplay(1);
@ -74,7 +120,7 @@ export class NavigationService {
* @param navigation A collection of navigation nodes that are to be mapped
*/
private computeUrlToNodesMap(navigation: NavigationViews) {
const navMap = {};
const navMap: NavigationMap = {};
Object.keys(navigation).forEach(key => navigation[key].forEach(node => walkNodes(node)));
return navMap;
@ -90,3 +136,9 @@ export class NavigationService {
}
}
}
function unpluck(obj: any, property: string) {
const result = Object.assign({}, obj);
delete result[property];
return result;
}

View File

@ -162,3 +162,7 @@
.level-1:not(.expanded) .material-icons, .level-2:not(.expanded) .material-icons {
@include rotate(0deg);
}
.version-info {
border: 3px $blue solid;
}

View File

@ -50,10 +50,13 @@ module.exports =
.processor(require('./processors/filterPrivateDocs'))
.processor(require('./processors/filterIgnoredDocs'))
.processor(require('./processors/fixInternalDocumentLinks'))
.processor(require('./processors/processNavigationMap'))
// overrides base packageInfo and returns the one for the 'angular/angular' repo.
.factory('packageInfo', function() { return require(path.resolve(PROJECT_ROOT, 'package.json')); })
.factory(require('./readers/navigation'))
.config(function(checkAnchorLinksProcessor, log) {
// TODO: re-enable
checkAnchorLinksProcessor.$enabled = false;
@ -61,12 +64,13 @@ module.exports =
// Where do we get the source files?
.config(function(
readTypeScriptModules, readFilesProcessor, collectExamples, generateKeywordsProcessor) {
readTypeScriptModules, readFilesProcessor, collectExamples, generateKeywordsProcessor, navigationFileReader) {
// API files are typescript
readTypeScriptModules.basePath = API_SOURCE_PATH;
readTypeScriptModules.ignoreExportsMatching = [/^_/];
readTypeScriptModules.hidePrivateMembers = true;
readFilesProcessor.fileReaders.push(navigationFileReader)
readTypeScriptModules.sourceFiles = [
'common/index.ts',
'common/testing/index.ts',
@ -117,6 +121,11 @@ module.exports =
include: CONTENTS_PATH + '/examples/**/*',
fileReader: 'exampleFileReader'
},
{
basePath: CONTENTS_PATH,
include: CONTENTS_PATH + '/navigation.json',
fileReader: 'navigationFileReader'
},
];
collectExamples.exampleFolders = ['examples', 'examples'];
@ -242,7 +251,8 @@ module.exports =
outputPathTemplate: '${path}'
},
{docTypes: ['example-region'], getOutputPath: function() {}},
{docTypes: ['content'], pathTemplate: '${id}', outputPathTemplate: '${path}.json'}
{docTypes: ['content'], pathTemplate: '${id}', outputPathTemplate: '${path}.json'},
{docTypes: ['navigation-map'], pathTemplate: '${id}', outputPathTemplate: '../${id}.json'}
];
})

View File

@ -0,0 +1,50 @@
module.exports = function processNavigationMap(versionInfo, log) {
return {
$runAfter: ['paths-computed'],
$runBefore: ['rendering-docs'],
$process: function(docs) {
const navigationDoc = docs.find(doc => doc.docType === 'navigation-map');
if (!navigationDoc) {
throw new Error(
'Missing navigation map document (docType="navigation-map").' +
'Did you forget to add it to the readFileProcessor?');
}
// Verify that all the navigation paths are to valid docs
const pathMap = {};
docs.forEach(doc => pathMap[doc.path] = true);
const errors = walk(navigationDoc.data, pathMap, []);
if (errors.length) {
log.error(`Navigation doc: ${navigationDoc.fileInfo.relativePath} contains invalid urls`);
console.log(errors);
// TODO(petebd): fail if there are errors: throw new Error('processNavigationMap failed');
}
// Add in the version data in a "secret" field to be extracted in the docs app
navigationDoc.data['__versionInfo'] = versionInfo.currentVersion;
}
}
};
function walk(node, map, path) {
let errors = [];
for(const key in node) {
const child = node[key];
if (key === 'url') {
const url = child.replace(/#.*$/, ''); // strip hash
if (isRelative(url) && !map[url]) {
errors.push({ path: path.join('.'), url });
}
} else if (typeof child !== 'string') {
errors = errors.concat(walk(child, map, path.concat([key])));
}
}
return errors;
}
function isRelative(url) {
return !/^(https?:)?\/\//.test(url);
}

View File

@ -0,0 +1,19 @@
/**
* Read in the navigation JSON
*/
module.exports = function navigationFileReader() {
return {
name: 'navigationFileReader',
getDocs: function(fileInfo) {
// We return a single element array because content files only contain one document
return [{
docType: 'navigation-map',
data: JSON.parse(fileInfo.content),
template: 'json-doc.template.json',
id: 'navigation',
aliases: ['navigation', 'navigation.json']
}];
}
};
};