diff --git a/aio/.gitignore b/aio/.gitignore index 0a503cdc1e..2f721086ca 100644 --- a/aio/.gitignore +++ b/aio/.gitignore @@ -8,5 +8,5 @@ yarn-error.log # Ignore generated content /dist /tmp -/src/content/docs +/src/content /.sass-cache diff --git a/aio/src/content/navigation.json b/aio/content/navigation.json similarity index 100% rename from aio/src/content/navigation.json rename to aio/content/navigation.json diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index b65a14ec00..9f17aa2949 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -8,6 +8,7 @@ +
{{ (versionInfo | async)?.full }}
@@ -16,5 +17,4 @@ -
\ No newline at end of file diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 688d7a6cee..8e63ac7cf9 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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; navigationViews: Observable; selectedNodes: Observable; + versionInfo: Observable; @ViewChildren('searchBox, searchResults', { read: ElementRef }) searchElements: QueryList; @@ -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() { diff --git a/aio/src/app/navigation/navigation.service.spec.ts b/aio/src/app/navigation/navigation.service.spec.ts index e2aae81f9f..104a4fe821 100644 --- a/aio/src/app/navigation/navigation.service.spec.ts +++ b/aio/src/app/navigation/navigation.service.spec.ts @@ -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' }); + }); + }); }); diff --git a/aio/src/app/navigation/navigation.service.ts b/aio/src/app/navigation/navigation.service.ts index de5d4a500a..3b7db011af 100644 --- a/aio/src/app/navigation/navigation.service.ts +++ b/aio/src/app/navigation/navigation.service.ts @@ -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; + + /** + * The current version of doc-app that we are running + */ + versionInfo: Observable; + /** * An observable array of nodes that indicate which nodes in the `navigationViews` match the current URL location */ - selectedNodes = this.getSelectedNodes(); + selectedNodes: Observable; - 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 { - const navigationViews = this.http.get(navigationPath) - .map(res => res.json() as NavigationViews) + private fetchNavigationInfo(): Observable { + const navigationInfo = this.http.get(navigationPath) + .map(res => res.json() as NavigationResponse) .publishLast(); + navigationInfo.connect(); + return navigationInfo; + } + + private getVersionInfo(navigationInfo: Observable) { + const versionInfo = navigationInfo.map(response => response['__versionInfo']).publishReplay(1); + versionInfo.connect(); + return versionInfo; + } + + private getNavigationViews(navigationInfo: Observable) { + 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) { 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; +} diff --git a/aio/src/styles/1-layouts/_sidenav.scss b/aio/src/styles/1-layouts/_sidenav.scss index 7bdb1654d7..26637827b1 100644 --- a/aio/src/styles/1-layouts/_sidenav.scss +++ b/aio/src/styles/1-layouts/_sidenav.scss @@ -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; +} \ No newline at end of file diff --git a/aio/transforms/angular.io-package/index.js b/aio/transforms/angular.io-package/index.js index da97986215..757ebbfe36 100644 --- a/aio/transforms/angular.io-package/index.js +++ b/aio/transforms/angular.io-package/index.js @@ -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'} ]; }) diff --git a/aio/transforms/angular.io-package/processors/processNavigationMap.js b/aio/transforms/angular.io-package/processors/processNavigationMap.js new file mode 100644 index 0000000000..e625f9d683 --- /dev/null +++ b/aio/transforms/angular.io-package/processors/processNavigationMap.js @@ -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); +} \ No newline at end of file diff --git a/aio/transforms/angular.io-package/readers/navigation.js b/aio/transforms/angular.io-package/readers/navigation.js new file mode 100644 index 0000000000..43a82f19bb --- /dev/null +++ b/aio/transforms/angular.io-package/readers/navigation.js @@ -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'] + }]; + } + }; +}; \ No newline at end of file