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