diff --git a/package.json b/package.json
index 096b55a0569a4ce24467970318e290ffdd2116d8..c20bebfb9fab1116be01b4c09b23a2ab50fa7d5e 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
     "postinstall": "node ./scripts/postinstall.js",
     "start": "cross-env NODE_ENV=development webpack-dev-server",
     "build": "rimraf ./public && cross-env NODE_ENV=production webpack --mode=production",
+    "doc": "node ./scripts/doc/build.js",
     "prettier": "prettier --write '*.js' 'src/**/*.js'",
     "lint:js": "eslint '*.js' 'scripts/**/*.js' 'src/**/*.js'",
     "lint:html": "node ./scripts/lint-html.js 'src/**/*.html'",
diff --git a/scripts/doc/build.js b/scripts/doc/build.js
new file mode 100644
index 0000000000000000000000000000000000000000..88822f37c95ee79ec5db4421265e9667d8e69359
--- /dev/null
+++ b/scripts/doc/build.js
@@ -0,0 +1,60 @@
+const fs = require("fs");
+const { promisify } = require("util");
+const readFile = promisify(fs.readFile);
+const writeFile = promisify(fs.writeFile);
+const shell = require("shelljs");
+const flatten = require("lodash/flatten");
+const indexTemplate = require("./index.js");
+
+async function extractDocs(file) {
+  const contents = (await readFile(file)).toString();
+  // Find all the doc strings in the file.
+  const matches = contents.match(/\/\*\*.+?\*\//gs);
+  if (matches) {
+    return matches.map(match => {
+      return { doc: match, file };
+    });
+  } else {
+    return null;
+  }
+}
+
+function parseDocs(doc) {
+  const _doc = doc;
+  // Capture the description and tags in the doc string
+  const matches = _doc.doc.match(/\/\*\*([^@]+)(.+)\*\//s);
+  // Trim whitespace and asterisks from a line
+  const trimLine = line => line.trim().replace(/^\*\s*|\s*\*$/g, "");
+
+  _doc.doc = {
+    desc: matches[1]
+      .split("\n")
+      .map(trimLine)
+      .filter(x => x)
+      .join(" "),
+    tags: matches[2]
+      .split(/[\r\n]/)
+      .map(trimLine)
+      .filter(x => x.startsWith("@"))
+      .reduce((a, x) => {
+        const tag = x.split(" ");
+        a[tag[0].substring(1)] = tag.slice(1).join();
+        return a;
+      }, {})
+  };
+  return _doc;
+}
+
+function aframeDocs(doc) {
+  const keys = Object.keys(doc.doc.tags);
+  return keys.includes("component") || keys.includes("system");
+}
+
+(async function() {
+  const files = shell.ls("src/components/*.js", "src/systems/*.js");
+  const parsedDocs = flatten(await Promise.all(files.map(extractDocs)))
+    .filter(x => x)
+    .map(parseDocs)
+    .filter(aframeDocs);
+  writeFile("doc/index.html", indexTemplate(parsedDocs));
+})();
diff --git a/scripts/doc/index.js b/scripts/doc/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ab6c68d9588d5ad147789176fcc024e745537ec
--- /dev/null
+++ b/scripts/doc/index.js
@@ -0,0 +1,87 @@
+module.exports = function(docs) {
+  const systems = docs.filter(doc => Object.keys(doc.doc.tags).includes("system"));
+  const components = docs.filter(doc => Object.keys(doc.doc.tags).includes("component")).reduce((acc, doc) => {
+    const namespace = doc.doc.tags.namespace || "misc";
+    if (!acc[namespace]) {
+      acc[namespace] = [];
+    }
+    acc[namespace].push(doc);
+    return acc;
+  }, {});
+  return `
+  <html>
+    <head>
+      <style>
+        body { font-family: sans-serif; }
+        article { margin-left: 1em; }
+        span { font-size: 70%; color: grey; }
+      </style>
+    </head>
+    <body>
+      <h1>Docs</h1>
+
+      <ul>
+        <li>Systems
+          <ul>
+            ${systems
+              .map(system => {
+                return `<li><a href="#systems/${system.doc.tags.system}">${system.doc.tags.system}</a></li>`;
+              })
+              .join("")}
+            </ul>
+        </li>
+
+        <li>Components
+          <ul>
+            ${Object.entries(components)
+              .sort((a, b) => a[0] > b[0])
+              .map(([namespace, components]) => {
+                return `<li><a href="#components/${namespace}">${namespace}</a><ul>
+                  ${components
+                    .map(
+                      component => `<li>
+                        <a href="#components/${namespace}/${component.doc.tags.component}">
+                          ${component.doc.tags.component}
+                        </a>
+                      </li>`
+                    )
+                    .join("")}
+                </ul></li>`;
+              })
+              .join("")}
+          </ul>
+        </li>
+      </ul>
+
+      <h2>Systems</h2>
+      ${systems
+        .map(system => {
+          return `<article>
+            <a name="systems/${system.doc.tags.system}"></a><h4>${system.doc.tags.system}</h4>
+            <p>${system.doc.desc}</p>
+            <span>${system.file}</span>
+          </article>`;
+        })
+        .join("")}
+
+      <h2>Components</h2>
+      ${Object.entries(components)
+        .map(([namespace, components]) => {
+          return `<a name="components/${namespace}"></a><h3>${namespace}</h3>
+            ${components
+              .map(
+                component => `<article>
+                  <a name="components/${namespace}/${component.doc.tags.component}"></a>
+                  <h4>${component.doc.tags.component}</h4>
+                  <p>${component.doc.desc}</p>
+                  <span>${component.file}</span>
+                </article>`
+              )
+              .join("")}
+          `;
+        })
+        .join("")}
+    </body>
+  </html>
+  `;
+};