:py:`module_doc_contents`   Module documentation contents
 :py:`class_doc_contents`    Class documentation contents
 :py:`data_doc_contents`     Data documentation contents
+:py:`hooks_pre_page`        Hooks to call before each page gets rendered
+:py:`hooks_post_run`        Hooks to call at the very end of the script run
 =========================== ===================================================
 
 The :py:`module_doc_contents`, :py:`class_doc_contents` and
         'details': "This class is *pretty*."
     }
 
+The :py:`hooks_pre_page` and :py:`hooks_post_run` variables are lists of
+parameter-less functions. Plugins that need to do something before each page
+of output gets rendered (for example, resetting an some internal counter for
+page-wide unique element IDs) or after the whole run is done (for example to
+serialize cached internal state) are supposed to add functions to the list.
+
 Registration function for a plugin that needs to query the :py:`OUTPUT` setting
 might look like this --- the remaining keyword arguments will collapse into
 the :py:`**kwargs` parameter. See code of various m.css plugins for actual
-examples.
+examples. The below example shows registration of a hypothetic HTML validator
+plugin --- it saves the output path from settings and registers a post-run hook
+that validates everything in given output directory.
 
 .. code:: py
 
 
     …
 
-    def register_mcss(mcss_settings, **kwargs):
+    def _validate_output():
+        validate_all_html_files(output_dir)
+
+    def register_mcss(mcss_settings, hooks_post_run, **kwargs):
         global output_dir
         output_dir = mcss_settings['OUTPUT']
+        hooks_post_run += [_validate_output]
 
 `External documentation content`_
 =================================
 
         self.data_docs: Dict[str, Dict[str, str]] = {}
         self.external_data: Set[str] = set()
 
+        self.hooks_pre_page: List = []
+        self.hooks_post_run: List = []
+
 def is_internal_function_name(name: str) -> bool:
     """If the function name is internal.
 
 def render_module(state: State, path, module, env):
     logging.debug("generating %s.html", '.'.join(path))
 
+    # Call all registered page begin hooks
+    for hook in state.hooks_pre_page: hook()
+
     url_base = ''
     breadcrumb = []
     for i in path:
 def render_class(state: State, path, class_, env):
     logging.debug("generating %s.html", '.'.join(path))
 
+    # Call all registered page begin hooks
+    for hook in state.hooks_pre_page: hook()
+
     url_base = ''
     breadcrumb = []
     for i in path:
 def render_doc(state: State, filename):
     logging.debug("parsing docs from %s", filename)
 
+    # Page begin hooks are called before this in run(), once for all docs since
+    # these functions are not generating any pages
+
     # Render the file. The directives should take care of everything, so just
     # discard the output afterwards.
     with open(filename, 'r') as f: publish_rst(state, f.read())
 def render_page(state: State, path, filename, env):
     logging.debug("generating %s.html", '.'.join(path))
 
+    # Call all registered page begin hooks
+    for hook in state.hooks_pre_page: hook()
+
     # Render the file
     with open(filename, 'r') as f: pub = publish_rst(state, f.read())
 
             jinja_environment=env,
             module_doc_contents=state.module_docs,
             class_doc_contents=state.class_docs,
-            data_doc_contents=state.data_docs)
+            data_doc_contents=state.data_docs,
+            hooks_pre_page=state.hooks_pre_page,
+            hooks_post_run=state.hooks_post_run)
+
+    # Call all registered page begin hooks for the first time
+    for hook in state.hooks_pre_page: hook()
 
     # First process the doc input files so we have all data for rendering
     # module pages
         logging.debug("copying %s to output", i)
         shutil.copy(i, os.path.join(config['OUTPUT'], os.path.basename(i)))
 
+    # Call all registered finalization hooks for the first time
+    for hook in state.hooks_post_run: hook()
+
 if __name__ == '__main__': # pragma: no cover
     parser = argparse.ArgumentParser()
     parser.add_argument('conf', help="configuration file")
 
         node['classes'] += ['m-transition']
         return [node]
 
-def register_mcss(**kwargs):
+pre_page_call_count = 0
+post_run_call_count = 0
+
+def _pre_page():
+    global pre_page_call_count
+    pre_page_call_count = pre_page_call_count + 1
+
+def _post_run():
+    global post_run_call_count
+    post_run_call_count = post_run_call_count + 1
+
+def register_mcss(hooks_pre_page, hooks_post_run, **kwargs):
+    hooks_pre_page += [_pre_page]
+    hooks_post_run += [_post_run]
+
     rst.directives.register_directive('fancy-line', FancyLine)
 
         # The output is different for older Graphviz
         self.assertEqual(*self.actual_expected_contents('dot.html', 'dot.html' if LooseVersion(dot_version()) >= LooseVersion("2.40.1") else 'dot-238.html'))
         self.assertTrue(os.path.exists(os.path.join(self.path, 'output/tiny.png')))
+
+        import fancyline
+        self.assertEqual(fancyline.pre_page_call_count, 3)
+        self.assertEqual(fancyline.post_run_call_count, 1)
 
         container.append(node)
         return [container]
 
-def new_page(content):
+def new_page(*args):
     latex2svgextra.counter = 0
 
 def math(role, rawtext, text, lineno, inliner, options={}, content=[]):
     node = nodes.raw(rawtext, latex2svgextra.patch(text, svg, depth, attribs), format='html', **options)
     return [node], []
 
-def save_cache(pelicanobj):
+def save_cache(*args):
     if settings['M_MATH_CACHE_FILE']:
         latex2svgextra.pickle_cache(settings['M_MATH_CACHE_FILE'])
 
-def register_mcss(mcss_settings, **kwargs):
+def register_mcss(mcss_settings, hooks_pre_page, hooks_post_run, **kwargs):
     global default_settings, settings
     settings = copy.deepcopy(default_settings)
     for key in settings.keys():
         else:
             latex2svgextra.unpickle_cache(None)
 
+    hooks_pre_page += [new_page]
+    hooks_post_run += [save_cache]
+
     rst.directives.register_directive('math', Math)
     rst.roles.register_canonical_role('math', math)
 
 def _configure_pelican(pelicanobj):
-    register_mcss(mcss_settings=pelicanobj.settings)
+    register_mcss(mcss_settings=pelicanobj.settings, hooks_pre_page=[], hooks_post_run=[])
 
 def register():
     pelican.signals.initialized.connect(_configure_pelican)
 
         container.append(node)
         return [container]
 
-def new_page(content):
+def new_page(*args):
     mpl.rcParams['svg.hashsalt'] = 0
 
-def register_mcss(mcss_settings, **kwargs):
+def register_mcss(mcss_settings, hooks_pre_page, **kwargs):
     font = mcss_settings.get('M_PLOTS_FONT', 'Source Sans Pro')
     for i in range(len(_class_mapping)):
         src, dst = _class_mapping[i]
         _class_mapping[i] = (src.format(font=font), dst)
     mpl.rcParams['font.family'] = font
 
+    hooks_pre_page += [new_page]
+
     rst.directives.register_directive('plot', Plot)
 
 def _pelican_configure(pelicanobj):
-    register_mcss(mcss_settings=pelicanobj.settings)
+    register_mcss(mcss_settings=pelicanobj.settings, hooks_pre_page=[])
 
 def register(): # for Pelican
     pelican.signals.initialized.connect(_pelican_configure)