From 2e1413016efe6a4b8c05a545779667fc020159b8 Mon Sep 17 00:00:00 2001 From: Ward Bell Date: Tue, 7 Feb 2017 12:57:18 -0800 Subject: [PATCH] feat(aio): refactor DocViewer w/ services component builder (#14294) Adds basic functionality to fetch documents and display them based on the v42 prototype https://github.com/igorMinar/angular-io-v42 --- angular.io/e2e/app.e2e-spec.ts | 17 + angular.io/e2e/app.po.ts | 9 +- angular.io/src/app/app.component.html | 1 + angular.io/src/app/app.component.spec.ts | 2 - angular.io/src/app/app.module.ts | 20 +- .../doc-viewer/doc-viewer.component.spec.ts | 298 +++++++++++++++++- .../app/doc-viewer/doc-viewer.component.ts | 124 +++++++- .../embedded/code-example.component.spec.ts | 28 ++ .../app/embedded/code-example.component.ts | 73 +++++ angular.io/src/app/embedded/index.ts | 11 + angular.io/src/app/logger.service.ts | 17 + .../nav-engine/doc-fetching.service.spec.ts | 2 + .../app/nav-engine/doc-fetching.service.ts | 45 +++ angular.io/src/app/nav-engine/doc.model.ts | 10 + .../src/app/nav-engine/doc.service.spec.ts | 74 +++++ angular.io/src/app/nav-engine/doc.service.ts | 60 ++++ angular.io/src/app/nav-engine/index.ts | 18 ++ .../app/nav-engine/nav-engine.service.spec.ts | 46 +++ .../src/app/nav-engine/nav-engine.service.ts | 39 +-- .../app/nav-engine/sitemap.service.spec.ts | 32 ++ .../src/app/nav-engine/sitemap.service.ts | 38 +++ 21 files changed, 915 insertions(+), 49 deletions(-) create mode 100644 angular.io/src/app/embedded/code-example.component.spec.ts create mode 100644 angular.io/src/app/embedded/code-example.component.ts create mode 100644 angular.io/src/app/embedded/index.ts create mode 100644 angular.io/src/app/logger.service.ts create mode 100644 angular.io/src/app/nav-engine/doc-fetching.service.spec.ts create mode 100644 angular.io/src/app/nav-engine/doc-fetching.service.ts create mode 100644 angular.io/src/app/nav-engine/doc.model.ts create mode 100644 angular.io/src/app/nav-engine/doc.service.spec.ts create mode 100644 angular.io/src/app/nav-engine/doc.service.ts create mode 100644 angular.io/src/app/nav-engine/index.ts create mode 100644 angular.io/src/app/nav-engine/nav-engine.service.spec.ts create mode 100644 angular.io/src/app/nav-engine/sitemap.service.spec.ts create mode 100644 angular.io/src/app/nav-engine/sitemap.service.ts diff --git a/angular.io/e2e/app.e2e-spec.ts b/angular.io/e2e/app.e2e-spec.ts index 552fd75ce5..3e70eff31d 100644 --- a/angular.io/e2e/app.e2e-spec.ts +++ b/angular.io/e2e/app.e2e-spec.ts @@ -3,6 +3,13 @@ import { SitePage } from './app.po'; describe('site App', function() { let page: SitePage; + beforeAll(done => { + // Hack: CI has been failing on first test so + // buying time by giving the browser a wake-up call. + // Todo: Find and fix the root cause for flakes. + new SitePage().navigateTo().then(done); + }); + beforeEach(() => { page = new SitePage(); }); @@ -16,4 +23,14 @@ describe('site App', function() { expect(page.getDocViewerText()).toContain('Progressive web apps'); }); }); + + it('should convert code-example in pipe.html', () => { + page.navigateTo() + .then(() => { + return page.datePipeLink.click(); + }) + .then(() => { + expect(page.codeExample.count()).toBeGreaterThan(0, 'should have code-example content'); + }); + }); }); diff --git a/angular.io/e2e/app.po.ts b/angular.io/e2e/app.po.ts index 4d6ce57dee..7811690ab7 100644 --- a/angular.io/e2e/app.po.ts +++ b/angular.io/e2e/app.po.ts @@ -2,13 +2,18 @@ import { browser, element, by } from 'protractor'; export class SitePage { - featureLink = element(by.css('md-toolbar a[aioNavLink=features]')); + links = element.all(by.css('md-toolbar a')); + datePipeLink = element(by.css('md-toolbar a[aioNavLink="docs/api/common/date-pipe"]')); + docViewer = element(by.css('aio-doc-viewer')); + codeExample = element.all(by.css('aio-doc-viewer code-example > pre > code')); + featureLink = element(by.css('md-toolbar a[aioNavLink="features"]')); navigateTo() { return browser.get('/'); } getDocViewerText() { - return element(by.css('aio-doc-viewer')).getText(); + return this.docViewer.getText(); } + } diff --git a/angular.io/src/app/app.component.html b/angular.io/src/app/app.component.html index 118fa24c4c..62bb13df22 100644 --- a/angular.io/src/app/app.component.html +++ b/angular.io/src/app/app.component.html @@ -3,6 +3,7 @@ Home News Features + DatePipe
diff --git a/angular.io/src/app/app.component.spec.ts b/angular.io/src/app/app.component.spec.ts index e83e1d3138..f3513f6f5c 100644 --- a/angular.io/src/app/app.component.spec.ts +++ b/angular.io/src/app/app.component.spec.ts @@ -1,5 +1,3 @@ -// /* tslint:disable:no-unused-variable */ - import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { AppComponent } from './app.component'; import { AppModule } from './app.module'; diff --git a/angular.io/src/app/app.module.ts b/angular.io/src/app/app.module.ts index a2b5a87ce5..491bb9dcdf 100644 --- a/angular.io/src/app/app.module.ts +++ b/angular.io/src/app/app.module.ts @@ -1,25 +1,35 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; +import { HttpModule } from '@angular/http'; import { MdToolbarModule } from '@angular/material/toolbar'; import { MdButtonModule} from '@angular/material/button'; import { AppComponent } from './app.component'; import { DocViewerComponent } from './doc-viewer/doc-viewer.component'; -import { NavEngine } from './nav-engine/nav-engine.service'; -import { NavLinkDirective } from './nav-engine/nav-link.directive'; +import { embeddedComponents, EmbeddedComponents } from './embedded'; +import { Logger } from './logger.service'; +import { navDirectives, navProviders } from './nav-engine'; @NgModule({ imports: [ BrowserModule, + HttpModule, MdToolbarModule.forRoot(), MdButtonModule.forRoot() ], declarations: [ AppComponent, + embeddedComponents, DocViewerComponent, - NavLinkDirective + navDirectives, ], - providers: [NavEngine], + providers: [ + EmbeddedComponents, + Logger, + navProviders + ], + entryComponents: [ embeddedComponents ], bootstrap: [AppComponent] }) -export class AppModule { } +export class AppModule { +} diff --git a/angular.io/src/app/doc-viewer/doc-viewer.component.spec.ts b/angular.io/src/app/doc-viewer/doc-viewer.component.spec.ts index 52dfe9fbdf..8918eb109b 100644 --- a/angular.io/src/app/doc-viewer/doc-viewer.component.spec.ts +++ b/angular.io/src/app/doc-viewer/doc-viewer.component.spec.ts @@ -1,28 +1,308 @@ -/* tslint:disable:no-unused-variable */ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; +import { Component, DebugElement } from '@angular/core'; -import { DocViewerComponent } from './doc-viewer.component'; +import { ComponentFactoryResolver, ElementRef, Injector, NgModule, OnInit, ViewChild } from '@angular/core'; + +import { Doc, DocMetadata } from '../nav-engine'; +import { DocViewerComponent } from '../doc-viewer/doc-viewer.component'; + +import { embeddedComponents, EmbeddedComponents } from '../embedded'; + + +/// Embedded Test Components /// + +///// FooComponent ///// + +@Component({ + selector: 'aio-foo', + template: `Foo Component` +}) +class FooComponent { + +} + +///// BarComponent ///// + +@Component({ + selector: 'aio-bar', + template: ` +
+

Bar Component

+

+
+ ` +}) +class BarComponent implements OnInit { + + @ViewChild('barContent') barContentRef: ElementRef; + + constructor(public elementRef: ElementRef) { } + + // Project content in ngOnInit just like CodeExampleComponent + ngOnInit() { + // Security: this is a test component; never deployed + this.barContentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioBarContent; + } +} + +///// BazComponent ///// + +@Component({ + selector: 'aio-baz', + template: ` +
++++++++++++++
+

Baz Component

+

+
++++++++++++++
+ ` +}) +class BazComponent implements OnInit { + + @ViewChild('bazContent') bazContentRef: ElementRef; + + constructor(public elementRef: ElementRef) { } + + // Project content in ngOnInit just like CodeExampleComponent + ngOnInit() { + // Security: this is a test component; never deployed + this.bazContentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioBazContent; + } +} +///// Test Module ////// + +const embeddedTestComponents = [FooComponent, BarComponent, BazComponent, ...embeddedComponents]; + +@NgModule({ + entryComponents: embeddedTestComponents +}) +class TestModule { } + +//// Test Component ////// + +@Component({ + selector: 'aio-test', + template: ` + Test Component + ` +}) +class TestComponent { + private currentDoc: Doc; + + @ViewChild(DocViewerComponent) docViewer: DocViewerComponent; + + setDoc(doc: Doc) { + if (this.docViewer) { + this.docViewer.doc = doc; + } + } +} + +//////// Tests ////////////// describe('DocViewerComponent', () => { - let component: DocViewerComponent; - let fixture: ComponentFixture; + const mockDocMetadata: DocMetadata = { id: 'mock', title: 'Mock Doc', url: '' }; + let component: TestComponent; + let docViewerDE: DebugElement; + let docViewerEl: HTMLElement; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ DocViewerComponent ] + imports: [ TestModule ], + declarations: [ + TestComponent, + DocViewerComponent, + embeddedTestComponents + ], + providers: [ + {provide: EmbeddedComponents, useValue: {components: embeddedTestComponents}} + ] }) .compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(DocViewerComponent); + fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; fixture.detectChanges(); + docViewerDE = fixture.debugElement.children[0]; + docViewerEl = docViewerDE.nativeElement; }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should create a DocViewer', () => { + expect(component.docViewer).toBeTruthy(); + }); + + it(('should display nothing when set DocViewer.doc to doc w/o content'), () => { + component.docViewer.doc = { metadata: mockDocMetadata, content: '' }; + expect(docViewerEl.innerHTML).toBe(''); + }); + + it(('should display simple static content doc'), () => { + const content = '

Howdy, doc viewer

'; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + expect(docViewerEl.innerHTML).toEqual(content); + }); + + it(('should display nothing after reset static content doc'), () => { + const content = '

Howdy, doc viewer

'; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + fixture.detectChanges(); + component.docViewer.doc = { metadata: mockDocMetadata, content: '' }; + expect(docViewerEl.innerHTML).toEqual(''); + }); + + it(('should apply FooComponent'), () => { + const content = ` +

Above Foo

+

+

Below Foo

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML; + expect(fooHtml).toContain('Foo Component'); + }); + + it(('should apply multiple FooComponents'), () => { + const content = ` +

Above Foo

+

+
+ Holds a + Ignored text +
+

Below Foo

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + const foos = docViewerEl.querySelectorAll('aio-foo'); + expect(foos.length).toBe(2); + }); + + it(('should apply BarComponent'), () => { + const content = ` +

Above Bar

+ +

Below Bar

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + const barHtml = docViewerEl.querySelector('aio-bar').innerHTML; + expect(barHtml).toContain('Bar Component'); + }); + + it(('should project bar content into BarComponent'), () => { + const content = ` +

Above Bar

+ ###bar content### +

Below Bar

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + + // necessary to trigger projection within ngOnInit + fixture.detectChanges(); + + const barHtml = docViewerEl.querySelector('aio-bar').innerHTML; + expect(barHtml).toContain('###bar content###'); + }); + + + it(('should include Foo and Bar'), () => { + const content = ` +

Top

+

ignored

+ ###bar content### +

+

Bottom

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + + // necessary to trigger Bar's projection within ngOnInit + fixture.detectChanges(); + + const foos = docViewerEl.querySelectorAll('aio-foo'); + expect(foos.length).toBe(2, 'should have 2 foos'); + + const barHtml = docViewerEl.querySelector('aio-bar').innerHTML; + expect(barHtml).toContain('###bar content###', 'should have bar with projected content'); + }); + + it(('should not include Bar within Foo'), () => { + const content = ` +

Top

+
+ + ###bar content### + +
+

+

Bottom

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + + // necessary to trigger Bar's projection within ngOnInit + fixture.detectChanges(); + + const foos = docViewerEl.querySelectorAll('aio-foo'); + expect(foos.length).toBe(2, 'should have 2 foos'); + + const bars = docViewerEl.querySelectorAll('aio-bar'); + expect(bars.length).toBe(0, 'did not expect Bar inside Foo'); + }); + + // because FooComponents are processed before BazComponents + it(('should include Foo within Bar'), () => { + const content = ` +

Top

+ +
+ Inner +
+
+

+

Bottom

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + + // necessary to trigger Bar's projection within ngOnInit + fixture.detectChanges(); + + const foos = docViewerEl.querySelectorAll('aio-foo'); + expect(foos.length).toBe(2, 'should have 2 foos'); + + const bars = docViewerEl.querySelectorAll('aio-bar'); + expect(bars.length).toBe(1, 'should have a bar'); + expect(bars[0].innerHTML).toContain('Bar Component', 'should have bar template content'); + }); + + // The tag and its inner content is copied + // But the BazComponent is not created and therefore its template content is not displayed + // because BarComponents are processed before BazComponents + // and no chance for first Baz inside Bar to be processed by builder. + it(('should NOT include Bar within Baz'), () => { + const content = ` +

Top

+ +
+ Inner ---baz stuff--- +
+
+

---More baz--

+

Bottom

+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + + // necessary to trigger Bar's projection within ngOnInit + fixture.detectChanges(); + const bazs = docViewerEl.querySelectorAll('aio-baz'); + + // Both baz tags are there ... + expect(bazs.length).toBe(2, 'should have 2 bazs'); + + expect(bazs[0].innerHTML).not.toContain('Baz Component', + 'did not expect 1st Baz template content'); + + expect(bazs[1].innerHTML).toContain('Baz Component', + 'expected 2nd Baz template content'); + }); }); diff --git a/angular.io/src/app/doc-viewer/doc-viewer.component.ts b/angular.io/src/app/doc-viewer/doc-viewer.component.ts index 8314cf77f1..b46783cc9b 100644 --- a/angular.io/src/app/doc-viewer/doc-viewer.component.ts +++ b/angular.io/src/app/doc-viewer/doc-viewer.component.ts @@ -1,4 +1,19 @@ -import { Component, Input, ElementRef, ViewEncapsulation } from '@angular/core'; +import { + Component, ComponentFactory, ComponentFactoryResolver, ComponentRef, + DoCheck, ElementRef, Injector, Input, OnDestroy, ViewEncapsulation +} from '@angular/core'; + +import { Doc, DocMetadata } from '../nav-engine'; +import { EmbeddedComponents } from '../embedded'; + +interface EmbeddedComponentFactory { + contentPropertyName: string; + factory: ComponentFactory; +} + +// Initialization prevents flicker once pre-rendering is on +const initialDocViewerElement = document.querySelector('aio-doc-viewer'); +const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElement.innerHTML : ''; @Component({ selector: 'aio-doc-viewer', @@ -6,15 +21,110 @@ import { Component, Input, ElementRef, ViewEncapsulation } from '@angular/core'; // TODO(robwormald): shadow DOM and emulated don't work here (?!) // encapsulation: ViewEncapsulation.Native }) -export class DocViewerComponent { +export class DocViewerComponent implements DoCheck, OnDestroy { - @Input() - set doc(currentDoc) { - if (currentDoc) { - this.element.nativeElement.innerHTML = currentDoc.content; + private displayedDoc: DisplayedDoc; + private embeddedComponentFactories: Map = new Map(); + private hostElement: HTMLElement; + + constructor( + componentFactoryResolver: ComponentFactoryResolver, + elementRef: ElementRef, + embeddedComponents: EmbeddedComponents, + private injector: Injector, + ) { + this.hostElement = elementRef.nativeElement; + // Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure + this.hostElement.innerHTML = initialDocViewerContent; + + for (const component of embeddedComponents.components) { + const factory = componentFactoryResolver.resolveComponentFactory(component); + const selector = factory.selector; + const contentPropertyName = this.selectorToContentPropertyName(selector); + this.embeddedComponentFactories.set(selector, { contentPropertyName, factory }); } } - constructor(private element: ElementRef) { } + @Input() + set doc(newDoc: Doc) { + this.ngOnDestroy(); + if (newDoc) { + window.scrollTo(0, 0); + this.build(newDoc); + } + } + /** + * Add doc content to host element and build it out with embedded components + */ + private build(doc: Doc) { + + const displayedDoc = this.displayedDoc = new DisplayedDoc(doc); + + // security: the doc.content is always authored by the documentation team + // and is considered to be safe + this.hostElement.innerHTML = doc.content || ''; + + if (!doc.content) { return; } + + // TODO(i): why can't I use for-of? why doesn't typescript like Map#value() iterators? + this.embeddedComponentFactories.forEach(({ contentPropertyName, factory }, selector) => { + const embeddedComponentElements = this.hostElement.querySelectorAll(selector); + + // cast due to https://github.com/Microsoft/TypeScript/issues/4947 + for (const element of embeddedComponentElements as any as HTMLElement[]){ + // hack: preserve the current element content because the factory will empty it out + // security: the source of this innerHTML is always authored by the documentation team + // and is considered to be safe + element[contentPropertyName] = element.innerHTML; + displayedDoc.addEmbeddedComponent(factory.create(this.injector, [], element)); + } + }); + } + + ngDoCheck() { + if (this.displayedDoc) { this.displayedDoc.detectChanges(); } + } + + ngOnDestroy() { + // destroy components otherwise there will be memory leaks + if (this.displayedDoc) { + this.displayedDoc.destroy(); + this.displayedDoc = undefined; + } + } + + /** + * Compute the component content property name by converting the selector to camelCase and appending + * 'Content', e.g. live-example => liveExampleContent + */ + private selectorToContentPropertyName(selector: string) { + return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content'; + } +} + +class DisplayedDoc { + + metadata: DocMetadata; + + private embeddedComponents: ComponentRef[] = []; + + constructor(doc: Doc) { + // ignore doc.content ... don't need to keep it around + this.metadata = doc.metadata; + } + + addEmbeddedComponent(component: ComponentRef) { + this.embeddedComponents.push(component); + } + + detectChanges() { + this.embeddedComponents.forEach(comp => comp.changeDetectorRef.detectChanges()); + } + + destroy() { + // destroy components otherwise there will be memory leaks + this.embeddedComponents.forEach(comp => comp.destroy()); + this.embeddedComponents.length = 0; + } } diff --git a/angular.io/src/app/embedded/code-example.component.spec.ts b/angular.io/src/app/embedded/code-example.component.spec.ts new file mode 100644 index 0000000000..4c1d154a32 --- /dev/null +++ b/angular.io/src/app/embedded/code-example.component.spec.ts @@ -0,0 +1,28 @@ +/* tslint:disable:no-unused-variable */ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; + +import { CodeExampleComponent } from './code-example.component'; + +describe('CodeExampleComponent', () => { + let component: CodeExampleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ CodeExampleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CodeExampleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/angular.io/src/app/embedded/code-example.component.ts b/angular.io/src/app/embedded/code-example.component.ts new file mode 100644 index 0000000000..7b79bc736a --- /dev/null +++ b/angular.io/src/app/embedded/code-example.component.ts @@ -0,0 +1,73 @@ +/* tslint:disable component-selector */ + +import { Component, OnInit, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; + +// TODO(i): add clipboard copy functionality + +/** + * Angular.io Code Example + * + * Pretty renders a code block, primarily used in the docs and API reference. Can be used within an Angular app, or + * independently, provided that it is dynamically generated by the component resolver. + * + * Usage: + * + * console.log('Hello World') + * + */ +@Component({ + selector: 'code-example', + template: '
' +}) +export class CodeExampleComponent implements OnInit, AfterViewInit { + + @ViewChild('codeContainer') codeContainerRef: ElementRef; + + language: string; // could be javascript, dart, typescript + // TODO(i): escape doesn't seem to be currently supported in the original code + escape: string; // could be 'html' + format: string; // some css class + showcase: string; // a string with the value 'true' + animated = false; + + // TODO(i): could we use @HostBinding instead or does the CSS have to be scoped to
 and 
+  classes: string;
+  animatedClasses: string;
+
+
+  constructor(private elementRef: ElementRef) {
+    // TODO(i): @Input should be supported for host elements and should just do a one off initialization of properties
+    //          from the host element => talk to Tobias
+    ['language', 'escape', 'format', 'showcase', 'animated'].forEach(inputName => {
+      if (!this[inputName]) {
+        this[inputName] = this.elementRef.nativeElement.getAttribute(inputName);
+      }
+    });
+  }
+
+
+  ngOnInit() {
+    const showcaseClass = this.showcase === 'true' ? ' is-showcase' : '';
+    this.classes = `
+      prettyprint
+      ${this.format ? this.format : ''}
+      ${this.language ? 'lang-' + this.language : '' }
+      ${showcaseClass ? showcaseClass : ''}
+    `.trim();
+
+    this.animatedClasses = `${this.animated ? 'animated fadeIn' : ''}`;
+
+    // Security: the codeExampleContent is the original innerHTML of the host element provided by
+    //           docs authors and as such its considered to be safe for innerHTML purposes
+    this.codeContainerRef.nativeElement.innerHTML = this.elementRef.nativeElement.codeExampleContent;
+  }
+
+
+  ngAfterViewInit() {
+    // TODO(i): import prettify.js from this file so that we don't need to preload it via index.html
+    // whenever a code example is used, use syntax highlighting.
+    // if(prettyPrint) {
+    //   prettyPrint();
+    // }
+  }
+}
diff --git a/angular.io/src/app/embedded/index.ts b/angular.io/src/app/embedded/index.ts
new file mode 100644
index 0000000000..ac4cdc52ed
--- /dev/null
+++ b/angular.io/src/app/embedded/index.ts
@@ -0,0 +1,11 @@
+import { CodeExampleComponent } from './code-example.component';
+
+/** Components that can be embedded in docs such as CodeExampleComponent, LiveExampleComponent,... */
+export const embeddedComponents = [
+  CodeExampleComponent
+];
+
+/** Injectable class w/ property returning components that can be embedded in docs */
+export class EmbeddedComponents {
+  components = embeddedComponents;
+}
diff --git a/angular.io/src/app/logger.service.ts b/angular.io/src/app/logger.service.ts
new file mode 100644
index 0000000000..900de146c1
--- /dev/null
+++ b/angular.io/src/app/logger.service.ts
@@ -0,0 +1,17 @@
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class Logger {
+
+  log(value: any, ...rest) {
+    console.log(value, ...rest);
+  }
+
+  error(value: any, ...rest) {
+    console.error(value, ...rest);
+  }
+
+  warn(value: any, ...rest) {
+    console.warn(value, ...rest);
+  }
+}
diff --git a/angular.io/src/app/nav-engine/doc-fetching.service.spec.ts b/angular.io/src/app/nav-engine/doc-fetching.service.spec.ts
new file mode 100644
index 0000000000..9b69019df8
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc-fetching.service.spec.ts
@@ -0,0 +1,2 @@
+import { DocFetchingService } from './doc-fetching.service';
+// Write tests when/if this service is retained.
diff --git a/angular.io/src/app/nav-engine/doc-fetching.service.ts b/angular.io/src/app/nav-engine/doc-fetching.service.ts
new file mode 100644
index 0000000000..827263ca04
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc-fetching.service.ts
@@ -0,0 +1,45 @@
+import { Http, Response } from '@angular/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/catch';
+import 'rxjs/add/operator/do';
+import 'rxjs/add/operator/map';
+
+import { Logger } from '../logger.service';
+
+@Injectable()
+export class DocFetchingService {
+
+  constructor(private http: Http, private logger: Logger) { }
+
+  /**
+   * Fetch document from server.
+   * NB: pass 404 response to caller as empty string content
+   * Other errors and non-OK status responses are thrown errors.
+   * TODO: add timeout and retry for lost connection
+   */
+  getFile(url: string): Observable {
+
+    if (!url) {
+      const emsg = 'getFile: no URL';
+      this.logger.error(emsg);
+      throw new Error(emsg);
+    }
+
+    this.logger.log('fetching document file at ', url);
+
+    return this.http.get(url)
+      .map(res => res.text())
+      .do(content => this.logger.log('fetched document file at ', url) )
+      .catch((error: Response) => {
+        if (error.status === 404) {
+          this.logger.error(`Document file not found at '$(url)'`);
+          return of('');
+        } else {
+          throw error;
+        }
+      });
+  }
+}
diff --git a/angular.io/src/app/nav-engine/doc.model.ts b/angular.io/src/app/nav-engine/doc.model.ts
new file mode 100644
index 0000000000..6833ff348f
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc.model.ts
@@ -0,0 +1,10 @@
+export interface DocMetadata {
+  id: string;    // 'home'
+  title: string; // 'Home'
+  url: string;   // 'assets/documents/home.html'
+}
+
+export interface Doc {
+  metadata: DocMetadata;
+  content: string;
+}
diff --git a/angular.io/src/app/nav-engine/doc.service.spec.ts b/angular.io/src/app/nav-engine/doc.service.spec.ts
new file mode 100644
index 0000000000..1782f1ae97
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc.service.spec.ts
@@ -0,0 +1,74 @@
+import { fakeAsync, tick } from '@angular/core/testing';
+
+import { DocService } from './doc.service';
+import { Doc, DocMetadata } from './doc.model';
+import { DocFetchingService } from './doc-fetching.service';
+import { SiteMapService } from './sitemap.service';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/catch';
+import 'rxjs/add/operator/delay';
+
+describe('DocService', () => {
+  let docFetchingService: DocFetchingService;
+  let getFileSpy: jasmine.Spy;
+  let loggerSpy: any;
+  let siteMapService: SiteMapService;
+  let docService: DocService;
+
+  beforeEach(() => {
+
+    this.content = 'fake file contents';
+    this.metadata = {
+      id: 'fake',
+      title: 'All about the fake',
+      url: 'assets/documents/fake.html'
+    };
+
+    loggerSpy = jasmine.createSpyObj('logger', ['log', 'warn', 'error']);
+    siteMapService = new SiteMapService();
+    spyOn(siteMapService, 'getDocMetadata').and
+      .callFake((id: string) => of(this.metadata).delay(0));
+
+    docFetchingService = new DocFetchingService(null, loggerSpy);
+    getFileSpy = spyOn(docFetchingService, 'getFile').and
+      .callFake((url: string) => of(this.content).delay(0));
+
+    docService = new DocService(docFetchingService, loggerSpy, siteMapService);
+  });
+
+  it('should return fake doc for fake id', fakeAsync(() => {
+    docService.getDoc('fake').subscribe(doc =>
+      expect(doc.content).toBe(this.content)
+    );
+    tick();
+  }));
+
+  it('should retrieve file once for first file request', fakeAsync(() => {
+    docService.getDoc('fake').subscribe();
+    expect(getFileSpy.calls.count()).toBe(0, 'no call before tick');
+    tick();
+    expect(getFileSpy.calls.count()).toBe(1, 'one call after tick');
+  }));
+
+  it('should retrieve file from cache the second time', fakeAsync(() => {
+    docService.getDoc('fake').subscribe();
+    tick();
+    expect(getFileSpy.calls.count()).toBe(1, 'one call after 1st request');
+
+    docService.getDoc('fake').subscribe();
+    tick();
+    expect(getFileSpy.calls.count()).toBe(1, 'still only one call after 2nd request');
+  }));
+
+  it('should pass along file error through its getDoc observable result', fakeAsync(() => {
+    const err = 'deliberate file error';
+    getFileSpy.and.throwError(err);
+    docService.getDoc('fake').subscribe(
+      doc => expect(false).toBe(true, 'should have failed'),
+      error => expect(error.message).toBe(err)
+    );
+    tick();
+  }));
+});
diff --git a/angular.io/src/app/nav-engine/doc.service.ts b/angular.io/src/app/nav-engine/doc.service.ts
new file mode 100644
index 0000000000..4cc1de54c5
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc.service.ts
@@ -0,0 +1,60 @@
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/switchMap';
+
+import { Doc, DocMetadata } from './doc.model';
+import { DocFetchingService } from './doc-fetching.service';
+import { Logger } from '../logger.service';
+
+import { SiteMapService } from './sitemap.service';
+
+interface DocCache {
+  [index: string]: Doc;
+}
+
+@Injectable()
+export class DocService {
+  private cache: DocCache = {};
+
+  constructor(
+    private fileService: DocFetchingService,
+    private logger: Logger,
+    private siteMapService: SiteMapService
+    ) { }
+
+  /**
+   * Get document for documentId, from cache if found else server.
+   * Pass server errors along to caller
+   * Caller should interpret empty string content as "404 - file not found"
+   */
+  getDoc(documentId: string): Observable {
+    let doc = this.cache[documentId];
+    if (doc) {
+      this.logger.log('returned cached content for ', doc.metadata);
+      return of(cloneDoc(doc));
+    }
+
+    return this.siteMapService
+      .getDocMetadata(documentId)
+      .switchMap(metadata => {
+
+        return this.fileService.getFile(metadata.url)
+          .map(content => {
+            this.logger.log('fetched content for', metadata);
+            doc = { metadata, content };
+            this.cache[metadata.id] = doc;
+            return cloneDoc(doc);
+          });
+      });
+  }
+}
+
+function cloneDoc(doc: Doc) {
+  return {
+    metadata: Object.assign({}, doc.metadata),
+    content: doc.content
+  };
+}
diff --git a/angular.io/src/app/nav-engine/index.ts b/angular.io/src/app/nav-engine/index.ts
new file mode 100644
index 0000000000..b8ad833621
--- /dev/null
+++ b/angular.io/src/app/nav-engine/index.ts
@@ -0,0 +1,18 @@
+import { DocService } from './doc.service';
+import { DocFetchingService } from './doc-fetching.service';
+import { NavEngine } from './nav-engine.service';
+import { NavLinkDirective } from './nav-link.directive';
+import { SiteMapService } from './sitemap.service';
+
+export { Doc, DocMetadata } from './doc.model';
+
+export const navDirectives = [
+  NavLinkDirective
+];
+
+export const navProviders = [
+  DocService,
+  DocFetchingService,
+  NavEngine,
+  SiteMapService,
+];
diff --git a/angular.io/src/app/nav-engine/nav-engine.service.spec.ts b/angular.io/src/app/nav-engine/nav-engine.service.spec.ts
new file mode 100644
index 0000000000..f113cd03c2
--- /dev/null
+++ b/angular.io/src/app/nav-engine/nav-engine.service.spec.ts
@@ -0,0 +1,46 @@
+import { fakeAsync, tick} from '@angular/core/testing';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/delay';
+
+import { DocService } from './doc.service';
+import { Doc, DocMetadata } from './doc.model';
+
+import { NavEngine } from './nav-engine.service';
+
+const fakeDoc: Doc = {
+  metadata: {
+    id: 'fake',
+    title: 'All about the fake',
+    url: 'assets/documents/fake.html'
+  },
+  content: 'fake content'
+};
+
+describe('NavEngine', () => {
+
+  let navEngine: NavEngine;
+
+  beforeEach(() => {
+    this.fakeDoc = {
+      metadata: {
+        id: 'fake',
+        title: 'All about the fake',
+        url: 'assets/documents/fake.html'
+      },
+      content: 'fake content'
+    };
+
+    const docService: any = jasmine.createSpyObj('docService', ['getDoc']);
+    docService.getDoc.and.callFake((id: string) => of(this.fakeDoc).delay(0));
+
+    navEngine = new NavEngine(docService);
+  });
+
+  it('should set currentDoc to fake doc when navigate to fake id', fakeAsync(() => {
+    navEngine.navigate('fake');
+    tick();
+    expect(navEngine.currentDoc.content).toBe(this.fakeDoc.content);
+  }));
+});
diff --git a/angular.io/src/app/nav-engine/nav-engine.service.ts b/angular.io/src/app/nav-engine/nav-engine.service.ts
index 79493e694d..5bd3301320 100644
--- a/angular.io/src/app/nav-engine/nav-engine.service.ts
+++ b/angular.io/src/app/nav-engine/nav-engine.service.ts
@@ -1,33 +1,24 @@
-declare var fetch;
-
 import { Injectable } from '@angular/core';
 
-// TODO(robwormald): figure out how to handle this properly...
-const siteMap = [
-  { 'title': 'Home', 'url': 'assets/documents/home.html', id: 'home'},
-  { 'title': 'Features', 'url': 'assets/documents/features.html', id: 'features'},
-  { 'title': 'News', 'url': 'assets/documents/news.html', id: 'news'}
-];
+import { Doc } from './doc.model';
+import { DocService } from './doc.service';
 
 @Injectable()
 export class NavEngine {
-  currentDoc: any;
-  constructor() {}
-  navigate(documentId) {
-    console.log('navigating to', documentId);
-    const doc = siteMap.find(d => d.id === documentId);
-    if (doc) {
-      this.fetchDoc(doc.url)
-        .then(content => {
-          console.log('fetched content', content);
-          this.currentDoc = Object.assign({}, doc, {content});
-        });
-    }
-  }
 
-  private fetchDoc(url) {
-    // TODO(robwormald): use Http proper once new API is done.
-    return fetch(url).then(res => res.text());
+  /** Document result of most recent `navigate` call */
+  currentDoc: Doc;
+  constructor(private docService: DocService) {}
+
+  /**
+   * Navigate sets `currentDoc` to the document for `documentId`.
+   * TODO: handle 'Document not found', signaled by empty string content
+   * TODO: handle document retrieval error
+   */
+  navigate(documentId: string) {
+    this.docService.getDoc(documentId).subscribe(
+      doc => this.currentDoc = doc
+    );
   }
 }
 
diff --git a/angular.io/src/app/nav-engine/sitemap.service.spec.ts b/angular.io/src/app/nav-engine/sitemap.service.spec.ts
new file mode 100644
index 0000000000..9ad1555d1c
--- /dev/null
+++ b/angular.io/src/app/nav-engine/sitemap.service.spec.ts
@@ -0,0 +1,32 @@
+import { fakeAsync, tick } from '@angular/core/testing';
+import { DocMetadata } from './doc.model';
+import { SiteMapService } from './sitemap.service';
+
+describe('SiteMapService', () => {
+  let siteMapService: SiteMapService;
+
+  beforeEach(() => {
+    siteMapService = new SiteMapService();
+  });
+
+  it('should get News metadata', fakeAsync(() => {
+    siteMapService.getDocMetadata('news').subscribe(
+      metadata => expect(metadata.url).toBe('assets/documents/news.html')
+    );
+    tick();
+  }));
+
+  it('should calculate expected doc url for unknown id', fakeAsync(() => {
+    siteMapService.getDocMetadata('fizbuz').subscribe(
+      metadata => expect(metadata.url).toBe('assets/documents/fizbuz.html')
+    );
+    tick();
+  }));
+
+  it('should calculate expected index doc url for unknown id ending in /', fakeAsync(() => {
+    siteMapService.getDocMetadata('fizbuz/').subscribe(
+      metadata => expect(metadata.url).toBe('assets/documents/fizbuz/index.html')
+    );
+    tick();
+  }));
+});
diff --git a/angular.io/src/app/nav-engine/sitemap.service.ts b/angular.io/src/app/nav-engine/sitemap.service.ts
new file mode 100644
index 0000000000..f98787eb9f
--- /dev/null
+++ b/angular.io/src/app/nav-engine/sitemap.service.ts
@@ -0,0 +1,38 @@
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import 'rxjs/add/operator/map';
+
+import { DocMetadata } from './doc.model';
+
+const siteMap: DocMetadata[] = [
+  { 'title': 'Home', 'url': 'assets/documents/home.html', id: 'home'},
+  { 'title': 'Features', 'url': 'assets/documents/features.html', id: 'features'},
+  { 'title': 'News', 'url': 'assets/documents/news.html', id: 'news'}
+];
+
+@Injectable()
+export class SiteMapService {
+  private siteMap = new BehaviorSubject(siteMap);
+
+  getDocMetadata(id: string) {
+    const missing = () => this.getMissingMetadata(id);
+    return this.siteMap
+      .map(map =>
+        map.find(d => d.id === id) || missing());
+  }
+
+  // Alternative way to calculate metadata. Will it be used?
+  private getMissingMetadata(id: string) {
+
+    const filename = id.startsWith('/') ? id.substring(1) : id; // strip leading '/'
+
+    return {
+      id,
+      title: id,
+      url: `assets/documents/${filename}${filename.endsWith('/') ? 'index' : ''}.html`
+    } as DocMetadata;
+  }
+}