@bonniernews/local-esi
Advanced tools
Comparing version 1.2.9 to 2.0.0
142
index.js
"use strict"; | ||
const ESIEvaluator = require("./lib/ESIEvaluator"); | ||
const ListenerContext = require("./lib/ListenerContext"); | ||
const {asStream, transform, createESIParser} = require("./lib/transformHtml"); | ||
const { pipeline, Readable } = require("stream"); | ||
const ESI = require("./lib/ESI"); | ||
const HTMLStream = require("@bonniernews/atlas-html-stream"); | ||
const HTMLWriter = require("./lib/HTMLWriter"); | ||
const redirectCodes = [301, 302, 303, 307, 308]; | ||
module.exports = { | ||
ESI, | ||
HTMLWriter, | ||
parse, | ||
}; | ||
module.exports = localEsi; | ||
module.exports.createStream = streaming; | ||
module.exports.createParser = createParser; | ||
module.exports.htmlWriter = require("./lib/htmlWriter"); | ||
function parse(html, options) { | ||
const response = {}; | ||
function localEsi(html, req, res, next) { | ||
const context = ListenerContext(req); | ||
let completed = false; | ||
let body = ""; | ||
context.on("set_response_code", (statusCode, body) => { | ||
completed = true; | ||
res.status(statusCode).send(body === undefined ? "" : body); | ||
}); | ||
context.on("add_header", (name, value) => { | ||
if (name.toLowerCase() === "set-cookie") { | ||
const cookie = parseCookie(value); | ||
if (cookie) { | ||
res.cookie(cookie.name, cookie.value, cookie.attributes); | ||
} | ||
} else { | ||
res.set(name, value); | ||
} | ||
}); | ||
context.once("set_redirect", (statusCode, location) => { | ||
completed = true; | ||
res.redirect(location); | ||
}); | ||
const esi = new ESI(options) | ||
.on("set_response_code", onSetResponseCode) | ||
.on("add_header", onAddHeader) | ||
.once("set_redirect", onRedirect); | ||
const listener = ESIEvaluator(context); | ||
return transform(html, listener, (err, parsed) => { | ||
if (err) return next(err); | ||
if (!completed) res.send(parsed); | ||
return new Promise((resolve, reject) => { | ||
pipeline([ | ||
Readable.from(html), | ||
new HTMLStream({ preserveWS: true }), | ||
esi, | ||
new HTMLWriter(), | ||
], (err) => { | ||
if (err && ![ "ERR_STREAM_DESTROYED", "ERR_STREAM_PREMATURE_CLOSE" ].includes(err.code)) return reject(err); | ||
resolve({ | ||
body, | ||
...response, | ||
}); | ||
}).on("data", (chunk) => { | ||
body += chunk; | ||
}); | ||
}); | ||
} | ||
function streaming(req) { | ||
const context = ListenerContext(req); | ||
const listener = ESIEvaluator(context); | ||
const pipeline = asStream(listener); | ||
context.emitter = pipeline; | ||
let responseCode; | ||
const headers = {}; | ||
pipeline | ||
.on("set_response_code", onResponseCode) | ||
.on("add_header", onAddHeader) | ||
.once("set_redirect", close); | ||
return pipeline; | ||
function onResponseCode(int, body) { | ||
responseCode = int; | ||
if (int > 399 || body) return close(); | ||
if (headers.location && redirectCodes.includes(int)) pipeline.emit("set_redirect", responseCode, headers.location); | ||
function onRedirect(statusCode, location) { | ||
response.statusCode = statusCode; | ||
if (location) { | ||
response.headers = response.headers || {}; | ||
response.headers.location = location; | ||
} | ||
this.destroy(); | ||
} | ||
function onAddHeader(name, value) { | ||
const headerName = name.toLowerCase(); | ||
headers[headerName] = value; | ||
if (headerName === "location" && redirectCodes.includes(responseCode)) pipeline.emit("set_redirect", responseCode, value); | ||
const headers = response.headers = response.headers || {}; | ||
const lname = name.toLowerCase(); | ||
if (lname === "set-cookie") { | ||
headers[lname] = headers[lname] || []; | ||
headers[lname].push(value); | ||
} else { | ||
headers[lname] = value; | ||
} | ||
} | ||
function close() { | ||
pipeline | ||
.removeListener("set_response_code", onResponseCode) | ||
.removeListener("add_header", onAddHeader) | ||
.removeListener("set_redirect", close) | ||
.destroy(); | ||
function onSetResponseCode(statusCode, withBody) { | ||
response.statusCode = statusCode; | ||
if (!withBody) return; | ||
response.body = withBody; | ||
this.destroy(); | ||
} | ||
} | ||
function createParser(req) { | ||
const context = ListenerContext(req); | ||
const listener = ESIEvaluator(context); | ||
const optimusPrime = createESIParser(listener); | ||
context.emitter = optimusPrime; | ||
return optimusPrime; | ||
} | ||
function parseCookie(cookieStr) { | ||
const attrs = (cookieStr || "").split(";"); | ||
const [name, value] = attrs[0].split("="); | ||
if (!name || !value) return; | ||
const attributes = attrs.reduce((acc, attr, index) => { | ||
if (index > 0) { | ||
const [attrName, attrValue] = attr.split("="); | ||
acc[attrName.trim()] = attrValue && attrValue.trim() || ""; | ||
} | ||
return acc; | ||
}, {}); | ||
return { | ||
name, | ||
value, | ||
attributes | ||
}; | ||
} |
@@ -0,256 +1,222 @@ | ||
/* eslint-disable no-use-before-define */ | ||
"use strict"; | ||
const HtmlParser = require("@bonniernews/atlas-html-stream"); | ||
const request = require("got"); | ||
const url = require("url"); | ||
const {assign, test, replace} = require("./evaluateExpression"); | ||
const {convert, createESIParser} = require("./transformHtml"); | ||
const {chunkToMarkup} = require("./markup"); | ||
const {Readable} = require("stream"); | ||
const { assign, test, replace } = require("./evaluateExpression"); | ||
const { pipeline, Readable } = require("stream"); | ||
const ESIBase = require("./ESIBase"); | ||
const HTMLStream = require("@bonniernews/atlas-html-stream"); | ||
module.exports = function ESIEvaluator(context) { | ||
const esiTags = {}; | ||
class ESITag { | ||
constructor(context) { | ||
this.context = context; | ||
} | ||
open(data, next) { | ||
next(); | ||
} | ||
close(next) { | ||
next(); | ||
} | ||
} | ||
esiTags["esi:except"] = { | ||
open(data, next) { | ||
context.inExcept = true; | ||
next(); | ||
}, | ||
close(next) { | ||
context.inExcept = false; | ||
next(); | ||
} | ||
}; | ||
class ESIAttempt extends ESITag { | ||
open(data, next) { | ||
this.context.inAttempt = true; | ||
next(); | ||
} | ||
} | ||
esiTags["esi:choose"] = { | ||
open(data, next) { | ||
context.chooses.push({ hasEvaluatedToTrue: false, isCurrentlyEvaluatedTo: false }); | ||
class ESIExcept extends ESITag { | ||
open(data, next) { | ||
this.context.inExcept = true; | ||
next(); | ||
} | ||
close(next) { | ||
this.context.inExcept = false; | ||
next(); | ||
} | ||
} | ||
return next(); | ||
}, | ||
close(next) { | ||
context.chooses.pop(); | ||
class ESIChoose extends ESITag { | ||
open(data, next) { | ||
this.context.chooses.push({ hasEvaluatedToTrue: false, isCurrentlyEvaluatedTo: false }); | ||
return next(); | ||
} | ||
close(next) { | ||
this.context.chooses.pop(); | ||
return next(); | ||
} | ||
} | ||
return next(); | ||
class ESIWhen extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
const lastChoose = context.chooses[context.chooses.length - 1]; | ||
const result = test(data.test, context); | ||
if (data.matchname) { | ||
context.assigns[data.matchname] = result; | ||
} | ||
}; | ||
esiTags["esi:assign"] = { | ||
open(data, next) { | ||
if (!shouldWrite()) { | ||
return next(); | ||
} | ||
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.isCurrentlyEvaluatedTo && result; | ||
lastChoose.hasEvaluatedToTrue = lastChoose.hasEvaluatedToTrue || result; | ||
const value = data.value; | ||
try { | ||
context.assigns[data.name] = assign(value, context); | ||
} catch (err) { | ||
if (/unknown keyword/i.test(err.message)) context.assigns[data.name] = value; | ||
else return next(err); | ||
} | ||
return next(); | ||
} | ||
} | ||
next(); | ||
} | ||
}; | ||
class ESIOtherwise extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
const lastChoose = context.chooses[context.chooses.length - 1]; | ||
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.hasEvaluatedToTrue; | ||
return next(); | ||
} | ||
} | ||
esiTags["esi:vars"] = { | ||
open(data, next) { | ||
next(); | ||
}, | ||
close(next) { | ||
next(); | ||
} | ||
}; | ||
class ESIText extends ESITag { | ||
get plainText() { | ||
return true; | ||
} | ||
} | ||
esiTags["esi:include"] = { | ||
open(data, next) { | ||
if (!shouldWrite()) return next(); | ||
fetchIncluded(data, (fetchError, fetchResult) => { | ||
if (fetchError) { | ||
return next(fetchError); | ||
} | ||
const listener = ESIEvaluator(context.clone()); | ||
const chunks = []; | ||
let pipeline = convert(fetchResult).pipe(new HtmlParser({ preserveWS: true })); | ||
if (data.dca === "esi") { | ||
pipeline = pipeline.pipe(createESIParser(listener)); | ||
} | ||
pipeline.on("data", (chunk) => chunks.push(chunk)) | ||
.on("finish", () => { | ||
writeToResult(chunks, next); | ||
}) | ||
.on("error", next); | ||
}); | ||
class ESIAssign extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
if (!context.shouldWrite()) { | ||
return next(); | ||
} | ||
}; | ||
esiTags["esi:eval"] = { | ||
open(data, next) { | ||
if (!shouldWrite()) return next(); | ||
fetchIncluded(data, (fetchError, fetchResult) => { | ||
if (fetchError) { | ||
return next(fetchError); | ||
} | ||
const listener = ESIEvaluator(context.clone(true)); | ||
const chunks = []; | ||
convert(fetchResult) | ||
.pipe(new HtmlParser({ preserveWS: true })) | ||
.pipe(createESIParser(listener)) | ||
.on("data", (chunk) => chunks.push(chunk)) | ||
.on("finish", () => { | ||
writeToResult(chunks, next); | ||
}) | ||
.on("error", next); | ||
}); | ||
const value = data.value; | ||
try { | ||
context.assigns[data.name] = assign(value, context); | ||
} catch (err) { | ||
if (/unknown keyword/i.test(err.message)) context.assigns[data.name] = value; | ||
else return next(err); | ||
} | ||
}; | ||
esiTags["esi:try"] = { | ||
open(data, next) { | ||
next(); | ||
} | ||
}; | ||
next(); | ||
} | ||
} | ||
esiTags["esi:text"] = { | ||
plainText: true, | ||
open(data, next) { | ||
next(); | ||
}, | ||
close(next) { | ||
next(); | ||
} | ||
}; | ||
class ESIBreak extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
if (!context.inForeach) return next(new Error("esi:break outside esi:foreach")); | ||
context.breakHit = context.breakHit || context.shouldWrite(); | ||
return context.breakHit ? next(null, { name: "esi:break" }) : next(); | ||
} | ||
} | ||
esiTags["esi:attempt"] = { | ||
open(data, next) { | ||
context.inAttempt = true; | ||
next(); | ||
} | ||
}; | ||
class ESIEval extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
if (!context.shouldWrite()) return next(); | ||
esiTags["esi:when"] = { | ||
open(data, next) { | ||
const lastChoose = context.chooses[context.chooses.length - 1]; | ||
const result = test(data.test, context); | ||
if (data.matchname) { | ||
context.assigns[data.matchname] = result; | ||
const chunks = []; | ||
pipeline([ | ||
context.fetch(data), | ||
new HTMLStream({ preserveWS: true }), | ||
new ESIBase(new ESIEvaluator(context.clone(true))), | ||
], (err) => { | ||
if (err) { | ||
if (err.inAttempt) return next(); | ||
return next(err); | ||
} | ||
return context.writeToResult(chunks, next); | ||
}).on("data", (chunk) => chunks.push(chunk)); | ||
} | ||
} | ||
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.isCurrentlyEvaluatedTo && result; | ||
lastChoose.hasEvaluatedToTrue = lastChoose.hasEvaluatedToTrue || result; | ||
class ESIInclude extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
if (!context.shouldWrite()) return next(); | ||
return next(); | ||
}, | ||
close(next) { | ||
next(); | ||
const chunks = []; | ||
const streams = [ | ||
context.fetch(data), | ||
new HTMLStream({ preserveWS: true }), | ||
]; | ||
if (data.dca === "esi") { | ||
streams.push(new ESIBase(new ESIEvaluator(context.clone()))); | ||
} | ||
}; | ||
pipeline(streams, (err) => { | ||
if (err) { | ||
if (err.inAttempt) return next(); | ||
return next(err); | ||
} | ||
return context.writeToResult(chunks, next); | ||
}).on("data", (chunk) => chunks.push(chunk)); | ||
} | ||
} | ||
esiTags["esi:otherwise"] = { | ||
open(data, next) { | ||
const lastChoose = context.chooses[context.chooses.length - 1]; | ||
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.hasEvaluatedToTrue; | ||
return next(); | ||
}, | ||
close(next) { | ||
next(); | ||
class ESIForEach extends ESITag { | ||
open(data, next) { | ||
const context = this.context; | ||
context.items = assign(data.collection, context); | ||
if (!Array.isArray(context.items)) { | ||
context.items = Object.entries(context.items); | ||
} | ||
}; | ||
esiTags["esi:foreach"] = { | ||
open(data, next) { | ||
context.items = assign(data.collection, context); | ||
if (!Array.isArray(context.items)) { | ||
context.items = Object.entries(context.items); | ||
} | ||
context.foreachChunks = []; | ||
return next(); | ||
} | ||
close(next) { | ||
const context = this.context; | ||
const foreachChunks = context.foreachChunks; | ||
delete context.foreachChunks; | ||
context.foreachChunks = []; | ||
return next(); | ||
}, | ||
close(next) { | ||
const foreachChunks = context.foreachChunks; | ||
delete context.foreachChunks; | ||
let buffered = []; | ||
let buffered = []; | ||
context.items.forEach((value) => { | ||
if (Array.isArray(value)) value = `[${value.map((v) => typeof v === "string" ? `'${v}'` : v).join(",")}]`; | ||
buffered = buffered.concat([ { | ||
name: "esi:assign", data: { name: "item", value: value.toString() }, | ||
}, { name: "esi:assign" } ], foreachChunks); | ||
}); | ||
context.items.forEach((value) => { | ||
if (Array.isArray(value)) value = `[${value.map((v) => typeof v === "string" ? `'${v}'` : v).join(",")}]`; | ||
buffered = buffered.concat([{ | ||
name: "esi:assign", data: {name: "item", value: value.toString()} | ||
}, {name: "esi:assign"}], foreachChunks); | ||
}); | ||
const localContext = context.subContext(); | ||
localContext.inForeach = true; | ||
const chunks = []; | ||
const localContext = context.subContext(); | ||
localContext.inForeach = true; | ||
const listener = ESIEvaluator(localContext); | ||
const chunks = []; | ||
createChunkStream(buffered) | ||
.pipe(createESIParser(listener)) | ||
.on("data", function onData(chunk) { | ||
if (chunk.name === "esi:break") { | ||
this.pause(); | ||
return process.nextTick(() => this.destroy()); | ||
} | ||
chunks.push(chunk); | ||
}) | ||
.on("finish", complete) | ||
.on("error", next); | ||
function complete() { | ||
writeToResult(chunks, next); | ||
pipeline([ | ||
Readable.from(buffered), | ||
new ESIBase(new ESIEvaluator(localContext)), | ||
], (err) => { | ||
if (err) return next(err); | ||
return context.writeToResult(chunks, next); | ||
}).on("data", function onData(chunk) { | ||
if (chunk.name === "esi:break") { | ||
this.pause(); | ||
return process.nextTick(() => this.push(null)); | ||
} | ||
} | ||
}; | ||
esiTags["esi:break"] = { | ||
open(data, next) { | ||
if (!context.inForeach) return next(new Error("esi:break outside esi:foreach")); | ||
context.breakHit = context.breakHit || shouldWrite(); | ||
return context.breakHit ? next(null, {name: "esi:break"}) : next(); | ||
} | ||
}; | ||
chunks.push(chunk); | ||
}); | ||
} | ||
} | ||
async function fetchIncluded(data, fetchCallback) { | ||
const options = { | ||
throwHttpErrors: false, | ||
retry: 0, | ||
headers: Object.assign({}, context.req.headers) | ||
}; | ||
const EsiTags = { | ||
"esi:assign": ESIAssign, | ||
"esi:attempt": ESIAttempt, | ||
"esi:break": ESIBreak, | ||
"esi:choose": ESIChoose, | ||
"esi:except": ESIExcept, | ||
"esi:otherwise": ESIOtherwise, | ||
"esi:text": ESIText, | ||
"esi:try": ESITag, | ||
"esi:vars": ESITag, | ||
"esi:when": ESIWhen, | ||
"esi:eval": ESIEval, | ||
"esi:include": ESIInclude, | ||
"esi:foreach": ESIForEach, | ||
}; | ||
options.headers.host = undefined; | ||
delete options.headers["content-type"]; | ||
let includeUrl = replace(data.src, context); | ||
if (!includeUrl.startsWith("http")) { | ||
includeUrl = url.resolve(`http://localhost:${context.req.socket.server.address().port}`, includeUrl); | ||
} | ||
try { | ||
const response = await request.get(includeUrl, options); | ||
if (response.statusCode > 399) { | ||
throw new Error(`Response code: ${response.statusCode}`); | ||
} | ||
return fetchCallback(null, response.body); | ||
} catch (err) { | ||
if (context.inAttempt) { | ||
context.lastAttemptWasError = true; | ||
return fetchCallback(null, ""); | ||
} | ||
return fetchCallback(err); | ||
} | ||
class ESIEvaluator { | ||
constructor(context) { | ||
this.context = context; | ||
} | ||
return { | ||
onopentag, | ||
ontext, | ||
onclosetag, | ||
}; | ||
function onopentag(name, data, next) { | ||
onopentag(name, data, next) { | ||
const context = this.context; | ||
if (context.foreachChunks) { | ||
context.foreachChunks.push({name, data}); | ||
context.foreachChunks.push({ name, data }); | ||
return next(); | ||
@@ -260,8 +226,9 @@ } | ||
if (name.startsWith("esi:")) { | ||
const esiFunc = esiTags[name]; | ||
const Tag = EsiTags[name]; | ||
const wasInPlainText = context.isInPlainText(); | ||
if (!esiFunc && !wasInPlainText) { | ||
if (!Tag && !wasInPlainText) { | ||
throw new Error(`ESI tag ${name} not implemented.`); | ||
} | ||
let esiFunc; | ||
if (Tag) esiFunc = new Tag(context); | ||
context.tags.push(esiFunc); | ||
@@ -271,8 +238,8 @@ if (!wasInPlainText) return esiFunc.open(data, next); | ||
writeToResult({name, data: makeAttributes(data)}, next); | ||
context.writeToResult({ name, data: this.makeAttributes(data) }, next); | ||
} | ||
function onclosetag(name, next) { | ||
onclosetag(name, next) { | ||
const context = this.context; | ||
if (name !== "esi:foreach" && context.foreachChunks) { | ||
context.foreachChunks.push({name}); | ||
context.foreachChunks.push({ name }); | ||
return next(); | ||
@@ -290,8 +257,8 @@ } | ||
writeToResult({name}, next); | ||
context.writeToResult({ name }, next); | ||
} | ||
function ontext(text, next) { | ||
ontext(text, next) { | ||
const context = this.context; | ||
if (context.foreachChunks) { | ||
context.foreachChunks.push({text}); | ||
context.foreachChunks.push({ text }); | ||
return next(); | ||
@@ -301,3 +268,3 @@ } | ||
if (!context.isProcessing()) { | ||
return writeToResult({text}, next); | ||
return context.writeToResult({ text }, next); | ||
} | ||
@@ -311,7 +278,7 @@ | ||
try { | ||
return writeToResult((currentContext) => { | ||
const result = {text: replace(text, currentContext || context)}; | ||
return context.writeToResult((currentContext) => { | ||
const result = { text: replace(text, currentContext || context) }; | ||
context.bufferingString = false; | ||
return result; | ||
}, next); //handleProcessingInstructions may cause an (expected) error and we're not sure writeToResult will actually write so we pass a function that it can call if it should write | ||
}, next); // handleProcessingInstructions may cause an (expected) error and we're not sure writeToResult will actually write so we pass a function that it can call if it should write | ||
} catch (err) { | ||
@@ -327,6 +294,6 @@ if (err.message.includes("Found end of file before end")) { | ||
} | ||
function makeAttributes(data) { | ||
makeAttributes(data) { | ||
if (!data) return {}; | ||
const context = this.context; | ||
return Object.keys(data).reduce((attributes, key) => { | ||
@@ -341,51 +308,4 @@ let value = data[key]; | ||
} | ||
} | ||
function writeToResult(chunk, next) { | ||
if (context.bufferingString) { | ||
const [current = {}] = context.tags.slice(-1); | ||
if (typeof chunk === "function") { | ||
chunk = chunk(); | ||
} | ||
current.text += chunkToMarkup(chunk); | ||
return next(); | ||
} | ||
if (shouldWrite()) { | ||
if (typeof chunk === "function") { | ||
chunk = chunk(); | ||
} | ||
return next(null, chunk); | ||
} | ||
next(); | ||
} | ||
function shouldWrite() { | ||
if (context.inExcept && !context.lastAttemptWasError) return false; | ||
if (context.breakHit) return false; | ||
if (context.chooses.length) { | ||
return context.chooses.every((choose) => choose.isCurrentlyEvaluatedTo); | ||
} | ||
return true; | ||
} | ||
}; | ||
function createChunkStream(chunks) { | ||
const reader = new Readable({objectMode: true}); | ||
reader._read = function () { | ||
if (!chunks.length) return reader.push(null); | ||
while (reader.push(chunks.shift())) { | ||
if (!chunks.length) { | ||
reader.push(null); | ||
break; | ||
} | ||
} | ||
}; | ||
return reader; | ||
} | ||
module.exports = ESIEvaluator; |
"use strict"; | ||
const evaluate = require("./expression/evaluate"); | ||
const {parse, split} = require("./expression/parser"); | ||
const { parse, split } = require("./expression/parser"); | ||
@@ -53,9 +53,9 @@ module.exports = { | ||
text = text.replace(/(^|[^\\])(\\)($|[^\\])/ig, (_, group1, _2, group3) => { //Remove backslashes, but not escaped ones | ||
text = text.replace(/(^|[^\\])(\\)($|[^\\])/ig, (_, group1, _2, group3) => { // Remove backslashes, but not escaped ones | ||
return `${group1}${group3}`; | ||
}); | ||
text = text.replace(/\\\\/g, "\\"); //Escaped backslashes, remove the escaping backslash | ||
text = text.replace(/\\\\/g, "\\"); // Escaped backslashes, remove the escaping backslash | ||
return text; | ||
} |
@@ -6,187 +6,191 @@ /* eslint-disable camelcase */ | ||
module.exports = function evaluate(ast, context) { | ||
const funcs = { | ||
exists([arg]) { | ||
return !!getFunc(arg.type)(arg); | ||
}, | ||
int([arg]) { | ||
return parseInt(getFunc(arg.type)(arg)) || 0; | ||
}, | ||
index([arg1, arg2]) { | ||
return getFunc(arg1.type)(arg1).indexOf(getFunc(arg2.type)(arg2)); | ||
}, | ||
base64_decode([arg]) { | ||
const string = getFunc(arg.type)(arg); | ||
if (!string) { | ||
return ""; | ||
} | ||
return Buffer.from(string, "base64").toString("utf8"); | ||
}, | ||
base64_encode([arg]) { | ||
const string = getFunc(arg.type)(arg); | ||
if (!string) { | ||
return ""; | ||
} | ||
return Buffer.from(string, "utf8").toString("base64"); | ||
}, | ||
digest_md5([arg]) { | ||
const string = getFunc(arg.type)(arg); | ||
if (!string) { | ||
return []; | ||
} | ||
class Evaluator { | ||
constructor(context) { | ||
this.context = context; | ||
} | ||
exists([ arg ]) { | ||
return !!this.execute(arg.type, arg); | ||
} | ||
int([ arg ]) { | ||
return parseInt(this.execute(arg.type, arg)) || 0; | ||
} | ||
index([ arg1, arg2 ]) { | ||
return this.execute(arg1.type, arg1).indexOf(this.execute(arg2.type, arg2)); | ||
} | ||
base64_decode([ arg ]) { | ||
const string = this.execute(arg.type, arg); | ||
if (!string) { | ||
return ""; | ||
} | ||
return Buffer.from(string, "base64").toString("utf8"); | ||
} | ||
base64_encode([ arg ]) { | ||
const string = this.execute(arg.type, arg); | ||
if (!string) { | ||
return ""; | ||
} | ||
return Buffer.from(string, "utf8").toString("base64"); | ||
} | ||
digest_md5([ arg ]) { | ||
const string = this.execute(arg.type, arg); | ||
if (!string) { | ||
return []; | ||
} | ||
const md5 = crypto.createHash("md5").update(string).digest(); | ||
const esihash = []; | ||
for (let offset = 0; offset < 16; offset += 4) { | ||
esihash.push(md5.readInt32LE(offset)); | ||
} | ||
const md5 = crypto.createHash("md5").update(string).digest(); | ||
const esihash = []; | ||
for (let offset = 0; offset < 16; offset += 4) { | ||
esihash.push(md5.readInt32LE(offset)); | ||
} | ||
return esihash; | ||
}, | ||
url_encode([arg]) { | ||
const string = getFunc(arg.type)(arg); | ||
if (!string) { | ||
return ""; | ||
} | ||
return encodeURIComponent(string); | ||
}, | ||
add_header([name, value]) { | ||
context.emit("add_header", getFunc(name.type)(name), getFunc(value.type)(value)); | ||
}, | ||
set_redirect([location]) { | ||
context.emit("set_redirect", 302, getFunc(location.type)(location)); | ||
context.redirected = true; | ||
}, | ||
set_response_code([code, body]) { | ||
if (body) { | ||
return context.emit("set_response_code", getFunc(code.type)(code), getFunc(body.type)(body)); | ||
} | ||
return esihash; | ||
} | ||
url_encode([ arg ]) { | ||
const string = this.execute(arg.type, arg); | ||
if (!string) { | ||
return ""; | ||
} | ||
return encodeURIComponent(string); | ||
} | ||
add_header([ name, value ]) { | ||
this.context.emitter.emit("add_header", this.execute(name.type, name), this.execute(value.type, value)); | ||
} | ||
set_redirect([ location ]) { | ||
this.context.emitter.emit("set_redirect", 302, this.execute(location.type, location)); | ||
this.context.redirected = true; | ||
} | ||
set_response_code([ code, body ]) { | ||
if (body) { | ||
return this.context.emitter.emit("set_response_code", this.execute(code.type, code), this.execute(body.type, body)); | ||
} | ||
context.emit("set_response_code", getFunc(code.type)(code)); | ||
}, | ||
str([arg]) { | ||
const value = getFunc(arg.type)(arg); | ||
return (typeof value === "undefined") ? "None" : String(value); | ||
}, | ||
substr([arg1, arg2, arg3]) { | ||
const string = getFunc(arg1.type)(arg1); | ||
if (typeof string !== "string") { | ||
throw new Error("substr invoked on non-string"); | ||
} | ||
let startIndex; | ||
let length; | ||
this.context.emitter.emit("set_response_code", this.execute(code.type, code)); | ||
} | ||
str([ arg ]) { | ||
const value = this.execute(arg.type, arg); | ||
return (typeof value === "undefined") ? "None" : String(value); | ||
} | ||
substr([ arg1, arg2, arg3 ]) { | ||
const string = this.execute(arg1.type, arg1); | ||
if (typeof string !== "string") { | ||
throw new Error("substr invoked on non-string"); | ||
} | ||
let startIndex; | ||
let length; | ||
if (arg2) { | ||
startIndex = getFunc(arg2.type)(arg2); | ||
} | ||
if (arg2) { | ||
startIndex = this.execute(arg2.type, arg2); | ||
} | ||
if (typeof startIndex !== "number") { | ||
throw new Error("substr invoked with non-number as start index"); | ||
} | ||
if (typeof startIndex !== "number") { | ||
throw new Error("substr invoked with non-number as start index"); | ||
} | ||
if (arg3) { | ||
length = getFunc(arg3.type)(arg3); | ||
} | ||
if (arg3) { | ||
length = this.execute(arg3.type, arg3); | ||
} | ||
if (length < 0) { | ||
length = string.length - startIndex + length; | ||
} | ||
return string.substr(startIndex, length); | ||
}, | ||
time() { | ||
return Math.round(Date.now() / 1000); | ||
}, | ||
http_time([seconds]) { | ||
const secondsInt = parseInt(getFunc(seconds.type)(seconds)); | ||
const now = new Date(secondsInt * 1000); | ||
return now.toUTCString(); | ||
}, | ||
BinaryExpression(node) { | ||
const left = getFunc(node.left.type)(node.left); | ||
const right = getFunc(node.right.type)(node.right); | ||
if (length < 0) { | ||
length = string.length - startIndex + length; | ||
} | ||
return string.substr(startIndex, length); | ||
} | ||
time() { | ||
return Math.round(Date.now() / 1000); | ||
} | ||
http_time([ seconds ]) { | ||
const secondsInt = parseInt(this.execute(seconds.type, seconds)); | ||
const now = new Date(secondsInt * 1000); | ||
return now.toUTCString(); | ||
} | ||
BinaryExpression(node) { | ||
const left = this.execute(node.left.type, node.left); | ||
const right = this.execute(node.right.type, node.right); | ||
if (node.operator === "==") return left === castRight(left, right); | ||
if (node.operator === "!=") return left !== castRight(left, right); | ||
if (node.operator === ">=") return left >= castRight(left, right); | ||
if (node.operator === "<=") return left <= castRight(left, right); | ||
if (node.operator === "<") return left < castRight(left, right); | ||
if (node.operator === ">") return left > castRight(left, right); | ||
if (node.operator === "+") return left + right; | ||
if (node.operator === "-") return left - right; | ||
if (node.operator === "*") return left * right; | ||
if (node.operator === "/") return left / right; | ||
if (node.operator === "%") return left % right; | ||
if (node.operator === "has") return castString(left).indexOf(castString(right)) > -1; | ||
if (node.operator === "has_i") return castString(left).toLowerCase().indexOf(castString(right).toLowerCase()) > -1; | ||
if (node.operator === "matches") { | ||
if (!left) { | ||
return; | ||
} | ||
return left.match(right); | ||
if (node.operator === "==") return left === castRight(left, right); | ||
if (node.operator === "!=") return left !== castRight(left, right); | ||
if (node.operator === ">=") return left >= castRight(left, right); | ||
if (node.operator === "<=") return left <= castRight(left, right); | ||
if (node.operator === "<") return left < castRight(left, right); | ||
if (node.operator === ">") return left > castRight(left, right); | ||
if (node.operator === "+") return left + right; | ||
if (node.operator === "-") return left - right; | ||
if (node.operator === "*") return left * right; | ||
if (node.operator === "/") return left / right; | ||
if (node.operator === "%") return left % right; | ||
if (node.operator === "has") return castString(left).indexOf(castString(right)) > -1; | ||
if (node.operator === "has_i") return castString(left).toLowerCase().indexOf(castString(right).toLowerCase()) > -1; | ||
if (node.operator === "matches") { | ||
if (!left) { | ||
return; | ||
} | ||
if (node.operator === "matches_i") { | ||
if (!left) { | ||
return; | ||
} | ||
return left.match(new RegExp(right, "i")); | ||
return left.match(right); | ||
} | ||
if (node.operator === "matches_i") { | ||
if (!left) { | ||
return; | ||
} | ||
return left.match(new RegExp(right, "i")); | ||
} | ||
throw new Error(`Unknown BinaryExpression operator ${node.operator}`); | ||
}, | ||
BlockStatement(node) { | ||
return getFunc(node.body.type)(node.body); | ||
}, | ||
Identifier(node, nodeContext = context.assigns) { | ||
return nodeContext[node.name]; | ||
}, | ||
CallExpression(node) { | ||
return getFunc(node.callee.name)(node.arguments); | ||
}, | ||
LogicalExpression(node) { | ||
const left = getFunc(node.left.type); | ||
const right = getFunc(node.right.type); | ||
throw new Error(`Unknown BinaryExpression operator ${node.operator}`); | ||
} | ||
BlockStatement(node) { | ||
return this.execute(node.body.type, node.body); | ||
} | ||
Identifier(node, nodeContext) { | ||
if (!nodeContext) nodeContext = this.context.assigns; | ||
return nodeContext[node.name]; | ||
} | ||
CallExpression(node) { | ||
return this.execute(node.callee.name, node.arguments); | ||
} | ||
LogicalExpression(node) { | ||
const left = this.execute(node.left.type, node.left); | ||
const right = this.execute(node.right.type, node.right); | ||
if (node.operator === "&" || node.operator === "&&") return left(node.left) && right(node.right); | ||
if (node.operator === "|" || node.operator === "||") return left(node.left) || right(node.right); | ||
if (node.operator === "&" || node.operator === "&&") return left && right; | ||
if (node.operator === "|" || node.operator === "||") return left || right; | ||
throw new Error(`Unknown BinaryExpression operator ${node.operator}`); | ||
}, | ||
MemberExpression(node) { | ||
const object = getFunc(node.object.type)(node.object); | ||
if (!object) return; | ||
throw new Error(`Unknown BinaryExpression operator ${node.operator}`); | ||
} | ||
MemberExpression(node) { | ||
const object = this.execute(node.object.type, node.object); | ||
if (!object) return; | ||
const property = getFunc(node.property.type)(node.property); | ||
if (property === undefined) return; | ||
const property = this.execute(node.property.type, node.property); | ||
if (property === undefined) return; | ||
return object[property]; | ||
}, | ||
ObjectExpression(node) { | ||
if (!node.properties) return {}; | ||
return node.properties.reduce((obj, property) => { | ||
obj[property.key.name] = getFunc(property.value.type)(property.value); | ||
return obj; | ||
}, {}); | ||
}, | ||
ArrayExpression(node) { | ||
if (!node.elements) return []; | ||
return node.elements.map((elm) => getFunc(elm.type)(elm)); | ||
}, | ||
Literal(node) { | ||
return node.value; | ||
}, | ||
UnaryExpression(node) { | ||
if (node.operator !== "!") { | ||
throw new Error(`Unary operator ${node.operator} not implemented`); | ||
} | ||
return !getFunc(node.argument.type)(node.argument); | ||
return object[property]; | ||
} | ||
ObjectExpression(node) { | ||
if (!node.properties) return {}; | ||
return node.properties.reduce((obj, property) => { | ||
obj[property.key.name] = this.execute(property.value.type, property.value); | ||
return obj; | ||
}, {}); | ||
} | ||
ArrayExpression(node) { | ||
if (!node.elements) return []; | ||
return node.elements.map((elm) => this.execute(elm.type, elm)); | ||
} | ||
Literal(node) { | ||
return node.value; | ||
} | ||
UnaryExpression(node) { | ||
if (node.operator !== "!") { | ||
throw new Error(`Unary operator ${node.operator} not implemented`); | ||
} | ||
}; | ||
return getFunc(ast.type)(ast); | ||
return !this.execute(node.argument.type, node.argument); | ||
} | ||
execute(name, ...args) { | ||
if (!this[name]) throw new Error(`${name} is not implemented`); | ||
const fn = this[name]; | ||
return fn.call(this, ...args); | ||
} | ||
} | ||
function getFunc(name) { | ||
if (!funcs[name]) throw new Error(`${name} is not implemented`); | ||
return funcs[name]; | ||
} | ||
module.exports = function evaluate(ast, context) { | ||
return new Evaluator(context).execute(ast.type, ast); | ||
}; | ||
@@ -193,0 +197,0 @@ |
@@ -15,3 +15,3 @@ /* eslint-disable prefer-template */ | ||
const keywords = ["matches", "matches_i", "has", "has_i"]; | ||
const keywords = [ "matches", "matches_i", "has", "has_i" ]; | ||
@@ -40,379 +40,426 @@ const NUMBERS = "0123456789"; | ||
module.exports = { | ||
Lexer, | ||
Scanner, | ||
Token, | ||
}; | ||
class EsiSyntaxError extends SyntaxError { | ||
constructor(message, source, column) { | ||
super(message); | ||
this.columnNumber = column; | ||
this.source = source; | ||
Error.captureStackTrace(this, EsiSyntaxError); | ||
} | ||
} | ||
function Lexer(str, columnOffset, line) { | ||
const scanner = Scanner(str); | ||
class Scanner { | ||
constructor(str) { | ||
this.str = str; | ||
this.l = str.length; | ||
this.column = -1; | ||
} | ||
get() { | ||
const str = this.str; | ||
const l = this.l; | ||
const idx = ++this.column; | ||
const c = str[idx]; | ||
const c2 = (idx + 1) < l ? c + str[idx + 1] : undefined; | ||
const c3 = (idx + 2) < l ? c2 + str[idx + 2] : undefined; | ||
return { column: idx, c, c2, c3 }; | ||
} | ||
} | ||
let column, char, c1, c2, c3, source = ""; | ||
getChar(); | ||
class Token { | ||
constructor(parent, startChar, columnOffset = 0, line = 1) { | ||
this.parent = parent; | ||
this.startChar = startChar; | ||
this.columnOffset = columnOffset; | ||
this.line = line; | ||
const { column, c: cargo } = startChar; | ||
this.column = column; | ||
this.cargo = cargo; | ||
this.loc = { | ||
start: { | ||
line, | ||
column, | ||
}, | ||
}; | ||
} | ||
abort(...args) { | ||
this.parent.abort(this.column, ...args); | ||
} | ||
end(source, endColumn) { | ||
this.loc.source = source; | ||
this.loc.start.column += this.columnOffset; | ||
this.loc.end = { | ||
line: this.line, | ||
column: endColumn + this.columnOffset, | ||
}; | ||
} | ||
get() { | ||
switch (this.type) { | ||
case BLOCK: { | ||
return { | ||
type: this.type, | ||
loc: this.loc, | ||
}; | ||
} | ||
case BOOLEAN: { | ||
return { | ||
type: LITERAL, | ||
value: this.cargo === "true", | ||
loc: this.loc, | ||
}; | ||
} | ||
case IDENTIFIER: { | ||
return { | ||
type: this.type, | ||
name: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
case LITERAL: { | ||
return { | ||
type: this.type, | ||
value: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
case NUMBER: { | ||
return { | ||
type: LITERAL, | ||
value: Number(this.cargo), | ||
loc: this.loc, | ||
}; | ||
} | ||
case MEMBER: { | ||
return { | ||
type: this.type, | ||
object: { | ||
type: "Identifier", | ||
name: this.cargo, | ||
}, | ||
loc: this.loc, | ||
}; | ||
} | ||
case FUNCTION: { | ||
return { | ||
type: this.type, | ||
callee: { | ||
type: "Identifier", | ||
name: this.cargo, | ||
}, | ||
arguments: [], | ||
loc: this.loc, | ||
}; | ||
} | ||
case ARRAY: { | ||
return { | ||
type: this.type, | ||
elements: [], | ||
loc: this.loc, | ||
}; | ||
} | ||
case OBJECT: { | ||
return { | ||
type: this.type, | ||
loc: this.loc, | ||
}; | ||
} | ||
case UNARY: { | ||
return { | ||
type: this.type, | ||
operator: this.cargo, | ||
prefix: true, | ||
loc: this.loc, | ||
}; | ||
} | ||
case "&": | ||
case "&&": | ||
case "|": | ||
case "||": { | ||
return { | ||
type: LOGICAL, | ||
operator: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
case "matches": | ||
case "matches_i": | ||
case "has": | ||
case "has_i": | ||
case "*": | ||
case "/": | ||
case "+": | ||
case "%": | ||
case "-": | ||
case "<": | ||
case ">": | ||
case "!=": | ||
case ">=": | ||
case "<=": | ||
case "==": { | ||
return { | ||
type: BINARY, | ||
operator: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
default: | ||
return { | ||
type: this.type, | ||
cargo: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
} | ||
} | ||
return { | ||
get, | ||
}; | ||
class Lexer { | ||
constructor(str, columnOffset, line) { | ||
this.str = str; | ||
this.scanner = new Scanner(str); | ||
this.columnOffset = columnOffset; | ||
this.line = line; | ||
this.column = -1; | ||
this.char = null; | ||
this.c1 = null; | ||
this.c2 = null; | ||
this.c3 = null; | ||
this.source = ""; | ||
this.getChar(); | ||
} | ||
getChar() { | ||
if (this.c1) this.source += this.c1; | ||
function get() { | ||
const token = Token(char, columnOffset, line); | ||
const char = this.char = this.scanner.get(); | ||
this.column = char.column; | ||
this.c1 = char.c; | ||
this.c2 = char.c2; | ||
this.c3 = char.c3; | ||
return char; | ||
} | ||
consumeWhitespace(token) { | ||
token.type = WHITESPACE; | ||
token.cargo = this.c1; | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeFunction(token) { | ||
token.type = FUNCTION; | ||
let c = this.getChar().c; | ||
if (c === "(") { | ||
token.type = IDENTIFIER; | ||
c = this.getChar().c; | ||
} | ||
if (c1 === undefined) { | ||
token.type = ENDMARK; | ||
return next(); | ||
token.cargo = ""; | ||
while (IDENTIFIER_CHARS.indexOf(c) > -1) { | ||
token.cargo += c; | ||
c = this.getChar().c; | ||
} | ||
if (c1 === " ") { | ||
token.type = WHITESPACE; | ||
token.cargo = c1; | ||
getChar(); | ||
return next(); | ||
if (token.type === IDENTIFIER && c === "{") { | ||
token.type = MEMBER; | ||
} else if (token.type === FUNCTION) { | ||
if (c !== "(") return this._abort(this.column, "Unexpected", c ? "char " + c : "end of line"); | ||
} else if (token.type === IDENTIFIER) { | ||
if (c !== ")") return this._abort(this.column, "Unexpected", c ? "char " + c : "end of line"); | ||
} | ||
if (c1 === "$") { | ||
token.type = FUNCTION; | ||
getChar(); | ||
if (c1 === "(") { | ||
token.type = IDENTIFIER; | ||
getChar(); | ||
} | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeSingleQuoute(token) { | ||
let quoteChars = this.c1; | ||
token.cargo = ""; | ||
token.type = LITERAL; | ||
let c3 = this.c3; | ||
if (c3 === "'''") { | ||
quoteChars = "'''"; | ||
token.cargo = ""; | ||
this.getChar(); | ||
this.getChar(); | ||
c3 = this.getChar().c3; | ||
while (IDENTIFIER_CHARS.indexOf(c1) > -1) { | ||
token.cargo += c1; | ||
getChar(); | ||
while (c3 !== "'''") { | ||
if (c3 === undefined) this._abort(this.column, "Found end of file before end of string literal"); | ||
token.cargo += this.c1; | ||
c3 = this.getChar().c3; | ||
} | ||
if (token.type === IDENTIFIER && c1 === "{") { | ||
token.type = MEMBER; | ||
} else if (token.type === FUNCTION) { | ||
if (c1 !== "(") abort({column}, "Unexpected", c1 ? "char " + c1 : "end of line"); | ||
} else if (token.type === IDENTIFIER) { | ||
if (c1 !== ")") abort({column}, "Unexpected", c1 ? "char " + c1 : "end of line"); | ||
this.getChar(); | ||
this.getChar(); | ||
this.getChar(); | ||
} else { | ||
let c = this.getChar().c; | ||
while (c !== quoteChars) { | ||
if (c === undefined) this._abort(this.column, "Found end of file before end of string literal"); | ||
else if (c === "\\") { | ||
c = this.getChar().c; | ||
} | ||
token.cargo += c; | ||
c = this.getChar().c; | ||
} | ||
getChar(); | ||
this.getChar(); | ||
} | ||
return next(); | ||
return this.next(quoteChars, token.cargo, quoteChars); | ||
} | ||
consumeNumber(token) { | ||
token.cargo = ""; | ||
token.type = NUMBER; | ||
let c = this.c1; | ||
while (NUMBER_CHARS.indexOf(c) > -1) { | ||
token.cargo += c; | ||
c = this.getChar().c; | ||
} | ||
if (c1 === "'") { | ||
let quoteChars = c1; | ||
token.cargo = ""; | ||
token.type = LITERAL; | ||
return this.next(); | ||
} | ||
consumeArray(token) { | ||
token.type = ARRAY; | ||
token.cargo = this.c1; | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeObject(token) { | ||
token.type = OBJECT; | ||
token.cargo = this.c1; | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeBlock(token) { | ||
token.type = BLOCK; | ||
token.cargo = this.c1; | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeTwoCharacters(token) { | ||
const c2 = this.c2; | ||
token.cargo = c2; | ||
token.type = c2; | ||
if (c3 === "'''") { | ||
quoteChars = "'''"; | ||
if (this.c3 === undefined || this.column === 0) return this.unexpectedToken(c2); | ||
getChar(); | ||
getChar(); | ||
getChar(); | ||
this.getChar(); | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeUnary(token) { | ||
token.cargo = this.c1; | ||
token.type = UNARY; | ||
while (c3 !== "'''") { | ||
if (c3 === undefined) token.abort("Found end of file before end of string literal"); | ||
token.cargo += c1; | ||
getChar(); | ||
} | ||
const c = this.getChar().c; | ||
if (c === "(" || c === "$") { | ||
return this.next(); | ||
} | ||
getChar(); | ||
getChar(); | ||
getChar(); | ||
} else { | ||
getChar(); | ||
return this.unexpectedToken(token.cargo); | ||
} | ||
consumeOneCharacter(token) { | ||
const c1 = this.c1; | ||
token.cargo = c1; | ||
token.type = c1; | ||
while (c1 !== quoteChars) { | ||
if (c1 === undefined) token.abort("Found end of file before end of string literal"); | ||
else if (c1 === "\\") { | ||
getChar(); | ||
} | ||
if (c1 === "|" && (this.c2 === undefined || this.column === 0)) return this.unexpectedToken("|"); | ||
token.cargo += c1; | ||
getChar(); | ||
} | ||
this.getChar(); | ||
return this.next(); | ||
} | ||
consumeIdentifier(token) { | ||
token.cargo = ""; | ||
getChar(); | ||
} | ||
let c = this.c1; | ||
while (IDENTIFIER_CHARS.indexOf(c) > -1) { | ||
token.cargo += c; | ||
c = this.getChar().c; | ||
} | ||
return next(quoteChars, token.cargo, quoteChars); | ||
if (token.cargo === "true") { | ||
token.type = BOOLEAN; | ||
return this.next(); | ||
} | ||
if (NUMBER_STARTCHARS.indexOf(c1) > -1 && c2 !== "- ") { | ||
token.cargo = ""; | ||
token.type = NUMBER; | ||
if (token.cargo === "false") { | ||
token.type = BOOLEAN; | ||
return this.next(); | ||
} | ||
while (NUMBER_CHARS.indexOf(c1) > -1) { | ||
token.cargo += c1; | ||
getChar(); | ||
} | ||
return next(); | ||
if (keywords.indexOf(token.cargo) === -1) { | ||
return this._abort(token.column, `Unknown keyword "${token.cargo}"`); | ||
} | ||
if (c1 === "[") { | ||
token.type = ARRAY; | ||
token.cargo = c1; | ||
getChar(); | ||
return next(); | ||
token.type = token.cargo; | ||
return this.next(); | ||
} | ||
get() { | ||
const token = this.token = new Token(this, this.char, this.columnOffset, this.line); | ||
const c1 = this.c1; | ||
if (c1 === undefined) { | ||
token.type = ENDMARK; | ||
return this.next(); | ||
} | ||
if (c1 === "{") { | ||
token.type = OBJECT; | ||
token.cargo = c1; | ||
getChar(); | ||
return next(); | ||
switch (c1) { | ||
case " ": | ||
return this.consumeWhitespace(token); | ||
case "$": | ||
return this.consumeFunction(token); | ||
case "'": | ||
return this.consumeSingleQuoute(token); | ||
case "[": | ||
return this.consumeArray(token); | ||
case "{": | ||
return this.consumeObject(token); | ||
case "(": | ||
return this.consumeBlock(token); | ||
} | ||
if (c1 === "(") { | ||
token.type = BLOCK; | ||
token.cargo = c1; | ||
getChar(); | ||
return next(); | ||
if (NUMBER_STARTCHARS.indexOf(c1) > -1 && this.c2 !== "- ") { | ||
return this.consumeNumber(token); | ||
} | ||
if (twoCharacterSymbols.indexOf(c2) > -1) { | ||
token.cargo = c2; | ||
token.type = c2; | ||
if (c3 === undefined || column === 0) token.abort("Unexpected token", c2); | ||
getChar(); | ||
getChar(); | ||
return next(); | ||
if (twoCharacterSymbols.indexOf(this.c2) > -1) { | ||
return this.consumeTwoCharacters(token); | ||
} | ||
if (c1 === "!") { | ||
token.cargo = c1; | ||
token.type = UNARY; | ||
getChar(); | ||
if (c1 === "(" || c1 === "$") { | ||
return next(); | ||
} | ||
return token.abort("Unexpected token", token.cargo); | ||
return this.consumeUnary(token); | ||
} | ||
if (oneCharacterSymbols.indexOf(c1) > -1) { | ||
token.cargo = c1; | ||
token.type = c1; | ||
if (c1 === "|" && (c2 === undefined || column === 0)) token.abort("Unexpected token |"); | ||
getChar(); | ||
return next(); | ||
return this.consumeOneCharacter(token); | ||
} | ||
if (IDENTIFIER_CHARS.indexOf(c1) > -1) { | ||
token.cargo = ""; | ||
while (IDENTIFIER_CHARS.indexOf(c1) > -1) { | ||
token.cargo += c1; | ||
getChar(); | ||
} | ||
if (token.cargo === "true") { | ||
token.type = BOOLEAN; | ||
return next(); | ||
} | ||
if (token.cargo === "false") { | ||
token.type = BOOLEAN; | ||
return next(); | ||
} | ||
if (keywords.indexOf(token.cargo) === -1) { | ||
token.abort("Unknown keyword", token.cargo); | ||
} | ||
token.type = token.cargo; | ||
return next(); | ||
return this.consumeIdentifier(token); | ||
} | ||
return abort({column}, "Unexpected token", c1); | ||
function next() { | ||
while (c1 === " ") { | ||
getChar(); | ||
} | ||
token.end(source, column); | ||
source = ""; | ||
return token; | ||
return this.unexpectedToken(c1); | ||
} | ||
next() { | ||
let c = this.c1; | ||
while (c === " ") { | ||
c = this.getChar().c; | ||
} | ||
this.token.end(this.source, this.column); | ||
this.source = ""; | ||
return this.token; | ||
} | ||
function getChar() { | ||
if (c1) source += c1; | ||
char = scanner.get(); | ||
column = char.column; | ||
c1 = char.c; | ||
c2 = char.c2; | ||
c3 = char.c3; | ||
return char; | ||
unexpectedToken(c) { | ||
return this._abort(this.column, `Unexpected token "${c}"`); | ||
} | ||
} | ||
function Scanner(str) { | ||
const l = str.length; | ||
let column = -1; | ||
return { | ||
get, | ||
}; | ||
function get() { | ||
const idx = ++column; | ||
const c = str[idx]; | ||
const c2 = (idx + 1) < l ? c + str[idx + 1] : undefined; | ||
const c3 = (idx + 2) < l ? c2 + str[idx + 2] : undefined; | ||
return {column: idx, c, c2, c3}; | ||
abort(...args) { | ||
this._abort(this.column, ...args); | ||
} | ||
_abort(column, ...args) { | ||
args.push(`at "${this.str}" 0:${column}`); | ||
const err = new EsiSyntaxError(args.join(" "), this.str, column); | ||
throw err; | ||
} | ||
} | ||
function Token(startChar, columnOffset = 0, line = 1) { | ||
const {column, c: cargo} = startChar; | ||
return { | ||
column, | ||
cargo, | ||
loc: { | ||
start: { | ||
line, | ||
column, | ||
} | ||
}, | ||
end(source, endColumn) { | ||
this.loc.source = source; | ||
this.loc.start.column += columnOffset; | ||
this.loc.end = { | ||
line, | ||
column: endColumn + columnOffset, | ||
}; | ||
}, | ||
abort(...args) { | ||
abort(this, ...args); | ||
}, | ||
get(opts) { | ||
switch (this.type) { | ||
case BLOCK: { | ||
return { | ||
type: this.type, | ||
loc: this.loc, | ||
}; | ||
} | ||
case BOOLEAN: { | ||
return { | ||
type: LITERAL, | ||
value: this.cargo === "true", | ||
loc: this.loc, | ||
}; | ||
} | ||
case IDENTIFIER: { | ||
return { | ||
type: this.type, | ||
name: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
case LITERAL: { | ||
return { | ||
type: this.type, | ||
value: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
case NUMBER: { | ||
return { | ||
type: LITERAL, | ||
value: Number(this.cargo), | ||
loc: this.loc, | ||
}; | ||
} | ||
case MEMBER: { | ||
return { | ||
type: this.type, | ||
object: { | ||
type: "Identifier", | ||
name: this.cargo | ||
}, | ||
loc: this.loc | ||
}; | ||
} | ||
case FUNCTION: { | ||
return { | ||
type: this.type, | ||
callee: { | ||
type: "Identifier", | ||
name: this.cargo | ||
}, | ||
arguments: [], | ||
loc: this.loc, | ||
}; | ||
} | ||
case ARRAY: { | ||
return { | ||
type: this.type, | ||
elements: [], | ||
loc: this.loc, | ||
}; | ||
} | ||
case OBJECT: { | ||
return { | ||
type: this.type, | ||
loc: this.loc, | ||
}; | ||
} | ||
case UNARY: { | ||
return { | ||
type: this.type, | ||
operator: this.cargo, | ||
prefix: true, | ||
loc: this.loc, | ||
}; | ||
} | ||
case "&": | ||
case "&&": | ||
case "|": | ||
case "||": { | ||
return { | ||
type: LOGICAL, | ||
operator: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
case "matches": | ||
case "matches_i": | ||
case "has": | ||
case "has_i": | ||
case "*": | ||
case "/": | ||
case "+": | ||
case "%": | ||
case "-": | ||
case "<": | ||
case ">": | ||
case "!=": | ||
case ">=": | ||
case "<=": | ||
case "==": { | ||
return { | ||
type: BINARY, | ||
operator: this.cargo, | ||
loc: this.loc, | ||
...opts, | ||
}; | ||
} | ||
default: | ||
return { | ||
type: this.type, | ||
cargo: this.cargo, | ||
loc: this.loc, | ||
}; | ||
} | ||
}, | ||
}; | ||
} | ||
function abort({column}, ...args) { | ||
args.push(`at 0:${column}`); | ||
throw new SyntaxError(args.join(" ")); | ||
} | ||
module.exports = { | ||
Lexer, | ||
}; |
/* eslint-disable prefer-template */ | ||
"use strict"; | ||
const {Lexer} = require("./lexer"); | ||
const { Lexer } = require("./lexer"); | ||
@@ -27,80 +27,24 @@ const { | ||
function parse(input, columnOffset) { | ||
if (!input) return; | ||
input = input.trim(); | ||
const ast = AST(columnOffset); | ||
const parser = Parser(input, ast.openNode, ast.closeNode, columnOffset); | ||
while ((parser.consume())) { | ||
// No-op | ||
class AST { | ||
constructor() { | ||
const expression = this.expression = { | ||
type: EXPRESSION, | ||
body: {}, | ||
}; | ||
this.stack = [ expression ]; | ||
} | ||
return ast.tree; | ||
} | ||
function split(input) { | ||
const lines = input.split("\n"); | ||
return lines.reduce((result, str, idx) => { | ||
let columnOffset = 0; | ||
let match; | ||
while ((match = str.match(/(?<!\\)\$(.*)/))) { | ||
const line = idx + 1; | ||
columnOffset += match.index; | ||
if (match.index > 0) { | ||
result.push({type: "TEXT", text: str.substring(0, match.index)}); | ||
} | ||
const ast = AST(); | ||
const parser = Parser(match[0], ast.openNode, ast.closeNode, columnOffset, line); | ||
const hit = { | ||
expression: parser.consume(), | ||
}; | ||
result.push(hit); | ||
const hitSourceLength = hit.expression.loc.source.trim().length; | ||
columnOffset += hitSourceLength; | ||
str = str.substring(match.index + hitSourceLength); | ||
get tree() { | ||
while (this.stack.length > 1) { | ||
this.doClose(this.stack.pop(), this.getLast()); | ||
} | ||
return this.expression.body; | ||
} | ||
openNode(node) { | ||
const current = this.getLast(); | ||
this.stack.push(node); | ||
if (lines.length > 1 && idx < lines.length - 1) { | ||
str += "\n"; | ||
} | ||
if (str) { | ||
result.push({type: "TEXT", text: str}); | ||
} | ||
return result; | ||
}, []); | ||
} | ||
function AST() { | ||
const tree = { | ||
type: EXPRESSION, | ||
body: {}, | ||
}; | ||
const stack = [tree]; | ||
return { | ||
openNode, | ||
closeNode, | ||
get tree() { | ||
while (stack.length > 1) { | ||
doClose(stack.pop(), getLast()); | ||
} | ||
return tree.body; | ||
} | ||
}; | ||
function openNode(node) { | ||
const current = getLast(); | ||
stack.push(node); | ||
switch (node.type) { | ||
case BINARY: | ||
case LOGICAL: | ||
switchLeft(); | ||
this.switchLeft(current, node); | ||
break; | ||
@@ -111,29 +55,27 @@ case UNARY: | ||
} | ||
function switchLeft() { | ||
switch (current && current.type) { | ||
case BLOCK: | ||
case EXPRESSION: { | ||
node.left = current.body; | ||
current.body = node; | ||
break; | ||
} | ||
case LOGICAL: { | ||
node.left = current.right; | ||
current.right = node; | ||
break; | ||
} | ||
case FUNCTION: { | ||
const left = current.arguments.pop(); | ||
node.left = left; | ||
current.arguments.push(node); | ||
break; | ||
} | ||
} | ||
switchLeft(current, node) { | ||
switch (current && current.type) { | ||
case BLOCK: | ||
case EXPRESSION: { | ||
node.left = current.body; | ||
current.body = node; | ||
break; | ||
} | ||
case LOGICAL: { | ||
node.left = current.right; | ||
current.right = node; | ||
break; | ||
} | ||
case FUNCTION: { | ||
const left = current.arguments.pop(); | ||
node.left = left; | ||
current.arguments.push(node); | ||
break; | ||
} | ||
} | ||
} | ||
doClose(node, current) { | ||
current = current || this.getLast(); | ||
function doClose(node, current) { | ||
current = current || getLast(); | ||
switch (node.type) { | ||
@@ -143,4 +85,4 @@ case LOGICAL: | ||
node.loc.source = node.left.loc.source + node.loc.source + node.right.loc.source; | ||
node.loc.start = {...node.left.loc.start}; | ||
node.loc.end = {...node.right.loc.end}; | ||
node.loc.start = { ...node.left.loc.start }; | ||
node.loc.end = { ...node.right.loc.end }; | ||
break; | ||
@@ -150,3 +92,3 @@ } | ||
node.loc.source = node.loc.source + node.argument.loc.source; | ||
node.loc.end = {...node.argument.loc.end}; | ||
node.loc.end = { ...node.argument.loc.end }; | ||
break; | ||
@@ -161,3 +103,3 @@ } | ||
current.right = node; | ||
closeNode(current); | ||
this.closeNode(current); | ||
break; | ||
@@ -178,7 +120,7 @@ } | ||
current.argument = node; | ||
closeNode(current); | ||
this.closeNode(current); | ||
break; | ||
} | ||
case ARRAY: { | ||
addArrayElement(current, node); | ||
this.addArrayElement(current, node); | ||
break; | ||
@@ -188,3 +130,3 @@ } | ||
if (node.type === BINARY) break; | ||
addFunctionArgument(current, node); | ||
this.addFunctionArgument(current, node); | ||
break; | ||
@@ -194,15 +136,12 @@ } | ||
} | ||
closeNode(node) { | ||
this.stack.pop(); | ||
const current = this.getLast(); | ||
function closeNode(node) { | ||
stack.pop(); | ||
const current = getLast(); | ||
doClose(node, current); | ||
this.doClose(node, current); | ||
} | ||
function getLast() { | ||
return stack[stack.length - 1] || tree.body; | ||
getLast() { | ||
return this.stack[this.stack.length - 1] || this.expression.body; | ||
} | ||
function addArrayElement(current, node) { | ||
addArrayElement(current, node) { | ||
const lastIdx = current.elements.length - 1; | ||
@@ -219,3 +158,3 @@ if (lastIdx > -1) { | ||
node.loc.source = lastElm.loc.source + node.loc.source; | ||
node.loc.start = {...lastElm.loc.start}; | ||
node.loc.start = { ...lastElm.loc.start }; | ||
} | ||
@@ -226,4 +165,3 @@ } else { | ||
} | ||
function addFunctionArgument(current, node) { | ||
addFunctionArgument(current, node) { | ||
const lastIdx = current.arguments.length - 1; | ||
@@ -236,8 +174,7 @@ if (lastIdx > -1) { | ||
} else { | ||
// if (lastArg.type !== ",") throw new SyntaxError(`Unexpected ${node.type} in ${current.type} ----`); | ||
if (lastArg.type !== ",") return node.abort("Unexpected", node.type, "in", current.type); | ||
if (lastArg.type !== ",") throw new SyntaxError(`Unexpected ${node.type} in ${current.type}`); | ||
current.arguments[lastIdx] = node; | ||
node.loc.source = lastArg.loc.source + node.loc.source; | ||
node.loc.start = {...lastArg.loc.start}; | ||
node.loc.start = { ...lastArg.loc.start }; | ||
} | ||
@@ -250,14 +187,14 @@ } else { | ||
function Parser(sourceText, onStart, onEnd, columnOffset, line) { | ||
const lexer = Lexer(sourceText, columnOffset, line); | ||
let token; | ||
return { | ||
consume, | ||
}; | ||
function consume() { | ||
token = lexer.get(); | ||
class Parser { | ||
constructor(sourceText, ast, columnOffset, line) { | ||
this.source = sourceText; | ||
this.lexer = new Lexer(sourceText, columnOffset, line); | ||
this.ast = ast; | ||
this.token = null; | ||
} | ||
consume() { | ||
const token = this.lexer.get(); | ||
if (token.type === ENDMARK) return; | ||
const node = Node(token.get()); | ||
const node = token.get(); | ||
@@ -268,24 +205,24 @@ switch (node.type) { | ||
case UNARY: { | ||
onStart(node); | ||
this.openNode(node); | ||
break; | ||
} | ||
case ARRAY: { | ||
onStart(node); | ||
this.openNode(node); | ||
node.elements = []; | ||
let elm; | ||
elm = consume(); | ||
elm = this.consume(); | ||
while (elm && elm.type !== "]") { | ||
if (elm.type === ",") return token.abort("Unexpected", elm.type, "in", node.type); | ||
if (elm.type === ",") return this.lexer.abort("Unexpected", elm.type, "in", node.type); | ||
elm = consume(); | ||
elm = this.consume(); | ||
if (!elm) break; | ||
if (elm.type === ",") { | ||
elm = consume(); | ||
} else if (elm.type !== "]") return token.abort("Unexpected", elm.type, "in", node.type); | ||
elm = this.consume(); | ||
} else if (elm.type !== "]") return this.lexer.abort("Unexpected", elm.type, "in", node.type); | ||
} | ||
if (!elm || elm.type !== "]") return token.abort("Unclosed", node.type); | ||
if (!elm || elm.type !== "]") return this.lexer.abort("Unclosed", node.type); | ||
@@ -297,17 +234,17 @@ node.elements.forEach((a) => { | ||
node.loc.source += elm.loc.source; | ||
node.loc.end = {...elm.loc.end}; | ||
node.loc.end = { ...elm.loc.end }; | ||
onEnd(node); | ||
this.closeNode(node); | ||
break; | ||
} | ||
case BLOCK: { | ||
onStart(node); | ||
this.openNode(node); | ||
const source = node.loc.source; | ||
let arg = consume(); | ||
let arg = this.consume(); | ||
while (arg && arg.type !== ")") { | ||
arg = consume(); | ||
arg = this.consume(); | ||
} | ||
if (!arg || arg.type !== ")") return token.abort("Unclosed", node.type); | ||
if (!arg || arg.type !== ")") return this.lexer.abort("Unclosed", node.type); | ||
@@ -317,18 +254,18 @@ node.loc.source = source + node.body.loc.source + arg.loc.source; | ||
onEnd(node); | ||
this.closeNode(node); | ||
break; | ||
} | ||
case WHITESPACE: { | ||
return consume(); | ||
return this.consume(); | ||
} | ||
case FUNCTION: { | ||
onStart(node); | ||
this.openNode(node); | ||
let arg = consume(); | ||
if (arg && arg.type === ",") return token.abort("Unexpected", arg.type, "in", node.type); | ||
let arg = this.consume(); | ||
if (arg && arg.type === ",") return this.lexer.abort("Unexpected", arg.type, "in", node.type); | ||
while (arg && arg.type !== ")") { | ||
arg = consume(); | ||
arg = this.consume(); | ||
} | ||
if (!arg || arg.type !== ")") return token.abort("Unclosed", node.type); | ||
if (!arg || arg.type !== ")") return this.lexer.abort("Unclosed", node.type); | ||
@@ -340,15 +277,15 @@ node.arguments.forEach((a) => { | ||
node.loc.source += arg.loc.source; | ||
node.loc.end = {...arg.loc.end}; | ||
node.loc.end = { ...arg.loc.end }; | ||
onEnd(node); | ||
this.closeNode(node); | ||
break; | ||
} | ||
case IDENTIFIER: { | ||
onStart(node); | ||
onEnd(node); | ||
this.openNode(node); | ||
this.closeNode(node); | ||
break; | ||
} | ||
case LITERAL: { | ||
onStart(node); | ||
onEnd(node); | ||
this.openNode(node); | ||
this.closeNode(node); | ||
break; | ||
@@ -358,7 +295,7 @@ } | ||
let prop; | ||
onStart(node); | ||
this.openNode(node); | ||
do { | ||
node.property = prop; | ||
prop = consume(); | ||
prop = this.consume(); | ||
if (!prop) continue; | ||
@@ -368,8 +305,8 @@ node.loc.source += prop.loc.source; | ||
const end = consume(); | ||
if (!end || end.type !== ")") return token.abort("Unclosed", node.type); | ||
const end = this.consume(); | ||
if (!end || end.type !== ")") return this.lexer.abort("Unclosed", node.type); | ||
node.loc.source += end.loc.source; | ||
node.loc.end = end.loc.end; | ||
onEnd(node); | ||
this.closeNode(node); | ||
@@ -379,15 +316,15 @@ break; | ||
case OBJECT: { | ||
onStart(node); | ||
this.openNode(node); | ||
node.properties = []; | ||
let prop = consume(); | ||
addObjectProperty(node, null, prop); | ||
let prop = this.consume(); | ||
this.addObjectProperty(node, null, prop); | ||
while (prop && prop.type !== "}") { | ||
prop = consume(); | ||
prop = this.consume(); | ||
if (prop && prop.type === ",") { | ||
addObjectProperty(node, prop, consume()); | ||
this.addObjectProperty(node, prop, this.consume()); | ||
} | ||
} | ||
if (!prop || prop.type !== "}") return token.abort("Unclosed", node.type); | ||
if (!prop || prop.type !== "}") return this.lexer.abort("Unclosed", node.type); | ||
@@ -401,8 +338,8 @@ node.properties.forEach((a) => { | ||
onEnd(node); | ||
this.closeNode(node); | ||
break; | ||
} | ||
case ",": | ||
onStart(node); | ||
onEnd(node); | ||
this.openNode(node); | ||
this.closeNode(node); | ||
break; | ||
@@ -412,49 +349,102 @@ } | ||
return node; | ||
} | ||
openNode(node) { | ||
try { | ||
this.ast.openNode(node); | ||
} catch (err) { | ||
if (!(err instanceof SyntaxError)) throw err; | ||
const start = node.loc.start; | ||
throw new SyntaxError(`${err.message} at "${this.source}" ${start.line}:${start.column}`); | ||
} | ||
} | ||
closeNode(node) { | ||
try { | ||
this.ast.closeNode(node); | ||
} catch (err) { | ||
if (!(err instanceof SyntaxError)) throw err; | ||
const start = node.loc.start; | ||
throw new SyntaxError(`${err.message} at "${this.source}" ${start.line}:${start.column}`); | ||
} | ||
} | ||
addObjectProperty(objectNode, commaNode, keyNode) { | ||
if (keyNode && keyNode.type === "}") return; | ||
if (!keyNode) return this.lexer.abort("Unclosed", OBJECT); | ||
if (keyNode.type !== LITERAL) return this.lexer.abort("Unexpected key", keyNode.type); | ||
function Node(firstToken) { | ||
const tokenNode = {...firstToken}; | ||
const colon = this.consume(); | ||
if (!colon) return this.lexer.abort("Missing key value separator"); | ||
if (colon.type !== ":") return this.lexer.abort("Unexpected", colon.type, "in object"); | ||
Object.defineProperty(tokenNode, "abort", { | ||
enumerable: false, | ||
value: abort, | ||
}); | ||
const value = this.consume(); | ||
const property = { | ||
type: "Property", | ||
key: { | ||
type: IDENTIFIER, | ||
name: keyNode.value, | ||
}, | ||
value, | ||
loc: { | ||
source: [ commaNode, keyNode, colon, value ] | ||
.filter(Boolean) | ||
.map((n) => n.loc.source) | ||
.join(""), | ||
start: { ...((commaNode || keyNode).loc.start) }, | ||
end: { ...value.loc.end }, | ||
}, | ||
}; | ||
return tokenNode; | ||
objectNode.properties.push(property); | ||
} | ||
} | ||
function abort(...args) { | ||
args.push("at", `${firstToken.loc.start.line}:${firstToken.loc.start.column}`); | ||
throw new SyntaxError(args.join(" ")); | ||
} | ||
} | ||
function parse(input, columnOffset) { | ||
if (!input) return; | ||
input = input.trim(); | ||
function addObjectProperty(objectNode, commaNode, keyNode) { | ||
if (keyNode && keyNode.type === "}") return; | ||
if (!keyNode) return token.abort("Unclosed", OBJECT); | ||
if (keyNode.type !== LITERAL) return token.abort("Unexpected key", keyNode.type); | ||
const ast = new AST(columnOffset); | ||
const parser = new Parser(input, ast, columnOffset); | ||
while ((parser.consume())) { | ||
// No-op | ||
} | ||
const colon = consume(); | ||
if (!colon) return token.abort("Missing key value separator"); | ||
if (colon.type !== ":") return token.abort("Unexpected", colon.type, "in object"); | ||
return ast.tree; | ||
} | ||
const value = consume(); | ||
const property = { | ||
type: "Property", | ||
key: { | ||
type: IDENTIFIER, | ||
name: keyNode.value, | ||
}, | ||
value, | ||
loc: { | ||
source: [commaNode, keyNode, colon, value] | ||
.filter(Boolean) | ||
.map((n) => n.loc.source) | ||
.join(""), | ||
start: {...((commaNode || keyNode).loc.start)}, | ||
end: {...value.loc.end}, | ||
} | ||
function split(input) { | ||
const lines = input.split("\n"); | ||
return lines.reduce((result, str, idx) => { | ||
let columnOffset = 0; | ||
let match; | ||
while ((match = str.match(/(?<!\\)\$(.*)/))) { | ||
const line = idx + 1; | ||
columnOffset += match.index; | ||
if (match.index > 0) { | ||
result.push({ type: "TEXT", text: str.substring(0, match.index) }); | ||
} | ||
const ast = new AST(); | ||
const parser = new Parser(match[0], ast, columnOffset, line); | ||
const hit = { | ||
expression: parser.consume(), | ||
}; | ||
result.push(hit); | ||
objectNode.properties.push(property); | ||
const hitSourceLength = hit.expression.loc.source.trim().length; | ||
columnOffset += hitSourceLength; | ||
str = str.substring(match.index + hitSourceLength); | ||
} | ||
} | ||
if (lines.length > 1 && idx < lines.length - 1) { | ||
str += "\n"; | ||
} | ||
if (str) { | ||
result.push({ type: "TEXT", text: str }); | ||
} | ||
return result; | ||
}, []); | ||
} |
"use strict"; | ||
const {EventEmitter} = require("events"); | ||
const { chunkToMarkup } = require("./markup"); | ||
const { EventEmitter } = require("events"); | ||
const { replace } = require("./evaluateExpression"); | ||
const request = require("got"); | ||
module.exports = function ListenerContext(req, res, emitter) { | ||
emitter = emitter || new EventEmitter(); | ||
module.exports = class ListenerContext { | ||
constructor(options = {}, emitter) { | ||
this.options = options; | ||
this.emitter = emitter || new EventEmitter(); | ||
this.inAttempt = false; | ||
this.lastAttemptWasError = false; | ||
this.inExcept = false; | ||
this.includeError = false; | ||
this.replacement = ""; | ||
this.chooses = []; | ||
this.tags = []; | ||
this.cookies = options.cookies; | ||
this.assigns = { | ||
...buildHeaderVariables(options.headers), | ||
HTTP_COOKIE: options.cookies || {}, | ||
REQUEST_PATH: options.path || {}, | ||
QUERY_STRING: options.query || {}, | ||
}; | ||
} | ||
isProcessing() { | ||
return Boolean((this.tags.length || this.isSubContext) && !this.isInPlainText()); | ||
} | ||
isInPlainText() { | ||
return this.tags.some((tag) => tag.plainText); | ||
} | ||
clone(linkAssigns) { | ||
const c = new ListenerContext(this.options, this.emitter); | ||
if (linkAssigns) { | ||
c.assigns = this.assigns; | ||
} | ||
return c; | ||
} | ||
subContext() { | ||
const clone = this.clone(true); | ||
clone.isSubContext = true; | ||
return clone; | ||
} | ||
shouldWrite() { | ||
if (this.inExcept && !this.lastAttemptWasError) return false; | ||
if (this.breakHit) return false; | ||
return { | ||
assigns: Object.assign(buildHeaderVariables(req && req.headers), { | ||
"HTTP_COOKIE": req.cookies || {}, | ||
"REQUEST_PATH": req.path || {}, | ||
"QUERY_STRING": req.query || {} | ||
}), | ||
cookies: req.cookies, | ||
req, | ||
res, | ||
isProcessing() { | ||
return Boolean((this.tags.length || this.isSubContext) && !this.isInPlainText()); | ||
}, | ||
isInPlainText() { | ||
return this.tags.some((tag) => tag.plainText); | ||
}, | ||
inAttempt: false, | ||
lastAttemptWasError: false, | ||
inExcept: false, | ||
includeError: false, | ||
replacement: "", | ||
chooses: [], | ||
tags: [], | ||
get emitter() { | ||
return emitter; | ||
}, | ||
set emitter(value) { | ||
emitter = value; | ||
}, | ||
on(...args) { | ||
emitter.on(...args); | ||
}, | ||
once(...args) { | ||
emitter.once(...args); | ||
}, | ||
emit(...args) { | ||
emitter.emit(...args); | ||
}, | ||
removeListener(...args) { | ||
emitter.removeListener(...args); | ||
}, | ||
clone(linkAssigns) { | ||
const c = ListenerContext(req, res, emitter); | ||
if (linkAssigns) { | ||
c.assigns = this.assigns; | ||
if (this.chooses.length) { | ||
return this.chooses.every((choose) => choose.isCurrentlyEvaluatedTo); | ||
} | ||
return true; | ||
} | ||
writeToResult(chunk, next) { | ||
if (this.bufferingString) { | ||
const [ current = {} ] = this.tags.slice(-1); | ||
if (typeof chunk === "function") { | ||
chunk = chunk(); | ||
} | ||
return c; | ||
}, | ||
subContext() { | ||
const clone = this.clone(true); | ||
clone.isSubContext = true; | ||
return clone; | ||
current.text += chunkToMarkup(chunk); | ||
return next(); | ||
} | ||
}; | ||
function buildHeaderVariables(headers) { | ||
if (!headers) return {}; | ||
return Object.entries(headers).reduce((acc, pair) => { | ||
const header = pair[0]; | ||
if (header === "x-forwarded-for") { | ||
acc.REMOTE_ADDR = pair[1]; | ||
if (this.shouldWrite()) { | ||
if (typeof chunk === "function") { | ||
chunk = chunk(); | ||
} | ||
return next(null, chunk); | ||
} | ||
const httpKey = header.replace(/-/g, "_").toUpperCase(); | ||
acc[`HTTP_${httpKey}`] = pair[1]; | ||
return acc; | ||
}, {}); | ||
next(); | ||
} | ||
fetch(data) { | ||
const self = this; | ||
const options = { | ||
throwHttpErrors: false, | ||
method: "GET", | ||
retry: 0, | ||
headers: { | ||
...self.options.headers, | ||
host: undefined, | ||
"content-type": undefined, | ||
}, | ||
}; | ||
let fetchUrl = replace(data.src, self); | ||
if (!fetchUrl.startsWith("http")) { | ||
const host = this.options.localhost || self.assigns.HTTP_HOST; | ||
fetchUrl = new URL(fetchUrl, `http://${host}`).toString(); | ||
} | ||
return request.stream(fetchUrl, options) | ||
.on("response", function onResponse(resp) { | ||
if (resp.statusCode < 400) return; | ||
if (self.inAttempt) { | ||
self.lastAttemptWasError = true; | ||
return this.push(null); | ||
} | ||
return this.destroy(new request.HTTPError(resp)); | ||
}) | ||
.on("error", (err) => { | ||
if (!self.inAttempt) return; | ||
self.lastAttemptWasError = true; | ||
err.inAttempt = true; | ||
}); | ||
} | ||
}; | ||
function buildHeaderVariables(headers) { | ||
if (!headers) return {}; | ||
return Object.entries(headers).reduce((acc, pair) => { | ||
const header = pair[0]; | ||
if (header === "x-forwarded-for") { | ||
acc.REMOTE_ADDR = pair[1]; | ||
} | ||
const httpKey = header.replace(/-/g, "_").toUpperCase(); | ||
acc[`HTTP_${httpKey}`] = pair[1]; | ||
return acc; | ||
}, {}); | ||
} |
"use strict"; | ||
const voidElements = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]; | ||
const selfClosingElements = ["esi:include", "esi:eval", "esi:assign", "esi:debug", "esi:break"]; | ||
const { selfClosingElements, voidElements } = require("./voidElements"); | ||
@@ -14,3 +13,3 @@ module.exports = { | ||
function chunkToMarkup({name, data, text}) { | ||
function chunkToMarkup({ name, data, text }) { | ||
let markup = ""; | ||
@@ -46,4 +45,3 @@ if (text) markup += text; | ||
if (!attr) return ""; | ||
return Object.keys(attr).reduce((attributes, key) => { | ||
const value = attr[key]; | ||
return Object.entries(attr).reduce((attributes, [ key, value ]) => { | ||
if (value === "") { | ||
@@ -50,0 +48,0 @@ return `${attributes} ${key}`; |
{ | ||
"name": "@bonniernews/local-esi", | ||
"version": "1.2.9", | ||
"version": "2.0.0", | ||
"description": "Local Edge Side Includes parser", | ||
@@ -16,17 +16,20 @@ "main": "index.js", | ||
"license": "MIT", | ||
"peerDependencies": { | ||
"@bonniernews/atlas-html-stream": ">=2", | ||
"got": ">11", | ||
"pumpify": ">=2" | ||
}, | ||
"devDependencies": { | ||
"@bonniernews/atlas-html-stream": "^2.0.0", | ||
"chai": "^4.3.4", | ||
"chronokinesis": "^3.1.2", | ||
"eslint": "^8.0.0", | ||
"mocha": "^9.1.2", | ||
"nock": "^13.1.3" | ||
}, | ||
"dependencies": { | ||
"@bonniernews/atlas-html-stream": "^1.2.2", | ||
"got": "^11.8.2", | ||
"pump": "^3.0.0", | ||
"eslint": "^8.4.1", | ||
"eslint-config-exp": "0.0.9", | ||
"got": "^11.8.3", | ||
"mocha": "^9.1.3", | ||
"nock": "^13.2.1", | ||
"pumpify": "^2.0.1" | ||
}, | ||
"engines": { | ||
"node": ">=9.11.2" | ||
"node": ">=14" | ||
}, | ||
@@ -33,0 +36,0 @@ "repository": { |
176
README.md
@@ -10,27 +10,73 @@ Local-ESI | ||
- [`localEsi(html, req, res, next)`](#localesihtml-req-res-next) | ||
- [`localEsi.createStream(req)`](#localesicreatestreamreq) | ||
- [`localEsi.createParser(req)`](#localesicreateparserreq) | ||
- [`localEsi.htmlWriter()`](#localesihtmlwriter) | ||
- [`ESI`](#new-esioptions): transform class that returns an ESI transform stream | ||
- [`HTMLWriter`](#new-htmlwriter): transform class that returns markup from object stream | ||
- [`parse`](#parsehtml-options): async function that returns ESI evaluated markup | ||
## `localEsi(html, req, res, next)` | ||
## `new ESI([options])` | ||
Use as an expressjs request callback function. | ||
Create an ESI transform stream. Emits [events](#esi-parsing-events). | ||
Arguments: | ||
- `html`: string with markup | ||
- `req`: request with headers and cookies | ||
- `res`: response with send, redirect, set, and status function | ||
- `next`: function to catch occasional error in | ||
- `options`: optional options object with headers and cookies | ||
- `headers`: request headers, accessible through ESI globals `HTTP_<HEADER_NAME>`, `x-forwarded-for` will be accessible as `REMOTE_ADDR` | ||
- `cookies`: object with request cookies, accessible through ESI global `HTTP_COOKIE` | ||
- `path`: string request path, mapped to ESI global `REQUEST_PATH` | ||
- `query`: object request query parameters, accessible through ESI global `QUERY_STRING` | ||
- `localhost`: host to use when a relative src is used by eval or include, defaults to `headers.host` | ||
Returns: | ||
- esi evaluated object stream | ||
__Example express route:__ | ||
```javascript | ||
"use strict"; | ||
const localEsi = require("@bonniernews/local-esi"); | ||
const HTMLParser = require("@bonniernews/atlas-html-stream"); | ||
const {ESI, HTMLWriter} = require("@bonniernews/local-esi"); | ||
const {pipeline} = require("stream"); | ||
module.exports = (req, res, next) => { | ||
res.render("index", { data: "a" }, (err, html) => { | ||
if (err) return next(err); | ||
module.exports = function streamRender(req, res, next) { | ||
const { headers, cookies, path, query } = req; | ||
localEsi(html, req, res, next); | ||
const options = { | ||
headers, | ||
cookies, | ||
path, | ||
query, | ||
localhost: `localhost:${req.socket.server.address().port}`, | ||
}; | ||
const esi = new ESI(options) | ||
.once("set_redirect", function onSetRedirect(statusCode, location) { | ||
res.status(statusCode).redirect(location); | ||
this.destroy(); | ||
}) | ||
.on("set_response_code", function onSetResponseCode(statusCode, body) { | ||
res.status(statusCode); | ||
if (!body) return; | ||
res.send(body); | ||
this.destroy(); | ||
}) | ||
.on("add_header", (name, value) => { | ||
res.set(name, value); | ||
}); | ||
const body = ""; | ||
pipeline([ | ||
res.render("index"), | ||
new HTMLParser({preserveWS: true}), | ||
esi, | ||
new HTMLWriter(), | ||
], (err) => { | ||
if (err?.code === "ERR_STREAM_PREMATURE_CLOSE"]) { | ||
return; | ||
} else if (err) { | ||
return next(err); | ||
} | ||
return res.send(body); | ||
}).on("data", (chunk) => { | ||
body += chunk; | ||
}); | ||
@@ -40,67 +86,45 @@ }; | ||
## `localEsi.createStream(req)` | ||
## `parse(html, options)` | ||
Create pipable ESI parse as stream. Emits [events](#esi-parsing-events). | ||
Arguments: | ||
- `req`: request with headers and cookies | ||
- `html`: markup to parse | ||
- `options`: same as for for [ESI](#new-esioptions) | ||
Returns markup stream. | ||
Returns promise: | ||
- `body`: string with ESI evaluated markup or body from `$set_response_code` | ||
- `statusCode`: occasional status code from `$set_response_code` or `$set_redirect` | ||
- `headers`: object with added headers (in lowercase) from `$add_header` or `$set_redirect(location)`, NB! `set-cookie` will be in a list | ||
__Example express route:__ | ||
```javascript | ||
"use strict"; | ||
const {createStream} = require("@bonniernews/local-esi"); | ||
const HTMLParser = require("@bonniernews/atlas-html-stream"); | ||
const {parse} = require("@bonniernews/local-esi"); | ||
module.exports = (req, res, next) => { | ||
const esiParseStream = createStream(req) | ||
.on("add_header", (name, value) => res.set(name, value)) | ||
.once("set_redirect", (statusCode, location) => res.redirect(statusCode, location)); | ||
module.exports = function render(req, res, next) { | ||
const { headers, cookies, path, query } = req; | ||
res.render("index") | ||
.pipe(esiParseStream) | ||
.on("error", next); | ||
}; | ||
``` | ||
const options = { | ||
headers, | ||
cookies, | ||
path, | ||
query, | ||
localhost: `localhost:${req.socket.server.address().port}`, | ||
}; | ||
## `localEsi.createParser(req)` | ||
const html = res.render("index"); | ||
Create ESI parse transform stream. Emits [events](#esi-parsing-events). | ||
const {statusCode, headers, body} = await parse(html, options); | ||
if (statusCode < 309 && statusCode > 300) { | ||
return res.redirect(response.statusCode, headers.location); | ||
} | ||
Arguments: | ||
- `req`: request with headers and cookies | ||
Requires [markup stream](#markup-object-stream) to read from. Writes object stream. | ||
```javascript | ||
"use strict"; | ||
const HtmlParser = require("@bonniernews/atlas-html-stream"); | ||
const {createParser: createESIParser, htmlWriter} = require("@bonniernews/local-esi"); | ||
module.exports = function channelRendering(req, res, next) { | ||
const esiParser = createESIParser(req) | ||
.once("set_redirect", (statusCode, location) => { | ||
res.status(statusCode).redirect(location); | ||
}) | ||
.on("set_response_code", (statusCode, body) => { | ||
res.status(statusCode); | ||
if (body) res.send(body); | ||
}) | ||
.on("add_header", (name, value) => { | ||
res.set(name, value); | ||
}); | ||
return res.render("index") | ||
.pipe(new HtmlParser({preserveWS: true})) | ||
.pipe(esiParser) | ||
.pipe(htmlWriter()) | ||
.pipe(res) | ||
.once("error", (err) => { | ||
next(err); | ||
}); | ||
res.status(statusCode || 200); | ||
return res.send(body); | ||
}; | ||
``` | ||
## `localEsi.htmlWriter()` | ||
## `new HTMLWriter()` | ||
@@ -115,14 +139,28 @@ Returns transform [object stream](#markup-object-stream) to markup buffer stream. | ||
Set status code and optional body. | ||
Parser encountered a `$set_response_code` instruction with status code and optional body. | ||
Signature: | ||
- `statusCode`: number HTTP status code | ||
- `body`: optional string body | ||
### `add_header` | ||
Set header name and value. | ||
Parser encountered a `$add_header` instruction with HTTP header name and value. | ||
Signature: | ||
- `name`: HTTP header name | ||
- `value`: HTTP header value | ||
### `set_redirect` | ||
Redirect with status code and location. | ||
Parser encountered a `$set_redirect` instruction with optional status code and location. | ||
Signature: | ||
- `statusCode`: redirect HTTP status code | ||
- `location`: redirect location | ||
## Markup object stream | ||
Object streams requires the schema `{name, data, text}` representing tag name, tag attributes, and text. This project uses [atlas-html-stream](https://www.npmjs.com/package/atlas-html-stream) for html parsing. | ||
Object streams requires the schema `{name, data, text}` representing tag name, tag attributes, and text. This project uses [@bonniernews/atlas-html-stream][0] for html parsing. | ||
[0]: https://www.npmjs.com/package/@bonniernews/atlas-html-stream |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
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
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
52624
3
17
164
9
1586
1
+ Added@bonniernews/atlas-html-stream@2.0.1(transitive)
+ Added@sec-ant/readable-stream@0.4.1(transitive)
+ Added@sindresorhus/is@7.0.1(transitive)
+ Added@szmarczak/http-timer@5.0.1(transitive)
+ Addedcacheable-lookup@7.0.0(transitive)
+ Addedcacheable-request@12.0.1(transitive)
+ Addedform-data-encoder@4.0.2(transitive)
+ Addedget-stream@9.0.1(transitive)
+ Addedgot@14.4.5(transitive)
+ Addedhttp2-wrapper@2.2.1(transitive)
+ Addedis-stream@4.0.1(transitive)
+ Addedlowercase-keys@3.0.0(transitive)
+ Addedmimic-response@4.0.0(transitive)
+ Addednormalize-url@8.0.1(transitive)
+ Addedp-cancelable@4.0.1(transitive)
+ Addedresponselike@3.0.0(transitive)
+ Addedtype-fest@4.31.0(transitive)
- Removedgot@^11.8.2
- Removedpump@^3.0.0
- Removedpumpify@^2.0.1
- Removed@bonniernews/atlas-html-stream@1.2.2(transitive)
- Removed@bonniernews/atlas-seq-matcher@1.0.4(transitive)
- Removed@sindresorhus/is@4.6.0(transitive)
- Removed@szmarczak/http-timer@4.0.6(transitive)
- Removed@types/cacheable-request@6.0.3(transitive)
- Removed@types/keyv@3.1.4(transitive)
- Removed@types/node@22.10.2(transitive)
- Removed@types/responselike@1.0.3(transitive)
- Removedcacheable-lookup@5.0.4(transitive)
- Removedcacheable-request@7.0.4(transitive)
- Removedclone-response@1.0.3(transitive)
- Removedget-stream@5.2.0(transitive)
- Removedgot@11.8.6(transitive)
- Removedhttp2-wrapper@1.0.3(transitive)
- Removedlowercase-keys@2.0.0(transitive)
- Removedmimic-response@1.0.1(transitive)
- Removednormalize-url@6.1.0(transitive)
- Removedp-cancelable@2.1.1(transitive)
- Removedresponselike@2.0.1(transitive)
- Removedundici-types@6.20.0(transitive)