diff --git a/1-tokenizer.js b/1-tokenizer.js index 770aefd..281eab5 100644 --- a/1-tokenizer.js +++ b/1-tokenizer.js @@ -15,12 +15,42 @@ * (add 2 (subtract 4 2)) => [{ type: 'paren', value: '(' }, ...] */ +/** + * First, let’s create a class to remember the position of each token. + */ +class Position { + constructor(index, line = 1, column = 1) { + this.line = line; + this.column = column; + this.index = index; + } + nextCh() { + this.column++; + this.index++; + return this; + } + nextLine() { + this.column = 1; + this.line++; + return this; + } + clone() { + return new Position( + this.index, + this.line, + this.column + ); + } + toString() { + return this.line + ':' + this.column; + } +} + // We start by accepting an input string of code, and we're gonna set up two // things... function tokenizer(input) { - // A `current` variable for tracking our position in the code like a cursor. - let current = 0; + let current = new Position(0); // And a `tokens` array for pushing our tokens to. let tokens = []; @@ -30,10 +60,9 @@ function tokenizer(input) { // // We do this because we may want to increment `current` many times within a // single loop because our tokens can be any length. - while (current < input.length) { - + while (current.index < input.length) { // We're also going to store the `current` character in the `input`. - let char = input[current]; + let char = input[current.index]; // The first thing we want to check for is an open parenthesis. This will // later be used for `CallExpression` but for now we only care about the @@ -43,14 +72,16 @@ function tokenizer(input) { if (char === '(') { // If we do, we push a new token with the type `paren` and set the value - // to an open parenthesis. + // to an open parenthesis. We also store the `start` and `end` of this + // token for future reference. tokens.push({ type: 'paren', value: '(', + start: current.clone(), + end: current.clone(), }); - - // Then we increment `current` - current++; + // Then we increment `current`. + current.nextCh(); // And we `continue` onto the next cycle of the loop. continue; @@ -63,8 +94,10 @@ function tokenizer(input) { tokens.push({ type: 'paren', value: ')', + start: current.clone(), + end: current.clone().nextCh(), }); - current++; + current.nextCh(); continue; } @@ -77,7 +110,12 @@ function tokenizer(input) { // going to just `continue` on. let WHITESPACE = /\s/; if (WHITESPACE.test(char)) { - current++; + current.nextCh(); + // If the character is a newline, we'll tell the cursor that we've + // moved to the next line. + if (char === '\n') { + current.nextLine(); + } continue; } @@ -96,17 +134,23 @@ function tokenizer(input) { // We're going to create a `value` string that we are going to push // characters to. let value = ''; + // We'll also save the start of the number for later. + const start = current.clone(); // Then we're going to loop through each character in the sequence until // we encounter a character that is not a number, pushing each character // that is a number to our `value` and incrementing `current` as we go. while (NUMBERS.test(char)) { value += char; - char = input[++current]; + current.nextCh(); + if (current.index >= input.length) { + break; + } + char = input[current.index]; } // After that we push our `number` token to the `tokens` array. - tokens.push({ type: 'number', value }); + tokens.push({ type: 'number', value, start, end: current.clone() }); // And we continue on. continue; @@ -122,22 +166,41 @@ function tokenizer(input) { if (char === '"') { // Keep a `value` variable for building up our string token. let value = ''; + // We'll also save the start of the string for later. + const start = current.clone(); + // If the quote is the last character in the program, + // throw a syntax error: + if (current.index + 1 >= input.length) { + throw new SyntaxError(`Unterminated string at ${start}-${current}`); + } + // Otherwise, skip past the quote... + current.nextCh(); + - // We'll skip the opening double quote in our token. - char = input[++current]; + // ...and grab the first character of the string. + char = input[current.index]; // Then we'll iterate through each character until we reach another // double quote. while (char !== '"') { value += char; - char = input[++current]; + // If the string is not terminated before the end of the program, + // throw a syntax error + if (current.index + 1 >= input.length) { + throw new SyntaxError(`Unterminated string at ${start}-${current}`); + } + // Otherwise, increment the cursor + current.nextCh(); + // And grab the next character. + char = input[current.index]; } // Skip the closing double quote. - char = input[++current]; + current.nextCh(); + char = input[current.index]; // And add our `string` token to the `tokens` array. - tokens.push({ type: 'string', value }); + tokens.push({ type: 'string', value, start, end: current.clone() }); continue; } @@ -152,24 +215,28 @@ function tokenizer(input) { // let LETTERS = /[a-z]/i; if (LETTERS.test(char)) { + // First, we'll create a string to hold the value let value = ''; + // And save the current position for later. + const start = current.clone(); // Again we're just going to loop through all the letters pushing them to // a value. - while (LETTERS.test(char)) { + while (LETTERS.test(char) && current.index < input.length) { value += char; - char = input[++current]; + current.nextCh(); + char = input[current.index]; } // And pushing that value as a token with the type `name` and continuing. - tokens.push({ type: 'name', value }); + tokens.push({ type: 'name', value, start, end: current.clone() }); continue; } // Finally if we have not matched a character by now, we're going to throw - // an error and completely exit. - throw new TypeError('I dont know what this character is: ' + char); + // a syntax error and completely exit. + throw new SyntaxError('I dont know what this character is: ' + char); } // Then at the end of our `tokenizer` we simply return the tokens array. diff --git a/2-parser.js b/2-parser.js index fe66a46..be34b15 100644 --- a/2-parser.js +++ b/2-parser.js @@ -14,8 +14,9 @@ // Okay, so we define a `parser` function that accepts our array of `tokens`. function parser(tokens) { - // Again we keep a `current` variable that we will use as a cursor. + // Because we're not iterating over the source code, we are just + // using a number. let current = 0; // But this time we're going to use recursion instead of a `while` loop. So we @@ -24,6 +25,7 @@ function parser(tokens) { // Inside the walk function we start by grabbing the `current` token. let token = tokens[current]; + if (!token) return; // We're going to split each type of token off into a different code path, // starting off with `number` tokens. @@ -39,6 +41,8 @@ function parser(tokens) { return { type: 'NumberLiteral', value: token.value, + start: token.start, + end: token.end, }; } @@ -50,6 +54,8 @@ function parser(tokens) { return { type: 'StringLiteral', value: token.value, + start: token.start, + end: token.end, }; } @@ -60,9 +66,14 @@ function parser(tokens) { token.value === '(' ) { + const initial = token; // We'll increment `current` to skip the parenthesis since we don't care // about it in our AST. token = tokens[++current]; + // If we've reached the end of the program, throw a syntax error. + if (!token) { + throw new SyntaxError(`Unclosed function call at ${initial.start}`); + } // We create a base node with the type `CallExpression`, and we're going // to set the name as the current token's value since the next token after @@ -71,10 +82,19 @@ function parser(tokens) { type: 'CallExpression', name: token.value, params: [], + start: token.start, }; + // If the user writes `()` as code, throw a syntax error. + if (token.type === 'paren' && token.value === ')') { + throw new SyntaxError(`Unexpected empty function call at ${tokens[current - 1].start}–${token.end}`) + } // We increment `current` *again* to skip the name token. token = tokens[++current]; + // If we've reached the end of the program (for example: `(foo`), throw a syntax error. + if (!token) { + throw new SyntaxError(`Unclosed function call at ${initial.start}-${tokens[current - 1].end}`); + } // And now we want to loop through each token that will be the `params` of // our `CallExpression` until we encounter a closing parenthesis. @@ -118,7 +138,13 @@ function parser(tokens) { // push it into our `node.params`. node.params.push(walk()); token = tokens[current]; + // If we've reached the end of the program, throw a syntax error. + if (!token) { + throw new SyntaxError('Unclosed function call at ' + initial.start + '-' + tokens[current - 1].end); + } } + // Next, we save the end position of the call + node.end = token.end; // Finally we will increment `current` one last time to skip the closing // parenthesis. @@ -127,10 +153,15 @@ function parser(tokens) { // And return the node. return node; } + + // If the user writes a literal name token (`foo`), throw a syntax error. + if (token.type === 'name') { + throw new SyntaxError(`Unexpected name '${token.value}' at ${token.start}–${token.end}`) + } // Again, if we haven't recognized the token type by now we're going to // throw an error. - throw new TypeError(token.type); + throw new SyntaxError(`Unrecognized token: ${JSON.stringify(token)}`); } // Now, we're going to create our AST which will have a root which is a diff --git a/3-traverser.js b/3-traverser.js index d199874..1decd65 100644 --- a/3-traverser.js +++ b/3-traverser.js @@ -78,7 +78,7 @@ function traverser(ast, visitor) { // And again, if we haven't recognized the node type then we'll throw an // error. default: - throw new TypeError(node.type); + throw new SyntaxError(`Unrecognized node ${node.type}: ${JSON.stringify(node)}}`); } // If there is an `exit` method for this node type we'll call it with the diff --git a/7-test.html b/7-test.html new file mode 100644 index 0000000..f94ce37 --- /dev/null +++ b/7-test.html @@ -0,0 +1,8 @@ +

