diff --git a/_includes/syllabus.html b/_includes/syllabus.html
index 217e7afe995c16085fccb305a873fe422d682c7d..0240d85b126206a1cbcb4d1a6bd8316ac06c978b 100644
--- a/_includes/syllabus.html
+++ b/_includes/syllabus.html
@@ -2,57 +2,59 @@
   Display syllabus in tabular form.
   Days are displayed if at least one episode has 'start = true'.
 {% endcomment %}
-<h2>Schedule</h2>
+<div class="syllabus">
+  <h2>Schedule</h2>
 
-{% assign day = 0 %}
-{% assign multiday = false %}
-{% for episode in site.episodes %}
-  {% if episode.start %}{% assign multiday = true %}{% break %}{% endif %}
-{% endfor %}
-{% assign current = site.start_time %}
+  {% assign day = 0 %}
+  {% assign multiday = false %}
+  {% for episode in site.episodes %}
+    {% if episode.start %}{% assign multiday = true %}{% break %}{% endif %}
+  {% endfor %}
+  {% assign current = site.start_time %}
 
-<table class="table table-striped">
-{% for episode in site.episodes %}
-  {% if episode.start %} {% comment %} Starting a new day? {% endcomment %}
-    {% assign day = day | plus: 1 %}
-    {% if day > 1 %} {% comment %} If about to start day 2 or later, show finishing time for previous day {% endcomment %}
-      {% assign hours = current | divided_by: 60 %}
-      {% assign minutes = current | modulo: 60 %}
-      <tr>
-        {% if multiday %}<td></td>{% endif %}
-        <td class="col-md-1">{% if hours < 10 %}0{% endif %}{{ hours }}:{% if minutes < 10 %}0{% endif %}{{ minutes }}</td>
-        <td class="col-md-3">Finish</td>
-        <td class="col-md-7"></td>
-      </tr>
+  <table class="table table-striped">
+  {% for episode in site.episodes %}
+    {% if episode.start %} {% comment %} Starting a new day? {% endcomment %}
+      {% assign day = day | plus: 1 %}
+      {% if day > 1 %} {% comment %} If about to start day 2 or later, show finishing time for previous day {% endcomment %}
+        {% assign hours = current | divided_by: 60 %}
+        {% assign minutes = current | modulo: 60 %}
+        <tr>
+          {% if multiday %}<td></td>{% endif %}
+          <td class="col-md-1">{% if hours < 10 %}0{% endif %}{{ hours }}:{% if minutes < 10 %}0{% endif %}{{ minutes }}</td>
+          <td class="col-md-3">Finish</td>
+          <td class="col-md-7"></td>
+        </tr>
+      {% endif %}
+      {% assign current = site.start_time %} {% comment %}Re-set start time of this episode to general daily start time {% endcomment %}
     {% endif %}
-    {% assign current = site.start_time %} {% comment %}Re-set start time of this episode to general daily start time {% endcomment %}
-  {% endif %}
+    {% assign hours = current | divided_by: 60 %}
+    {% assign minutes = current | modulo: 60 %}
+    <tr>
+      {% if multiday %}<td class="col-md-1">{% if episode.start %}Day {{ day }}{% endif %}</td>{% endif %}
+      <td class="col-md-1">{% if hours < 10 %}0{% endif %}{{ hours }}:{% if minutes < 10 %}0{% endif %}{{ minutes }}</td>
+      <td class="col-md-3">
+        <a href="{{ site.root }}/{{ episode.url }}">{{ episode.title }}</a>
+      </td>
+      <td class="col-md-7">
+        {% if episode.break %}
+          Break
+        {% else %}
+          {% if episode.questions %}
+            {{ episode.questions | join: '<br/>' }}
+          {% endif %}
+        {% endif %}
+      </td>
+    </tr>
+    {% assign current = current | plus: episode.teaching | plus: episode.exercises | plus: episode.break %}
+  {% endfor %}
   {% assign hours = current | divided_by: 60 %}
   {% assign minutes = current | modulo: 60 %}
   <tr>
-    {% if multiday %}<td class="col-md-1">{% if episode.start %}Day {{ day }}{% endif %}</td>{% endif %}
+    {% if multiday %}<td></td>{% endif %}
     <td class="col-md-1">{% if hours < 10 %}0{% endif %}{{ hours }}:{% if minutes < 10 %}0{% endif %}{{ minutes }}</td>
-    <td class="col-md-3">
-      <a href="{{ site.root }}/{{ episode.url }}">{{ episode.title }}</a>
-    </td>
-    <td class="col-md-7">
-      {% if episode.break %}
-        Break
-      {% else %}
-        {% if episode.questions %}
-          {{ episode.questions | join: '<br/>' }}
-        {% endif %}
-      {% endif %}
-    </td>
+    <td class="col-md-3">Finish</td>
+    <td class="col-md-7"></td>
   </tr>
