aboutsummaryrefslogtreecommitdiffstats
path: root/lv2specgen/lv2specgen.py
diff options
context:
space:
mode:
Diffstat (limited to 'lv2specgen/lv2specgen.py')
-rwxr-xr-xlv2specgen/lv2specgen.py461
1 files changed, 120 insertions, 341 deletions
diff --git a/lv2specgen/lv2specgen.py b/lv2specgen/lv2specgen.py
index 14c88c3..d6e86f8 100755
--- a/lv2specgen/lv2specgen.py
+++ b/lv2specgen/lv2specgen.py
@@ -1,34 +1,13 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
#
-# lv2specgen, a documentation generator for LV2 specifications.
-# Copyright (c) 2009-2014 David Robillard <d@drobilla.net>
+# Copyright 2009-2022 David Robillard <d@drobilla.net>
+# Copyright 2003-2008 Christopher Schmidt <crschmidt@crschmidt.net>
+# Copyright 2005-2008 Uldis Bojars <uldis.bojars@deri.org>
+# Copyright 2007-2008 Sergio Fernández <sergio.fernandez@fundacionctic.org>
+# SPDX-License-Identifier: MIT
#
# Based on SpecGen:
# <http://forge.morfeo-project.org/wiki_en/index.php/SpecGen>
-# Copyright (c) 2003-2008 Christopher Schmidt <crschmidt@crschmidt.net>
-# Copyright (c) 2005-2008 Uldis Bojars <uldis.bojars@deri.org>
-# Copyright (c) 2007-2008 Sergio Fernández <sergio.fernandez@fundacionctic.org>
-#
-# This software is licensed under the terms of the MIT License.
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
import datetime
import markdown
@@ -86,20 +65,13 @@ spec_pre = None
spec_bundle = None
specgendir = None
ns_list = {
+ "http://purl.org/dc/terms/": "dcterms",
+ "http://usefulinc.com/ns/doap#": "doap",
+ "http://xmlns.com/foaf/0.1/": "foaf",
+ "http://www.w3.org/2002/07/owl#": "owl",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
"http://www.w3.org/2000/01/rdf-schema#": "rdfs",
- "http://www.w3.org/2002/07/owl#": "owl",
"http://www.w3.org/2001/XMLSchema#": "xsd",
- "http://rdfs.org/sioc/ns#": "sioc",
- "http://xmlns.com/foaf/0.1/": "foaf",
- "http://purl.org/dc/elements/1.1/": "dc",
- "http://purl.org/dc/terms/": "dct",
- "http://purl.org/rss/1.0/modules/content/": "content",
- "http://www.w3.org/2003/01/geo/wgs84_pos#": "geo",
- "http://www.w3.org/2004/02/skos/core#": "skos",
- "http://lv2plug.in/ns/lv2core#": "lv2",
- "http://usefulinc.com/ns/doap#": "doap",
- "http://ontologi.es/doap-changeset#": "dcs",
}
rdf = rdflib.Namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
@@ -107,7 +79,6 @@ rdfs = rdflib.Namespace("http://www.w3.org/2000/01/rdf-schema#")
owl = rdflib.Namespace("http://www.w3.org/2002/07/owl#")
lv2 = rdflib.Namespace("http://lv2plug.in/ns/lv2core#")
doap = rdflib.Namespace("http://usefulinc.com/ns/doap#")
-dcs = rdflib.Namespace("http://ontologi.es/doap-changeset#")
foaf = rdflib.Namespace("http://xmlns.com/foaf/0.1/")
@@ -140,21 +111,21 @@ def getLiteralString(s):
def isResource(n):
- return type(n) == rdflib.URIRef
+ return isinstance(n, rdflib.URIRef)
def isBlank(n):
- return type(n) == rdflib.BNode
+ return isinstance(n, rdflib.BNode)
def isLiteral(n):
- return type(n) == rdflib.Literal
+ return isinstance(n, rdflib.Literal)
def niceName(uri):
global spec_bundle
if uri.startswith(spec_ns_str):
- return uri[len(spec_ns_str) :]
+ return uri.replace(spec_ns_str, "")
elif uri == str(rdfs.seeAlso):
return "See also"
@@ -166,8 +137,6 @@ def niceName(uri):
if pref in ns_list:
return ns_list.get(pref, pref) + ":" + rez.group(2)
else:
- print("warning: prefix %s not in ns list:" % pref)
- print(ns_list)
return uri
@@ -239,9 +208,9 @@ def linkifyVocabIdentifiers(m, string, classlist, proplist, instalist):
def prettifyHtml(m, markup, subject, classlist, proplist, instalist):
# Syntax highlight all C code
if have_pygments:
- code_rgx = re.compile('<pre class="c-code">(.*?)</pre>', re.DOTALL)
+ code_re = re.compile('<pre class="c-code">(.*?)</pre>', re.DOTALL)
while True:
- code = code_rgx.search(markup)
+ code = code_re.search(markup)
if not code:
break
match_str = xml.sax.saxutils.unescape(code.group(1))
@@ -250,15 +219,13 @@ def prettifyHtml(m, markup, subject, classlist, proplist, instalist):
pygments.lexers.CLexer(),
pygments.formatters.HtmlFormatter(),
)
- markup = code_rgx.sub(code_str, markup, 1)
+ markup = code_re.sub(code_str, markup, 1)
# Syntax highlight all Turtle code
if have_pygments:
- code_rgx = re.compile(
- '<pre class="turtle-code">(.*?)</pre>', re.DOTALL
- )
+ code_re = re.compile('<pre class="turtle-code">(.*?)</pre>', re.DOTALL)
while True:
- code = code_rgx.search(markup)
+ code = code_re.search(markup)
if not code:
break
match_str = xml.sax.saxutils.unescape(code.group(1))
@@ -267,7 +234,7 @@ def prettifyHtml(m, markup, subject, classlist, proplist, instalist):
pygments.lexers.rdf.TurtleLexer(),
pygments.formatters.HtmlFormatter(),
)
- markup = code_rgx.sub(code_str, markup, 1)
+ markup = code_re.sub(code_str, markup, 1)
# Add links to code documentation for identifiers
markup = linkifyCodeIdentifiers(markup)
@@ -768,12 +735,15 @@ def docTerms(category, list, m, classlist, proplist, instalist):
doc = ""
for term in list:
if not term.startswith(spec_ns_str):
- sys.stderr.write("warning: Skipping external term `%s'" % term)
continue
t = termName(m, term)
curie = term.split(spec_ns_str[-1])[1]
- doc += '<div class="specterm" id="%s" about="%s">' % (t, term)
+ if t:
+ doc += '<div class="specterm" id="%s" about="%s">' % (t, term)
+ else:
+ doc += '<div class="specterm" about="%s">' % term
+
doc += '<h3><a href="#%s">%s</a></h3>' % (getAnchor(term), curie)
doc += '<span class="spectermtype">%s</span>' % category
@@ -830,7 +800,7 @@ def getShortName(uri):
def getAnchor(uri):
uri = str(uri)
if uri.startswith(spec_ns_str):
- return uri[len(spec_ns_str) :].replace("/", "_")
+ return uri.replace(spec_ns_str, "").replace("/", "_")
else:
return getShortName(uri)
@@ -861,7 +831,7 @@ def buildIndex(m, classlist, proplist, instalist=None):
local_subclass = False
for p in findStatements(m, c, rdfs.subClassOf, None):
parent = str(p[2])
- if parent[0 : len(spec_ns_str)] == spec_ns_str:
+ if parent.startswith(spec_ns_str):
local_subclass = True
if local_subclass:
continue
@@ -1020,17 +990,14 @@ def specAuthors(m, subject):
for d in sorted(dev):
if not first:
devdoc += ", "
- devdoc += (
- '<span class="author" property="doap:developer">%s</span>' % d
- )
+
+ devdoc += f'<span class="author" property="doap:developer">{d}</span>'
first = False
if len(dev) == 1:
- doc += (
- '<tr><th class="metahead">Developer</th><td>%s</td></tr>' % devdoc
- )
+ doc += f'<tr><th class="metahead">Developer</th><td>{devdoc}</td></tr>'
elif len(dev) > 0:
doc += (
- '<tr><th class="metahead">Developers</th><td>%s</td></tr>' % devdoc
+ f'<tr><th class="metahead">Developers</th><td>{devdoc}</td></tr>'
)
maintdoc = ""
@@ -1038,95 +1005,18 @@ def specAuthors(m, subject):
for m in sorted(maint):
if not first:
maintdoc += ", "
+
maintdoc += (
- '<span class="author" property="doap:maintainer">%s</span>' % m
+ f'<span class="author" property="doap:maintainer">{m}</span>'
)
first = False
- if len(maint) == 1:
- doc += (
- '<tr><th class="metahead">Maintainer</th><td>%s</td></tr>'
- % maintdoc
- )
- elif len(maint) > 0:
- doc += (
- '<tr><th class="metahead">Maintainers</th><td>%s</td></tr>'
- % maintdoc
- )
+ if len(maint):
+ label = "Maintainer" if len(maint) == 1 else "Maintainers"
+ doc += f'<tr><th class="metahead">{label}</th><td>{maintdoc}</td></tr>'
return doc
-def releaseChangeset(m, release, prefix=""):
- changeset = findOne(m, release, dcs.changeset, None)
- if changeset is None:
- return ""
-
- entry = ""
- # entry = '<dd><ul>\n'
- for i in sorted(findStatements(m, getObject(changeset), dcs.item, None)):
- item = getObject(i)
- label = findOne(m, item, rdfs.label, None)
- if not label:
- print("error: dcs:item has no rdfs:label")
- continue
-
- text = getLiteralString(getObject(label))
- if prefix:
- text = prefix + ": " + text
-
- entry += "<li>%s</li>\n" % text
-
- # entry += '</ul></dd>\n'
- return entry
-
-
-def specHistoryEntries(m, subject, entries):
- for r in findStatements(m, subject, doap.release, None):
- release = getObject(r)
- revNode = findOne(m, release, doap.revision, None)
- if not revNode:
- print("error: doap:release has no doap:revision")
- continue
-
- rev = getLiteralString(getObject(revNode))
-
- created = findOne(m, release, doap.created, None)
-
- dist = findOne(m, release, doap["file-release"], None)
- if dist:
- entry = '<dt><a href="%s">Version %s</a>' % (getObject(dist), rev)
- else:
- entry = "<dt>Version %s" % rev
- # print("warning: doap:release has no doap:file-release")
-
- if created:
- entry += " (%s)</dt>\n" % getLiteralString(getObject(created))
- else:
- entry += ' (<span class="warning">EXPERIMENTAL</span>)</dt>'
-
- entry += "<dd><ul>\n%s" % releaseChangeset(m, release)
-
- if dist is not None:
- entries[(getObject(created), getObject(dist))] = entry
-
- return entries
-
-
-def specHistoryMarkup(entries):
- if len(entries) > 0:
- history = "<dl>\n"
- for e in sorted(entries.keys(), reverse=True):
- history += entries[e] + "</ul></dd>"
- history += "</dl>\n"
- return history
- else:
- return ""
-
-
-def specHistory(m, subject):
- return specHistoryMarkup(specHistoryEntries(m, subject, {}))
-
-
def specVersion(m, subject):
"""
Return a (minorVersion, microVersion, date) tuple
@@ -1222,7 +1112,6 @@ def load_tags(path, docdir):
and cn.tagName == "compound"
and cn.getAttribute("kind") != "page"
):
-
name = getChildText(cn, "name")
filename = getChildText(cn, "filename")
anchor = getChildText(cn, "anchor")
@@ -1246,129 +1135,14 @@ def load_tags(path, docdir):
return linkmap
-def writeIndex(model, specloc, index_path, root_path, root_uri, online):
- # Get extension URI
- ext_node = model.value(None, rdf.type, lv2.Specification)
- if not ext_node:
- ext_node = model.value(None, rdf.type, owl.Ontology)
- if not ext_node:
- print("no extension found in %s" % bundle)
- sys.exit(1)
-
- ext = str(ext_node)
-
- # Get version
- minor = 0
- micro = 0
- try:
- minor = int(model.value(ext_node, lv2.minorVersion, None))
- micro = int(model.value(ext_node, lv2.microVersion, None))
- except Exception:
- print("warning: %s: failed to find version for %s" % (bundle, ext))
-
- # Get date
- date = None
- for r in model.triples([ext_node, doap.release, None]):
- revision = model.value(r[2], doap.revision, None)
- if str(revision) == ("%d.%d" % (minor, micro)):
- date = model.value(r[2], doap.created, None)
- break
-
- # Verify that this date is the latest
- if date is None:
- print("warning: %s has no doap:created date" % ext_node)
- else:
- for r in model.triples([ext_node, doap.release, None]):
- this_date = model.value(r[2], doap.created, None)
- if this_date is None:
- print(
- "warning: %s has no doap:created date"
- % (ext_node, minor, micro, date)
- )
- continue
-
- if this_date > date:
- print(
- "warning: %s revision %d.%d (%s) is not the latest release"
- % (ext_node, minor, micro, date)
- )
- break
-
- # Get name and short description
- name = model.value(ext_node, doap.name, None)
- shortdesc = model.value(ext_node, doap.shortdesc, None)
-
- # Chop 'LV2' prefix from name for cleaner index
- if name.startswith("LV2 "):
- name = name[4:]
-
- # Find relative link target
- if root_uri and ext_node.startswith(root_uri):
- target = ext_node[len(root_uri) :]
- else:
- target = os.path.relpath(ext_node, root_path)
-
- if not online:
- target += ".html"
-
- stem = os.path.splitext(os.path.basename(target))[0]
-
- # Specification (comment is to act as a sort key)
- row = '<tr><!-- %s --><td><a rel="rdfs:seeAlso" href="%s">%s</a></td>' % (
- b,
- target,
- name,
- )
-
- # API
- row += "<td>"
- row += '<a rel="rdfs:seeAlso" href="../doc/html/group__%s.html">%s</a>' % (
- stem,
- name,
- )
- row += "</td>"
-
- # Description
- if shortdesc:
- row += "<td>" + str(shortdesc) + "</td>"
- else:
- row += "<td></td>"
-
- # Version
- version_str = "%s.%s" % (minor, micro)
- if minor == 0 or (micro % 2 != 0):
- row += '<td><span style="color: red">' + version_str + "</span></td>"
- else:
- row += "<td>" + version_str + "</td>"
-
- # Status
- deprecated = model.value(ext_node, owl.deprecated, None)
- if minor == 0:
- row += '<td><span class="error">Experimental</span></td>'
- elif deprecated and str(deprecated[2]) != "false":
- row += '<td><span class="warning">Deprecated</span></td>'
- elif micro % 2 == 0:
- row += '<td><span class="success">Stable</span></td>'
-
- row += "</tr>"
-
- index = open(index_path, "w")
- index.write(row)
- index.close()
-
-
def specgen(
specloc,
- indir,
+ template_path,
style_uri,
docdir,
tags,
opts,
instances=False,
- root_link=None,
- index_path=None,
- root_path=None,
- root_uri=None,
):
"""The meat and potatoes: Everything starts here."""
@@ -1382,19 +1156,21 @@ def specgen(
global linkmap
spec_bundle = "file://%s/" % os.path.abspath(os.path.dirname(specloc))
- specgendir = os.path.abspath(indir)
# Template
- temploc = os.path.join(indir, "template.html")
- template = None
- f = open(temploc, "r")
- template = f.read()
- f.close()
+ with open(template_path, "r") as f:
+ template = f.read()
# Load code documentation link map from tags file
linkmap = load_tags(tags, docdir)
m = rdflib.ConjunctiveGraph()
+
+ # RDFLib adds its own prefixes, so kludge around "time" prefix conflict
+ m.namespace_manager.bind(
+ "time", rdflib.URIRef("http://lv2plug.in/ns/ext/time#"), replace=True
+ )
+
manifest_path = os.path.join(os.path.dirname(specloc), "manifest.ttl")
if os.path.exists(manifest_path):
m.parse(manifest_path, format="n3")
@@ -1490,8 +1266,6 @@ def specgen(
name = specProperty(m, spec, doap.name)
title = name
- if root_link:
- name = '<a href="%s">%s</a>' % (root_link, name)
template = template.replace("@TITLE@", title)
template = template.replace("@NAME@", name)
@@ -1500,15 +1274,9 @@ def specgen(
)
template = template.replace("@URI@", spec)
template = template.replace("@PREFIX@", spec_pre)
- if spec_pre == "lv2":
- template = template.replace("@XMLNS@", "")
- else:
- template = template.replace(
- "@XMLNS@", ' xmlns:%s="%s"' % (spec_pre, spec_ns_str)
- )
filename = os.path.basename(specloc)
- basename = filename[0 : filename.rfind(".")]
+ basename = os.path.splitext(filename)[0]
template = template.replace("@STYLE_URI@", style_uri)
template = template.replace("@PREFIXES@", str(prefixes_html))
@@ -1518,7 +1286,6 @@ def specgen(
template = template.replace("@REFERENCE@", termlist)
template = template.replace("@FILENAME@", filename)
template = template.replace("@HEADER@", basename + ".h")
- template = template.replace("@HISTORY@", specHistory(m, spec))
mail_row = ""
if "list_email" in opts:
@@ -1564,10 +1331,6 @@ def specgen(
template = template.replace("@DATE@", build_date.strftime("%F"))
template = template.replace("@TIME@", build_date.strftime("%F %H:%M UTC"))
- # Write index row
- if index_path is not None:
- writeIndex(m, specloc, index_path, root_path, root_uri, opts["online"])
-
# Validate complete output page
try:
oldcwd = os.getcwd()
@@ -1580,7 +1343,9 @@ def specgen(
etree.XMLParser(dtd_validation=True, no_network=True),
)
except Exception as e:
- sys.stderr.write("error: Validation failed for %s: %s" % (specloc, e))
+ sys.stderr.write(
+ "error: Validation failed for %s: %s\n" % (specloc, e)
+ )
finally:
os.chdir(oldcwd)
@@ -1589,10 +1354,9 @@ def specgen(
def save(path, text):
try:
- f = open(path, "w")
- f.write(text)
- f.flush()
- f.close()
+ with open(path, "w") as f:
+ f.write(text)
+ f.flush()
except Exception:
e = sys.exc_info()[1]
print('Error writing to file "' + path + '": ' + str(e))
@@ -1628,12 +1392,57 @@ def usage():
return "Usage: %s ONTOLOGY_TTL OUTPUT_HTML [OPTION]..." % script
+def _path_from_env(variable, default):
+ value = os.environ.get(variable)
+ return value if value and os.path.isabs(value) else default
+
+
+def _paths_from_env(variable, default):
+ paths = []
+ value = os.environ.get(variable)
+ if value:
+ paths = [p for p in value.split(os.pathsep) if os.path.isabs(p)]
+
+ return paths if paths else default
+
+
+def _data_dirs():
+ return _paths_from_env(
+ "XDG_DATA_DIRS", ["/usr/local/share/", "/usr/share/"]
+ )
+
+
if __name__ == "__main__":
"""Ontology specification generator tool"""
- indir = os.path.abspath(os.path.dirname(sys.argv[0]))
- if not os.path.exists(os.path.join(indir, "template.html")):
- indir = os.path.join(os.path.dirname(indir), "share", "lv2specgen")
+ data_dir = None
+ for d in _data_dirs():
+ path = os.path.join(d, "lv2specgen")
+ if (
+ os.path.exists(os.path.join(path, "template.html"))
+ and os.path.exists(os.path.join(path, "style.css"))
+ and os.path.exists(os.path.join(path, "pygments.css"))
+ ):
+ data_dir = path
+ break
+
+ if data_dir:
+ # Use installed files
+ specgendir = data_dir
+ default_template_path = os.path.join(data_dir, "template.html")
+ default_style_dir = data_dir
+ else:
+ script_path = os.path.realpath(__file__)
+ script_dir = os.path.dirname(os.path.realpath(__file__))
+ if os.path.exists(os.path.join(script_dir, "template.html")):
+ # Run from source repository
+ specgendir = script_dir
+ lv2_source_root = os.path.dirname(script_dir)
+ default_template_path = os.path.join(script_dir, "template.html")
+ default_style_dir = os.path.join(lv2_source_root, "doc", "style")
+ else:
+ sys.stderr.write("error: Unable to find lv2specgen data\n")
+ sys.exit(-2)
opt = optparse.OptionParser(
usage=usage(),
@@ -1652,11 +1461,18 @@ if __name__ == "__main__":
help="Mailing list info page address",
)
opt.add_option(
- "--template-dir",
+ "--template",
type="string",
- dest="template_dir",
- default=indir,
- help="Template directory",
+ dest="template",
+ default=default_template_path,
+ help="Template file for output page",
+ )
+ opt.add_option(
+ "--style-dir",
+ type="string",
+ dest="style_dir",
+ default=default_style_dir,
+ help="Stylesheet directory path",
)
opt.add_option(
"--style-uri",
@@ -1673,13 +1489,6 @@ if __name__ == "__main__":
help="Doxygen output directory",
)
opt.add_option(
- "--index",
- type="string",
- dest="index_path",
- default=None,
- help="Index row output file",
- )
- opt.add_option(
"--tags",
type="string",
dest="tags",
@@ -1687,22 +1496,6 @@ if __name__ == "__main__":
help="Doxygen tags file",
)
opt.add_option(
- "-r",
- "--root-path",
- type="string",
- dest="root_path",
- default="",
- help="Root path",
- )
- opt.add_option(
- "-R",
- "--root-uri",
- type="string",
- dest="root_uri",
- default="",
- help="Root URI",
- )
- opt.add_option(
"-p",
"--prefix",
type="string",
@@ -1722,13 +1515,6 @@ if __name__ == "__main__":
dest="copy_style",
help="Copy style from template directory to output directory",
)
- opt.add_option(
- "-o",
- "--online",
- action="store_true",
- dest="online",
- help="Generate index for online documentation",
- )
(options, args) = opt.parse_args()
opts = vars(options)
@@ -1740,7 +1526,6 @@ if __name__ == "__main__":
spec_pre = options.prefix
ontology = "file:" + str(args[0])
output = args[1]
- index_path = options.index_path
docdir = options.docdir
tags = options.tags
@@ -1753,27 +1538,18 @@ if __name__ == "__main__":
b = os.path.basename(outdir)
if not os.access(os.path.abspath(spec), os.R_OK):
- print("warning: extension %s has no %s.ttl file" % (b, b))
+ sys.stderr.write("error: extension %s has no %s.ttl file\n" % (b, b))
sys.exit(1)
- # Root link
- root_path = opts["root_path"]
- root_uri = opts["root_uri"]
- root_link = os.path.join(root_path, "index.html")
-
# Generate spec documentation
specdoc = specgen(
spec,
- indir,
+ opts["template"],
opts["style_uri"],
docdir,
tags,
opts,
instances=True,
- root_link=root_link,
- index_path=index_path,
- root_path=root_path,
- root_uri=root_uri,
)
# Save to HTML output file
@@ -1782,7 +1558,10 @@ if __name__ == "__main__":
if opts["copy_style"]:
import shutil
- shutil.copyfile(
- os.path.join(indir, "style.css"),
- os.path.join(os.path.dirname(output), "style.css"),
- )
+ for stylesheet in ["pygments.css", "style.css"]:
+ style_dir = opts["style_dir"]
+ output_dir = os.path.dirname(output)
+ shutil.copyfile(
+ os.path.join(style_dir, stylesheet),
+ os.path.join(output_dir, stylesheet),
+ )