nixos-render-docs: move options conversion to options module

This commit is contained in:
pennae 2023-01-25 21:27:45 +01:00
parent aa3fd2865b
commit ccb586299d
4 changed files with 236 additions and 166 deletions

View File

@ -16,129 +16,13 @@ from mdit_py_plugins.deflist import deflist_plugin
from mdit_py_plugins.myst_role import myst_role_plugin
from xml.sax.saxutils import escape, quoteattr
from .docbook import make_xml_id, DocBookRenderer
from .md import md_escape
from .options import option_is
class Converter:
def __init__(self, manpage_urls: Dict[str, str]):
self._manpage_urls = frozendict(manpage_urls)
self._md = markdown_it.MarkdownIt(
"commonmark",
{
'maxNesting': 100, # default is 20
'html': False, # not useful since we target many formats
'typographer': True, # required for smartquotes
},
renderer_cls=lambda parser: DocBookRenderer(self._manpage_urls, parser)
)
# TODO maybe fork the plugin and have only a single rule for all?
self._md.use(container_plugin, name="{.note}")
self._md.use(container_plugin, name="{.important}")
self._md.use(container_plugin, name="{.warning}")
self._md.use(deflist_plugin)
self._md.use(myst_role_plugin)
self._md.enable(["smartquotes", "replacements"])
def render(self, src: str) -> str:
return self._md.render(src)
md = Converter(json.load(open(os.getenv('MANPAGE_URLS'))))
# converts in-place!
def convertMD(options: Dict[str, Any]) -> str:
def convertCode(name: str, option: Dict[str, Any], key: str):
if option_is(option, key, 'literalMD'):
option[key] = md.render(f"*{key.capitalize()}:*\n{option[key]['text']}")
elif option_is(option, key, 'literalExpression'):
code = option[key]['text']
# for multi-line code blocks we only have to count ` runs at the beginning
# of a line, but this is much easier.
multiline = '\n' in code
longest, current = (0, 0)
for c in code:
current = current + 1 if c == '`' else 0
longest = max(current, longest)
# inline literals need a space to separate ticks from content, code blocks
# need newlines. inline literals need one extra tick, code blocks need three.
ticks, sep = ('`' * (longest + (3 if multiline else 1)), '\n' if multiline else ' ')
code = f"{ticks}{sep}{code}{sep}{ticks}"
option[key] = md.render(f"*{key.capitalize()}:*\n{code}")
elif option_is(option, key, 'literalDocBook'):
option[key] = f"<para><emphasis>{key.capitalize()}:</emphasis> {option[key]['text']}</para>"
elif key in option:
raise Exception(f"{name} {key} has unrecognized type", option[key])
for (name, option) in options.items():
try:
if option_is(option, 'description', 'mdDoc'):
option['description'] = md.render(option['description']['text'])
elif markdownByDefault:
option['description'] = md.render(option['description'])
else:
option['description'] = ("<nixos:option-description><para>" +
option['description'] +
"</para></nixos:option-description>")
convertCode(name, option, 'example')
convertCode(name, option, 'default')
if typ := option.get('type'):
ro = " *(read only)*" if option.get('readOnly', False) else ""
option['type'] = md.render(f'*Type:* {md_escape(typ)}{ro}')
if 'relatedPackages' in option:
option['relatedPackages'] = md.render(option['relatedPackages'])
except Exception as e:
raise Exception(f"Failed to render option {name}") from e
return options
from .options import DocBookConverter
def need_env(n):
if n not in os.environ:
raise RuntimeError("required environment variable not set", n)
return os.environ[n]
OTD_REVISION = need_env('OTD_REVISION')
OTD_DOCUMENT_TYPE = need_env('OTD_DOCUMENT_TYPE')
OTD_VARIABLE_LIST_ID = need_env('OTD_VARIABLE_LIST_ID')
OTD_OPTION_ID_PREFIX = need_env('OTD_OPTION_ID_PREFIX')
def print_decl_def(header, locs):
print(f"""<para><emphasis>{header}:</emphasis></para>""")
print(f"""<simplelist>""")
for loc in locs:
# locations can be either plain strings (specific to nixpkgs), or attrsets
# { name = "foo/bar.nix"; url = "https://github.com/....."; }
if isinstance(loc, str):
# Hyperlink the filename either to the NixOS github
# repository (if its a module and we have a revision number),
# or to the local filesystem.
if not loc.startswith('/'):
if OTD_REVISION == 'local':
href = f"https://github.com/NixOS/nixpkgs/blob/master/{loc}"
else:
href = f"https://github.com/NixOS/nixpkgs/blob/{OTD_REVISION}/{loc}"
else:
href = f"file://{loc}"
# Print the filename and make it user-friendly by replacing the
# /nix/store/<hash> prefix by the default location of nixos
# sources.
if not loc.startswith('/'):
name = f"<nixpkgs/{loc}>"
elif loc.contains('nixops') and loc.contains('/nix/'):
name = f"<nixops/{loc[loc.find('/nix/') + 5:]}>"
else:
name = loc
print(f"""<member><filename xlink:href={quoteattr(href)}>""")
print(escape(name))
print(f"""</filename></member>""")
else:
href = f" xlink:href={quoteattr(loc['url'])}" if 'url' in loc else ""
print(f"""<member><filename{href}>{escape(loc['name'])}</filename></member>""")
print(f"""</simplelist>""")
def main():
markdownByDefault = False
optOffset = 0
@ -147,49 +31,15 @@ def main():
optOffset += 1
markdownByDefault = True
options = convertMD(json.load(open(sys.argv[1 + optOffset], 'r')))
md = DocBookConverter(
json.load(open(os.getenv('MANPAGE_URLS'))),
revision = need_env('OTD_REVISION'),
document_type = need_env('OTD_DOCUMENT_TYPE'),
varlist_id = need_env('OTD_VARIABLE_LIST_ID'),
id_prefix = need_env('OTD_OPTION_ID_PREFIX'),
markdown_by_default = markdownByDefault
)
keys = list(options.keys())
keys.sort(key=lambda opt: [ (0 if p.startswith("enable") else 1 if p.startswith("package") else 2, p)
for p in options[opt]['loc'] ])
print(f"""<?xml version="1.0" encoding="UTF-8"?>""")
if OTD_DOCUMENT_TYPE == 'appendix':
print("""<appendix xmlns="http://docbook.org/ns/docbook" xml:id="appendix-configuration-options">""")
print(""" <title>Configuration Options</title>""")
print(f"""<variablelist xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:nixos="tag:nixos.org"
xmlns="http://docbook.org/ns/docbook"
xml:id="{OTD_VARIABLE_LIST_ID}">""")
for name in keys:
opt = options[name]
id = OTD_OPTION_ID_PREFIX + make_xml_id(name)
print(f"""<varlistentry>""")
# NOTE adding extra spaces here introduces spaces into xref link expansions
print(f"""<term xlink:href={quoteattr("#" + id)} xml:id={quoteattr(id)}>""", end='')
print(f"""<option>{escape(name)}</option>""", end='')
print(f"""</term>""")
print(f"""<listitem>""")
print(opt['description'])
if typ := opt.get('type'):
print(typ)
if default := opt.get('default'):
print(default)
if example := opt.get('example'):
print(example)
if related := opt.get('relatedPackages'):
print(f"""<para>""")
print(f""" <emphasis>Related packages:</emphasis>""")
print(f"""</para>""")
print(related)
if decl := opt.get('declarations'):
print_decl_def("Declared by", decl)
if defs := opt.get('definitions'):
print_decl_def("Defined by", defs)
print(f"""</listitem>""")
print(f"""</varlistentry>""")
print("""</variablelist>""")
if OTD_DOCUMENT_TYPE == 'appendix':
print("""</appendix>""")
options = json.load(open(sys.argv[1 + optOffset], 'r'))
md.add_options(options)
print(md.finalize())

