diff --git a/3x5.js b/3x5.js
index db59db9..7630677 100644
--- a/3x5.js
+++ b/3x5.js
@@ -24,12 +24,8 @@
  * @returns {string} Markup to render / output
  */
 function renderCard(state, code) {
-  /* Preprocess: fold line endings */
-  code = code.replace(/(?<!\\)(\\\\)*\\q/g, "$1");
-  /* Preprocess: escape HTML */
-  code = escapeHtml(code);
-
-  state.output = code;
+  const script = parseNotcl(code);
+  state.output = JSON.stringify(script, null, 2);
   return state.output;
 }
 
@@ -59,6 +55,7 @@ let theCard = {
     } {
       Since we want escapes to work, these blocks [i will] be subject to substitutions.
     }
+    # A comment
     para {
       line endings escaped\
       one slash
diff --git a/index.html b/index.html
index e9cad93..f9c3689 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,7 @@
   </head>
   <body>
     <script src="helpers.js"></script>
+    <script src="notcl.js"></script>
     <script src="3x5.js"></script>
   </body>
 </html>
diff --git a/notcl.js b/notcl.js
new file mode 100644
index 0000000..6068dbd
--- /dev/null
+++ b/notcl.js
@@ -0,0 +1,73 @@
+/**
+ * @typedef {Command[]} Script
+ * @typedef {Word[]} Command
+ * @typedef {object} Word
+ * @property {string} text
+ */
+
+/**
+ * Parse out a Notcl script into an easier-to-interpret representation.
+ * No script is actually executed yet.
+ *
+ * @param {string} code
+ * @returns Script
+ */
+function parseNotcl(code) {
+  /* Preprocess */
+  // fold line endings
+  code = code.replace(/(?<!\\)(\\\\)*\\n/g, "$1");
+  // escape HTML
+  code = escapeHtml(code);
+
+  /* Parse */
+  function nextWord(/* TODO: null/] terminator */) {
+    // Strip whitespace
+    code = code.replace(/^[^\S\n;]*/, "");
+    // TODO: handle all kinds of brace/substitution stuff
+    const word = code.match(/^[^\s;]+/);
+    if (word) {
+      code = code.substring(word[0].length);
+      return { text: word[0] };
+    } else {
+      return null;
+    }
+  }
+
+  function nextCommand(/* TODO: null/] terminator */) {
+    const command = /** @type {Word[]} */ ([]);
+    while (true) {
+      // Strip whitespace
+      code = code.replace(/^\s*/, "");
+      // Strip comments
+      if (code[0] == "#") {
+        code = code.replace(/^.*\n/, "");
+        continue;
+      }
+      // Strip semicolons
+      if (code[0] == ";") {
+        code = code.substring(1);
+        continue;
+      }
+      break;
+    }
+
+    while (true) {
+      const word = nextWord();
+      if (word) {
+        command.push(word);
+        continue;
+      }
+      break;
+    }
+
+    return command;
+  }
+
+  /* Loop through commands, with safety check */
+  const script = /** @type {Command[]} */ ([]);
+  for (let i = 0; i < 1000 && code != ""; i++) {
+    script.push(nextCommand());
+  }
+
+  return script;
+}