Initial commit
This commit is contained in:
commit
44c21e670f
24
cli.js
Normal file
24
cli.js
Normal file
@ -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
Normal file
14
example_templates/everything.n0m
Normal file
@ -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
Normal file
5
example_templates/inline_tags.n0m
Normal file
@ -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
Normal file
12
example_templates/restaurants.n0m
Normal file
@ -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
Normal file
3
example_templates/restaurants.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Luigi's
|
||||||
|
Papa Johns
|
||||||
|
SushiQ
|
1
index.js
Normal file
1
index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { TemplateEngine } from './src/template_engine.js';
|
13
package.json
Normal file
13
package.json
Normal file
@ -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
Normal file
85
src/character_stream.js
Normal file
@ -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
Normal file
7
src/errors/template_syntax_error.js
Normal file
@ -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
Normal file
82
src/template_engine.js
Normal file
@ -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
Normal file
207
src/token_stream.js
Normal file
@ -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
Block a user