@popeindustries/lit-html-server
Advanced tools
Comparing version 1.0.0-rc.1 to 1.0.0-rc.2
@@ -20,3 +20,3 @@ 'use strict'; | ||
} | ||
part.setValue(`${index_js.unsafeStringPrefix}${value}`); | ||
part.setValue(`${index_js.unsafePrefixString}${value}`); | ||
}; | ||
@@ -23,0 +23,0 @@ } |
695
index.js
@@ -8,33 +8,16 @@ 'use strict'; | ||
/** | ||
* @typedef { Array<string|Promise<any>> } TemplateResult - an array of template strings (or Promises) and their resolved values | ||
* @property { boolean } isTemplateResult | ||
* An empty string Buffer | ||
*/ | ||
const emptyStringBuffer = Buffer.from(''); | ||
/** | ||
* @typedef TemplateResultProcessor { import('./default-template-result-processor.js').TemplateResultProcessor } | ||
* A value for strings that signals a Part to clear its content | ||
*/ | ||
/** | ||
* Determine if 'obj' is a template result | ||
* | ||
* @param { any } obj | ||
* @returns { boolean } | ||
*/ | ||
function isTemplateResult(obj) { | ||
return Array.isArray(obj) && obj.isTemplateResult; | ||
} | ||
const nothingString = '__nothing-lit-html-server-string__'; | ||
/** | ||
* Reduce a Template's strings and values to an array of resolved strings (or Promises) | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
* @param { TemplateResultProcessor } processor | ||
* @returns { TemplateResult } | ||
* A prefix value for strings that should not be escaped | ||
*/ | ||
function TemplateResult(template, values, processor) { | ||
const result = processor.processTemplate(template, values); | ||
const unsafePrefixString = '__unsafe-lit-html-server-string__'; | ||
result.isTemplateResult = true; | ||
return result; | ||
} | ||
/** | ||
@@ -225,12 +208,2 @@ * Determine if "promise" is a Promise instance | ||
/** | ||
* A value for strings that signals a Part to clear its content | ||
*/ | ||
const nothingString = '__nothing-lit-html-server-string__'; | ||
/** | ||
* A prefix value for strings that should not be escaped | ||
*/ | ||
const unsafeStringPrefix = '__unsafe-lit-html-server-string__'; | ||
/** | ||
* Base class interface for Node/Attribute parts | ||
@@ -284,3 +257,3 @@ */ | ||
getValue(value) { | ||
return resolveValue(value, this, true); | ||
return resolveNodeValue(value, this); | ||
} | ||
@@ -298,3 +271,3 @@ } | ||
* @param { string } name | ||
* @param { Array<string> } strings | ||
* @param { Array<Buffer> } strings | ||
*/ | ||
@@ -306,6 +279,8 @@ constructor(name, strings) { | ||
this.length = strings.length - 1; | ||
this.prefix = Buffer.from(`${this.name}="`); | ||
this.suffix = Buffer.from(`${this.strings[this.length]}"`); | ||
} | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* Resolves to a single string, or Promise for a single string, | ||
@@ -315,47 +290,45 @@ * even when responsible for multiple values. | ||
* @param { Array<any> } values | ||
* @returns { string|Promise<string> } | ||
* @returns { Buffer|Promise<Buffer> } | ||
*/ | ||
getValue(values) { | ||
const strings = this.strings; | ||
const endIndex = strings.length - 1; | ||
const result = [`${this.name}="`]; | ||
let pending; | ||
let chunks = [this.prefix]; | ||
let pendingChunks; | ||
for (let i = 0; i < endIndex; i++) { | ||
const string = strings[i]; | ||
let value = resolveValue(values[i], this, false); | ||
for (let i = 0; i < this.length; i++) { | ||
const string = this.strings[i]; | ||
let value = resolveAttributeValue(values[i], this); | ||
result.push(string); | ||
// Bail if 'nothing' | ||
if (value === nothingString) { | ||
return ''; | ||
return emptyStringBuffer; | ||
} | ||
chunks.push(string); | ||
if (Buffer.isBuffer(value)) { | ||
chunks.push(value); | ||
} else if (isPromise(value)) { | ||
if (pending === undefined) { | ||
pending = []; | ||
// Lazy init for uncommon scenario | ||
if (pendingChunks === undefined) { | ||
pendingChunks = []; | ||
} | ||
const index = result.push(value) - 1; | ||
const index = chunks.push(value) - 1; | ||
pending.push( | ||
pendingChunks.push( | ||
value.then((value) => { | ||
result[index] = value; | ||
chunks[index] = value; | ||
}) | ||
); | ||
} else if (Array.isArray(value)) { | ||
result.push(value.join('')); | ||
} else { | ||
result.push(value); | ||
chunks = chunks.concat(value); | ||
} | ||
} | ||
result.push(`${strings[endIndex]}"`); | ||
chunks.push(this.suffix); | ||
if (pending !== undefined) { | ||
// Flatten in case array returned from Promise | ||
return Promise.all(pending).then(() => | ||
result.reduce((result, value) => result.concat(value), []).join('') | ||
); | ||
if (pendingChunks !== undefined) { | ||
return Promise.all(pendingChunks).then(() => Buffer.concat(chunks)); | ||
} | ||
return result.join(''); | ||
return Buffer.concat(chunks); | ||
} | ||
@@ -373,3 +346,3 @@ } | ||
* @param { string } name | ||
* @param { Array<string> } strings | ||
* @param { Array<Buffer> } strings | ||
* @throws error when multiple expressions | ||
@@ -380,3 +353,9 @@ */ | ||
if (strings.length !== 2 || strings[0] !== '' || strings[1] !== '') { | ||
this.name = Buffer.from(this.name); | ||
if ( | ||
strings.length !== 2 || | ||
!strings[0].equals(emptyStringBuffer) || | ||
!strings[1].equals(emptyStringBuffer) | ||
) { | ||
throw Error('Boolean attributes can only contain a single expression'); | ||
@@ -387,6 +366,6 @@ } | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* | ||
* @param { Array<any> } values | ||
* @returns { string|Promise<string> } | ||
* @returns { Buffer|Promise<Buffer> } | ||
*/ | ||
@@ -401,6 +380,6 @@ getValue(values) { | ||
if (isPromise(value)) { | ||
return value.then((value) => (value ? this.name : '')); | ||
return value.then((value) => (value ? this.name : emptyStringBuffer)); | ||
} | ||
return value ? this.name : ''; | ||
return value ? this.name : emptyStringBuffer; | ||
} | ||
@@ -415,3 +394,3 @@ } | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* Properties have no server-side representation, | ||
@@ -424,3 +403,3 @@ * so always returns an empty string. | ||
getValue(/* values */) { | ||
return ''; | ||
return emptyStringBuffer; | ||
} | ||
@@ -435,3 +414,3 @@ } | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* Event bindings have no server-side representation, | ||
@@ -444,3 +423,3 @@ * so always returns an empty string. | ||
getValue(/* values */) { | ||
return ''; | ||
return emptyStringBuffer; | ||
} | ||
@@ -453,7 +432,6 @@ } | ||
* @param { any } value | ||
* @param { Part } part | ||
* @param { boolean } ignoreNothingAndUndefined | ||
* @param { AttributePart } part | ||
* @returns { any } | ||
*/ | ||
function resolveValue(value, part, ignoreNothingAndUndefined = true) { | ||
function resolveAttributeValue(value, part) { | ||
if (isDirective(value)) { | ||
@@ -463,15 +441,66 @@ value = getDirectiveValue(value, part); | ||
if (ignoreNothingAndUndefined && (value === nothingString || value === undefined)) { | ||
return ''; | ||
if (value === nothingString) { | ||
return value; | ||
} | ||
// Pass-through template result | ||
if (isTemplateResult(value)) { | ||
value = value.read(); | ||
} | ||
if (isPrimitive(value)) { | ||
const string = typeof value !== 'string' ? String(value) : value; | ||
// Escape if not prefixed with unsafePrefixString, otherwise strip prefix | ||
return Buffer.from( | ||
string.indexOf(unsafePrefixString) === 0 ? string.slice(33) : escapeTextForBrowser(string) | ||
); | ||
} else if (Buffer.isBuffer(value)) { | ||
return value; | ||
} else if (isPrimitive(value)) { | ||
} else if (isPromise(value)) { | ||
return value.then((value) => resolveAttributeValue(value, part)); | ||
} else if (isSyncIterator(value)) { | ||
if (!Array.isArray(value)) { | ||
value = Array.from(value); | ||
} | ||
return Buffer.concat( | ||
value.reduce((values, value) => { | ||
value = resolveAttributeValue(value, part); | ||
// Flatten | ||
if (Array.isArray(value)) { | ||
return values.concat(value); | ||
} | ||
values.push(value); | ||
return values; | ||
}, []) | ||
); | ||
} else { | ||
throw Error('unknown AttributPart value', value); | ||
} | ||
} | ||
/** | ||
* Resolve "value" to string Buffer if possible | ||
* | ||
* @param { any } value | ||
* @param { NodePart } part | ||
* @returns { any } | ||
*/ | ||
function resolveNodeValue(value, part) { | ||
if (isDirective(value)) { | ||
value = getDirectiveValue(value, part); | ||
} | ||
if (value === nothingString || value === undefined) { | ||
return emptyStringBuffer; | ||
} | ||
if (isPrimitive(value)) { | ||
const string = typeof value !== 'string' ? String(value) : value; | ||
// Escape if not prefixed with unsafeStringPrefix, otherwise strip prefix | ||
return string.indexOf(unsafeStringPrefix) === 0 ? string.slice(33) : escapeTextForBrowser(string); | ||
// Escape if not prefixed with unsafePrefixString, otherwise strip prefix | ||
return Buffer.from( | ||
string.indexOf(unsafePrefixString) === 0 ? string.slice(33) : escapeTextForBrowser(string) | ||
); | ||
} else if (isTemplateResult(value) || Buffer.isBuffer(value)) { | ||
return value; | ||
} else if (isPromise(value)) { | ||
return value.then((value) => resolveValue(value, part, ignoreNothingAndUndefined)); | ||
return value.then((value) => resolveNodeValue(value, part)); | ||
} else if (isSyncIterator(value)) { | ||
@@ -482,4 +511,4 @@ if (!Array.isArray(value)) { | ||
return value.reduce((values, value) => { | ||
value = resolveValue(value, part, ignoreNothingAndUndefined); | ||
// Allow nested template results to also be flattened by not checking isTemplateResult | ||
value = resolveNodeValue(value, part); | ||
// Flatten | ||
if (Array.isArray(value)) { | ||
@@ -492,3 +521,3 @@ return values.concat(value); | ||
} else { | ||
return value; | ||
throw Error('unknown NodePart value', value); | ||
} | ||
@@ -512,3 +541,183 @@ } | ||
const pool = []; | ||
let id = 0; | ||
/** | ||
* Retrieve TemplateResult instance. | ||
* Uses an object pool to recycle instances. | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
* @returns { TemplateResult } | ||
*/ | ||
function templateResult(template, values) { | ||
let instance = pool.pop(); | ||
if (instance) { | ||
instance.template = template; | ||
instance.values = values; | ||
} else { | ||
instance = new TemplateResult(template, values); | ||
} | ||
return instance; | ||
} | ||
/** | ||
* Determine whether "result" is a TemplateResult | ||
* | ||
* @param { TemplateResult } result | ||
* @returns { boolean } | ||
*/ | ||
function isTemplateResult(result) { | ||
return result instanceof TemplateResult; | ||
} | ||
/** | ||
* A class for consuming the combined static and dynamic parts of a lit-html Template. | ||
* TemplateResults | ||
*/ | ||
class TemplateResult { | ||
/** | ||
* Constructor | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
*/ | ||
constructor(template, values) { | ||
this.template = template; | ||
this.values = values; | ||
this.id = id++; | ||
this.index = 0; | ||
} | ||
/** | ||
* Consume template result content. | ||
* *Note* that instances may only be read once, | ||
* and will be destroyed upon completion. | ||
* | ||
* @param { boolean } deep - recursively resolve nested TemplateResults | ||
* @returns { any } | ||
*/ | ||
read(deep) { | ||
let buffer = emptyStringBuffer; | ||
let chunk, chunks; | ||
while ((chunk = this.readChunk()) !== null) { | ||
if (Buffer.isBuffer(chunk)) { | ||
buffer = Buffer.concat([buffer, chunk]); | ||
} else { | ||
if (chunks === undefined) { | ||
chunks = []; | ||
} | ||
buffer = reduce(buffer, chunks, chunk, deep); | ||
} | ||
} | ||
if (chunks !== undefined) { | ||
chunks.push(buffer); | ||
return chunks.length > 1 ? chunks : chunks[0]; | ||
} | ||
return buffer; | ||
} | ||
/** | ||
* Consume template result content one chunk at a time. | ||
* *Note* that instances may only be read once, | ||
* and will be destroyed when the last chunk is read. | ||
* | ||
* @returns { any } | ||
*/ | ||
readChunk() { | ||
const isString = this.index % 2 === 0; | ||
const index = (this.index / 2) | 0; | ||
// Finished | ||
if (!isString && index >= this.template.strings.length - 1) { | ||
this.destroy(); | ||
return null; | ||
} | ||
this.index++; | ||
if (isString) { | ||
return this.template.strings[index]; | ||
} | ||
const part = this.template.parts[index]; | ||
let value; | ||
if (part instanceof AttributePart) { | ||
// AttributeParts can have multiple values, so slice based on length | ||
// (strings in-between values are already handled the instance) | ||
if (part.length > 1) { | ||
value = part.getValue(this.values.slice(index, index + part.length)); | ||
this.index += part.length; | ||
} else { | ||
value = part.getValue([this.values[index]]); | ||
} | ||
} else { | ||
value = part.getValue(this.values[index]); | ||
} | ||
return value; | ||
} | ||
/** | ||
* Destroy the instance, | ||
* returning it to the object pool | ||
* | ||
* @param { boolean } permanent - permanently destroy instance and it's children | ||
* @returns { void } | ||
*/ | ||
destroy(permanent) { | ||
if (this.values !== undefined) { | ||
if (permanent) { | ||
for (const value of this.values) { | ||
if (isTemplateResult(value)) { | ||
value.destroy(permanent); | ||
} | ||
} | ||
} | ||
this.values.length = 0; | ||
} | ||
this.values = undefined; | ||
this.template = undefined; | ||
this.index = 0; | ||
if (!permanent) { | ||
pool.push(this); | ||
} | ||
} | ||
} | ||
/** | ||
* Commit "chunk" to string "buffer". | ||
* Returns new "buffer" value. | ||
* | ||
* @param { Buffer } buffer | ||
* @param { Array<any> } chunks | ||
* @param { any } chunk | ||
* @param { boolean } [deep] | ||
* @returns { Buffer } | ||
*/ | ||
function reduce(buffer, chunks, chunk, deep = false) { | ||
if (Buffer.isBuffer(chunk)) { | ||
return Buffer.concat([buffer, chunk]); | ||
} else if (isTemplateResult(chunk)) { | ||
if (deep) { | ||
return reduce(buffer, chunks, chunk.read(deep), deep); | ||
} else { | ||
chunks.push(buffer, chunk); | ||
return emptyStringBuffer; | ||
} | ||
} else if (Array.isArray(chunk)) { | ||
return chunk.reduce((buffer, chunk) => reduce(buffer, chunks, chunk), buffer); | ||
} else if (isPromise(chunk)) { | ||
chunks.push(buffer, chunk); | ||
return emptyStringBuffer; | ||
} | ||
} | ||
/** | ||
* @typedef TemplateProcessor | ||
@@ -558,7 +767,9 @@ * @property { (name: string, strings: Array<string>) => AttributePart } handleAttributeExpressions | ||
* @typedef TemplateResultProcessor | ||
* @property { (template: Template, values: Array<any>) => TemplateResult } processTemplate | ||
* @property { (renderer: TemplateResultRenderer, stack: Array<any>) => void } process | ||
*/ | ||
/** | ||
* Class representing the default template result processor. | ||
* Class for the default TemplateResult processor | ||
* used by Promise/Stream TemplateRenderers. | ||
* | ||
* @implements TemplateResultProcessor | ||
@@ -568,45 +779,64 @@ */ | ||
/** | ||
* Create template result array from "template" instance and dynamic "values". | ||
* The returned array contains Template "strings" concatenated with known string "values", | ||
* and any Promises that will eventually resolve asynchronous string "values". | ||
* A synchronous template tree will reduce to an array containing a single string. | ||
* An asynchronous template tree will reduce to an array of strings and Promises. | ||
* Process "stack" and push chunks to "renderer" | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
* @returns { TemplateResult } | ||
* @param { TemplateResultRenderer } renderer | ||
* @param { Array<any> } stack | ||
*/ | ||
processTemplate(template, values) { | ||
const { strings, parts } = template; | ||
const endIndex = strings.length - 1; | ||
const result = []; | ||
let buffer = ''; | ||
process(renderer, stack) { | ||
while (!renderer.awaitingPromise) { | ||
let chunk = stack[0]; | ||
let breakLoop = false; | ||
let popStack = true; | ||
for (let i = 0; i < endIndex; i++) { | ||
const string = strings[i]; | ||
const part = parts[i]; | ||
let value = values[i]; | ||
if (chunk === undefined) { | ||
return renderer.push(null); | ||
} | ||
buffer += string; | ||
if (isTemplateResult(chunk)) { | ||
popStack = false; | ||
chunk = getTemplateResultChunk(chunk, stack); | ||
} | ||
if (part instanceof AttributePart) { | ||
// AttributeParts can have multiple values, so slice based on length | ||
// (strings in-between values are already stored in the instance) | ||
if (part.length > 1) { | ||
value = part.getValue(values.slice(i, i + part.length)); | ||
i += part.length - 1; | ||
// Skip if finished reading TemplateResult (null) | ||
if (chunk !== null) { | ||
if (Buffer.isBuffer(chunk)) { | ||
if (!renderer.push(chunk)) { | ||
breakLoop = true; | ||
} | ||
} else if (isPromise(chunk)) { | ||
breakLoop = true; | ||
renderer.awaitingPromise = true; | ||
stack.unshift(chunk); | ||
chunk | ||
.then((chunk) => { | ||
renderer.awaitingPromise = false; | ||
stack[0] = chunk; | ||
this.process(renderer, stack); | ||
}) | ||
.catch((err) => { | ||
destroy(stack); | ||
renderer.destroy(err); | ||
}); | ||
} else if (Array.isArray(chunk)) { | ||
// An existing TemplateResult will have already set this to "false", | ||
// so only remove existing Array if there is no active TemplateResult | ||
if (popStack === true) { | ||
popStack = false; | ||
stack.shift(); | ||
} | ||
stack.unshift(...chunk); | ||
} else { | ||
value = part.getValue([value]); | ||
destroy(stack); | ||
return renderer.destroy(Error('unknown chunk type:', chunk)); | ||
} | ||
} else { | ||
value = part.getValue(value); | ||
} | ||
buffer = reduce(buffer, result, value); | ||
if (popStack) { | ||
stack.shift(); | ||
} | ||
if (breakLoop) { | ||
break; | ||
} | ||
} | ||
buffer += strings[endIndex]; | ||
result.push(buffer); | ||
return result; | ||
} | ||
@@ -616,18 +846,14 @@ } | ||
/** | ||
* Commit value to string "buffer" | ||
* Permanently destroy all remaining TemplateResults in "stack". | ||
* (Triggered on error) | ||
* | ||
* @param { string } buffer | ||
* @param { TemplateResult } result | ||
* @param { any } value | ||
* @returns { string } | ||
* @param { Array<any> } stack | ||
*/ | ||
function reduce(buffer, result, value) { | ||
if (typeof value === 'string') { | ||
buffer += value; | ||
return buffer; | ||
} else if (Array.isArray(value)) { | ||
return value.reduce((buffer, value) => reduce(buffer, result, value), buffer); | ||
} else if (isPromise(value)) { | ||
result.push(buffer, value); | ||
return ''; | ||
function destroy(stack) { | ||
if (stack.length > 0) { | ||
for (const chunk of stack) { | ||
if (isTemplateResult(chunk)) { | ||
chunk.destroy(true); | ||
} | ||
} | ||
} | ||
@@ -637,39 +863,26 @@ } | ||
/** | ||
* @typedef TemplateResult { import('./template-result.js).TemplateResult } | ||
*/ | ||
/** | ||
* Buffer strings from "result" and store them on "accumulator" | ||
* Retrieve next chunk from "result". | ||
* Adds nested TemplateResults to the stack if necessary. | ||
* | ||
* @param { TemplateResult } result | ||
* @param { object } [accumulator] | ||
* @returns { Promise<string> } | ||
* @param { Array<any> } stack | ||
*/ | ||
async function bufferResult( | ||
result, | ||
accumulator = { | ||
buffer: '', | ||
bufferChunk(chunk) { | ||
this.buffer += chunk; | ||
} | ||
function getTemplateResultChunk(result, stack) { | ||
let chunk = result.readChunk(); | ||
// Skip empty strings | ||
if (Buffer.isBuffer(chunk) && chunk.length === 0) { | ||
chunk = result.readChunk(); | ||
} | ||
) { | ||
let stack = result.slice(); | ||
let chunk; | ||
accumulator.buffer = ''; | ||
while ((chunk = stack.shift()) !== undefined) { | ||
if (typeof chunk === 'string') { | ||
accumulator.bufferChunk(chunk); | ||
} else if (Array.isArray(chunk)) { | ||
stack = chunk.concat(stack); | ||
} else if (isPromise(chunk)) { | ||
stack.unshift(await chunk); | ||
} else { | ||
throw Error('unknown value type', chunk); | ||
} | ||
// Finished reading, dispose | ||
if (chunk === null) { | ||
stack.shift(); | ||
} else if (isTemplateResult(chunk)) { | ||
// Add to top of stack | ||
stack.unshift(chunk); | ||
chunk = getTemplateResultChunk(chunk, stack); | ||
} | ||
return accumulator.buffer; | ||
return chunk; | ||
} | ||
@@ -680,2 +893,8 @@ | ||
*/ | ||
/** | ||
* @typedef TemplateResultProcessor { import('./default-template-result-processor.js).TemplateResultProcessor } | ||
*/ | ||
/** | ||
* @typedef TemplateResultRenderer { import('./default-template-result-renderer.js).TemplateResultRenderer } | ||
*/ | ||
@@ -690,6 +909,32 @@ /** | ||
* @param { TemplateResult } result | ||
* @param { TemplateResultProcessor } processor | ||
* @returns { Promise<string> } | ||
*/ | ||
constructor(result) { | ||
return bufferResult(result); | ||
constructor(result, processor) { | ||
return new Promise((resolve, reject) => { | ||
let stack = [result]; | ||
let chunks = []; | ||
processor.process( | ||
{ | ||
awaitingPromise: false, | ||
push(chunk) { | ||
if (chunk === null) { | ||
resolve(Buffer.concat(chunks).toString()); | ||
} else { | ||
chunks.push(chunk); | ||
} | ||
return true; | ||
}, | ||
destroy(err) { | ||
chunks.length = 0; | ||
chunks = undefined; | ||
stack.length = 0; | ||
stack = undefined; | ||
reject(err); | ||
} | ||
}, | ||
stack | ||
); | ||
}); | ||
} | ||
@@ -703,3 +948,5 @@ } | ||
/** | ||
* A custom Readable stream class that renders a TemplateResult | ||
* A custom Readable stream class for rendering a template result to a stream | ||
* | ||
* @implements TemplateResultRenderer | ||
*/ | ||
@@ -710,72 +957,22 @@ class StreamTemplateRenderer extends stream.Readable { | ||
* | ||
* @param { TemplateResult } result | ||
* @param { object } [options] Readable options | ||
* @see https://nodejs.org/api/stream.html#stream_new_stream_readable_options | ||
* @param { TemplateResult } result - a template result returned from call to "html`...`" | ||
* @param { TemplateResultProcessor } processor | ||
* @returns { Readable } | ||
*/ | ||
constructor(result, options) { | ||
super({ autoDestroy: true, ...options }); | ||
constructor(result, processor) { | ||
super({ autoDestroy: true }); | ||
this.canPushData = true; | ||
this.done = false; | ||
this.buffer = ''; | ||
this.index = 0; | ||
bufferResult(result, this) | ||
.then(() => { | ||
this.done = true; | ||
this._drainBuffer(); | ||
}) | ||
.catch((err) => { | ||
this.destroy(err); | ||
}); | ||
this.awaitingPromise = false; | ||
this.processor = processor; | ||
this.stack = [result]; | ||
} | ||
/** | ||
* Push "chunk" onto buffer | ||
* (Called by "bufferResult" utility) | ||
* | ||
* @param { string } chunk | ||
*/ | ||
bufferChunk(chunk) { | ||
this.buffer += chunk; | ||
this._drainBuffer(); | ||
} | ||
/** | ||
* Extend Readable.read() | ||
*/ | ||
_read() { | ||
this.canPushData = true; | ||
this._drainBuffer(); | ||
this.processor.process(this, this.stack); | ||
} | ||
/** | ||
* Write all buffered content to stream. | ||
* Returns "false" if write triggered backpressure, otherwise "true". | ||
* | ||
* @returns { boolean } | ||
*/ | ||
_drainBuffer() { | ||
if (!this.canPushData) { | ||
return false; | ||
} | ||
const bufferLength = this.buffer.length; | ||
if (this.index < bufferLength) { | ||
// Strictly speaking we shouldn't compare character length with byte length, but close enough | ||
const length = Math.min(bufferLength - this.index, this.readableHighWaterMark); | ||
const chunk = this.buffer.slice(this.index, this.index + length); | ||
this.canPushData = this.push(chunk, 'utf8'); | ||
this.index += length; | ||
} else if (this.done) { | ||
this.push(null); | ||
} | ||
return this.canPushData; | ||
} | ||
/** | ||
* Extend Readalbe.destroy() | ||
@@ -786,6 +983,2 @@ * | ||
_destroy(err) { | ||
if (this.done) { | ||
return; | ||
} | ||
if (err) { | ||
@@ -796,6 +989,5 @@ this.emit('error', err); | ||
this.canPushData = false; | ||
this.done = true; | ||
this.buffer = ''; | ||
this.index = 0; | ||
this.stack.length = 0; | ||
this.stack = []; | ||
this.processor = null; | ||
this.removeAllListeners(); | ||
@@ -914,3 +1106,3 @@ } | ||
// Store any text between quote character and value | ||
const attributeStrings = [suffix.slice(matchQuote.index + 1)]; | ||
const attributeStrings = [Buffer.from(suffix.slice(matchQuote.index + 1))]; | ||
let open = true; | ||
@@ -926,6 +1118,6 @@ skip = 0; | ||
if (closingQuoteIndex === -1) { | ||
attributeStrings.push(attributeString); | ||
attributeStrings.push(Buffer.from(attributeString)); | ||
skip++; | ||
} else { | ||
attributeStrings.push(attributeString.slice(0, closingQuoteIndex)); | ||
attributeStrings.push(Buffer.from(attributeString.slice(0, closingQuoteIndex))); | ||
nextString = attributeString.slice(closingQuoteIndex + 1); | ||
@@ -939,3 +1131,6 @@ i += skip; | ||
} else { | ||
part = processor.handleAttributeExpressions(name, ['', '']); | ||
part = processor.handleAttributeExpressions(name, [ | ||
emptyStringBuffer, | ||
emptyStringBuffer | ||
]); | ||
} | ||
@@ -947,7 +1142,7 @@ } | ||
this.strings.push(string); | ||
this.strings.push(Buffer.from(string)); | ||
this.parts.push(part); | ||
// Add placehholders for strings/parts that wil be skipped due to multple values in a single AttributePart | ||
if (skip > 0) { | ||
this.strings.push(''); | ||
this.strings.push(null); | ||
this.parts.push(null); | ||
@@ -958,3 +1153,3 @@ skip = 0; | ||
this.strings.push(nextString); | ||
this.strings.push(Buffer.from(nextString)); | ||
} | ||
@@ -1011,3 +1206,3 @@ } | ||
return TemplateResult(template, values, defaultTemplateResultProcessor); | ||
return templateResult(template, values); | ||
} | ||
@@ -1017,20 +1212,20 @@ | ||
* Render a template result to a Readable stream | ||
* *Note* that TemplateResults are single use, and can only be rendered once. | ||
* | ||
* @param { TemplateResult } result - a template result returned from call to "html`...`" | ||
* @param { object } [options] - Readable stream options | ||
* @see https://nodejs.org/api/stream.html#stream_new_stream_readable_options | ||
* @returns { Readable } | ||
*/ | ||
function renderToStream(result, options) { | ||
return new StreamTemplateRenderer(result, options); | ||
function renderToStream(result) { | ||
return new StreamTemplateRenderer(result, defaultTemplateResultProcessor); | ||
} | ||
/** | ||
* Render a template result to a string resolving Promise | ||
* Render a template result to a string resolving Promise. | ||
* *Note* that TemplateResults are single use, and can only be rendered once. | ||
* | ||
* @param { TemplateResult } result | ||
* @param { TemplateResult } result - a template result returned from call to "html`...`" | ||
* @returns { Promise<string> } | ||
*/ | ||
function renderToString(result) { | ||
return new PromiseTemplateRenderer(result); | ||
return new PromiseTemplateRenderer(result, defaultTemplateResultProcessor); | ||
} | ||
@@ -1049,3 +1244,3 @@ | ||
exports.nothingString = nothingString; | ||
exports.unsafeStringPrefix = unsafeStringPrefix; | ||
exports.unsafePrefixString = unsafePrefixString; | ||
exports.directive = directive; |
{ | ||
"name": "@popeindustries/lit-html-server", | ||
"version": "1.0.0-rc.1", | ||
"version": "1.0.0-rc.2", | ||
"description": "Render lit-html templates on the server", | ||
@@ -31,2 +31,3 @@ "keywords": [ | ||
"lit-html": "^1.0.0-rc.2", | ||
"lorem-ipsum": "^1.0.6", | ||
"mocha": "^5.2.0", | ||
@@ -45,3 +46,3 @@ "prettier": "^1.16.0", | ||
"lint": "eslint './{directives,lib,test}/**/*.js'", | ||
"perf": "node test/server.js & PID=$!; autocannon -c 100 -d 5 -p 10 http://localhost:3000 && kill $PID", | ||
"perf": "node test/perf.js", | ||
"precommit": "lint-staged", | ||
@@ -48,0 +49,0 @@ "prepublishOnly": "npm run build", |
@@ -6,5 +6,5 @@ [](https://npmjs.org/package/@popeindustries/lit-html-server) | ||
Render [**lit-html**](https://polymer.github.io/lit-html/) templates on the server as Node.js streams. Supports all **lit-html** types, special attribute expressions, and most of the standard directives. | ||
Render [**lit-html**](https://polymer.github.io/lit-html/) templates on the server as Node.js streams. Supports all **lit-html** types, special attribute expressions, and many of the standard directives. | ||
> Although based on **lit-html** semantics, **lit-html-server** is a great general purpose HTML template streaming library. Tagged template literals are a native JavaScript feature, and the HTML rendered is 100% standard markup, with no special syntax or client-side runtime required. | ||
> Although based on **lit-html** semantics, **lit-html-server** is a great general purpose HTML template streaming library. Tagged template literals are a native JavaScript feature, and the HTML rendered is 100% standard markup, with no special syntax or client-side runtime required! | ||
@@ -53,9 +53,16 @@ ## Usage | ||
...and render: | ||
...and render (plain HTTP server example, though similar for Express/Fastify/etc): | ||
```js | ||
const http = require('http'); | ||
const { renderToStream } = require('@popeindustries/lit-html-server'); | ||
// Returns a Node.js Readable stream which can be piped to `response` | ||
renderToStream(layout({ title: 'Home', api: '/api/home' })); | ||
http | ||
.createServer((request, response) => { | ||
const data = { title: 'Home', api: '/api/home' }; | ||
res.writeHead(200); | ||
// Returns a Node.js Readable stream which can be piped to "response" | ||
renderToStream(layout(data)).pipe(response); | ||
} | ||
``` | ||
@@ -90,3 +97,3 @@ | ||
```js | ||
render( | ||
renderToStream( | ||
html` | ||
@@ -108,2 +115,3 @@ <h1>Hello ${name}!</h1> | ||
); | ||
response.end(markup); | ||
``` | ||
@@ -163,3 +171,3 @@ | ||
`; | ||
//=> <input > | ||
//=> <input /> | ||
``` | ||
@@ -231,2 +239,26 @@ | ||
- `cache(value)`: Enables fast switching between multiple templates by caching previous results. Since it's generally not desireable to cache between requests, this is a no-op: | ||
```js | ||
const cache = require('@popeindustries/lit-html-server/directives/cache.js'); | ||
cache( | ||
loggedIn | ||
? html` | ||
You are logged in | ||
` | ||
: html` | ||
Please log in | ||
` | ||
); | ||
``` | ||
- `classMap(classInfo)`: applies css classes to the `class` attribute. 'classInfo' keys are added as class names if values are truthy: | ||
```js | ||
const classMap = require('@popeindustries/lit-html-server/directives/class-map.js'); | ||
html` | ||
<div class="${classMap({ red: true })}"></div> | ||
`; | ||
``` | ||
- `guard(value, fn)`: no-op since re-rendering does not apply (renders result of `fn`): | ||
@@ -277,2 +309,11 @@ | ||
- `styleMap(styleInfo)`: applies css properties to the `style` attribute. 'styleInfo' keys and values are added as style properties: | ||
```js | ||
const styleMap = require('@popeindustries/lit-html-server/directives/style-map.js'); | ||
html` | ||
<div style="${styleMap({ color: 'red' })}"></div> | ||
`; | ||
``` | ||
- `until(...args)`: renders one of a series of values, including Promises, in priority order. Since it's not possible to render more than once in a server context, primitive sync values are prioritised over async Promises, unless there are no more pending values, in which case the last value is rendered regardless of type: | ||
@@ -309,37 +350,4 @@ | ||
- `classMap(classInfo)`: applies css classes to the `class` attribute. 'classInfo' keys are added as class names if values are truthy: | ||
```js | ||
const classMap = require('@popeindustries/lit-html-server/directives/class-map.js'); | ||
html` | ||
<div class="${classMap({ red: true })}"></div> | ||
`; | ||
``` | ||
- `styleMap(styleInfo)`: applies css properties to the `style` attribute. 'styleInfo' keys and values are added as style properties: | ||
```js | ||
const styleMap = require('@popeindustries/lit-html-server/directives/style-map.js'); | ||
html` | ||
<div style="${styleMap({ color: 'red' })}"></div> | ||
`; | ||
``` | ||
- `cache(value)`: Enables fast switching between multiple templates by caching previous results. Since it's generally not desireable to cache between requests, this is a no-op: | ||
```js | ||
const cache = require('@popeindustries/lit-html-server/directives/cache.js'); | ||
cache( | ||
loggedIn | ||
? html` | ||
You are logged in | ||
` | ||
: html` | ||
Please log in | ||
` | ||
); | ||
``` | ||
## Thanks! | ||
Thanks to [Thomas Parslow](https://github.com/almost) for the [stream-template](https://github.com/almost/stream-template) library that was the basis for this streaming implementation, and thanks to [Justin Fagnani](https://github.com/justinfagnani) and the [team](https://github.com/Polymer/lit-html/graphs/contributors) behind the **lit-html** project! |
/** | ||
* @typedef TemplateResultProcessor | ||
* @property { (template: Template, values: Array<any>) => TemplateResult } processTemplate | ||
* @property { (renderer: TemplateResultRenderer, stack: Array<any>) => void } process | ||
*/ | ||
/** | ||
* @typedef Template { import('./template.js).Template } | ||
* @typedef TemplateResult { import('./template-result.js).TemplateResult } | ||
* @typedef TemplateResultRenderer | ||
* @property { boolean } awaitingPromise | ||
* @property { (chunk: Buffer) => boolean } push | ||
* @property { (err: Error) => void } destroy | ||
*/ | ||
import { AttributePart } from './parts.js'; | ||
import { isPromise } from './is.js'; | ||
import { isTemplateResult } from './template-result.js'; | ||
/** | ||
* Class representing the default template result processor. | ||
* Class for the default TemplateResult processor | ||
* used by Promise/Stream TemplateRenderers. | ||
* | ||
* @implements TemplateResultProcessor | ||
@@ -18,45 +22,80 @@ */ | ||
/** | ||
* Create template result array from "template" instance and dynamic "values". | ||
* The returned array contains Template "strings" concatenated with known string "values", | ||
* and any Promises that will eventually resolve asynchronous string "values". | ||
* A synchronous template tree will reduce to an array containing a single string. | ||
* An asynchronous template tree will reduce to an array of strings and Promises. | ||
* Process "stack" and push chunks to "renderer" | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
* @returns { TemplateResult } | ||
* @param { TemplateResultRenderer } renderer | ||
* @param { Array<any> } stack | ||
*/ | ||
processTemplate(template, values) { | ||
const { strings, parts } = template; | ||
const endIndex = strings.length - 1; | ||
const result = []; | ||
let buffer = ''; | ||
process(renderer, stack) { | ||
while (!renderer.awaitingPromise) { | ||
let chunk = stack[0]; | ||
let breakLoop = false; | ||
let popStack = true; | ||
for (let i = 0; i < endIndex; i++) { | ||
const string = strings[i]; | ||
const part = parts[i]; | ||
let value = values[i]; | ||
if (chunk === undefined) { | ||
return renderer.push(null); | ||
} | ||
buffer += string; | ||
if (isTemplateResult(chunk)) { | ||
popStack = false; | ||
chunk = getTemplateResultChunk(chunk, stack); | ||
} | ||
if (part instanceof AttributePart) { | ||
// AttributeParts can have multiple values, so slice based on length | ||
// (strings in-between values are already stored in the instance) | ||
if (part.length > 1) { | ||
value = part.getValue(values.slice(i, i + part.length)); | ||
i += part.length - 1; | ||
// Skip if finished reading TemplateResult (null) | ||
if (chunk !== null) { | ||
if (Buffer.isBuffer(chunk)) { | ||
if (!renderer.push(chunk)) { | ||
breakLoop = true; | ||
} | ||
} else if (isPromise(chunk)) { | ||
breakLoop = true; | ||
renderer.awaitingPromise = true; | ||
stack.unshift(chunk); | ||
chunk | ||
.then((chunk) => { | ||
renderer.awaitingPromise = false; | ||
stack[0] = chunk; | ||
this.process(renderer, stack); | ||
}) | ||
.catch((err) => { | ||
destroy(stack); | ||
renderer.destroy(err); | ||
}); | ||
} else if (Array.isArray(chunk)) { | ||
// An existing TemplateResult will have already set this to "false", | ||
// so only remove existing Array if there is no active TemplateResult | ||
if (popStack === true) { | ||
popStack = false; | ||
stack.shift(); | ||
} | ||
stack.unshift(...chunk); | ||
} else { | ||
value = part.getValue([value]); | ||
destroy(stack); | ||
return renderer.destroy(Error('unknown chunk type:', chunk)); | ||
} | ||
} else { | ||
value = part.getValue(value); | ||
} | ||
buffer = reduce(buffer, result, value); | ||
if (popStack) { | ||
stack.shift(); | ||
} | ||
if (breakLoop) { | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
buffer += strings[endIndex]; | ||
result.push(buffer); | ||
return result; | ||
/** | ||
* Permanently destroy all remaining TemplateResults in "stack". | ||
* (Triggered on error) | ||
* | ||
* @param { Array<any> } stack | ||
*/ | ||
function destroy(stack) { | ||
if (stack.length > 0) { | ||
for (const chunk of stack) { | ||
if (isTemplateResult(chunk)) { | ||
chunk.destroy(true); | ||
} | ||
} | ||
} | ||
@@ -66,19 +105,26 @@ } | ||
/** | ||
* Commit value to string "buffer" | ||
* Retrieve next chunk from "result". | ||
* Adds nested TemplateResults to the stack if necessary. | ||
* | ||
* @param { string } buffer | ||
* @param { TemplateResult } result | ||
* @param { any } value | ||
* @returns { string } | ||
* @param { Array<any> } stack | ||
*/ | ||
function reduce(buffer, result, value) { | ||
if (typeof value === 'string') { | ||
buffer += value; | ||
return buffer; | ||
} else if (Array.isArray(value)) { | ||
return value.reduce((buffer, value) => reduce(buffer, result, value), buffer); | ||
} else if (isPromise(value)) { | ||
result.push(buffer, value); | ||
return ''; | ||
function getTemplateResultChunk(result, stack) { | ||
let chunk = result.readChunk(); | ||
// Skip empty strings | ||
if (Buffer.isBuffer(chunk) && chunk.length === 0) { | ||
chunk = result.readChunk(); | ||
} | ||
// Finished reading, dispose | ||
if (chunk === null) { | ||
stack.shift(); | ||
} else if (isTemplateResult(chunk)) { | ||
// Add to top of stack | ||
stack.unshift(chunk); | ||
chunk = getTemplateResultChunk(chunk, stack); | ||
} | ||
return chunk; | ||
} |
@@ -1,2 +0,2 @@ | ||
import { directive, unsafeStringPrefix } from '../index.js'; | ||
import { directive, unsafePrefixString } from '../index.js'; | ||
@@ -16,4 +16,4 @@ export const unsafeHTML = directive(unsafeHTMLDirective); | ||
} | ||
part.setValue(`${unsafeStringPrefix}${value}`); | ||
part.setValue(`${unsafePrefixString}${value}`); | ||
}; | ||
} |
@@ -5,3 +5,3 @@ /** | ||
*/ | ||
import { isTemplateResult, TemplateResult } from './template-result.js'; | ||
import { isTemplateResult, templateResult } from './template-result.js'; | ||
import { DefaultTemplateProcessor } from './default-template-processor.js'; | ||
@@ -13,3 +13,4 @@ import { DefaultTemplateResultProcessor } from './default-template-result-processor.js'; | ||
export { AttributePart, NodePart, nothingString, unsafeStringPrefix } from './parts.js'; | ||
export { AttributePart, NodePart } from './parts.js'; | ||
export { nothingString, unsafePrefixString } from './string.js'; | ||
export { directive } from './directive.js'; | ||
@@ -47,3 +48,3 @@ export { | ||
return TemplateResult(template, values, defaultTemplateResultProcessor); | ||
return templateResult(template, values); | ||
} | ||
@@ -53,20 +54,20 @@ | ||
* Render a template result to a Readable stream | ||
* *Note* that TemplateResults are single use, and can only be rendered once. | ||
* | ||
* @param { TemplateResult } result - a template result returned from call to "html`...`" | ||
* @param { object } [options] - Readable stream options | ||
* @see https://nodejs.org/api/stream.html#stream_new_stream_readable_options | ||
* @returns { Readable } | ||
*/ | ||
function renderToStream(result, options) { | ||
return new StreamTemplateRenderer(result, options); | ||
function renderToStream(result) { | ||
return new StreamTemplateRenderer(result, defaultTemplateResultProcessor); | ||
} | ||
/** | ||
* Render a template result to a string resolving Promise | ||
* Render a template result to a string resolving Promise. | ||
* *Note* that TemplateResults are single use, and can only be rendered once. | ||
* | ||
* @param { TemplateResult } result | ||
* @param { TemplateResult } result - a template result returned from call to "html`...`" | ||
* @returns { Promise<string> } | ||
*/ | ||
function renderToString(result) { | ||
return new PromiseTemplateRenderer(result); | ||
return new PromiseTemplateRenderer(result, defaultTemplateResultProcessor); | ||
} |
171
src/parts.js
@@ -0,1 +1,2 @@ | ||
import { emptyStringBuffer, nothingString, unsafePrefixString } from './string.js'; | ||
import { isPrimitive, isPromise, isSyncIterator } from './is.js'; | ||
@@ -7,12 +8,2 @@ import escapeHTML from './escape.js'; | ||
/** | ||
* A value for strings that signals a Part to clear its content | ||
*/ | ||
export const nothingString = '__nothing-lit-html-server-string__'; | ||
/** | ||
* A prefix value for strings that should not be escaped | ||
*/ | ||
export const unsafeStringPrefix = '__unsafe-lit-html-server-string__'; | ||
/** | ||
* Base class interface for Node/Attribute parts | ||
@@ -66,3 +57,3 @@ */ | ||
getValue(value) { | ||
return resolveValue(value, this, true); | ||
return resolveNodeValue(value, this); | ||
} | ||
@@ -80,3 +71,3 @@ } | ||
* @param { string } name | ||
* @param { Array<string> } strings | ||
* @param { Array<Buffer> } strings | ||
*/ | ||
@@ -88,6 +79,8 @@ constructor(name, strings) { | ||
this.length = strings.length - 1; | ||
this.prefix = Buffer.from(`${this.name}="`); | ||
this.suffix = Buffer.from(`${this.strings[this.length]}"`); | ||
} | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* Resolves to a single string, or Promise for a single string, | ||
@@ -97,47 +90,45 @@ * even when responsible for multiple values. | ||
* @param { Array<any> } values | ||
* @returns { string|Promise<string> } | ||
* @returns { Buffer|Promise<Buffer> } | ||
*/ | ||
getValue(values) { | ||
const strings = this.strings; | ||
const endIndex = strings.length - 1; | ||
const result = [`${this.name}="`]; | ||
let pending; | ||
let chunks = [this.prefix]; | ||
let pendingChunks; | ||
for (let i = 0; i < endIndex; i++) { | ||
const string = strings[i]; | ||
let value = resolveValue(values[i], this, false); | ||
for (let i = 0; i < this.length; i++) { | ||
const string = this.strings[i]; | ||
let value = resolveAttributeValue(values[i], this); | ||
result.push(string); | ||
// Bail if 'nothing' | ||
if (value === nothingString) { | ||
return ''; | ||
return emptyStringBuffer; | ||
} | ||
chunks.push(string); | ||
if (Buffer.isBuffer(value)) { | ||
chunks.push(value); | ||
} else if (isPromise(value)) { | ||
if (pending === undefined) { | ||
pending = []; | ||
// Lazy init for uncommon scenario | ||
if (pendingChunks === undefined) { | ||
pendingChunks = []; | ||
} | ||
const index = result.push(value) - 1; | ||
const index = chunks.push(value) - 1; | ||
pending.push( | ||
pendingChunks.push( | ||
value.then((value) => { | ||
result[index] = value; | ||
chunks[index] = value; | ||
}) | ||
); | ||
} else if (Array.isArray(value)) { | ||
result.push(value.join('')); | ||
} else { | ||
result.push(value); | ||
chunks = chunks.concat(value); | ||
} | ||
} | ||
result.push(`${strings[endIndex]}"`); | ||
chunks.push(this.suffix); | ||
if (pending !== undefined) { | ||
// Flatten in case array returned from Promise | ||
return Promise.all(pending).then(() => | ||
result.reduce((result, value) => result.concat(value), []).join('') | ||
); | ||
if (pendingChunks !== undefined) { | ||
return Promise.all(pendingChunks).then(() => Buffer.concat(chunks)); | ||
} | ||
return result.join(''); | ||
return Buffer.concat(chunks); | ||
} | ||
@@ -155,3 +146,3 @@ } | ||
* @param { string } name | ||
* @param { Array<string> } strings | ||
* @param { Array<Buffer> } strings | ||
* @throws error when multiple expressions | ||
@@ -162,3 +153,9 @@ */ | ||
if (strings.length !== 2 || strings[0] !== '' || strings[1] !== '') { | ||
this.name = Buffer.from(this.name); | ||
if ( | ||
strings.length !== 2 || | ||
!strings[0].equals(emptyStringBuffer) || | ||
!strings[1].equals(emptyStringBuffer) | ||
) { | ||
throw Error('Boolean attributes can only contain a single expression'); | ||
@@ -169,6 +166,6 @@ } | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* | ||
* @param { Array<any> } values | ||
* @returns { string|Promise<string> } | ||
* @returns { Buffer|Promise<Buffer> } | ||
*/ | ||
@@ -183,6 +180,6 @@ getValue(values) { | ||
if (isPromise(value)) { | ||
return value.then((value) => (value ? this.name : '')); | ||
return value.then((value) => (value ? this.name : emptyStringBuffer)); | ||
} | ||
return value ? this.name : ''; | ||
return value ? this.name : emptyStringBuffer; | ||
} | ||
@@ -197,3 +194,3 @@ } | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* Properties have no server-side representation, | ||
@@ -206,3 +203,3 @@ * so always returns an empty string. | ||
getValue(/* values */) { | ||
return ''; | ||
return emptyStringBuffer; | ||
} | ||
@@ -217,3 +214,3 @@ } | ||
/** | ||
* Retrieve resolved string from passed "values". | ||
* Retrieve resolved string Buffer from passed "values". | ||
* Event bindings have no server-side representation, | ||
@@ -226,3 +223,3 @@ * so always returns an empty string. | ||
getValue(/* values */) { | ||
return ''; | ||
return emptyStringBuffer; | ||
} | ||
@@ -235,7 +232,6 @@ } | ||
* @param { any } value | ||
* @param { Part } part | ||
* @param { boolean } ignoreNothingAndUndefined | ||
* @param { AttributePart } part | ||
* @returns { any } | ||
*/ | ||
function resolveValue(value, part, ignoreNothingAndUndefined = true) { | ||
function resolveAttributeValue(value, part) { | ||
if (isDirective(value)) { | ||
@@ -245,15 +241,66 @@ value = getDirectiveValue(value, part); | ||
if (ignoreNothingAndUndefined && (value === nothingString || value === undefined)) { | ||
return ''; | ||
if (value === nothingString) { | ||
return value; | ||
} | ||
// Pass-through template result | ||
if (isTemplateResult(value)) { | ||
value = value.read(); | ||
} | ||
if (isPrimitive(value)) { | ||
const string = typeof value !== 'string' ? String(value) : value; | ||
// Escape if not prefixed with unsafePrefixString, otherwise strip prefix | ||
return Buffer.from( | ||
string.indexOf(unsafePrefixString) === 0 ? string.slice(33) : escapeHTML(string) | ||
); | ||
} else if (Buffer.isBuffer(value)) { | ||
return value; | ||
} else if (isPrimitive(value)) { | ||
} else if (isPromise(value)) { | ||
return value.then((value) => resolveAttributeValue(value, part)); | ||
} else if (isSyncIterator(value)) { | ||
if (!Array.isArray(value)) { | ||
value = Array.from(value); | ||
} | ||
return Buffer.concat( | ||
value.reduce((values, value) => { | ||
value = resolveAttributeValue(value, part); | ||
// Flatten | ||
if (Array.isArray(value)) { | ||
return values.concat(value); | ||
} | ||
values.push(value); | ||
return values; | ||
}, []) | ||
); | ||
} else { | ||
throw Error('unknown AttributPart value', value); | ||
} | ||
} | ||
/** | ||
* Resolve "value" to string Buffer if possible | ||
* | ||
* @param { any } value | ||
* @param { NodePart } part | ||
* @returns { any } | ||
*/ | ||
function resolveNodeValue(value, part) { | ||
if (isDirective(value)) { | ||
value = getDirectiveValue(value, part); | ||
} | ||
if (value === nothingString || value === undefined) { | ||
return emptyStringBuffer; | ||
} | ||
if (isPrimitive(value)) { | ||
const string = typeof value !== 'string' ? String(value) : value; | ||
// Escape if not prefixed with unsafeStringPrefix, otherwise strip prefix | ||
return string.indexOf(unsafeStringPrefix) === 0 ? string.slice(33) : escapeHTML(string); | ||
// Escape if not prefixed with unsafePrefixString, otherwise strip prefix | ||
return Buffer.from( | ||
string.indexOf(unsafePrefixString) === 0 ? string.slice(33) : escapeHTML(string) | ||
); | ||
} else if (isTemplateResult(value) || Buffer.isBuffer(value)) { | ||
return value; | ||
} else if (isPromise(value)) { | ||
return value.then((value) => resolveValue(value, part, ignoreNothingAndUndefined)); | ||
return value.then((value) => resolveNodeValue(value, part)); | ||
} else if (isSyncIterator(value)) { | ||
@@ -264,4 +311,4 @@ if (!Array.isArray(value)) { | ||
return value.reduce((values, value) => { | ||
value = resolveValue(value, part, ignoreNothingAndUndefined); | ||
// Allow nested template results to also be flattened by not checking isTemplateResult | ||
value = resolveNodeValue(value, part); | ||
// Flatten | ||
if (Array.isArray(value)) { | ||
@@ -274,3 +321,3 @@ return values.concat(value); | ||
} else { | ||
return value; | ||
throw Error('unknown NodePart value', value); | ||
} | ||
@@ -277,0 +324,0 @@ } |
/** | ||
* @typedef TemplateResult { import('./template-result.js).TemplateResult } | ||
*/ | ||
import { bufferResult } from './template-result-bufferer.js'; | ||
/** | ||
* @typedef TemplateResultProcessor { import('./default-template-result-processor.js).TemplateResultProcessor } | ||
*/ | ||
/** | ||
* @typedef TemplateResultRenderer { import('./default-template-result-renderer.js).TemplateResultRenderer } | ||
*/ | ||
@@ -14,7 +19,33 @@ /** | ||
* @param { TemplateResult } result | ||
* @param { TemplateResultProcessor } processor | ||
* @returns { Promise<string> } | ||
*/ | ||
constructor(result) { | ||
return bufferResult(result); | ||
constructor(result, processor) { | ||
return new Promise((resolve, reject) => { | ||
let stack = [result]; | ||
let chunks = []; | ||
processor.process( | ||
{ | ||
awaitingPromise: false, | ||
push(chunk) { | ||
if (chunk === null) { | ||
resolve(Buffer.concat(chunks).toString()); | ||
} else { | ||
chunks.push(chunk); | ||
} | ||
return true; | ||
}, | ||
destroy(err) { | ||
chunks.length = 0; | ||
chunks = undefined; | ||
stack.length = 0; | ||
stack = undefined; | ||
reject(err); | ||
} | ||
}, | ||
stack | ||
); | ||
}); | ||
} | ||
} |
/** | ||
* @typedef TemplateResult { import('./template-result.js).TemplateResult } | ||
*/ | ||
import { bufferResult } from './template-result-bufferer.js'; | ||
/** | ||
* @typedef TemplateResultProcessor { import('./default-template-result-processor.js).TemplateResultProcessor } | ||
*/ | ||
/** | ||
* @typedef TemplateResultRenderer { import('./default-template-result-renderer.js).TemplateResultRenderer } | ||
*/ | ||
import { Readable } from 'stream'; | ||
/** | ||
* A custom Readable stream class that renders a TemplateResult | ||
* A custom Readable stream class for rendering a template result to a stream | ||
* | ||
* @implements TemplateResultRenderer | ||
*/ | ||
@@ -14,72 +21,22 @@ export class StreamTemplateRenderer extends Readable { | ||
* | ||
* @param { TemplateResult } result | ||
* @param { object } [options] Readable options | ||
* @see https://nodejs.org/api/stream.html#stream_new_stream_readable_options | ||
* @param { TemplateResult } result - a template result returned from call to "html`...`" | ||
* @param { TemplateResultProcessor } processor | ||
* @returns { Readable } | ||
*/ | ||
constructor(result, options) { | ||
super({ autoDestroy: true, ...options }); | ||
constructor(result, processor) { | ||
super({ autoDestroy: true }); | ||
this.canPushData = true; | ||
this.done = false; | ||
this.buffer = ''; | ||
this.index = 0; | ||
bufferResult(result, this) | ||
.then(() => { | ||
this.done = true; | ||
this._drainBuffer(); | ||
}) | ||
.catch((err) => { | ||
this.destroy(err); | ||
}); | ||
this.awaitingPromise = false; | ||
this.processor = processor; | ||
this.stack = [result]; | ||
} | ||
/** | ||
* Push "chunk" onto buffer | ||
* (Called by "bufferResult" utility) | ||
* | ||
* @param { string } chunk | ||
*/ | ||
bufferChunk(chunk) { | ||
this.buffer += chunk; | ||
this._drainBuffer(); | ||
} | ||
/** | ||
* Extend Readable.read() | ||
*/ | ||
_read() { | ||
this.canPushData = true; | ||
this._drainBuffer(); | ||
this.processor.process(this, this.stack); | ||
} | ||
/** | ||
* Write all buffered content to stream. | ||
* Returns "false" if write triggered backpressure, otherwise "true". | ||
* | ||
* @returns { boolean } | ||
*/ | ||
_drainBuffer() { | ||
if (!this.canPushData) { | ||
return false; | ||
} | ||
const bufferLength = this.buffer.length; | ||
if (this.index < bufferLength) { | ||
// Strictly speaking we shouldn't compare character length with byte length, but close enough | ||
const length = Math.min(bufferLength - this.index, this.readableHighWaterMark); | ||
const chunk = this.buffer.slice(this.index, this.index + length); | ||
this.canPushData = this.push(chunk, 'utf8'); | ||
this.index += length; | ||
} else if (this.done) { | ||
this.push(null); | ||
} | ||
return this.canPushData; | ||
} | ||
/** | ||
* Extend Readalbe.destroy() | ||
@@ -90,6 +47,2 @@ * | ||
_destroy(err) { | ||
if (this.done) { | ||
return; | ||
} | ||
if (err) { | ||
@@ -100,8 +53,7 @@ this.emit('error', err); | ||
this.canPushData = false; | ||
this.done = true; | ||
this.buffer = ''; | ||
this.index = 0; | ||
this.stack.length = 0; | ||
this.stack = []; | ||
this.processor = null; | ||
this.removeAllListeners(); | ||
} | ||
} |
@@ -0,31 +1,183 @@ | ||
import { AttributePart } from './parts.js'; | ||
import { emptyStringBuffer } from './string.js'; | ||
import { isPromise } from './is.js'; | ||
const pool = []; | ||
let id = 0; | ||
/** | ||
* @typedef { Array<string|Promise<any>> } TemplateResult - an array of template strings (or Promises) and their resolved values | ||
* @property { boolean } isTemplateResult | ||
* Retrieve TemplateResult instance. | ||
* Uses an object pool to recycle instances. | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
* @returns { TemplateResult } | ||
*/ | ||
export function templateResult(template, values) { | ||
let instance = pool.pop(); | ||
if (instance) { | ||
instance.template = template; | ||
instance.values = values; | ||
} else { | ||
instance = new TemplateResult(template, values); | ||
} | ||
return instance; | ||
} | ||
/** | ||
* @typedef TemplateResultProcessor { import('./default-template-result-processor.js').TemplateResultProcessor } | ||
*/ | ||
/** | ||
* Determine if 'obj' is a template result | ||
* Determine whether "result" is a TemplateResult | ||
* | ||
* @param { any } obj | ||
* @param { TemplateResult } result | ||
* @returns { boolean } | ||
*/ | ||
export function isTemplateResult(obj) { | ||
return Array.isArray(obj) && obj.isTemplateResult; | ||
export function isTemplateResult(result) { | ||
return result instanceof TemplateResult; | ||
} | ||
/** | ||
* Reduce a Template's strings and values to an array of resolved strings (or Promises) | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
* @param { TemplateResultProcessor } processor | ||
* @returns { TemplateResult } | ||
* A class for consuming the combined static and dynamic parts of a lit-html Template. | ||
* TemplateResults | ||
*/ | ||
export function TemplateResult(template, values, processor) { | ||
const result = processor.processTemplate(template, values); | ||
class TemplateResult { | ||
/** | ||
* Constructor | ||
* | ||
* @param { Template } template | ||
* @param { Array<any> } values | ||
*/ | ||
constructor(template, values) { | ||
this.template = template; | ||
this.values = values; | ||
this.id = id++; | ||
this.index = 0; | ||
} | ||
result.isTemplateResult = true; | ||
return result; | ||
/** | ||
* Consume template result content. | ||
* *Note* that instances may only be read once, | ||
* and will be destroyed upon completion. | ||
* | ||
* @param { boolean } deep - recursively resolve nested TemplateResults | ||
* @returns { any } | ||
*/ | ||
read(deep) { | ||
let buffer = emptyStringBuffer; | ||
let chunk, chunks; | ||
while ((chunk = this.readChunk()) !== null) { | ||
if (Buffer.isBuffer(chunk)) { | ||
buffer = Buffer.concat([buffer, chunk]); | ||
} else { | ||
if (chunks === undefined) { | ||
chunks = []; | ||
} | ||
buffer = reduce(buffer, chunks, chunk, deep); | ||
} | ||
} | ||
if (chunks !== undefined) { | ||
chunks.push(buffer); | ||
return chunks.length > 1 ? chunks : chunks[0]; | ||
} | ||
return buffer; | ||
} | ||
/** | ||
* Consume template result content one chunk at a time. | ||
* *Note* that instances may only be read once, | ||
* and will be destroyed when the last chunk is read. | ||
* | ||
* @returns { any } | ||
*/ | ||
readChunk() { | ||
const isString = this.index % 2 === 0; | ||
const index = (this.index / 2) | 0; | ||
// Finished | ||
if (!isString && index >= this.template.strings.length - 1) { | ||
this.destroy(); | ||
return null; | ||
} | ||
this.index++; | ||
if (isString) { | ||
return this.template.strings[index]; | ||
} | ||
const part = this.template.parts[index]; | ||
let value; | ||
if (part instanceof AttributePart) { | ||
// AttributeParts can have multiple values, so slice based on length | ||
// (strings in-between values are already handled the instance) | ||
if (part.length > 1) { | ||
value = part.getValue(this.values.slice(index, index + part.length)); | ||
this.index += part.length; | ||
} else { | ||
value = part.getValue([this.values[index]]); | ||
} | ||
} else { | ||
value = part.getValue(this.values[index]); | ||
} | ||
return value; | ||
} | ||
/** | ||
* Destroy the instance, | ||
* returning it to the object pool | ||
* | ||
* @param { boolean } permanent - permanently destroy instance and it's children | ||
* @returns { void } | ||
*/ | ||
destroy(permanent) { | ||
if (this.values !== undefined) { | ||
if (permanent) { | ||
for (const value of this.values) { | ||
if (isTemplateResult(value)) { | ||
value.destroy(permanent); | ||
} | ||
} | ||
} | ||
this.values.length = 0; | ||
} | ||
this.values = undefined; | ||
this.template = undefined; | ||
this.index = 0; | ||
if (!permanent) { | ||
pool.push(this); | ||
} | ||
} | ||
} | ||
/** | ||
* Commit "chunk" to string "buffer". | ||
* Returns new "buffer" value. | ||
* | ||
* @param { Buffer } buffer | ||
* @param { Array<any> } chunks | ||
* @param { any } chunk | ||
* @param { boolean } [deep] | ||
* @returns { Buffer } | ||
*/ | ||
function reduce(buffer, chunks, chunk, deep = false) { | ||
if (Buffer.isBuffer(chunk)) { | ||
return Buffer.concat([buffer, chunk]); | ||
} else if (isTemplateResult(chunk)) { | ||
if (deep) { | ||
return reduce(buffer, chunks, chunk.read(deep), deep); | ||
} else { | ||
chunks.push(buffer, chunk); | ||
return emptyStringBuffer; | ||
} | ||
} else if (Array.isArray(chunk)) { | ||
return chunk.reduce((buffer, chunk) => reduce(buffer, chunks, chunk), buffer); | ||
} else if (isPromise(chunk)) { | ||
chunks.push(buffer, chunk); | ||
return emptyStringBuffer; | ||
} | ||
} |
/** | ||
* @typedef TemplateProcessor { import('./default-template-processor.js').TemplateProcessor } | ||
*/ | ||
import { emptyStringBuffer } from './string.js'; | ||
import { lastAttributeNameRegex } from 'lit-html/lib/template.js'; | ||
@@ -66,3 +67,3 @@ | ||
// Store any text between quote character and value | ||
const attributeStrings = [suffix.slice(matchQuote.index + 1)]; | ||
const attributeStrings = [Buffer.from(suffix.slice(matchQuote.index + 1))]; | ||
let open = true; | ||
@@ -78,6 +79,6 @@ skip = 0; | ||
if (closingQuoteIndex === -1) { | ||
attributeStrings.push(attributeString); | ||
attributeStrings.push(Buffer.from(attributeString)); | ||
skip++; | ||
} else { | ||
attributeStrings.push(attributeString.slice(0, closingQuoteIndex)); | ||
attributeStrings.push(Buffer.from(attributeString.slice(0, closingQuoteIndex))); | ||
nextString = attributeString.slice(closingQuoteIndex + 1); | ||
@@ -91,3 +92,6 @@ i += skip; | ||
} else { | ||
part = processor.handleAttributeExpressions(name, ['', '']); | ||
part = processor.handleAttributeExpressions(name, [ | ||
emptyStringBuffer, | ||
emptyStringBuffer | ||
]); | ||
} | ||
@@ -99,7 +103,7 @@ } | ||
this.strings.push(string); | ||
this.strings.push(Buffer.from(string)); | ||
this.parts.push(part); | ||
// Add placehholders for strings/parts that wil be skipped due to multple values in a single AttributePart | ||
if (skip > 0) { | ||
this.strings.push(''); | ||
this.strings.push(null); | ||
this.parts.push(null); | ||
@@ -110,3 +114,3 @@ skip = 0; | ||
this.strings.push(nextString); | ||
this.strings.push(Buffer.from(nextString)); | ||
} | ||
@@ -113,0 +117,0 @@ } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
85678
2501
346
0
17