Ben Ashton
2 years ago
commit
44c21e670f
11 changed files with 453 additions and 0 deletions
@ -0,0 +1,24 @@
|
||||
import { TemplateEngine } from './index.js'; |
||||
import { basename } from 'node:path'; |
||||
|
||||
// Check arguments
|
||||
if (process.argv.length !== 3) { |
||||
console.error(`Usage: node ${basename(process.argv[1])} filename`); |
||||
process.exit(1); |
||||
} |
||||
const fileName = process.argv[2]; |
||||
|
||||
const tp = new TemplateEngine(); |
||||
|
||||
let result; |
||||
|
||||
try { |
||||
result = await tp.render(fileName); |
||||
} catch (e) { |
||||
console.error(e.message); |
||||
process.exit(1); |
||||
} |
||||
|
||||
if (result) { |
||||
process.stdout.write(result); |
||||
} |
@ -0,0 +1,14 @@
|
||||
$ #!/bin/sh |
||||
$ title='My Website'; |
||||
$ dangerous='<b>Unescaped</b>'; |
||||
<!doctype html> |
||||
<html> |
||||
# This is a comment |
||||
<head> |
||||
<title><% $title %></title> |
||||
</head> |
||||
<body> |
||||
<h1>Welcome: <% $2 %></h1> |
||||
<p><!% $dangerous %></p> |
||||
</body> |
||||
</html> |
@ -0,0 +1,5 @@
|
||||
$ VAR1="So <b>many</b> cats!"; |
||||
<h1><% $VAR1 %></h1> |
||||
<h1><!% $VAR1 %></h1> |
||||
<h1><$% echo $VAR1 | cut -f 2 -d ' ' %></h1> |
||||
<h1><$!% echo $VAR1 | cut -f 2 -d ' ' %></h1> |
@ -0,0 +1,12 @@
|
||||
$ #!/bin/sh |
||||
<!doctype html> |
||||
<html> |
||||
<head><title>Restaurants</title></head> |
||||
<body> |
||||
<ul> |
||||
$ while read restaurant; do |
||||
<li><% $restaurant %></li> |
||||
$ done <restaurants.txt |
||||
</ul> |
||||
</body> |
||||
</html> |
@ -0,0 +1 @@
|
||||
export { TemplateEngine } from './src/template_engine.js'; |
@ -0,0 +1,13 @@
|
||||
{ |
||||
"name": "n0m-template-engine", |
||||
"version": "1.0.0", |
||||
"description": "", |
||||
"main": "index.js", |
||||
"scripts": { |
||||
"start": "node cli.js", |
||||
"test": "echo \"Error: no test specified\" && exit 1" |
||||
}, |
||||
"author": "", |
||||
"license": "ISC", |
||||
"type": "module" |
||||
} |
@ -0,0 +1,85 @@
|
||||
import { readFile } from 'node:fs/promises'; |
||||
import { basename } from 'node:path'; |
||||
|
||||
export class CharacterStream { |
||||
constructor() { |
||||
this.reset(); |
||||
} |
||||
|
||||
reset() { |
||||
this._data = ''; |
||||
this.fileName = ''; |
||||
this._pos = 0; |
||||
this.line = 0; |
||||
this.column = 0; |
||||
} |
||||
|
||||
async loadFile(fileName) { |
||||
this.reset(); |
||||
this.fileName = basename(fileName); |
||||
this._data = await readFile(fileName, 'utf-8'); |
||||
} |
||||
|
||||
peek(length = 1) { |
||||
return this._data.substring(this._pos, this._pos + length); |
||||
} |
||||
|
||||
peekWhile(predicate) { |
||||
let buffer = ''; |
||||
for ( |
||||
let offset = 0, c = this._data[this._pos + offset]; |
||||
c !== undefined && predicate(c); |
||||
offset++, c = this._data[this._pos + offset] |
||||
) { |
||||
buffer += c; |
||||
} |
||||
return buffer; |
||||
} |
||||
|
||||
// Peek first character after any characters that match predicate
|
||||
peekAfter(predicate) { |
||||
const skip = this.peekWhile(predicate); |
||||
return this.peek(skip.length + 1).substring(skip.length); |
||||
} |
||||
|
||||
isNext(str) { |
||||
return this.peek(str.length) === str; |
||||
} |
||||
|
||||
next(length = 1) { |
||||
const chunk = this.peek(length); |
||||
|
||||
this._pos += chunk.length; |
||||
|
||||
this.line += this._countOccurrences(chunk, '\n'); |
||||
|
||||
const lastNewLine = chunk.lastIndexOf('\n'); |
||||
if (lastNewLine === -1) { |
||||
this.column += chunk.length; |
||||
} else { |
||||
this.column = chunk.length - lastNewLine - 1; |
||||
} |
||||
|
||||
return chunk; |
||||
} |
||||
|
||||
nextWhile(predicate) { |
||||
return this.next(this.peekWhile(predicate).length); |
||||
} |
||||
|
||||
_countOccurrences = (str, substr) => { |
||||
let count = 0; |
||||
let i = 0; |
||||
while(true) { |
||||
i = str.indexOf(substr, i); |
||||
if (i === -1) break; |
||||
i += substr.length; |
||||
count++; |
||||
} |
||||
return count; |
||||
} |
||||
|
||||
eof() { |
||||
return this.peek() === ''; |
||||
} |
||||
} |
@ -0,0 +1,7 @@
|
||||
export class TemplateSyntaxError extends Error { |
||||
constructor(cs, message) { |
||||
super( |
||||
`${cs.fileName}:${cs.line + 1}:${cs.column + 1}: ${message}` |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,82 @@
|
||||
import { TokenStream } from './token_stream.js'; |
||||
|
||||
export class TemplateEngine { |
||||
async render(fileName) { |
||||
const ts = new TokenStream(); |
||||
await ts.loadFile(fileName); |
||||
|
||||
let buffer = ''; |
||||
|
||||
while (!ts.eof()) { |
||||
const token = ts.next(); |
||||
|
||||
switch (token.type) { |
||||
case 'raw': |
||||
buffer += this._renderRaw(token.value); |
||||
break; |
||||
case 'inline': |
||||
buffer += this._renderInline(token.value); |
||||
break; |
||||
case 'inline_unescaped': |
||||
buffer += this._renderInlineUnescaped(token.value); |
||||
break; |
||||
case 'inline_statement': |
||||
buffer += this._renderInlineStatement(token.value); |
||||
break; |
||||
case 'inline_statement_unescaped': |
||||
buffer += this._renderInlineStatementUnescaped(token.value); |
||||
break; |
||||
case 'statement': |
||||
buffer += this._renderStatement(token.value); |
||||
break; |
||||
case 'comment': |
||||
buffer += this._renderComment(token.value); |
||||
break; |
||||
default: |
||||
throw new Error(`Unrecognized token: ${token.type}`); |
||||
} |
||||
} |
||||
|
||||
return buffer; |
||||
} |
||||
|
||||
_renderComment(value) { |
||||
return `# ${value}\n`; |
||||
} |
||||
|
||||
_renderStatement(value) { |
||||
return `${value}\n`; |
||||
} |
||||
|
||||
_renderInline(value) { |
||||
return `printf '%s' "${value}" | jq -Rrj @html;\n`; |
||||
} |
||||
|
||||
_renderInlineUnescaped(value) { |
||||
return `printf '%s' "${value}";\n`; |
||||
} |
||||
|
||||
_renderInlineStatement(value) { |
||||
return `printf '%s' "$(${value})" | jq -Rrj @html;\n`; |
||||
} |
||||
|
||||
_renderInlineStatementUnescaped(value) { |
||||
return `printf '%s' "$(${value})";\n`; |
||||
} |
||||
|
||||
_renderRaw(value) { |
||||
const lines = value.split('\n'); |
||||
|
||||
let buffer = ''; |
||||
|
||||
for (let i = 0; i < lines.length; i++) { |
||||
const line = lines[i]; |
||||
const isLastLine = i === lines.length - 1; |
||||
const formatString = isLastLine ? '%s' : '%s\\n'; |
||||
|
||||
buffer += `printf '${formatString}' '${line.replace("'", "'\\''")}';\n`; |
||||
} |
||||
|
||||
return buffer; |
||||
} |
||||
} |
@ -0,0 +1,207 @@
|
||||
import { CharacterStream } from './character_stream.js'; |
||||
import { TemplateSyntaxError } from './errors/template_syntax_error.js'; |
||||
|
||||
export class TokenStream { |
||||
static OPEN_INLINE = '<%'; |
||||
static CLOSE_INLINE = '%>'; |
||||
static OPEN_INLINE_UNESCAPED = '<!%'; |
||||
static CLOSE_INLINE_UNESCAPED = '%>'; |
||||
static OPEN_INLINE_STATEMENT = '<$%'; |
||||
static CLOSE_INLINE_STATEMENT = '%>'; |
||||
static OPEN_INLINE_STATEMENT_UNESCAPED = '<$!%'; |
||||
static CLOSE_INLINE_STATEMENT_UNESCAPED = '%>'; |
||||
static STATEMENT = '$'; |
||||
static COMMENT = '#'; |
||||
|
||||
constructor() { |
||||
this.reset(); |
||||
} |
||||
|
||||
reset() { |
||||
this._cs = new CharacterStream(); |
||||
this._peekedToken = null; |
||||
} |
||||
|
||||
loadFile(fileName) { |
||||
this.reset(); |
||||
return this._cs.loadFile(fileName); |
||||
} |
||||
|
||||
peek() { |
||||
if (!this._peekedToken) this._peekedToken = this._readNext(); |
||||
return this._peekedToken; |
||||
} |
||||
|
||||
next() { |
||||
const token = this.peek(); |
||||
this._peekedToken = null; |
||||
return token; |
||||
} |
||||
|
||||
eof() { |
||||
return this.peek() === null; |
||||
} |
||||
|
||||
_readNext() { |
||||
let raw = ''; |
||||
|
||||
const flushRaw = () => { |
||||
if (raw.length) { |
||||
const value = raw; |
||||
raw = ''; |
||||
return {type: 'raw', value}; |
||||
} |
||||
} |
||||
|
||||
while (!this._cs.eof()) { |
||||
if (this._cs.isNext(TokenStream.OPEN_INLINE)) { |
||||
return flushRaw() || this._readInline(); |
||||
} if (this._cs.isNext(TokenStream.OPEN_INLINE_UNESCAPED)) { |
||||
return flushRaw() || this._readInlineUnescaped(); |
||||
} if (this._cs.isNext(TokenStream.OPEN_INLINE_STATEMENT)) { |
||||
return flushRaw() || this._readInlineStatement(); |
||||
} if (this._cs.isNext(TokenStream.OPEN_INLINE_STATEMENT_UNESCAPED)) { |
||||
return flushRaw() || this._readInlineStatementUnescaped(); |
||||
} else if ( |
||||
this._cs.column === 0 && |
||||
this._cs.peekAfter(this._isWhitespace) === TokenStream.STATEMENT |
||||
) { |
||||
return flushRaw() || this._readStatement(); |
||||
} else if ( |
||||
this._cs.column === 0 && |
||||
this._cs.peekAfter(this._isWhitespace) === TokenStream.COMMENT |
||||
) { |
||||
return flushRaw() || this._readComment(); |
||||
} else { |
||||
raw += this._cs.next(); |
||||
} |
||||
} |
||||
|
||||
return flushRaw() || null; |
||||
} |
||||
|
||||
_missingInlineSpaceError(tag) { |
||||
throw new TemplateSyntaxError( |
||||
this._cs, |
||||
`Inline tag: "${tag}" must be followed by a whitespace` |
||||
); |
||||
} |
||||
|
||||
_readInlineGeneric(tokenType, openTag, closeTag) { |
||||
this._cs.next(openTag.length); |
||||
|
||||
// Check for mandatory space
|
||||
if (this._cs.peek() !== ' ') { |
||||
this._missingInlineSpaceError(openTag); |
||||
} |
||||
this._cs.next(); |
||||
|
||||
let value = ''; |
||||
while (!this._cs.eof() && !this._cs.isNext(closeTag)) { |
||||
const char = this._cs.next(); |
||||
|
||||
if (this._cs.isNext(closeTag)) { |
||||
if (char !== ' ') { |
||||
this._missingInlineSpaceError(closeTag); |
||||
} |
||||
} else { |
||||
value += char; |
||||
} |
||||
} |
||||
this._cs.next(closeTag.length); |
||||
return {type: tokenType, value}; |
||||
} |
||||
|
||||
_readInline() { |
||||
return this._readInlineGeneric( |
||||
'inline', |
||||
TokenStream.OPEN_INLINE, |
||||
TokenStream.CLOSE_INLINE |
||||
); |
||||
} |
||||
|
||||
_readInlineUnescaped() { |
||||
return this._readInlineGeneric( |
||||
'inline_unescaped', |
||||
TokenStream.OPEN_INLINE_UNESCAPED, |
||||
TokenStream.CLOSE_INLINE_UNESCAPED |
||||
); |
||||
} |
||||
|
||||
_readInlineStatement() { |
||||
return this._readInlineGeneric( |
||||
'inline_statement', |
||||
TokenStream.OPEN_INLINE_STATEMENT, |
||||
TokenStream.CLOSE_INLINE_STATEMENT |
||||
); |
||||
} |
||||
|
||||
_readInlineStatementUnescaped() { |
||||
return this._readInlineGeneric( |
||||
'inline_statement_unescaped', |
||||
TokenStream.OPEN_INLINE_STATEMENT_UNESCAPED, |
||||
TokenStream.CLOSE_INLINE_STATEMENT_UNESCAPED |
||||
); |
||||
} |
||||
|
||||
_readStatement() { |
||||
// Skip whitespace
|
||||
this._cs.nextWhile(this._isWhitespace); |
||||
|
||||
// Skip STATEMENT
|
||||
this._cs.next(); |
||||
|
||||
// Check for mandatory space
|
||||
if (this._cs.peek() !== ' ') { |
||||
throw new TemplateSyntaxError( |
||||
this._cs, |
||||
`Statement character: "${TokenStream.STATEMENT}" must be ` + |
||||
`followed by a whitespace` |
||||
); |
||||
} |
||||
|
||||
// Consume space
|
||||
this._cs.next(); |
||||
|
||||
// Remainder of line is statement
|
||||
const value = this._cs.nextWhile((c) => c !== '\n'); |
||||
|
||||
// Swallow new line
|
||||
this._cs.next(); |
||||
|
||||
return {type: 'statement', value}; |
||||
} |
||||
|
||||
_readComment() { |
||||
// Skip whitespace
|
||||
this._cs.nextWhile(this._isWhitespace); |
||||
|
||||
// Skip COMMENT
|
||||
this._cs.next(); |
||||
|
||||
// Check for mandatory space
|
||||
if (this._cs.peek() !== ' ') { |
||||
throw new TemplateSyntaxError( |
||||
this._cs, |
||||
`Comment character: "${TokenStream.COMMENT}" must be followed by a ` + |
||||
`whitespace` |
||||
); |
||||
} |
||||
|
||||
// Consume space
|
||||
this._cs.next(); |
||||
|
||||
// Remainder of line is comment
|
||||
const value = this._cs.nextWhile((c) => c !== '\n'); |
||||
|
||||
// Swallow new line
|
||||
this._cs.next(); |
||||
|
||||
return {type: 'comment', value}; |
||||
} |
||||
|
||||
// Utility methods
|
||||
_isWhitespace(c) { |
||||
return /\s/.test(c); |
||||
} |
||||
} |
Loading…
Reference in new issue