View File

@ -1,9 +1,13 @@
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any, Optional
from frozendict import frozendict # type: ignore[attr-defined]
from typing import Any, Callable, Optional
import markdown_it
from markdown_it.token import Token
from markdown_it.utils import OptionsDict
from mdit_py_plugins.container import container_plugin # type: ignore[attr-defined]
from mdit_py_plugins.deflist import deflist_plugin # type: ignore[attr-defined]
from mdit_py_plugins.myst_role import myst_role_plugin # type: ignore[attr-defined]
_md_escape_table = {
ord('*'): '\\*',
@ -175,3 +179,29 @@ class Renderer(markdown_it.renderer.RendererProtocol):
def myst_role(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
env: MutableMapping[str, Any]) -> str:
raise RuntimeError("md token not supported", token)
class Converter:
__renderer__: Callable[[Mapping[str, str], markdown_it.MarkdownIt], Renderer]
def __init__(self, manpage_urls: Mapping[str, str]):
self._manpage_urls = frozendict(manpage_urls)
self._md = markdown_it.MarkdownIt(
"commonmark",
{
'maxNesting': 100, # default is 20
'html': False, # not useful since we target many formats
'typographer': True, # required for smartquotes
},
renderer_cls=lambda parser: self.__renderer__(self._manpage_urls, parser)
)
# TODO maybe fork the plugin and have only a single rule for all?
self._md.use(container_plugin, name="{.note}")
self._md.use(container_plugin, name="{.important}")
self._md.use(container_plugin, name="{.warning}")
self._md.use(deflist_plugin)
self._md.use(myst_role_plugin)
self._md.enable(["smartquotes", "replacements"])
def _render(self, src: str) -> str:
return self._md.render(src) # type: ignore[no-any-return]

View File

@ -1,6 +1,9 @@
from typing import Optional
from typing import Any, Optional
from xml.sax.saxutils import escape, quoteattr
from .types import Option
from .docbook import DocBookRenderer, make_xml_id
from .md import Converter, md_escape
from .types import OptionLoc, Option, RenderedOption
def option_is(option: Option, key: str, typ: str) -> Optional[dict[str, str]]:
if key not in option:
@ -10,3 +13,187 @@ def option_is(option: Option, key: str, typ: str) -> Optional[dict[str, str]]:
if option[key].get('_type') != typ: # type: ignore[union-attr]
return None
return option[key] # type: ignore[return-value]
class DocBookConverter(Converter):
__renderer__ = DocBookRenderer
_options: dict[str, RenderedOption]
def __init__(self, manpage_urls: dict[str, str],
revision: str,
document_type: str,
varlist_id: str,
id_prefix: str,
markdown_by_default: bool):
super().__init__(manpage_urls)
self._options = {}
self._revision = revision
self._document_type = document_type
self._varlist_id = varlist_id
self._id_prefix = id_prefix
self._markdown_by_default = markdown_by_default
def _format_decl_def_loc(self, loc: OptionLoc) -> tuple[Optional[str], str]:
# locations can be either plain strings (specific to nixpkgs), or attrsets
# { name = "foo/bar.nix"; url = "https://github.com/....."; }
if isinstance(loc, str):
# Hyperlink the filename either to the NixOS github
# repository (if its a module and we have a revision number),
# or to the local filesystem.
if not loc.startswith('/'):
if self._revision == 'local':
href = f"https://github.com/NixOS/nixpkgs/blob/master/{loc}"
else:
href = f"https://github.com/NixOS/nixpkgs/blob/{self._revision}/{loc}"
else:
href = f"file://{loc}"
# Print the filename and make it user-friendly by replacing the
# /nix/store/<hash> prefix by the default location of nixos
# sources.
if not loc.startswith('/'):
name = f"<nixpkgs/{loc}>"
elif 'nixops' in loc and '/nix/' in loc:
name = f"<nixops/{loc[loc.find('/nix/') + 5:]}>"
else:
name = loc
return (href, name)
else:
return (loc['url'] if 'url' in loc else None, loc['name'])
def _render_decl_def(self, header: str, locs: list[OptionLoc]) -> list[str]:
result = []
result += self._decl_def_header(header)
for loc in locs:
href, name = self._format_decl_def_loc(loc)
result += self._decl_def_entry(href, name)
result += self._decl_def_footer()
return result
def _render_code(self, option: Option, key: str) -> list[str]:
if lit := option_is(option, key, 'literalDocBook'):
return [ f"<para><emphasis>{key.capitalize()}:</emphasis> {lit['text']}</para>" ]
elif lit := option_is(option, key, 'literalMD'):
return [ self._render(f"*{key.capitalize()}:*\n{lit['text']}") ]
elif lit := option_is(option, key, 'literalExpression'):
code = lit['text']
# for multi-line code blocks we only have to count ` runs at the beginning
# of a line, but this is much easier.
multiline = '\n' in code
longest, current = (0, 0)
for c in code:
current = current + 1 if c == '`' else 0
longest = max(current, longest)
# inline literals need a space to separate ticks from content, code blocks
# need newlines. inline literals need one extra tick, code blocks need three.
ticks, sep = ('`' * (longest + (3 if multiline else 1)), '\n' if multiline else ' ')
code = f"{ticks}{sep}{code}{sep}{ticks}"
return [ self._render(f"*{key.capitalize()}:*\n{code}") ]
elif key in option:
raise Exception(f"{key} has unrecognized type", option[key])
else:
return []
def _render_description(self, desc: str | dict[str, str]) -> list[str]:
if isinstance(desc, str) and not self._markdown_by_default:
return [ f"<nixos:option-description><para>{desc}</para></nixos:option-description>" ]
elif isinstance(desc, str) and self._markdown_by_default:
return [ self._render(desc) ]
elif isinstance(desc, dict) and desc.get('_type') == 'mdDoc':
return [ self._render(desc['text']) ]
else:
raise Exception("description has unrecognized type", desc)
def _convert_one(self, option: dict[str, Any]) -> list[str]:
result = []
if desc := option.get('description'):
result += self._render_description(desc)
if typ := option.get('type'):
ro = " *(read only)*" if option.get('readOnly', False) else ""
result.append(self._render(f"*Type:* {md_escape(typ)}{ro}"))
result += self._render_code(option, 'default')
result += self._render_code(option, 'example')
if related := option.get('relatedPackages'):
result += self._related_packages_header()
result.append(self._render(related))
if decl := option.get('declarations'):
result += self._render_decl_def("Declared by", decl)
if defs := option.get('definitions'):
result += self._render_decl_def("Defined by", defs)
return result
def add_options(self, options: dict[str, Any]) -> None:
for (name, option) in options.items():
try:
self._options[name] = RenderedOption(option['loc'], self._convert_one(option))
except Exception as e:
raise Exception(f"Failed to render option {name}") from e
def _related_packages_header(self) -> list[str]:
return [
"<para>",
" <emphasis>Related packages:</emphasis>",
"</para>",
]
def _decl_def_header(self, header: str) -> list[str]:
return [
f"<para><emphasis>{header}:</emphasis></para>",
"<simplelist>"
]
def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
if href is not None:
href = " xlink:href=" + quoteattr(href)
return [
f"<member><filename{href}>",
escape(name),
"</filename></member>"
]
def _decl_def_footer(self) -> list[str]:
return [ "</simplelist>" ]
def finalize(self) -> str:
keys = list(self._options.keys())
keys.sort(key=lambda opt: [ (0 if p.startswith("enable") else 1 if p.startswith("package") else 2, p)
for p in self._options[opt].loc ])
result = []
result.append('<?xml version="1.0" encoding="UTF-8"?>')
if self._document_type == 'appendix':
result += [
'<appendix xmlns="http://docbook.org/ns/docbook"',
' xml:id="appendix-configuration-options">',
' <title>Configuration Options</title>',
]
result += [
f'<variablelist xmlns:xlink="http://www.w3.org/1999/xlink"',
' xmlns:nixos="tag:nixos.org"',
' xmlns="http://docbook.org/ns/docbook"',
f' xml:id="{self._varlist_id}">',
]
for name in keys:
id = make_xml_id(self._id_prefix + name)
result += [
"<varlistentry>",
# NOTE adding extra spaces here introduces spaces into xref link expansions
(f"<term xlink:href={quoteattr('#' + id)} xml:id={quoteattr(id)}>" +
f"<option>{escape(name)}</option></term>"),
"<listitem>"
]
result += self._options[name].lines
result += [
"</listitem>",
"</varlistentry>"
]
result.append("</variablelist>")
if self._document_type == 'appendix':
result.append("</appendix>")
return "\n".join(result)

View File

@ -1,4 +1,7 @@
from typing import Optional, Tuple
from typing import Optional, Tuple, NamedTuple
OptionLoc = str | dict[str, str]
Option = dict[str, str | dict[str, str] | list[OptionLoc]]
RenderedOption = NamedTuple('RenderedOption', [('loc', list[str]),
('lines', list[str])])