diff --git a/extraPlugin/newSample.js b/extraPlugin/newSample.js new file mode 100644 index 000000000..03792a552 --- /dev/null +++ b/extraPlugin/newSample.js @@ -0,0 +1,47 @@ +const {routeSplit, registerPlugin, httpGet} = require('../dist/scully'); + +const newsSamplePlugin = async (route, config) => { + const {createPath} = routeSplit(route); + const list = await httpGet('http://localhost:4200/assets/news.json'); + const handledRoutes = []; + for (item of list) { + const blogData = await httpget(`http://localhost:4200/assets/news/${list.id}.json`); + handledRoutes.push({ + route: createPath(item.id, blogdata.slug), + title: blogData.title, + description: blogData.short, + }); + } +}; + +registerPlugin('router', 'myBlog', newsSamplePlugin); + +const config = { + '/news/:id/:slug': { + type: 'myBlog', + postRenderers: postRenderers, + }, +}; + +/** + * + '/news/:id/:slug': { + type: 'json', + postRenderers: postRenderers, + id: { + url: 'http://localhost:4200/assets/news.json', + property: 'id', + }, + slug: { + url: 'http://localhost:4200/assets/news/${id}.json', + property: 'slug', + }, +}, + +{ + "id": 5, + "slug": "newsitem-5", + "title": "Newsitem #5", + "short": "Lorem ipsum dolor .." +} + */ diff --git a/projects/scullyio/ng-lib/package.json b/projects/scullyio/ng-lib/package.json index cf09d08a5..dbb132053 100644 --- a/projects/scullyio/ng-lib/package.json +++ b/projects/scullyio/ng-lib/package.json @@ -1,6 +1,6 @@ { "name": "@scullyio/ng-lib", - "version": "0.0.8", + "version": "0.0.9", "repository": { "type": "GIT", "url": "https://github.com/scullyio/scully/tree/master/projects/scullyio/ng-lib" diff --git a/projects/scullyio/ng-lib/src/lib/components.module.ts b/projects/scullyio/ng-lib/src/lib/components.module.ts index 5b4808451..2126bcfa9 100644 --- a/projects/scullyio/ng-lib/src/lib/components.module.ts +++ b/projects/scullyio/ng-lib/src/lib/components.module.ts @@ -1,16 +1,8 @@ -import {HttpClient} from '@angular/common/http'; -import {ModuleWithProviders, NgModule} from '@angular/core'; +import {NgModule} from '@angular/core'; import {ScullyContentComponent} from './scully-content/scully-content.component'; @NgModule({ declarations: [ScullyContentComponent], exports: [ScullyContentComponent], }) -export class ComponentsModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: ComponentsModule, - providers: [HttpClient], - }; - } -} +export class ComponentsModule {} diff --git a/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.spec.ts b/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.spec.ts index d6c7da05e..581c5a357 100644 --- a/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.spec.ts +++ b/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.spec.ts @@ -1,23 +1,13 @@ -import { - HttpClientTestingModule, - HttpTestingController, -} from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; +import {TestBed} from '@angular/core/testing'; -import { ScullyRoutesService } from './scully-routes.service'; +import {ScullyRoutesService} from './scully-routes.service'; describe('ScullyRoutesService', () => { let service: ScullyRoutesService; - let httpTestingController: HttpTestingController; beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - HttpClientTestingModule, - ], - }); + TestBed.configureTestingModule({}); service = TestBed.inject(ScullyRoutesService); - httpTestingController = TestBed.inject(HttpTestingController); }); it('should be created', () => { diff --git a/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.ts b/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.ts index 2a13c7912..de08f7f32 100644 --- a/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.ts +++ b/projects/scullyio/ng-lib/src/lib/route-service/scully-routes.service.ts @@ -1,7 +1,7 @@ -import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import {of, ReplaySubject, Observable} from 'rxjs'; -import {catchError, shareReplay, switchMap, map, tap} from 'rxjs/operators'; +import {Observable, of, ReplaySubject} from 'rxjs'; +import {catchError, map, shareReplay, switchMap} from 'rxjs/operators'; +import {fetchHttp} from '../utils/fetchHttp'; export interface ScullyRoute { route: string; @@ -16,7 +16,7 @@ export interface ScullyRoute { export class ScullyRoutesService { private refresh = new ReplaySubject(1); available$: Observable = this.refresh.pipe( - switchMap(() => this.http.get('/assets/scully-routes.json')), + switchMap(() => fetchHttp('/assets/scully-routes.json')), catchError(() => { console.warn('Scully routes file not found, are you running the in static version of your site?'); return of([] as ScullyRoute[]); @@ -29,7 +29,7 @@ export class ScullyRoutesService { shareReplay({refCount: false, bufferSize: 1}) ); - constructor(private http: HttpClient) { + constructor() { /** kick off first cycle */ this.reload(); } diff --git a/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.spec.ts b/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.spec.ts index 39dbbeeea..21322614b 100644 --- a/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.spec.ts +++ b/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.spec.ts @@ -1,30 +1,19 @@ -import { - HttpClientTestingModule, - HttpTestingController, -} from '@angular/common/http/testing'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { ScullyContentComponent } from './scully-content.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {RouterTestingModule} from '@angular/router/testing'; +import {ScullyContentComponent} from './scully-content.component'; describe('ScullyContentComponent', () => { let component: ScullyContentComponent; let fixture: ComponentFixture; - let httpTestingController: HttpTestingController; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ScullyContentComponent], - imports: [ - HttpClientTestingModule, - RouterTestingModule.withRoutes([]), - ], - }) - .compileComponents(); + imports: [RouterTestingModule.withRoutes([])], + }).compileComponents(); })); beforeEach(() => { - httpTestingController = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(ScullyContentComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.ts b/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.ts index 4aa7cfbdc..86647f9e3 100644 --- a/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.ts +++ b/projects/scullyio/ng-lib/src/lib/scully-content/scully-content.component.ts @@ -1,4 +1,3 @@ -import {HttpClient} from '@angular/common/http'; import { ChangeDetectionStrategy, Component, @@ -13,6 +12,7 @@ import {Observable, Subscription} from 'rxjs'; import {take} from 'rxjs/operators'; import {IdleMonitorService} from '../idleMonitor/idle-monitor.service'; import {ScullyRoutesService} from '../route-service/scully-routes.service'; +import {fetchHttp} from '../utils/fetchHttp'; /** this is needed, because otherwise the CLI borks while building */ const scullyBegin = ''; @@ -45,7 +45,6 @@ export class ScullyContentComponent implements OnInit, OnDestroy { constructor( private elmRef: ElementRef, private srs: ScullyRoutesService, - private http: HttpClient, private router: Router, private idle: IdleMonitorService ) {} @@ -67,16 +66,13 @@ export class ScullyContentComponent implements OnInit, OnDestroy { template.innerHTML = window['scullyContent']; } else { const curPage = location.href; - await this.http - .get(curPage, {responseType: 'text'}) - .toPromise() + await fetchHttp(curPage, 'text') .then((html: string) => { try { template.innerHTML = html.split(scullyBegin)[1].split(scullyEnd)[0]; } catch (e) { template.innerHTML = `

Sorry, could not parse static page content

This might happen if you are not using the static generated pages.

`; - console.error('problem during parsing static scully content', e); } }) .catch(e => { diff --git a/projects/scullyio/ng-lib/src/lib/transfer-state/transfer-state.service.ts b/projects/scullyio/ng-lib/src/lib/transfer-state/transfer-state.service.ts index 5d14a3947..eb0c2f571 100644 --- a/projects/scullyio/ng-lib/src/lib/transfer-state/transfer-state.service.ts +++ b/projects/scullyio/ng-lib/src/lib/transfer-state/transfer-state.service.ts @@ -1,16 +1,19 @@ -import {HttpClient} from '@angular/common/http'; -import {Inject, Injectable} from '@angular/core'; import {DOCUMENT} from '@angular/common'; -import {NavigationStart, Router} from '@angular/router'; +import {Inject, Injectable} from '@angular/core'; +import {NavigationEnd, NavigationStart, Router} from '@angular/router'; +import {BehaviorSubject, EMPTY, forkJoin, Observable} from 'rxjs'; +import {filter, first, map, pluck, switchMap, tap} from 'rxjs/operators'; +import {fetchHttp} from '../utils/fetchHttp'; import {isScullyGenerated, isScullyRunning} from '../utils/isScully'; -import {Observable, of, Subject} from 'rxjs'; -import {catchError, filter, map, switchMap, tap} from 'rxjs/operators'; const SCULLY_SCRIPT_ID = `scully-transfer-state`; -const SCULLY_STATE_START = `___SCULLY_STATE_START___`; -const SCULLY_STATE_END = `___SCULLY_STATE_END___`; +const SCULLY_STATE_START = `/** ___SCULLY_STATE_START___ */`; +const SCULLY_STATE_END = `/** ___SCULLY_STATE_END___ */`; -// Adding this dynamic comment to supress ngc error around Document as a DI token. +interface State { + [key: string]: any; +} +// Adding this dynamic comment to suppress ngc error around Document as a DI token. // https://github.com/angular/angular/issues/20351#issuecomment-344009887 /** @dynamic */ @Injectable({ @@ -18,14 +21,13 @@ const SCULLY_STATE_END = `___SCULLY_STATE_END___`; }) export class TransferStateService { private script: HTMLScriptElement; - private state: {[key: string]: any} = {}; - private fetching: Subject; + private isNavigatingBS = new BehaviorSubject(false); + private stateBS = new BehaviorSubject({}); + private state$ = this.isNavigatingBS.pipe( + switchMap(isNav => (isNav ? EMPTY : this.stateBS.asObservable())) + ); - constructor( - @Inject(DOCUMENT) private document: Document, - private router: Router, - private http: HttpClient - ) { + constructor(@Inject(DOCUMENT) private document: Document, private router: Router) { this.setupEnvForTransferState(); this.setupNavStartDataFetching(); } @@ -35,32 +37,28 @@ export class TransferStateService { // In Scully puppeteer this.script = this.document.createElement('script'); this.script.setAttribute('id', SCULLY_SCRIPT_ID); - this.script.setAttribute('type', `text/${SCULLY_SCRIPT_ID}`); this.document.head.appendChild(this.script); } else if (isScullyGenerated()) { // On the client AFTER scully rendered it - this.script = this.document.getElementById(SCULLY_SCRIPT_ID) as HTMLScriptElement; - try { - this.state = JSON.parse(unescapeHtml(this.script.textContent)); - } catch (e) { - this.state = {}; - } + this.stateBS.next((window && window[SCULLY_SCRIPT_ID]) || {}); } } + /** + * Getstate will return an observable that fires once and completes. + * It does so right after the navigation for the page has finished. + * @param name The name of the state to + */ getState(name: string): Observable { - if (this.fetching) { - return this.fetching.pipe(map(() => this.state[name])); - } else { - return of(this.state[name]); - } + return this.state$.pipe(pluck(name)); } setState(name: string, val: T): void { - this.state[name] = val; + const newState = {...this.stateBS.value, [name]: val}; + this.stateBS.next(newState); if (isScullyRunning()) { - this.script.textContent = `${SCULLY_STATE_START}${escapeHtml( - JSON.stringify(this.state) + this.script.textContent = `window['${SCULLY_SCRIPT_ID}']=${SCULLY_STATE_START}${JSON.stringify( + newState )}${SCULLY_STATE_END}`; } } @@ -69,69 +67,47 @@ export class TransferStateService { /** * Each time the route changes, get the Scully state from the server-rendered page */ - if (!isScullyGenerated()) return; + if (!isScullyGenerated()) { + return; + } this.router.events .pipe( filter(e => e instanceof NavigationStart), - tap(() => (this.fetching = new Subject())), switchMap((e: NavigationStart) => { - // Get the next route's page from the server - return this.http.get(e.url, {responseType: 'text'}).pipe( - catchError(err => { + this.isNavigatingBS.next(true); + return forkJoin([ + /** prevent emitting before navigation to _this_ URL is done. */ + this.router.events.pipe( + filter(ev => ev instanceof NavigationEnd && ev.url === e.url), + first() + ), + // Get the next route's page from the server + fetchHttp(e.url + '/index.html', 'text').catch(err => { console.warn('Failed transfering state from route', err); - return of(''); - }) - ); + return ''; + }), + ]); }), - map((html: string) => { - // Parse the scully state out of the next page - const startIndex = html.indexOf(SCULLY_STATE_START); - if (startIndex !== -1) { - const afterStart = html.split(SCULLY_STATE_START)[1] || ''; - const middle = afterStart.split(SCULLY_STATE_END)[0] || ''; - return middle; - } else { + /** parse out the relevant piece off text, and conver to json */ + map(([e, html]: [any, string]) => { + try { + const newStateStr = html.split(SCULLY_STATE_START)[1].split(SCULLY_STATE_END)[0]; + return JSON.parse(newStateStr); + } catch { return null; } }), + /** prevent progressing in case anything went sour above */ filter(val => val !== null), - tap(val => { - // Add parsed-out scully-state to the current scully-state - this.setFetchedRouteState(val); - this.fetching = null; + /** activate the new state */ + tap(newState => { + /** signal to send out update */ + this.isNavigatingBS.next(false); + /** replace the state, so we don't leak memory on old state */ + this.stateBS.next(newState); }) ) .subscribe(); } - - private setFetchedRouteState(unprocessedTextContext) { - // Exit if nothing to set - if (!unprocessedTextContext || !unprocessedTextContext.length) return; - - // Parse to JSON the next route's state content - const newState = JSON.parse(unescapeHtml(unprocessedTextContext)); - this.state = {...this.state, ...newState}; - this.fetching.next(); - } -} -export function unescapeHtml(text: string): string { - const unescapedText: {[k: string]: string} = { - '&a;': '&', - '&q;': '"', - '&s;': "'", - '&l;': '<', - '&g;': '>', - }; - return text.replace(/&[^;]+;/g, s => unescapedText[s]); -} -export function escapeHtml(text: string): string { - const escapedText: {[k: string]: string} = { - '&': '&a;', - '"': '&q;', - "'": '&s;', - '<': '&l;', - '>': '&g;', - }; - return text.replace(/[&"'<>]/g, s => escapedText[s]); } diff --git a/projects/scullyio/ng-lib/src/lib/utils/fetchHttp.ts b/projects/scullyio/ng-lib/src/lib/utils/fetchHttp.ts new file mode 100644 index 000000000..0e1732ea4 --- /dev/null +++ b/projects/scullyio/ng-lib/src/lib/utils/fetchHttp.ts @@ -0,0 +1,10 @@ +export function fetchHttp(url: string, responseType: XMLHttpRequestResponseType = 'json'): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = responseType; + xhr.addEventListener('load', ev => resolve(xhr.response)); + xhr.addEventListener('error', (...err) => reject(err)); + xhr.open('get', url, true); + xhr.send(); + }); +} diff --git a/scully.config.js b/scully.config.js index d0a8120be..f3763f34b 100644 --- a/scully.config.js +++ b/scully.config.js @@ -32,7 +32,7 @@ exports.config = { property: 'id', }, }, - '/ouser/:userId/:friendId': { + '/user/:userId/:friendCode': { // Type is mandatory type: 'json', /** @@ -42,7 +42,7 @@ exports.config = { url: 'https://jsonplaceholder.typicode.com/users', property: 'id', }, - friendId: { + friendCode: { /** users are their own friend in this sample ;) */ url: 'https://jsonplaceholder.typicode.com/users?userId=${userId}', property: 'id', diff --git a/scully/renderPlugins/routeContentRenderer.ts b/scully/renderPlugins/routeContentRenderer.ts index ca8b7b30c..6a1da88f3 100644 --- a/scully/renderPlugins/routeContentRenderer.ts +++ b/scully/renderPlugins/routeContentRenderer.ts @@ -14,13 +14,17 @@ export const routeContentRenderer = async (route: HandledRoute) => { const handler = plugins.render[plugin]; if (handler) { try { + /** return result of plugin */ return await handler(html, route); } catch { logError( - `Error during content generation with plugin "${yellow(plugin)}" for ${yellow(route.templateFile)}` + `Error during content generation with plugin "${yellow(plugin)}" for ${yellow( + route.templateFile + )}. This hander is skipped.` ); } } + /** return unhandled result */ return html; }, puppeteerRender(route)); };