Merge branch 'styling-workshops' into 2016-06
authorGreg Wilson <gvwilson@third-bit.com>
Mon, 27 Jun 2016 01:39:12 +0000 (21:39 -0400)
committerGreg Wilson <gvwilson@third-bit.com>
Mon, 27 Jun 2016 01:39:12 +0000 (21:39 -0400)
Makefile
_includes/all_keypoints.html
_includes/episode_keypoints.html
_includes/episode_overview.html
_includes/syllabus.html
assets/css/lesson.scss
bin/chunk-options.R
bin/lesson_check.py
bin/lesson_initialize.py
bin/util.py
bin/workshop_check.py

index c8011ddeedbbc546607b7b8413bcfcd2ffdcf78d..7b1b1b9710278773c04ac93197913fa6c22698c7 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -35,8 +35,11 @@ clean :
        @find . -name .DS_Store -exec rm {} \;
        @find . -name '*~' -exec rm {} \;
        @find . -name '*.pyc' -exec rm {} \;
+
+## clean-rmd      : clean intermediate R files (that need to be committed to the repo).
+clear-rmd :
        @rm -rf ${RMD_DST}
-       @rm -rf fig/swc-rmd-*
+       @rm -rf fig/rmd-*
 
 ## ----------------------------------------
 ## Commands specific to workshop websites.
index bedfd7375e4b14c932b402f5615afc4aae43dc00..898133a74077e32475ae9f24d6767a83304d2a4c 100644 (file)
@@ -11,9 +11,9 @@
       </td>
       <td class="col-md-9">
         <ul>
-       {% for keypoint in episode.keypoints %}
-       <li>{{ keypoint }}</li>
-       {% endfor %}
+        {% for keypoint in episode.keypoints %}
+        <li>{{ keypoint|markdownify }}</li>
+        {% endfor %}
         </ul>
       </td>
     </tr>
index e14de403a11f1fd101008f875e1be89e20ad872d..85378a568bca3cc554ee32a2e324e17f9382461b 100644 (file)
@@ -2,7 +2,7 @@
   <h2>Key Points</h2>
   <ul>
     {% for keypoint in page.keypoints %}
-    <li>{{ keypoint }}</li>
+    <li>{{ keypoint|markdownify }}</li>
     {% endfor %}
   </ul>
 </blockquote>
index f4a5a7ccb0ae1182554db7a86322768251b05ad3..f22a1a6922786962a7e7c2eed7a3cf6e25a4f8f2 100644 (file)
@@ -14,7 +14,7 @@
       <strong>Questions</strong>
       <ul>
        {% for question in page.questions %}
-       <li>{{ question }}</li>
+       <li>{{ question|markdownify }}</li>
        {% endfor %}
       </ul>
     </div>
@@ -27,7 +27,7 @@
       <strong>Objectives</strong>
       <ul>
        {% for objective in page.objectives %}
-       <li>{{ objective }}</li>
+       <li>{{ objective|markdownify }}</li>
        {% endfor %}
       </ul>
     </div>
index 5f4b7c8586ffd5669823fff401b597aa1e0845ec..bb7d1ef190f69e4383031a8a583b933e5d14c656 100644 (file)
           Break
         {% else %}
           {% if episode.questions %}
-            {{ episode.questions | join: '<br/>' }}
+            {% for question in episode.questions %}
+              {{question|markdownify|strip_html}}
+              {% unless forloop.last %}
+              <br/>
+              {% endunless %}
+            {% endfor %}
           {% endif %}
         {% endif %}
       </td>
index 0f3e47ede66b324b6635d15648848f444616c37e..7c6de9907f4c6419cd8a826203eb997228358a31 100644 (file)
@@ -131,7 +131,6 @@ ol {
   padding-left: 1em;
 }
 
