feat(aio): migrate embedded comp to elements (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin
2018-02-28 12:05:59 -08:00
committed by Miško Hevery
parent 22b96b9690
commit 7c9b411777
69 changed files with 1021 additions and 1753 deletions
@@ -0,0 +1,100 @@
import { Component, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CodeExampleComponent } from './code-example.component';
import { CodeExampleModule } from './code-example.module';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
describe('CodeExampleComponent', () => {
let hostComponent: HostComponent;
let codeExampleComponent: CodeExampleComponent;
let fixture: ComponentFixture<HostComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ CodeExampleModule ],
declarations: [
HostComponent,
],
providers: [
{ provide: Logger, useClass: MockLogger },
]
});
fixture = TestBed.createComponent(HostComponent);
hostComponent = fixture.componentInstance;
codeExampleComponent = hostComponent.codeExampleComponent;
fixture.detectChanges();
});
it('should be able to capture the code snippet provided in content', () => {
expect(codeExampleComponent.code.trim()).toBe(`const foo = "bar";`);
});
it('should change aio-code classes based on title presence', () => {
expect(codeExampleComponent.title).toBe('Great Example');
expect(fixture.nativeElement.querySelector('header')).toBeTruthy();
expect(codeExampleComponent.classes).toEqual({
'headed-code': true,
'simple-code': false
});
codeExampleComponent.title = '';
fixture.detectChanges();
expect(codeExampleComponent.title).toBe('');
expect(fixture.nativeElement.querySelector('header')).toBeFalsy();
expect(codeExampleComponent.classes).toEqual({
'headed-code': false,
'simple-code': true
});
});
it('should set avoidFile class if path has .avoid.', () => {
const codeExampleComponentElement: HTMLElement =
fixture.nativeElement.querySelector('code-example');
expect(codeExampleComponent.path).toBe('code-path');
expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(true);
codeExampleComponent.path = 'code-path.avoid.';
fixture.detectChanges();
expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(false);
});
it('should coerce hidecopy', () => {
expect(codeExampleComponent.hidecopy).toBe(false);
hostComponent.hidecopy = true;
fixture.detectChanges();
expect(codeExampleComponent.hidecopy).toBe(true);
hostComponent.hidecopy = 'false';
fixture.detectChanges();
expect(codeExampleComponent.hidecopy).toBe(false);
hostComponent.hidecopy = 'true';
fixture.detectChanges();
expect(codeExampleComponent.hidecopy).toBe(true);
});
});
@Component({
selector: 'aio-host-comp',
template: `
<code-example [title]="title" [path]="path" [hidecopy]="hidecopy">
{{code}}
</code-example>
`
})
class HostComponent {
code = `const foo = "bar";`;
title = 'Great Example';
path = 'code-path';
hidecopy: boolean | string = false;
@ViewChild(CodeExampleComponent) codeExampleComponent: CodeExampleComponent;
}
@@ -0,0 +1,91 @@
/* tslint:disable component-selector */
import { Component, HostBinding, ElementRef, ViewChild, Input, AfterViewInit } from '@angular/core';
import { CodeComponent } from './code.component';
/**
* An embeddable code block that displays nicely formatted code.
* Example usage:
*
* ```
* <code-example language="ts" linenums="2" class="special" title="Do Stuff">
* // a code block
* console.log('do stuff');
* </code-example>
* ```
*/
@Component({
selector: 'code-example',
template: `
<!-- Content projection is used to get the content HTML provided to this component -->
<div #content style="display: none"><ng-content></ng-content></div>
<header *ngIf="title">{{title}}</header>
<aio-code [ngClass]="classes"
[language]="language"
[linenums]="linenums"
[path]="path"
[region]="region"
[hideCopy]="hidecopy"
[title]="title">
</aio-code>
`,
})
export class CodeExampleComponent implements AfterViewInit {
classes: {};
code: string;
@Input() language: string;
@Input() linenums: string;
@Input() region: string;
@Input()
set title(title: string) {
this._title = title;
this.classes = {
'headed-code': !!this.title,
'simple-code': !this.title,
};
}
get title(): string { return this._title; }
private _title: string;
@Input()
set path(path: string) {
this._path = path;
this.isAvoid = this.path.indexOf('.avoid.') !== -1;
}
get path(): string { return this._path; }
private _path = '';
@Input()
set hidecopy(hidecopy: boolean) {
// Coerce the boolean value.
this._hidecopy = hidecopy != null && `${hidecopy}` !== 'false';
}
get hidecopy(): boolean { return this._hidecopy; }
private _hidecopy: boolean;
@Input('hide-copy')
set hyphenatedHideCopy(hidecopy: boolean) {
this.hidecopy = hidecopy;
}
@Input('hideCopy')
set capitalizedHideCopy(hidecopy: boolean) {
this.hidecopy = hidecopy;
}
@HostBinding('class.avoidFile') isAvoid = false;
@ViewChild('content') content: ElementRef;
@ViewChild(CodeComponent) aioCode: CodeComponent;
ngAfterViewInit() {
this.aioCode.code = this.content.nativeElement.innerHTML;
}
}
@@ -0,0 +1,15 @@
import { NgModule, Type } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CodeExampleComponent } from './code-example.component';
import { CodeModule } from './code.module';
import { WithCustomElementComponent } from '../element-registry';
@NgModule({
imports: [ CommonModule, CodeModule ],
declarations: [ CodeExampleComponent ],
exports: [ CodeExampleComponent ],
entryComponents: [ CodeExampleComponent ]
})
export class CodeExampleModule implements WithCustomElementComponent {
customElementComponent: Type<any> = CodeExampleComponent;
}
@@ -0,0 +1,96 @@
import { Component, ViewChild, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CodeTabsComponent } from './code-tabs.component';
import { CodeTabsModule } from './code-tabs.module';
describe('CodeTabsComponent', () => {
let fixture: ComponentFixture<HostComponent>;
let hostComponent: HostComponent;
let codeTabsComponent: CodeTabsComponent;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ HostComponent ],
imports: [ CodeTabsModule, NoopAnimationsModule ],
schemas: [ NO_ERRORS_SCHEMA ],
providers: [
{ provide: Logger, useClass: MockLogger },
]
});
fixture = TestBed.createComponent(HostComponent);
hostComponent = fixture.componentInstance;
codeTabsComponent = hostComponent.codeTabsComponent;
fixture.detectChanges();
});
it('should get correct tab info', () => {
const tabs = codeTabsComponent.tabs;
expect(tabs.length).toBe(2);
// First code pane expectations
expect(tabs[0].class).toBe('class-A');
expect(tabs[0].language).toBe('language-A');
expect(tabs[0].linenums).toBe('linenums-A');
expect(tabs[0].path).toBe('path-A');
expect(tabs[0].region).toBe('region-A');
expect(tabs[0].title).toBe('title-A');
expect(tabs[0].code.trim()).toBe('Code example 1');
// Second code pane expectations
expect(tabs[1].class).toBe('class-B');
expect(tabs[1].language).toBe('language-B');
expect(tabs[1].linenums).toBe('default-linenums', 'Default linenums should have been used');
expect(tabs[1].path).toBe('path-B');
expect(tabs[1].region).toBe('region-B');
expect(tabs[1].title).toBe('title-B');
expect(tabs[1].code.trim()).toBe('Code example 2');
});
it('should create the right number of tabs with the right labels and classes', () => {
const matTabs = fixture.nativeElement.querySelectorAll('.mat-tab-label');
expect(matTabs.length).toBe(2);
expect(matTabs[0].textContent.trim()).toBe('title-A');
expect(matTabs[0].querySelector('.class-A')).toBeTruthy();
expect(matTabs[1].textContent.trim()).toBe('title-B');
expect(matTabs[1].querySelector('.class-B')).toBeTruthy();
});
it('should show the first tab with the right code', () => {
const codeContent = fixture.nativeElement.querySelector('aio-code').textContent;
expect(codeContent.indexOf('Code example 1') !== -1).toBeTruthy();
});
});
@Component({
selector: 'aio-host-comp',
template: `
<code-tabs linenums="default-linenums">
<code-pane class="class-A"
language="language-A"
linenums="linenums-A"
path="path-A"
region="region-A"
title="title-A">
Code example 1
</code-pane>
<code-pane class="class-B"
language="language-B"
path="path-B"
region="region-B"
title="title-B">
Code example 2
</code-pane>
</code-tabs>
`
})
class HostComponent {
@ViewChild(CodeTabsComponent) codeTabsComponent: CodeTabsComponent;
}
@@ -0,0 +1,81 @@
/* tslint:disable component-selector */
import { Component, AfterViewInit, ViewChild, Input, ViewChildren, QueryList, OnInit } from '@angular/core';
import { CodeComponent } from './code.component';
export interface TabInfo {
class: string|null;
code: string;
language: string|null;
linenums: any;
path: string;
region: string;
title: string|null;
}
/**
* Renders a set of tab group of code snippets.
*
* The innerHTML of the `<code-tabs>` component should contain `<code-pane>` elements.
* Each `<code-pane>` has the same interface as the embedded `<code-example>` component.
* The optional `linenums` attribute is the default `linenums` for each code pane.
*/
@Component({
selector: 'code-tabs',
template: `
<!-- Use content projection so that the provided HTML's code-panes can be split into tabs -->
<div #content style="display: none"><ng-content></ng-content></div>
<mat-tab-group class="code-tab-group" disableRipple>
<mat-tab style="overflow-y: hidden;" *ngFor="let tab of tabs">
<ng-template mat-tab-label>
<span class="{{ tab.class }}">{{ tab.title }}</span>
</ng-template>
<aio-code class="{{ tab.class }}"
[language]="tab.language"
[linenums]="tab.linenums"
[path]="tab.path"
[region]="tab.region"
[title]="tab.title">
</aio-code>
</mat-tab>
</mat-tab-group>
`,
})
export class CodeTabsComponent implements OnInit, AfterViewInit {
tabs: TabInfo[];
@Input('linenums') linenums: string;
@ViewChild('content') content;
@ViewChildren(CodeComponent) codeComponents: QueryList<CodeComponent>;
ngOnInit() {
this.tabs = [];
const codeExamples = this.content.nativeElement.querySelectorAll('code-pane');
for (let i = 0; i < codeExamples.length; i++) {
const tabContent = codeExamples[i];
this.tabs.push(this.getTabInfo(tabContent));
}
}
ngAfterViewInit() {
this.codeComponents.toArray().forEach((codeComponent, i) => {
codeComponent.code = this.tabs[i].code;
});
}
/** Gets the extracted TabInfo data from the provided code-pane element. */
private getTabInfo(tabContent: HTMLElement): TabInfo {
return {
class: tabContent.getAttribute('class'),
code: tabContent.innerHTML,
language: tabContent.getAttribute('language'),
linenums: tabContent.getAttribute('linenums') || this.linenums,
path: tabContent.getAttribute('path') || '',
region: tabContent.getAttribute('region') || '',
title: tabContent.getAttribute('title')
};
}
}
@@ -0,0 +1,16 @@
import { NgModule, Type } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CodeTabsComponent } from './code-tabs.component';
import { MatTabsModule } from '@angular/material';
import { CodeModule } from './code.module';
import { WithCustomElementComponent } from '../element-registry';
@NgModule({
imports: [ CommonModule, MatTabsModule, CodeModule ],
declarations: [ CodeTabsComponent ],
exports: [ CodeTabsComponent ],
entryComponents: [ CodeTabsComponent ]
})
export class CodeTabsModule implements WithCustomElementComponent {
customElementComponent: Type<any> = CodeTabsComponent;
}
@@ -0,0 +1,292 @@
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CodeComponent } from './code.component';
import { CodeModule } from './code.module';
import { CopierService } from 'app/shared//copier.service';
import { Logger } from 'app/shared/logger.service';
import { PrettyPrinter } from './pretty-printer.service';
const oneLineCode = 'const foo = "bar";';
const smallMultiLineCode = `
&lt;hero-details&gt;
&lt;h2&gt;Bah Dah Bing&lt;/h2&gt;
&lt;hero-team&gt;
&lt;h3&gt;NYC Team&lt;/h3&gt;
&lt;/hero-team&gt;
&lt;/hero-details&gt;`;
const bigMultiLineCode = smallMultiLineCode + smallMultiLineCode + smallMultiLineCode;
describe('CodeComponent', () => {
let hostComponent: HostComponent;
let fixture: ComponentFixture<HostComponent>;
// WARNING: Chance of cross-test pollution
// CodeComponent injects PrettyPrintService
// Once PrettyPrintService runs once _anywhere_, its ctor loads `prettify.js`
// which sets `window['prettyPrintOne']`
// That global survives these tests unless
// we take strict measures to wipe it out in the `afterAll`
// and make sure THAT runs after the tests by making component creation async
afterAll(() => {
delete (window as any)['prettyPrint'];
delete (window as any)['prettyPrintOne'];
});
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ NoopAnimationsModule, CodeModule ],
declarations: [ HostComponent ],
providers: [
PrettyPrinter,
CopierService,
{provide: Logger, useClass: TestLogger }
]
}).compileComponents();
});
// Must be async because
// CodeComponent creates PrettyPrintService which async loads `prettify.js`.
// If not async, `afterAll` finishes before tests do!
beforeEach(async(() => {
fixture = TestBed.createComponent(HostComponent);
hostComponent = fixture.componentInstance;
fixture.detectChanges();
}));
describe('pretty printing', () => {
it('should format a one-line code sample', () => {
// 'pln' spans are a tell-tale for syntax highlighing
const spans = fixture.nativeElement.querySelectorAll('span.pln');
expect(spans.length).toBeGreaterThan(0, 'formatted spans');
});
function hasLineNumbers() {
// presence of `<li>`s are a tell-tale for line numbers
return 0 < fixture.nativeElement.querySelectorAll('li').length;
}
it('should format a one-line code sample without linenums by default', () => {
expect(hasLineNumbers()).toBe(false);
});
it('should add line numbers to one-line code sample when linenums set true', () => {
hostComponent.linenums = 'true';
fixture.detectChanges();
expect(hasLineNumbers()).toBe(true);
});
it('should format a small multi-line code without linenums by default', () => {
hostComponent.setCode(smallMultiLineCode);
expect(hasLineNumbers()).toBe(false);
});
it('should add line numbers to a big multi-line code by default', () => {
hostComponent.setCode(bigMultiLineCode);
expect(hasLineNumbers()).toBe(true);
});
it('should format big multi-line code without linenums when linenums set false', () => {
hostComponent.linenums = false;
fixture.detectChanges();
hostComponent.setCode(bigMultiLineCode);
expect(hasLineNumbers()).toBe(false);
});
});
describe('whitespace handling', () => {
it('should remove common indentation from the code before rendering', () => {
hostComponent.linenums = false;
fixture.detectChanges();
hostComponent.setCode(' abc\n let x = text.split(\'\\n\');\n ghi\n\n jkl\n');
const codeContent = fixture.nativeElement.querySelector('code').textContent;
expect(codeContent).toEqual('abc\n let x = text.split(\'\\n\');\nghi\n\njkl');
});
it('should trim whitespace from the code before rendering', () => {
hostComponent.linenums = false;
fixture.detectChanges();
hostComponent.setCode('\n\n\n' + smallMultiLineCode + '\n\n\n');
const codeContent = fixture.nativeElement.querySelector('code').textContent;
expect(codeContent).toEqual(codeContent.trim());
});
it('should trim whitespace from code before computing whether to format linenums', () => {
hostComponent.setCode('\n\n\n' + oneLineCode + '\n\n\n');
// `<li>`s are a tell-tale for line numbers
const lis = fixture.nativeElement.querySelectorAll('li');
expect(lis.length).toBe(0, 'should be no linenums');
});
});
describe('error message', () => {
function getErrorMessage() {
const missing: HTMLElement = fixture.nativeElement.querySelector('.code-missing');
return missing ? missing.textContent : null;
}
it('should not display "code-missing" class when there is some code', () => {
expect(getErrorMessage()).toBeNull('should not have element with "code-missing" class');
});
it('should display error message when there is no code (after trimming)', () => {
hostComponent.setCode(' \n ');
expect(getErrorMessage()).toContain('missing');
});
it('should show path and region in missing-code error message', () => {
hostComponent.path = 'fizz/buzz/foo.html';
hostComponent.region = 'something';
fixture.detectChanges();
hostComponent.setCode(' \n ');
expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html#something$/);
});
it('should show path only in missing-code error message when no region', () => {
hostComponent.path = 'fizz/buzz/foo.html';
fixture.detectChanges();
hostComponent.setCode(' \n ');
expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html$/);
});
it('should show simple missing-code error message when no path/region', () => {
hostComponent.setCode(' \n ');
expect(getErrorMessage()).toMatch(/missing.$/);
});
});
describe('copy button', () => {
function getButton() {
const btnDe = fixture.debugElement.query(By.css('button'));
return btnDe ? btnDe.nativeElement : null;
}
it('should be hidden if the `hideCopy` input is true', () => {
hostComponent.hideCopy = true;
fixture.detectChanges();
expect(getButton()).toBe(null);
});
it('should have title', () => {
expect(getButton().title).toBe('Copy code snippet');
});
it('should have no aria-label by default', () => {
expect(getButton().getAttribute('aria-label')).toBe('');
});
it('should have aria-label explaining what is being copied when title passed in', () => {
hostComponent.title = 'a/b/c/foo.ts';
fixture.detectChanges();
expect(getButton().getAttribute('aria-label')).toContain(hostComponent.title);
});
it('should call copier service when clicked', () => {
const copierService: CopierService = TestBed.get(CopierService);
const spy = spyOn(copierService, 'copyText');
expect(spy.calls.count()).toBe(0, 'before click');
getButton().click();
expect(spy.calls.count()).toBe(1, 'after click');
});
it('should copy code text when clicked', () => {
const copierService: CopierService = TestBed.get(CopierService);
const spy = spyOn(copierService, 'copyText');
getButton().click();
expect(spy.calls.argsFor(0)[0]).toBe(oneLineCode, 'after click');
});
it('should preserve newlines in the copied code', () => {
const copierService: CopierService = TestBed.get(CopierService);
const spy = spyOn(copierService, 'copyText');
const expectedCode = smallMultiLineCode.trim().replace(/&lt;/g, '<').replace(/&gt;/g, '>');
let actualCode;
hostComponent.setCode(smallMultiLineCode);
[false, true, 42].forEach(linenums => {
hostComponent.linenums = linenums;
fixture.detectChanges();
getButton().click();
actualCode = spy.calls.mostRecent().args[0];
expect(actualCode).toBe(expectedCode, `when linenums=${linenums}`);
expect(actualCode.match(/\r?\n/g).length).toBe(5);
spy.calls.reset();
});
});
it('should display a message when copy succeeds', () => {
const snackBar: MatSnackBar = TestBed.get(MatSnackBar);
const copierService: CopierService = TestBed.get(CopierService);
spyOn(snackBar, 'open');
spyOn(copierService, 'copyText').and.returnValue(true);
getButton().click();
expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { duration: 800 });
});
it('should display an error when copy fails', () => {
const snackBar: MatSnackBar = TestBed.get(MatSnackBar);
const copierService: CopierService = TestBed.get(CopierService);
const logger: TestLogger = TestBed.get(Logger);
spyOn(snackBar, 'open');
spyOn(copierService, 'copyText').and.returnValue(false);
getButton().click();
expect(snackBar.open).toHaveBeenCalledWith('Copy failed. Please try again!', '', { duration: 800 });
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(jasmine.any(Error));
expect(logger.error.calls.mostRecent().args[0].message).toMatch(/^ERROR copying code to clipboard:/);
});
});
});
//// Test helpers ////
// tslint:disable:member-ordering
@Component({
selector: 'aio-host-comp',
template: `
<aio-code [language]="language"
[linenums]="linenums" [path]="path" [region]="region"
[hideCopy]="hideCopy" [title]="title"></aio-code>
`
})
class HostComponent implements AfterViewInit {
hideCopy: boolean;
language: string;
linenums: boolean | number | string;
path: string;
region: string;
title: string;
@ViewChild(CodeComponent) codeComponent: CodeComponent;
ngAfterViewInit() {
this.setCode(oneLineCode);
}
/** Changes the displayed code on the code component. */
setCode(code: string) {
this.codeComponent.code = code;
}
}
class TestLogger {
log = jasmine.createSpy('log');
error = jasmine.createSpy('error');
}
@@ -0,0 +1,186 @@
import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core';
import { Logger } from 'app/shared/logger.service';
import { PrettyPrinter } from './pretty-printer.service';
import { CopierService } from 'app/shared/copier.service';
import { MatSnackBar } from '@angular/material/snack-bar';
/**
* If linenums is not set, this is the default maximum number of lines that
* an example can display without line numbers.
*/
const DEFAULT_LINE_NUMS_COUNT = 10;
/**
* Formatted Code Block
*
* Pretty renders a code block, used in the docs and API reference by the code-example and
* code-tabs embedded components.
* It includes a "copy" button that will send the content to the clipboard when clicked
*
* Example usage:
*
* ```
* <aio-code
* [language]="ts"
* [linenums]="true"
* [path]="router/src/app/app.module.ts"
* [region]="animations-module">
* </aio-code>
* ```
*
*
* Renders code provided through the `updateCode` method.
*/
@Component({
selector: 'aio-code',
template: `
<pre class="prettyprint lang-{{language}}">
<button *ngIf="!hideCopy" class="material-icons copy-button no-print"
title="Copy code snippet"
[attr.aria-label]="ariaLabel"
(click)="doCopy()">
<span aria-hidden="true">content_copy</span>
</button>
<code class="animated fadeIn" #codeContainer></code>
</pre>
`
})
export class CodeComponent implements OnChanges {
ariaLabel = '';
/** The code to be copied when clicking the copy button, this should not be HTML encoded */
private codeText: string;
/** Code that should be formatted with current inputs and displayed in the view. */
set code(code: string) {
this._code = code;
if (!this._code || !this._code.trim()) {
this.showMissingCodeMessage();
} else {
this.formatDisplayedCode();
}
}
get code(): string { return this._code; }
_code: string;
/** Whether the copy button should be shown. */
@Input() hideCopy: boolean;
/** Language to render the code (e.g. javascript, dart, typescript). */
@Input() language: string;
/**
* Whether to display line numbers:
* - If false: hide
* - If true: show
* - If number: show but start at that number
*/
@Input() linenums: boolean | number | string;
/** Path to the source of the code. */
@Input() path: string;
/** Region of the source of the code being displayed. */
@Input() region: string;
/** Optional title to be displayed above the code. */
@Input()
set title(title: string) {
this._title = title;
this.ariaLabel = this.title ? `Copy code snippet from ${this.title}` : '';
}
get title(): string { return this._title; }
private _title: string;
/** The element in the template that will display the formatted code. */
@ViewChild('codeContainer') codeContainer: ElementRef;
constructor(
private snackbar: MatSnackBar,
private pretty: PrettyPrinter,
private copier: CopierService,
private logger: Logger) {}
ngOnChanges() {
// If some inputs have changed and there is code displayed, update the view with the latest
// formatted code.
if (this.code) {
this.formatDisplayedCode();
}
}
private formatDisplayedCode() {
const leftAlignedCode = leftAlign(this.code);
this.setCodeHtml(leftAlignedCode); // start with unformatted code
this.codeText = this.getCodeText(); // store the unformatted code as text (for copying)
this.pretty.formatCode(leftAlignedCode, this.language, this.getLinenums(leftAlignedCode))
.subscribe(c => this.setCodeHtml(c), err => { /* ignore failure to format */ }
);
}
/** Sets the message showing that the code could not be found. */
private showMissingCodeMessage() {
const src = this.path ? this.path + (this.region ? '#' + this.region : '') : '';
const srcMsg = src ? ` for\n${src}` : '.';
this.setCodeHtml(`<p class="code-missing">The code sample is missing${srcMsg}</p>`);
}
/** Sets the innerHTML of the code container to the provided code string. */
private setCodeHtml(formattedCode: string) {
// **Security:** Code example content is provided by docs authors and as such its considered to
// be safe for innerHTML purposes.
this.codeContainer.nativeElement.innerHTML = formattedCode;
}
/** Gets the textContent of the displayed code element. */
private getCodeText() {
// `prettify` may remove newlines, e.g. when `linenums` are on. Retrieve the content of the
// container as text, before prettifying it.
// We take the textContent because we don't want it to be HTML encoded.
return this.codeContainer.nativeElement.textContent;
}
/** Copies the code snippet to the user's clipboard. */
doCopy() {
const code = this.codeText;
const successfullyCopied = this.copier.copyText(code);
if (successfullyCopied) {
this.logger.log('Copied code to clipboard:', code);
this.snackbar.open('Code Copied', '', { duration: 800 });
} else {
this.logger.error(new Error(`ERROR copying code to clipboard: "${code}"`));
this.snackbar.open('Copy failed. Please try again!', '', { duration: 800 });
}
}
/** Gets the calculated value of linenums (boolean/number). */
getLinenums(code: string) {
const linenums =
typeof this.linenums === 'boolean' ? this.linenums :
this.linenums === 'true' ? true :
this.linenums === 'false' ? false :
typeof this.linenums === 'string' ? parseInt(this.linenums, 10) :
this.linenums;
// if no linenums, enable line numbers if more than one line
return linenums == null || isNaN(linenums as number) ?
(code.match(/\n/g) || []).length > DEFAULT_LINE_NUMS_COUNT : linenums;
}
}
function leftAlign(text: string): string {
let indent = Number.MAX_VALUE;
const lines = text.split('\n');
lines.forEach(line => {
const lineIndent = line.search(/\S/);
if (lineIndent !== -1) {
indent = Math.min(lineIndent, indent);
}
});
return lines.map(line => line.substr(indent)).join('\n').trim();
}
@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CodeComponent } from './code.component';
import { MatSnackBarModule } from '@angular/material';
import { PrettyPrinter } from './pretty-printer.service';
import { CopierService } from 'app/shared/copier.service';
@NgModule({
imports: [ CommonModule, MatSnackBarModule ],
declarations: [ CodeComponent ],
entryComponents: [ CodeComponent ],
exports: [ CodeComponent ],
providers: [ PrettyPrinter, CopierService ]
})
export class CodeModule { }
@@ -0,0 +1,65 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { fromPromise } from 'rxjs/observable/fromPromise';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/first';
import { Logger } from 'app/shared/logger.service';
declare const System: {
import(name: string): Promise<any>;
};
type PrettyPrintOne = (code: string, language?: string, linenums?: number | boolean) => string;
/**
* Wrapper around the prettify.js library
*/
@Injectable()
export class PrettyPrinter {
private prettyPrintOne: Observable<PrettyPrintOne>;
constructor(private logger: Logger) {
this.prettyPrintOne = fromPromise(this.getPrettyPrintOne()).share();
}
private getPrettyPrintOne(): Promise<PrettyPrintOne> {
const ppo = (window as any)['prettyPrintOne'];
return ppo ? Promise.resolve(ppo) :
// prettify.js is not in window global; load it with webpack loader
System.import('assets/js/prettify.js')
.then(
() => (window as any)['prettyPrintOne'],
err => {
const msg = 'Cannot get prettify.js from server';
this.logger.error(msg, err);
// return a pretty print fn that always fails.
return () => { throw new Error(msg); };
});
}
/**
* Format code snippet as HTML
* @param {string} code - the code snippet to format; should already be HTML encoded
* @param {string} [language] - The language of the code to render (could be javascript, html, typescript, etc)
* @param {string|number} [linenums] - Whether to display line numbers:
* - false: don't display
* - true: do display
* - number: do display but start at the given number
* @returns Observable<string> - Observable of formatted code
*/
formatCode(code: string, language?: string, linenums?: number | boolean) {
return this.prettyPrintOne.map(ppo => {
try {
return ppo(code, language, linenums);
} catch (err) {
const msg = `Could not format code that begins '${code.substr(0, 50)}...'.`;
console.error(msg, err);
throw new Error(msg);
}
})
.first(); // complete immediately
}
}