Code in:

+ + + +

Code out:

+ +

diff --git a/content.ejs b/content.ejs
new file mode 100644
index 0000000..c502135
--- /dev/null
+++ b/content.ejs
@@ -0,0 +1,13 @@
+
class="is-code"<% } %>> + <% if (isCode) { %> +
<%- fileContents %>
+ <% } else { %> +
+ <%- fileContents %> +
+ <% } %> + + <% if (fileName === '6-compiler.js') { %> + Carlton Dance + <% } %> +
diff --git a/package.json b/package.json index 7b25ab7..675aeee 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "express": "^4.15.2", "markdown-it": "^8.3.1", "ejs": "^2.5.6", - "prismjs": "^9000.0.1" + "prismjs": "^9000.0.1", + "body-parser": "^1.17.1" }, "scripts": { "start": "node server.js" diff --git a/public/client.js b/public/client.js new file mode 100644 index 0000000..e4abea3 --- /dev/null +++ b/public/client.js @@ -0,0 +1,95 @@ +/* globals NProgress */ + +// Load pages using AJAX for a speed boost +var cache = Object.create(null); // Don’t include default Object properties +document.addEventListener('click', function (e) { + // When something gets clicked, + var target = closest(e.target, 'a'); + // Check if it’s a link to a page on this domain. + if (!target || target.host !== location.host) return; + + // If it is, stop the browser from loading the next page, + e.preventDefault(); + // get the path of the page, + var path = getPath(target); + // and load it ourselves. + loadPage(path).then(function (json) { + // Then, once the page is loaded, + // insert the rendered HTML in the correct place + document.querySelector('main').outerHTML = json.html; + // and change the file name in the header. + document.querySelector('.js-file-name').textContent = json.context.fileName; + + // Finally, update the title bar and address bar, + history.pushState(null, json.title, target.href); + // and tell listeners about the page load. + emitLoad(path, target); + }) +}); + +function getPath(l) { + return l.pathname + l.search + l.hash; +} + +function emitLoad(path, target) { + var event = new Event('page:load'); + event.target = target; + event.data = { + path: path, + }; + document.dispatchEvent(event); +} + +function loadPage(path) { + // If the path is in the cache, load it immediately. + if (path in cache) { + NProgress.done(true); // show bar anyway + return Promise.resolve(cache[path]); + } + + // Otherwise, fetch it from the server, + NProgress.start(); + var promise = fetch('/api/fetch?path=' + encodeURIComponent(path)).then(function (res) { + // then convert it to JSON. + return res.json(); + }) + // Once we have the JSON, + promise.then(function (json) { + // cache it! + cache[path] = json; + NProgress.done(); + return json + }).catch(function (error) { + // If something went wrong, log it in the console. + console.warn(error); + NProgress.done(); + }); + return promise; + +} + +// When the page loads, +document.addEventListener('page:load', function () { + // set the correct link as active. + document.querySelector('a.active').classList.remove('active'); + document.querySelector('a[href="' + window.location.pathname + '"]').classList.add('active'); +}) + + +// Emit an initial load event when the page is ready +window.addEventListener('load', function () { + emitLoad(getPath(location), document) +}) + + +if (!Element.prototype.matches && Element.prototype.matchesSelector) { + Element.prototype.matches = Element.prototype.matchesSelector; +} + +function closest(element, selector) { + if (!element) return null; + if (element.matches(selector)) { + return element; + } + return closest(element.parentElement, selector); +} \ No newline at end of file diff --git a/public/nprogress.css b/public/nprogress.css new file mode 100644 index 0000000..8cacb75 --- /dev/null +++ b/public/nprogress.css @@ -0,0 +1,75 @@ +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + background: #fff; + + position: fixed; + z-index: 1031; + top: 0; + left: 0; + + width: 100%; + height: 2px; +} + +/* Fancy blur effect */ +#nprogress .peg { + display: block; + position: absolute; + right: 0px; + width: 100px; + height: 100%; + box-shadow: 0 0 10px #fff, 0 0 5px #fff; + opacity: 1.0; + + -webkit-transform: rotate(3deg) translate(0px, -4px); + -ms-transform: rotate(3deg) translate(0px, -4px); + transform: rotate(3deg) translate(0px, -4px); +} + +/* Remove these to get rid of the spinner +#nprogress .spinner { + display: block; + position: fixed; + z-index: 1031; + top: 15px; + right: 15px; +} + +#nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + + border: solid 2px transparent; + border-top-color: #fff; + border-left-color: #fff; + border-radius: 50%; + + -webkit-animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; +} +*/ + +.nprogress-custom-parent { + overflow: hidden; + position: relative; +} + +.nprogress-custom-parent #nprogress .spinner, +.nprogress-custom-parent #nprogress .bar { + position: absolute; +} + +@-webkit-keyframes nprogress-spinner { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} +@keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + diff --git a/public/test.js b/public/test.js new file mode 100644 index 0000000..135d290 --- /dev/null +++ b/public/test.js @@ -0,0 +1,32 @@ +// It would be simpler to use this if we could just `require()` the `compiler`, +// but this code runs on the client side and it would be really complicated +// to enable using `require()` here, so we just call the server instead. + +document.addEventListener('page:load', function (e) { + if (e.data.path !== '/test') return; + // When the user types into the input field... + document.getElementById('input').addEventListener('input', function (event) { + // POST the code to the API + fetch('/api/convert', { + method: 'POST', + body: event.target.value, + }).then(function (res) { + // then get the JSON back + return res.json(); + }).then(function (json) { + // then either: + var output = document.getElementById('output'); + if (json.ok) { + // output the transformed code if + // the compilation succeeded, or + output.style.backgroundColor = 'black'; + output.textContent = json.code; + } else { + // output the error if there was + // a problem. + output.style.backgroundColor = 'darkred'; + output.textContent = json.stack; + } + }) + }) +}); \ No newline at end of file diff --git a/server.js b/server.js index 75b03a6..dc03eeb 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,21 @@ var markdown = require('markdown-it')(); var Prism = require('prismjs'); +var bodyParser = require('body-parser'); var express = require('express'); var path = require('path'); var ejs = require('ejs'); var fs = require('fs'); +// First, let's create the server, var app = express(); +// add a `req.body` property to requests containing +// the body as text, +app.use(bodyParser.text()); +// and serve any files in the `public` directory from the /assets path. +app.use('/assets', express.static(path.join(__dirname, 'public'))); + +// Next, let's create the URLs to read the code used: var ROUTES_MAP = { '/' : 'README.md', '/intro' : '0-introduction.md', @@ -15,7 +24,11 @@ var ROUTES_MAP = { '/traverser' : '3-traverser.js', '/transformer' : '4-transformer.js', '/code-generator' : '5-code-generator.js', - '/compiler' : '6-compiler.js' + '/compiler' : '6-compiler.js', + '/test' : '7-test.html', + '/server' : 'server.js', + '/client' : 'public/client.js', + '/test-js' : 'public/test.js', }; var routes = Object.keys(ROUTES_MAP).map(function(routePath) { @@ -25,46 +38,112 @@ var routes = Object.keys(ROUTES_MAP).map(function(routePath) { }; }); +// Next, let's create helpers to read files, function readFile(fileName) { return fs.readFileSync(path.join(__dirname, fileName)).toString(); } +// render Markdown, function renderMarkdown(fileContents) { return markdown.render(fileContents); } +// and highlight JavaScript code. function renderJavaScript(fileName, fileContents) { return Prism.highlight(fileContents, Prism.languages.javascript); } -var template = ejs.compile(readFile('./template.html.ejs')); +// We'll be using EJS (http://ejs.co) to render the files. +var template = ejs.compile(readFile('./template.html.ejs'), { + filename: path.join(__dirname, 'template.html.ejs') +}); function render(routeName) { + return template(getContext(routeName)); +} + +function getContext(routeName) { + // We'll read the file at the specified path, var fileName = routeName; var fileContents = readFile(fileName); + // render it appropriately, var extName = path.extname(fileName); if (extName === '.md') fileContents = renderMarkdown(fileContents); if (extName === '.js') fileContents = renderJavaScript(fileName, fileContents); - let isCode = extName !== '.md'; + let isCode = extName === '.js'; - return template({ - routes: routes, - fileName: fileName, - fileContents: fileContents, - isCode: isCode, - }); + return { + routes, + fileName, + fileContents, + isCode + } } +// Next, let's tell Express about each file we're rendering. routes.forEach(function(route) { - var html = render(route.routeName); - app.get(route.routePath, function(req, res) { + var html = render(route.routeName); res.send(html); }); }); +// To convert the code, +app.post('/api/convert', function(req, res) { + try { + // First, try to convert the code and send it back. + var code = require('./6-compiler')(req.body); + res.send({ + ok: true, + code: code, + }); + } catch (err) { + try { + // If that fails, send the error back. + res.send({ + ok: false, + err: err.toString(), + stack: err.stack, + }); + } catch (e) { + // If sending the error fails, send a generic error. + res.send({ + ok: false, + err: 'unknown error', + stack: 'unknown error\n', + }); + // (if *that* fails, you're on your own :)) + } + } +}); + + +var contentTemplate = ejs.compile(readFile('./content.ejs'), { + filename: path.join(__dirname, 'content.ejs') +}); + +var titleTemplate = ejs.compile(readFile('./title.ejs'), { + filename: path.join(__dirname, 'title.ejs') +}); + +app.get('/api/fetch', function(req, res) { + var path = ROUTES_MAP[req.query.path] + if (!path) { + res.status('404').send({ + error: 'route not found' + }); + } + var context = getContext(path); + res.send({ + title: titleTemplate(context), + html: contentTemplate(context), + context + }); +}); + +// Finally, start the server. var listener = app.listen(process.env.PORT, function () { console.log('Your app is listening on port ' + listener.address().port); }); diff --git a/template.html.ejs b/template.html.ejs index 9f2c3ed..b23e5da 100644 --- a/template.html.ejs +++ b/template.html.ejs @@ -1,12 +1,18 @@ -class="is-code"<% } %>> + - The Super Tiny Compiler - <%= fileName %> + <%- include('title') %> + + + + + +