-
 span.fold-unfold {
   margin-left: 1em;
   opacity: 0.5;
index 1890d3213890aefebdc812835c2962b636a08175..5836973226adfab41d0423143aa0cccc09317c43 100644 (file)
@@ -8,18 +8,20 @@ library("knitr")
 
 fix_fig_path <- function(pth) file.path("..", pth)
 
-## We use the swc-rmd- prefix for the figures generated by the lssons
-## so they can be easily identified and deleted by `make clean`.  The
+## We use the rmd- prefix for the figures generated by the lssons so
+## they can be easily identified and deleted by `make clean-rmd`.  The
 ## working directory when the lessons are generated is the root so the
 ## figures need to be saved in fig/, but when the site is generated,
 ## the episodes will be one level down. We fix the path using the
 ## `fig.process` option.
+
 opts_chunk$set(tidy = FALSE, results = "markup", comment = NA,
-               fig.align = "center", fig.path = "fig/swc-rmd-",
+               fig.align = "center", fig.path = "fig/rmd-",
                fig.process = fix_fig_path)
 
 # The hooks below add html tags to the code chunks and their output so that they
 # are properly formatted when the site is built.
+
 hook_in <- function(x, options) {
   stringr::str_c("\n\n~~~\n",
                  paste0(x, collapse="\n"),
index f858ebd14a513b13d800e8d3c6a851940028591f..c123984aad2e325770098e9eb7721ed91129f5ae 100755 (executable)
@@ -8,7 +8,6 @@ import sys
 import os
 import glob
 import json
-import yaml
 import re
 from optparse import OptionParser
 
@@ -38,12 +37,6 @@ REQUIRED_FILES = {
     '%/setup.md': True,
 }
 
-# Required non-Markdown files.
-NON_MARKDOWN_FILES = {
-    "AUTHORS",
-    "CITATION"
-}
-
 # Episode filename pattern.
 P_EPISODE_FILENAME = re.compile(r'/_episodes/(\d\d)-[-\w]+.md$')
 
@@ -97,10 +90,9 @@ def main():
     """Main driver."""
 
     args = parse_args()
-    args.reporter = Reporter(args)
-    check_config(args)
-    check_non_markdown_files(args.source_dir, args.reporter)
-    docs = read_all_markdown(args, args.source_dir)
+    args.reporter = Reporter()
+    check_config(args.reporter, args.source_dir)
+    docs = read_all_markdown(args.source_dir, args.parser)
     check_fileset(args.source_dir, args.reporter, docs.keys())
     for filename in docs.keys():
         checker = create_checker(args, filename, docs[filename])
@@ -134,27 +126,15 @@ def parse_args():
     return args
 
 
-def check_config(args):
+def check_config(reporter, source_dir):
     """Check configuration file."""
 
-    config_file = os.path.join(args.source_dir, '_config.yml')
-    with open(config_file, 'r') as reader:
-        config = yaml.load(reader)
-
-    args.reporter.check_field(config_file, 'configuration', config, 'kind', 'lesson')
-
-
-def check_non_markdown_files(source_dir, reporter):
-    """Check presence of non-Markdown files."""
+    config_file = os.path.join(source_dir, '_config.yml')
+    config = load_yaml(config_file)
+    reporter.check_field(config_file, 'configuration', config, 'kind', 'lesson')
 
-    for filename in NON_MARKDOWN_FILES:
-        path = os.path.join(source_dir, filename)
-        reporter.check(os.path.exists(path),
-                       filename,
-                       "File not found")
 
-
-def read_all_markdown(args, source_dir):
+def read_all_markdown(source_dir, parser):
     """Read source files, returning
     {path : {'metadata':yaml, 'metadata_len':N, 'text':text, 'lines':[(i, line, len)], 'doc':doc}}
     """
@@ -164,7 +144,7 @@ def read_all_markdown(args, source_dir):
     result = {}
     for pat in all_patterns:
         for filename in glob.glob(pat):
-            data = read_markdown(args.parser, filename)
+            data = read_markdown(parser, filename)
             if data:
                 result[filename] = data
     return result
@@ -192,9 +172,9 @@ def check_fileset(source_dir, reporter, filenames_present):
 
     # Check for duplicate episode numbers.
     reporter.check(len(seen) == len(set(seen)),
-                   None,
-                   'Duplicate episode numbers {0} vs {1}',
-                   sorted(seen), sorted(set(seen)))
+                        None,
+                        'Duplicate episode numbers {0} vs {1}',
+                        sorted(seen), sorted(set(seen)))
 
     # Check that numbers are consecutive.
     seen = [int(s) for s in seen]
@@ -239,6 +219,7 @@ class CheckBase(object):
         self.text = text
         self.lines = lines
         self.doc = doc
+
         self.layout = None
 
 
@@ -371,7 +352,6 @@ class CheckEpisode(CheckBase):
     def __init__(self, args, filename, metadata, metadata_len, text, lines, doc):
         super(CheckEpisode, self).__init__(args, filename, metadata, metadata_len, text, lines, doc)
 
-
     def check_metadata(self):
         super(CheckEpisode, self).check_metadata()
         if self.metadata:
index 0a7e7c449b97b971985014edf9271745423ee33b..853285e9dba4224f43a44b9c0e520578a877bc87 100755 (executable)
@@ -61,7 +61,24 @@ and to meet some of our community members.
     you can submit a pull request (PR).
     Instructions for doing this are [included below](#using-github).
 
-## What We're Looking For
+## Where to Contribute
+
+1.  If you wish to change this example lesson,
+    please work in <https://github.com/swcarpentry/lesson-example>.
+    This lesson documents the format of our lessons,
+    and can be viewed at <https://swcarpentry.github.io/lesson-example>.
+
+2.  If you wish to change the template used for workshop websites,
+    please work in <https://github.com/swcarpentry/workshop-template>.
+    The home page of that repository explains how to set up workshop websites,
+    while the extra pages in <https://swcarpentry.github.io/workshop-template>
+    provide more background on our design choices.
+
+3.  If you wish to change CSS style files, tools,
+    or HTML boilerplate for lessons or workshops stored in `_includes` or `_layouts`,
+    please work in <https://github.com/swcarpentry/styles>.
+
+## What to Contribute
 
 There are many ways to contribute,
 from writing new exercises and improving existing ones
@@ -80,7 +97,7 @@ it's easy for people who have been using these lessons for a while
 to forget how impenetrable some of this material can be,
 so fresh eyes are always welcome.
 
-## What We're *Not* Looking For
+## What *Not* to Contribute
 
 Our lessons already contain more material than we can cover in a typical workshop,
 so we are usually *not* looking for more concepts or tools to add to them.
@@ -96,16 +113,9 @@ Our workshops typically contain a mixture of Windows, Mac OS X, and Linux users;
 in order to be usable,
 our lessons must run equally well on all three.
 
-## Getting Started
+## Using GitHub
 
-The easiest way to get started is to file an issue
-to tell us about a spelling mistake,
-some awkward wording,
-or a factual error.
-This is a good way to introduce yourself
-and to meet some of our community members.
-
-If you want to start adding or fixing material yourself,
+If you choose to contribute via GitHub,
 you may want to look at
 [How to Contribute to an Open Source Project on GitHub][how-contribute].
 In brief:
@@ -132,11 +142,6 @@ or encourage others to do so.
 The maintainers are community volunteers,
 and have final say over what gets merged into the lesson.
 
-## Our Template
-
-[This documentation][example-site] explains how we format our lessons
-(and is itself an example of that formatting).
-
 ## Other Resources
 
 General discussion of [Software Carpentry][swc-site] and [Data Carpentry][dc-site]
@@ -154,8 +159,8 @@ You can also [reach us by email][contact].
 [github-flow]: https://guides.github.com/introduction/flow/
 [github-join]: https://github.com/join
 [how-contribute]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
-[issues]: https://github.com/{USERNAME}/{LESSON-NAME}/issues/
-[repo]: https://github.com/{USERNAME}/{LESSON-NAME}/
+[issues]: https://github.com/swcarpentry/lesson-example/issues/
+[repo]: https://github.com/swcarpentry/lesson-example/
 [swc-issues]: https://github.com/issues?q=user%3Aswcarpentry
 [swc-lessons]: http://software-carpentry.org/lessons/
 [swc-site]: http://software-carpentry.org/
index b2d66dc1757b7aa4bd3f60a2485f30dc089ebf66..6af0a3317061d8663dafad9de41aec2156b7e597 100644 (file)
@@ -1,13 +1,17 @@
 import sys
 import json
-import yaml
 from subprocess import Popen, PIPE
 
+try:
+    import yaml
+except ImportError:
+    print('Unable to import YAML module: please install PyYAML', file=sys.stderr)
+    sys.exit(1)
 
 class Reporter(object):
     """Collect and report errors."""
 
-    def __init__(self, args):
+    def __init__(self):
         """Constructor."""
 
         super(Reporter, self).__init__()
@@ -62,23 +66,13 @@ def read_markdown(parser, path):
     """
 
     # Split and extract YAML (if present).
-    metadata = None
-    metadata_len = None
     with open(path, 'r') as reader:
         body = reader.read()
-    pieces = body.split('---', 2)
-    if len(pieces) == 3:
-        try:
-            metadata = yaml.load(pieces[1])
-        except yaml.YAMLError as e:
-            print('Unable to parse YAML header in {0}:\n{1}'.format(path, e))
-            sys.exit(1)
-        metadata_len = pieces[1].count('\n')
-        body = pieces[2]
+    metadata_raw, metadata_yaml, body = split_metadata(path, body)
 
     # Split into lines.
-    offset = 0 if metadata_len is None else metadata_len
-    lines = [(offset+i+1, l, len(l)) for (i, l) in enumerate(body.split('\n'))]
+    metadata_len = 0 if metadata_raw is None else metadata_raw.count('\n')
+    lines = [(metadata_len+i+1, line, len(line)) for (i, line) in enumerate(body.split('\n'))]
 
     # Parse Markdown.
     cmd = 'ruby {0}'.format(parser)
@@ -87,9 +81,42 @@ def read_markdown(parser, path):
     doc = json.loads(stdout_data)
 
     return {
-        'metadata': metadata,
+        'metadata': metadata_yaml,
         'metadata_len': metadata_len,
         'text': body,
         'lines': lines,
         'doc': doc
     }
+
+
+def split_metadata(path, text):
+    """
+    Get raw (text) metadata, metadata as YAML, and rest of body.
+    If no metadata, return (None, None, body).
+    """
+
+    metadata_raw = None
+    metadata_yaml = None
+    metadata_len = None
+
+    pieces = text.split('---', 2)
+    if len(pieces) == 3:
+        metadata_raw = pieces[1]
+        text = pieces[2]
+        try:
+            metadata_yaml = yaml.load(metadata_raw)
+        except yaml.YAMLError as e:
+            print('Unable to parse YAML header in {0}:\n{1}'.format(path, e), file=sys.stderr)
+            sys.exit(1)
+
+    return metadata_raw, metadata_yaml, text
+
+
+def load_yaml(filename):
+    """
+    Wrapper around YAML loading so that 'import yaml' and error
+    handling is only needed in one place.
+    """
+
+    with open(filename, 'r') as reader:
+        return yaml.load(reader)
index 4c863ca48bff27ca95a8a606e10924e41051ec67..d018b78906171d3427350ad1dcba0d627435effd 100755 (executable)
@@ -7,37 +7,21 @@ docstrings on the checking functions for a summary of the checks.
 import sys
 import os
 import re
-import logging
-import yaml
-from collections import Counter
+from datetime import date
+from util import Reporter, split_metadata
 
-__version__ = '0.6'
 
-
-# basic logging configuration
-logger = logging.getLogger(__name__)
-verbosity = logging.INFO  # severity of at least INFO will emerge
-logger.setLevel(verbosity)
-
-# create console handler and set level to debug
-console_handler = logging.StreamHandler()
-console_handler.setLevel(verbosity)
-
-formatter = logging.Formatter('%(levelname)s: %(message)s')
-console_handler.setFormatter(formatter)
-logger.addHandler(console_handler)
-
-
-# TODO: these regexp patterns need comments inside
+# Metadata field patterns.
 EMAIL_PATTERN = r'[^@]+@[^@]+\.[^@]+'
 HUMANTIME_PATTERN = r'((0?[1-9]|1[0-2]):[0-5]\d(am|pm)(-|to)(0?[1-9]|1[0-2]):[0-5]\d(am|pm))|((0?\d|1\d|2[0-3]):[0-5]\d(-|to)(0?\d|1\d|2[0-3]):[0-5]\d)'
 EVENTBRITE_PATTERN = r'\d{9,10}'
 URL_PATTERN = r'https?://.+'
 
+# Defaults.
 CARPENTRIES = ("dc", "swc")
 DEFAULT_CONTACT_EMAIL = 'admin@software-carpentry.org'
 
-USAGE = 'Usage: "check-workshop path/to/index.html"\n'
+USAGE = 'Usage: "check-workshop path/to/root/directory"'
 
 # Country and language codes.  Note that codes mean different things: 'ar'
 # is 'Arabic' as a language but 'Argentina' as a country.
@@ -86,18 +70,9 @@ ISO_LANGUAGE = [
 ]
 
 
-def add_error(msg, errors):
-    """Add error to the list of errors."""
-    errors.append(msg)
-
-
-def add_suberror(msg, errors):
-    """Add sub error, ie. error indented by 1 level ("\t"), to the list of errors."""
-    errors.append("\t{0}".format(msg))
-
-
 def look_for_fixme(func):
-    '''Decorator to fail test if text argument starts with "FIXME".'''
+    """Decorator to fail test if text argument starts with "FIXME"."""
+
     def inner(arg):
         if (arg is not None) and \
            isinstance(arg, str) and \
@@ -137,24 +112,26 @@ def check_language(language):
 
 @look_for_fixme
 def check_humandate(date):
-    '''"humandate" must be a human-readable date with a 3-letter month and
-    4-digit year.  Examples include "Feb 18-20, 2025" and "Feb 18 and
-    20, 2025".  It may be in languages other than English, but the
+    """
+    'humandate' must be a human-readable date with a 3-letter month
+    and 4-digit year.  Examples include 'Feb 18-20, 2025' and 'Feb 18
+    and 20, 2025'.  It may be in languages other than English, but the
     month name should be kept short to aid formatting of the main
-    Software Carpentry web site.'''
+    Software Carpentry web site.
+    """
 
-    if "," not in date:
+    if ',' not in date:
         return False
 
-    month_dates, year = date.split(",")
+    month_dates, year = date.split(',')
 
     # The first three characters of month_dates are not empty
     month = month_dates[:3]
-    if any(char == " " for char in month):
+    if any(char == ' ' for char in month):
         return False
 
     # But the fourth character is empty ("February" is illegal)
-    if month_dates[3] != " ":
+    if month_dates[3] != ' ':
         return False
 
     # year contains *only* numbers
@@ -168,65 +145,80 @@ def check_humandate(date):
 
 @look_for_fixme
 def check_humantime(time):
-    '''"humantime" is a human-readable start and end time for the workshop,
-    such as "09:00 - 16:00".'''
+    """
+    'humantime' is a human-readable start and end time for the
+    workshop, such as '09:00 - 16:00'.
+    """
 
-    return bool(re.match(HUMANTIME_PATTERN, time.replace(" ", "")))
+    return bool(re.match(HUMANTIME_PATTERN, time.replace(' ', '')))
 
 
 def check_date(this_date):
-    '''"startdate" and "enddate" are machine-readable start and end dates for
-    the workshop, and must be in YYYY-MM-DD format, e.g., "2015-07-01".'''
+    """
+    'startdate' and 'enddate' are machine-readable start and end dates
+    for the workshop, and must be in YYYY-MM-DD format, e.g.,
+    '2015-07-01'.
+    """
 
-    from datetime import date
-    # yaml automatically loads valid dates as datetime.date
+    # YAML automatically loads valid dates as datetime.date.
     return isinstance(this_date, date)
 
 
 @look_for_fixme
 def check_latitude_longitude(latlng):
-    '''"latlng" must be a valid latitude and longitude represented as two
-    floating-point numbers separated by a comma.'''
+    """
+    'latlng' must be a valid latitude and longitude represented as two
+    floating-point numbers separated by a comma.
+    """
 
     try:
         lat, lng = latlng.split(',')
         lat = float(lat)
         long = float(lng)
+        return (-90.0 <= lat <= 90.0) and (-180.0 <= long <= 180.0)
     except ValueError:
         return False
-    return (-90.0 <= lat <= 90.0) and (-180.0 <= long <= 180.0)
 
 
 def check_instructors(instructors):
-    '''"instructor" must be a non-empty comma-separated list of quoted names,
-    e.g. ['First name', 'Second name', ...'].  Do not use "TBD" or other
-    placeholders.'''
+    """
+    'instructor' must be a non-empty comma-separated list of quoted
+    names, e.g. ['First name', 'Second name', ...'].  Do not use 'TBD'
+    or other placeholders.
+    """
 
-    # yaml automatically loads list-like strings as lists
+    # YAML automatically loads list-like strings as lists.
     return isinstance(instructors, list) and len(instructors) > 0
 
 
 def check_helpers(helpers):
-    '''"helper" must be a comma-separated list of quoted names,
-    e.g. ['First name', 'Second name', ...'].  The list may be empty.  Do
-    not use "TBD" or other placeholders.'''
+    """
+    'helper' must be a comma-separated list of quoted names,
+    e.g. ['First name', 'Second name', ...'].  The list may be empty.
+    Do not use 'TBD' or other placeholders.
+    """
 
-    # yaml automatically loads list-like strings as lists
+    # YAML automatically loads list-like strings as lists.
     return isinstance(helpers, list) and len(helpers) >= 0
 
 
 @look_for_fixme
 def check_email(email):
-    '''"contact" must be a valid email address consisting of characters, a
-    @, and more characters.  It should not be the default contact
-    email address "admin@software-carpentry.org".'''
+    """
+    'contact' must be a valid email address consisting of characters,
+    an '@', and more characters.  It should not be the default contact
+    email address 'admin@software-carpentry.org'.
+    """
 
     return bool(re.match(EMAIL_PATTERN, email)) and \
            (email != DEFAULT_CONTACT_EMAIL)
 
 
 def check_eventbrite(eventbrite):
-    '''"eventbrite" (the Eventbrite registration key) must be 9 or more digits.'''
+    """
+    'eventbrite' (the Eventbrite registration key) must be 9 or more
+    digits.  It may appear as an integer or as a string.
+    """
 
     if isinstance(eventbrite, int):
         return True
@@ -236,15 +228,19 @@ def check_eventbrite(eventbrite):
 
 @look_for_fixme
 def check_etherpad(etherpad):
-    '''"etherpad" must be a valid URL.'''
+    """
+    'etherpad' must be a valid URL.
+    """
 
     return bool(re.match(URL_PATTERN, etherpad))
 
 
 @look_for_fixme
 def check_pass(value):
-    '''This test always passes (it is used for "checking" things like
-    addresses, for which no sensible validation is feasible).'''
+    """
+    This test always passes (it is used for 'checking' things like the
+    workshop address, for which no sensible validation is feasible).
+    """
 
     return True
 
@@ -265,38 +261,38 @@ HANDLERS = {
 
     'humandate':  (True, check_humandate,
                    'humandate invalid. Please use three-letter months like ' +
-                   '"Jan" and four-letter years like "2025".'),
+                   '"Jan" and four-letter years like "2025"'),
 
     'humantime':  (True, check_humantime,
                    'humantime doesn\'t include numbers'),
 
     'startdate':  (True, check_date,
                    'startdate invalid. Must be of format year-month-day, ' +
-                   'i.e., 2014-01-31.'),
+                   'i.e., 2014-01-31'),
 
     'enddate':    (False, check_date,
                    'enddate invalid. Must be of format year-month-day, i.e.,' +
-                   ' 2014-01-31.'),
+                   ' 2014-01-31'),
 
     'latlng':     (True, check_latitude_longitude,
                    'latlng invalid. Check that it is two floating point ' +
-                   'numbers, separated by a comma.'),
+                   'numbers, separated by a comma'),
 
     'instructor': (True, check_instructors,
                    'instructor list isn\'t a valid list of format ' +
-                   '["First instructor", "Second instructor",..].'),
+                   '["First instructor", "Second instructor",..]'),
 
     'helper':     (True, check_helpers,
                    'helper list isn\'t a valid list of format ' +
-                   '["First helper", "Second helper",..].'),
+                   '["First helper", "Second helper",..]'),
 
     'contact':    (True, check_email,
                    'contact email invalid or still set to ' +
                    '"{0}".'.format(DEFAULT_CONTACT_EMAIL)),
 
-    'eventbrite': (False, check_eventbrite, 'Eventbrite key appears invalid.'),
+    'eventbrite': (False, check_eventbrite, 'Eventbrite key appears invalid'),
 
-    'etherpad':   (False, check_etherpad, 'Etherpad URL appears invalid.'),
+    'etherpad':   (False, check_etherpad, 'Etherpad URL appears invalid'),
 
     'venue':      (False, check_pass, 'venue name not specified'),
 
@@ -310,102 +306,85 @@ REQUIRED = set([k for k in HANDLERS if HANDLERS[k][0]])
 OPTIONAL = set([k for k in HANDLERS if not HANDLERS[k][0]])
 
 
-def check_validity(data, function, errors, error_msg):
-    '''Wrapper-function around the various check-functions.'''
-    valid = function(data)
-    if not valid:
-        add_error(error_msg, errors)
-        add_suberror('Offending entry is: "{0}"'.format(data), errors)
-    return valid
+def check_blank_lines(reporter, raw):
+    """
+    Blank lines are not allowed in category headers.
+    """
 
+    lines = [(i, x) for (i, x) in enumerate(raw.strip().split('\n')) if not x.strip()]
+    reporter.check(not lines,
+                   None,
+                   'Blank line(s) in header: {0}',
+                   ', '.join(["{0}: {1}".format(i, x.rstrip()) for (i, x) in lines]))
 
-def check_blank_lines(raw_data, errors, error_msg):
-    '''Blank lines are not allowed in category headers.'''
-    lines = [x.strip() for x in raw_data.split('\n')]
-    if '' in lines:
-        add_error(error_msg, errors)
-        add_suberror('{0} blank lines found in header'.format(lines.count('')), errors)
-        return False
-    return True
 
+def check_categories(reporter, left, right, msg):
+    """
+    Report differences (if any) between two sets of categories.
+    """
 
-def check_categories(left, right, errors, error_msg):
-    '''Report set difference of categories.'''
-    result = left - right
-    if result:
-        add_error(error_msg, errors)
-        add_suberror('Offending entries: {0}'.format(result), errors)
-        return False
-    return True
-
-
-def get_header(text):
-    '''Extract YAML header from raw data, returning (None, None) if no
-    valid header found and (raw, parsed) if header found.'''
-
-    # YAML header must be right at the start of the file.
-    if not text.startswith('---'):
-        return None, None
-
-    # YAML header must start and end with '---'
-    pieces = text.split('---')
-    if len(pieces) < 3:
-        return None, None
+    diff = left - right
+    reporter.check(len(diff) == 0,
+                   None,
+                   '{0}: offending entries {1}',
+                   msg, sorted(list(diff)))
 
-    # Return raw text and YAML-ized form.
-    raw = pieces[1].strip()
-    return raw, yaml.load(raw)
 
+def check_file(reporter, path, data):
+    """
+    Get header from file, call all other functions, and check file for
+    validity.
+    """
 
-def check_file(filename, data, errors):
-    '''Get header from file, call all other functions and check file
-    for validity. Return list of errors (empty when no errors).'''
-
-    raw, header = get_header(data)
-    if header is None:
-        msg = ('Cannot find YAML header in given file "{0}".'.format(filename))
-        add_error(msg, errors)
-        return errors
+    # Get metadata as text and as YAML.
+    raw, header, body = split_metadata(path, data)
 
     # Do we have any blank lines in the header?
-    is_valid = check_blank_lines(raw, errors,
-                                 'There are blank lines in the header')
+    check_blank_lines(reporter, raw)
 
     # Look through all header entries.  If the category is in the input
     # file and is either required or we have actual data (as opposed to
     # a commented-out entry), we check it.  If it *isn't* in the header
     # but is required, report an error.
     for category in HANDLERS:
-        required, handler_function, error_message = HANDLERS[category]
+        required, handlermessage = HANDLERS[category]
         if category in header:
             if required or header[category]:
-                is_valid &= check_validity(header[category],
-                                           handler_function, errors,
-                                           error_message)
+                reporter.check(handler(header[category]),
+                               None,
+                               '{0}\n    actual value "{1}"',
+                               message, header[category])
         elif required:
-            msg = 'index file is missing mandatory key "{0}"'.format(category)
-            add_error(msg, errors)
-            is_valid = False
+            reporter.add(None,
+                         'Missing mandatory key "{0}"',
+                         category)
 
     # Check whether we have missing or too many categories
     seen_categories = set(header.keys())
+    check_categories(reporter, REQUIRED, seen_categories,
+                     'Missing categories')
+    check_categories(reporter, seen_categories, REQUIRED.union(OPTIONAL),
+                     'Superfluous categories')
 
-    is_valid &= check_categories(REQUIRED, seen_categories, errors,
-                                 'There are missing categories')
-
-    is_valid &= check_categories(seen_categories, REQUIRED.union(OPTIONAL),
-                                 errors, 'There are superfluous categories')
 
+def check_config(reporter, filename):
+    """
+    Check YAML configuration file.
+    """
 
-def check_config(filename, errors):
-    '''Check YAML configuration file.'''
+    config = load_yaml(filename)
 
-    with open(filename, 'r') as reader:
-        config = yaml.load(reader)
+    kind = config.get('kind', None)
+    reporter.check(kind == 'workshop',
+                   filename,
+                   'Missing or unknown kind of event: {0}',
+                   kind)
 
-    if config['kind'] != 'workshop':
-        msg = 'Not configured as a workshop: found "{0}" instead'.format(config['kind'])
-        add_error(msg, errors)
+    carpentry = config.get('carpentry', None)
+    reporter.check(carpentry in ('swc', 'dc'),
+                   filename,
+                   'Missing or unknown carpentry: {0}',
+                   carpentry)
 
 
 def main():
@@ -418,21 +397,13 @@ def main():
     root_dir = sys.argv[1]
     index_file = os.path.join(root_dir, 'index.html')
     config_file = os.path.join(root_dir, '_config.yml')
-    logger.info('Testing "{0}" and "{1}"'.format(index_file, config_file))
 
-    errors = []
-    check_config(config_file, errors)
+    reporter = Reporter()
+    check_config(reporter, config_file)
     with open(index_file) as reader:
         data = reader.read()
-        check_file(index_file, data, errors)
-
-    if errors:
-        for m in errors:
-            logger.error(m)
-        sys.exit(1)
-    else:
-        logger.info('Everything seems to be in order')
-        sys.exit(0)
+        check_file(reporter, index_file, data)
+    reporter.report()
 
 
 if __name__ == '__main__':