Browse Source

Initial commit

master
Ben Ashton 2 years ago
commit
44c21e670f
  1. 24
      cli.js
  2. 14
      example_templates/everything.n0m
  3. 5
      example_templates/inline_tags.n0m
  4. 12
      example_templates/restaurants.n0m
  5. 3
      example_templates/restaurants.txt
  6. 1
      index.js
  7. 13
      package.json
  8. 85
      src/character_stream.js
  9. 7
      src/errors/template_syntax_error.js
  10. 82
      src/template_engine.js
  11. 207
      src/token_stream.js

24
cli.js

@ -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);
}

14
example_templates/everything.n0m

@ -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>

5
example_templates/inline_tags.n0m

@ -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>

12
example_templates/restaurants.n0m

@ -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>

3
example_templates/restaurants.txt

@ -0,0 +1,3 @@
Luigi's
Papa Johns
SushiQ

1
index.js

@ -0,0 +1 @@
export { TemplateEngine } from './src/template_engine.js';

13
package.json

@ -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"
}

85
src/character_stream.js

@ -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() === '';
}
}

7
src/errors/template_syntax_error.js

@ -0,0 +1,7 @@
export class TemplateSyntaxError extends Error {
constructor(cs, message) {
super(
`${cs.fileName}:${cs.line + 1}:${cs.column + 1}: ${message}`
);
}
}

82
src/template_engine.js

@ -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;
}
}

207
src/token_stream.js

@ -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…
Cancel
Save