-  {% assign current = current | plus: episode.teaching | plus: episode.exercises | plus: episode.break %}
-{% endfor %}
-{% assign hours = current | divided_by: 60 %}
-{% assign minutes = current | modulo: 60 %}
-<tr>
-  {% if multiday %}<td></td>{% endif %}
-  <td class="col-md-1">{% if hours < 10 %}0{% endif %}{{ hours }}:{% if minutes < 10 %}0{% endif %}{{ minutes }}</td>
-  <td class="col-md-3">Finish</td>
-  <td class="col-md-7"></td>
-</tr>
-</table>
+  </table>
+</div>
diff --git a/bin/validator b/bin/validator
new file mode 100755
index 0000000000000000000000000000000000000000..c173905a2fcbd7ce644fee71f7358ee717aef92d
--- /dev/null
+++ b/bin/validator
@@ -0,0 +1,190 @@
+#!/usr/bin/env python
+
+'''
+Validate layout of generated HTML pages.
+(We validate the HTML because source may not be in Markdown.)
+'''
+
+import sys
+import os
+import glob
+import fnmatch
+import yaml
+from optparse import OptionParser
+from bs4 import BeautifulSoup
+from lxml import etree
+
+
+# Default configuration.
+DEFAULT_CONFIG = '''\
+patterns:
+  '*.html':
+    - has_title_in_head
+    - has_navbar
+    - has_title_in_body
+    - has_footer
+  index.html:
+    - has_prereq
+    - has_syllabus
+  '*-*/index.html':
+    - has_objectives
+'''
+
+
+# Record all the rules.
+RULES = {}
+def rule(fn):
+    RULES[fn.__name__] = fn
+    return fn
+
+
+def main():
+    '''Main driver: check all files with all rules that apply.'''
+
+    args = parse_args()
+    read_config(args)
+    docs = read_all_docs(args.source_dir)
+    _require(docs, 'No source files found in {0}'.format(args.source_dir))
+    all_filenames = docs.keys()
+    for filename in all_filenames:
+        if args.verbose > 0:
+            print(filename, '...', file=sys.stderr)
+        for pattern in args.patterns:
+            full_pattern = os.path.join(args.source_dir, pattern)
+            if fnmatch.fnmatch(filename, full_pattern):
+                for rule in args.patterns[pattern]:
+                    if args.verbose > 1:
+                        print('...', rule, file=sys.stderr)
+                    RULES[rule](filename, docs[filename])
+
+
+def parse_args():
+    '''Parse command-line arguments.'''
+
+    parser = OptionParser()
+    parser.add_option('-c', '--config',
+                      default=None,
+                      dest='config_file',
+                      help='configuration file')
+    parser.add_option('-s', '--source',
+                      default='_site',
+                      dest='source_dir',
+                      help='source directory')
+    parser.add_option('-v', '--verbose',
+                      default=0,
+                      action='count',
+                      dest='verbose',
+                      help='report actions')
+
+    args, extras = parser.parse_args()
+
+    _require(not extras, 'Unexpected trailing command-line arguments "{0}"'.format(extras))
+
+    return args
+
+
+def read_config(args):
+    '''
+    Read configuration file.
+    '''
+
+    if args.config_file:
+        with open(args.config_file, 'r') as reader:
+            args.config = yaml.load(reader)
+    else:
+        args.config = yaml.load(DEFAULT_CONFIG)
+
+    args.patterns = args.config['patterns']
+
+
+def read_all_docs(source_dir):
+    '''
+    Read all HTML pages under the source directory.
+    Returns a dictionary of (path, doc).
+    '''
+
+    pattern = os.path.join(source_dir, '**/*.html')
+    result = {}
+    for path in glob.iglob(pattern, recursive=True):
+        try:
+            with open(path, 'r') as reader:
+                raw = reader.read().replace('<!doctype html>\n', '')
+                soup = BeautifulSoup(raw, 'html.parser').prettify()
+                doc = etree.fromstring(soup)
+                result[path] = doc
+        except IOError as e:
+            print('Unable to open {0}: {1}'.format(path, e), file=sys.stderr)
+            sys.exit(1)
+
+    return result
+
+
+@rule
+def has_footer(filename, doc):
+    '''Document has footer element.'''
+
+    _check_1(filename, doc, 'footers', '//footer')
+
+
+@rule
+def has_navbar(filename, doc):
+    '''Document has header element.'''
+
+    _check_1(filename, doc, 'div navbar', '//div[@class="navbar-header"]')
+
+
+@rule
+def has_objectives(filename, doc):
+    '''Episode has objectives.'''
+
+    _check_1(filename, doc, 'objectives div', '//blockquote[@class="objectives"]')
+
+
+@rule
+def has_prereq(filename, doc):
+    '''Index page has prerequisites block.'''
+
+    _check_1(filename, doc, 'prerequisites blockquote', '//blockquote[@class="prereq"]')
+
+
+@rule
+def has_syllabus(filename, doc):
+    '''Index page has syllabus.'''
+
+    _check_1(filename, doc, 'syllabus', '//div[@class="syllabus"]')
+    _check_1(filename, doc, 'syllabus title', '//div[@class="syllabus"]/h2')
+    _check_1(filename, doc, 'syllabus table', '//div[@class="syllabus"]/table')
+
+
+@rule
+def has_title_in_head(filename, doc):
+    '''Document has a title in the head.'''
+
+    _check_1(filename, doc, 'title in head', '//head//title')
+
+
+@rule
+def has_title_in_body(filename, doc):
+    '''Document has a title in the body.'''
+
+    _check_1(filename, doc, 'title in body', '//body//h1[@class="maintitle"]')
+
+
+def _check_1(filename, doc, rulename, xpath):
+    '''Check that an equality holds.'''
+
+    actual = doc.xpath(xpath)
+    if len(actual) != 1:
+        print('In {0}, checking {1}: expected 1 match, got {2}'.format(filename, rulename, len(actual)))
+
+
+def _require(condition, message):
+    '''Fail if condition not met.'''
+
+    if not condition:
+        print(message, file=sys.stderr)
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    main()