import {isDebugMode} from "@gongt/ts-stl-library/debug/is-debug-mode"; import {createLogger} from "@gongt/ts-stl-library/debug/create-logger"; import {LOG_LEVEL} from "@gongt/ts-stl-library/debug/levels"; import {GlobalVariable} from "@gongt/ts-stl-library/pattern/global-page-data"; import {createHash} from "crypto"; import {compile, Options as EjsOptions, TemplateFunction} from "ejs"; import {Request, RequestHandler, Response} from "express-serve-static-core"; import {appendFile, ensureDirSync, readFileSync, writeFileSync} from "fs-extra"; import {tmpdir} from "os"; import {dirname, extname, resolve} from "path"; import {format, parse} from "url"; import {inspect} from "util"; import {FileWatcher} from "../../file-operation/watcher"; import {polyfillScripts} from "./html-polyfill"; const debug = createLogger(LOG_LEVEL.SILLY, 'html'); const data = createLogger(LOG_LEVEL.DATA, 'html'); const projectName = process.env.PROJECT_NAME; export interface IContainerPlugin { __modify_html(html: HtmlContainer, options?: Partial): void; } interface IMetaTag { type: string; name: string; content: string; } export interface DynamicCallback { (context: RenderContext): Promise|void; } export interface DynamicCallbackDebug { name: string; cb: DynamicCallback; } export class HtmlContainer { private _lastOptions: EjsOptions = { strict: false, compileDebug: true, }; private _meta: IMetaTag[] = []; private _favicon: string[] = []; private _body: string = ''; private _jsData: string = ''; private _head: string = ''; private _script: string[] = []; private _script_dbg: string[] = []; private _stylesheet: [string, string][] = []; private renderFn: TemplateFunction; private dynamicCallbacks: DynamicCallbackDebug[] = []; private deps: string[] = []; private fileWatch: FileWatcher; meta(name: string, content: string) { this.change(); this._meta.push({ type: 'name', name, content, }); } property(name: string, content: string) { this.change(); this._meta.push({ type: 'property', name, content, }); } httpEquiv(name: string, content: string) { this.change(); this._meta.push({ type: 'http-equiv', name, content, }); } favicon(fileUrl: string) { this.change(); this._favicon.push(fileUrl); } stylesheet(url: string, id?: string) { this.change(); this._stylesheet.push([url, id]); } addHead(head: string) { this.change(); this._head += head; } addBody(body: string) { this.change(); this._body += body; } addJavascript(script: string) { this.change(); this._jsData += script.trim() + '\n'; } script(url: string, debugTag: string = '') { const u = parse(url, false); u.path = u.path.replace(/\/\//g, '/'); url = format(u); if (this._script.indexOf(url) === -1) { this.change(); this._script.push(url); this._script_dbg.push(debugTag); } } plugin(content: IContainerPlugin, options: Partial = {}) { content.__modify_html(this, options); } public dynamic(fn: DynamicCallback); public dynamic(name: string, fn: DynamicCallback); dynamic(name: DynamicCallback|string, fn?: DynamicCallback) { if (typeof name === 'function') { this.dynamicCallbacks.push({ cb: name, name: name.name || `--------\n${name.toString().substr(0, 300)}\n-------`, }); } else { this.dynamicCallbacks.push({ cb: fn, name: name, }); } } protected _base: string; base(url: string) { this._base = url; } private get baseTag() { return this._base? `\n` : ''; } protected _title: string; title(str: string) { this._title = str; } private get titleTag() { return this._title? `${this._title}\n` : ''; } compile(options?: EjsOptions) { if (options) { Object.assign(this._lastOptions, options); } const hasBody = /]/i.test(this._body); const scriptInject = `
${this._script.map((url, i) => { const debug_tag = this._script_dbg[i]; return ``; }).join('\n\t\t')}
`; let bodyContent: string; if (hasBody) { const s = scriptInject.replace(/^/mg, '\t'); bodyContent = this._body .replace(/<\/body>/i, s); } else { bodyContent = ` ${this._body} ${scriptInject.replace(/^/mg, '\t')} ` } //language=HTML const html = ` ${this.baseTag}${this.titleTag} ${this._meta.map(({type, name, content}) => { return ``; }).join('\n\t')} ${this._favicon.map((url) => { const ext = extname(url); return ``; }).join('\n\t')} ${this._stylesheet.map(([url, id]) => { const idstr = id? ` id="${id}"` : ''; return ``; }).join('\n\t')} ${polyfillScripts} ${this._head.replace(/\n/g, '\n\t')} ${bodyContent} `; const isDebug = isDebugMode(); let file: string; if (isDebug) { if (this._lastOptions.filename) { file = this._lastOptions.filename .replace(process.cwd(), '') .replace(/\//g, '.') .replace(/^\./g, ''); } else { file = md5(html); } const f = resolve(tmpdir(), 'compile-debug', projectName || 'unknown', file + '.html'); ensureDirSync(dirname(f)); writeFileSync(f, html, {encoding: 'utf8'}); debug('write debug [html] file to: %s', f); } try { // this._lastOptions.debug = isDebugMode(); this.renderFn = compile(html, this._lastOptions); this.deps = this.compileAllDeps(this.renderFn as any); debug(' dependencies: [%s]', this.deps); if (isDebug && !this.fileWatch) { this.registerWatchTemplate(); } } catch (e) { if (isDebug) { console.error('\x1B[0;38;5;9m== compile template failed ==\x1B[0m'); console.error('Error: %s', e.message); console.error('opiton = %j', this._lastOptions); console.error('\x1B[0;2m%s\x1B[0m', html.trim()); console.error('\x1B[0;38;5;9m== compile template failed ==\x1B[0m'); } else { console.log(`compile template failed: %s, opiton =`, e.message, this._lastOptions); appendFile("/tmp/template-fail.log", `--------------------\n[${JSON.stringify(this._lastOptions)}]\n${html.trim()}\n`, {encoding: 'utf-8'}); } this.renderFn = null; throw e; } } private change() { if (this.renderFn) { this.compile(); } } private deferred: DeferredFunction[] = []; defer(fn) { this.deferred.push(fn); } createMiddleware(): RequestHandler { this.compile(); return ((req, res, next) => { res.header('X-UA-Compatible', 'IE=edge,chrome=1'); const context: RenderContext = { global: new GlobalVariable(res), variables: res.locals || {}, request: req, }; this.render(context).then((text) => { for (let cb of this.deferred) { if (cb(context, res)) { return; } } res.send(text); }, (e) => { next(e); }); }); } render(context: RenderContext): Promise { debug('render'); context.global.set('request', context.request); context.global.set('isDebug', isDebugMode()); const ps = this.dynamicCallbacks.map(({cb, name}, index, self) => { debug(' layer: %s/%s - ', index + 1, self.length, name); return cb(context); }); return Promise.all(ps).then(() => { debug('render callback promise resolved'); context.global.unset('request'); context.variables._server_data_ = context.global.toString(); debug('render string from template'); data.enabled && data('variables: %s', Object.keys(context.variables)); return this.renderFn(context.variables); }); } private compileAllDeps({dependencies}: {dependencies: string[]}): string[] { if (!dependencies || !dependencies.length) { // console.log(' - nodeps'); return []; } const ret = [...dependencies]; for (const file of dependencies) { // console.log('compile subfile: %s', file); const render = compile(readFileSync(file, {encoding: 'utf-8'}), {...this._lastOptions, filename: file}); const sub: string[] = render['dependencies']; // console.log(' sub deps: %s', sub); if (sub.length) { ret.push(...this.compileAllDeps({dependencies: sub})); } } return ret; } private registerWatchTemplate() { this.fileWatch = new FileWatcher({ ignoreInitial: true, disableGlobbing: true, }); this.fileWatch.onChange((watchedPath) => { const currentDeps = [...this.deps]; this.compile(); this.fileWatch.unwatch(watchedPath); for (const item of currentDeps) { if (this.deps.indexOf(item) === -1) { this.fileWatch.unwatch(item); } } setTimeout(() => { this.fileWatch.watch(watchedPath); for (const item of this.deps) { if (currentDeps.indexOf(item) === -1) { this.fileWatch.watch(item); } } }, 200); }); this.fileWatch.watch(...this.deps); this.fileWatch.startWatching(); } } export interface RenderContext { global: GlobalVariable; variables: {[key: string]: string}; request: Request; } export type DeferredFunction = (context: RenderContext, res: Response) => void; function md5(data: Buffer|string, charset?: 'utf8') { return createHash('md5') .update(data, charset) .digest('hex') }