diff --git a/changelogs/fragments/4-semantic-markup.yml b/changelogs/fragments/4-semantic-markup.yml
new file mode 100644
index 00000000..dd5f1e4e
--- /dev/null
+++ b/changelogs/fragments/4-semantic-markup.yml
@@ -0,0 +1,2 @@
+major_changes:
+ - Support new semantic markup in documentation (https://github.com/ansible-community/antsibull-docs/pull/4).
diff --git a/pyproject.toml b/pyproject.toml
index 0cb6cd4d..ade46678 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,7 @@ ansible-pygments = "*"
antsibull-core = ">= 1.2.0, < 2.0.0"
asyncio-pool = "*"
docutils = "*"
-jinja2 = "*"
+jinja2 = ">= 3.0"
rstcheck = ">= 3.0.0, < 7.0.0"
sphinx = "*"
diff --git a/src/antsibull_docs/jinja2/filters.py b/src/antsibull_docs/jinja2/filters.py
index dcd78c11..d20347a3 100644
--- a/src/antsibull_docs/jinja2/filters.py
+++ b/src/antsibull_docs/jinja2/filters.py
@@ -13,7 +13,7 @@
from collections.abc import Mapping, Sequence
-from jinja2.runtime import Undefined
+from jinja2.runtime import Context, Undefined
from antsibull_core.logging import log
@@ -23,6 +23,17 @@
_EMAIL_ADDRESS = re.compile(r"(?:<{mail}>|\({mail}\)|{mail})".format(mail=r"[\w.+-]+@[\w.-]+\.\w+"))
+def extract_plugin_data(context: Context) -> t.Tuple[t.Optional[str], t.Optional[str]]:
+ plugin_fqcn = context.get('plugin_name')
+ plugin_type = context.get('plugin_type')
+ if plugin_fqcn is None or plugin_type is None:
+ return None, None
+ # if plugin_type == 'role':
+ # entry_point = context.get('entry_point', 'main')
+ # # FIXME: use entry_point
+ return plugin_fqcn, plugin_type
+
+
def documented_type(text) -> str:
''' Convert any python type to a type for documentation '''
diff --git a/src/antsibull_docs/jinja2/htmlify.py b/src/antsibull_docs/jinja2/htmlify.py
index 657c6350..de534a73 100644
--- a/src/antsibull_docs/jinja2/htmlify.py
+++ b/src/antsibull_docs/jinja2/htmlify.py
@@ -12,8 +12,14 @@
import typing as t
+from jinja2.runtime import Context
+from jinja2.utils import pass_context
+
from antsibull_core.logging import log
+from ..semantic_helper import parse_option, parse_return_value
+
+from .filters import extract_plugin_data
from .parser import Command, CommandSet, convert_text
@@ -35,9 +41,13 @@ def _create_error(text: str, error: str) -> str:
class _Context:
+ j2_context: Context
counts: t.Dict[str, int]
+ plugin_fqcn: t.Optional[str]
+ plugin_type: t.Optional[str]
- def __init__(self):
+ def __init__(self, j2_context: Context):
+ self.j2_context = j2_context
self.counts = {
'italic': 0,
'bold': 0,
@@ -53,6 +63,7 @@ def __init__(self):
'return-value': 0,
'ruler': 0,
}
+ self.plugin_fqcn, self.plugin_type = extract_plugin_data(j2_context)
# In the following, we make heavy use of escaped whitespace ("\ ") being removed from the output.
@@ -159,6 +170,107 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str:
return f"{html_escape(parameters[0])}
"
+class _OptionName(Command):
+ command = 'O'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['option-name'] += 1
+ if context.plugin_fqcn is None or context.plugin_type is None:
+ raise Exception('The markup O(...) cannot be used outside a plugin or role')
+ text = parameters[0]
+ try:
+ plugin_fqcn, plugin_type, option_link, option, value = parse_option(
+ text, context.plugin_fqcn, context.plugin_type, require_plugin=False)
+ except ValueError as exc:
+ return _create_error(f'O({text})', str(exc))
+ if value is None:
+ cls = 'ansible-option'
+ text = f'{option}'
+ strong_start = ''
+ strong_end = ''
+ else:
+ cls = 'ansible-option-value'
+ text = f'{option}={value}'
+ strong_start = ''
+ strong_end = ''
+ if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2:
+ # TODO: handle role arguments (entrypoint!)
+ namespace, name, plugin = plugin_fqcn.split('.', 2)
+ url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html'
+ fragment = f'parameter-{quote(option_link.replace(".", "/"))}'
+ link_start = (
+ f''
+ ''
+ )
+ link_end = ''
+ else:
+ link_start = ''
+ link_end = ''
+ return (
+ f''
+ f'{strong_start}{link_start}{text}{link_end}{strong_end}
'
+ )
+
+
+class _OptionValue(Command):
+ command = 'V'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['option-value'] += 1
+ text = parameters[0]
+ return f'{html_escape(text)}
'
+
+
+class _EnvVariable(Command):
+ command = 'E'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['environment-var'] += 1
+ text = parameters[0]
+ return f'{html_escape(text)}
'
+
+
+class _RetValue(Command):
+ command = 'RV'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['return-value'] += 1
+ if context.plugin_fqcn is None or context.plugin_type is None:
+ raise Exception('The markup RV(...) cannot be used outside a plugin or role')
+ text = parameters[0]
+ try:
+ plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value(
+ text, context.plugin_fqcn, context.plugin_type, require_plugin=False)
+ except ValueError as exc:
+ return _create_error(f'RV({text})', str(exc))
+ cls = 'ansible-return-value'
+ if value is None:
+ text = f'{rv}'
+ else:
+ text = f'{rv}={value}'
+ if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2:
+ namespace, name, plugin = plugin_fqcn.split('.', 2)
+ url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html'
+ fragment = f'return-{quote(rv_link.replace(".", "/"))}'
+ link_start = (
+ f''
+ ''
+ )
+ link_end = ''
+ else:
+ link_start = ''
+ link_end = ''
+ return f'{link_start}{text}{link_end}
'
+
+
class _HorizontalLine(Command):
command = 'HORIZONTALLINE'
parameter_count = 0
@@ -178,16 +290,21 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str:
_Link(),
_Ref(),
_Const(),
+ _OptionName(),
+ _OptionValue(),
+ _EnvVariable(),
+ _RetValue(),
_HorizontalLine(),
])
-def html_ify(text: str) -> str:
+@pass_context
+def html_ify(context: Context, text: str) -> str:
''' convert symbols like I(this is in italics) to valid HTML '''
flog = mlog.fields(func='html_ify')
flog.fields(text=text).debug('Enter')
- our_context = _Context()
+ our_context = _Context(context)
try:
text = convert_text(text, _COMMAND_SET, html_escape, our_context)
diff --git a/src/antsibull_docs/jinja2/rstify.py b/src/antsibull_docs/jinja2/rstify.py
index 6aa72a6f..203f4f37 100644
--- a/src/antsibull_docs/jinja2/rstify.py
+++ b/src/antsibull_docs/jinja2/rstify.py
@@ -11,8 +11,14 @@
import typing as t
+from jinja2.runtime import Context
+from jinja2.utils import pass_context
+
from antsibull_core.logging import log
+from ..semantic_helper import augment_plugin_name_type
+
+from .filters import extract_plugin_data
from .parser import Command, CommandSet, convert_text
@@ -63,9 +69,13 @@ def _create_error(text: str, error: str) -> str:
class _Context:
+ j2_context: Context
counts: t.Dict[str, int]
+ plugin_fqcn: t.Optional[str]
+ plugin_type: t.Optional[str]
- def __init__(self):
+ def __init__(self, j2_context: Context):
+ self.j2_context = j2_context
self.counts = {
'italic': 0,
'bold': 0,
@@ -81,6 +91,7 @@ def __init__(self):
'return-value': 0,
'ruler': 0,
}
+ self.plugin_fqcn, self.plugin_type = extract_plugin_data(j2_context)
# In the following, we make heavy use of escaped whitespace ("\ ") being removed from the output.
@@ -182,6 +193,52 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str:
return f"\\ :literal:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ "
+class _OptionName(Command):
+ command = 'O'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['option-name'] += 1
+ if context.plugin_fqcn is None or context.plugin_type is None:
+ raise Exception('The markup O(...) cannot be used outside a plugin or role')
+ text = augment_plugin_name_type(parameters[0], context.plugin_fqcn, context.plugin_type)
+ return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
+
+
+class _OptionValue(Command):
+ command = 'V'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['option-value'] += 1
+ return f"\\ :ansval:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ "
+
+
+class _EnvVariable(Command):
+ command = 'E'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['environment-var'] += 1
+ return f"\\ :envvar:`{rst_escape(parameters[0], escape_ending_whitespace=True)}`\\ "
+
+
+class _RetValue(Command):
+ command = 'RV'
+ parameter_count = 1
+ escaped_content = True
+
+ def handle(self, parameters: t.List[str], context: t.Any) -> str:
+ context.counts['return-value'] += 1
+ if context.plugin_fqcn is None or context.plugin_type is None:
+ raise Exception('The markup RV(...) cannot be used outside a plugin or role')
+ text = augment_plugin_name_type(parameters[0], context.plugin_fqcn, context.plugin_type)
+ return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
+
+
class _HorizontalLine(Command):
command = 'HORIZONTALLINE'
parameter_count = 0
@@ -201,16 +258,21 @@ def handle(self, parameters: t.List[str], context: t.Any) -> str:
_Link(),
_Ref(),
_Const(),
+ _OptionName(),
+ _OptionValue(),
+ _EnvVariable(),
+ _RetValue(),
_HorizontalLine(),
])
-def rst_ify(text: str) -> str:
+@pass_context
+def rst_ify(context: Context, text: str) -> str:
''' convert symbols like I(this is in italics) to valid restructured text '''
flog = mlog.fields(func='rst_ify')
flog.fields(text=text).debug('Enter')
- our_context = _Context()
+ our_context = _Context(context)
try:
text = convert_text(text, _COMMAND_SET, rst_escape, our_context)
diff --git a/src/antsibull_docs/lint_extra_docs.py b/src/antsibull_docs/lint_extra_docs.py
index 357a40e4..31f03d54 100644
--- a/src/antsibull_docs/lint_extra_docs.py
+++ b/src/antsibull_docs/lint_extra_docs.py
@@ -10,6 +10,8 @@
import re
import typing as t
+from sphinx_antsibull_ext import roles as antsibull_roles
+
from .extra_docs import (
find_extra_docs,
lint_required_conditions,
@@ -33,7 +35,7 @@ def lint_optional_conditions(content: str, path: str, collection_name: str
Return a list of errors.
'''
- return check_rst_content(content, filename=path)
+ return check_rst_content(content, filename=path, ignore_roles=list(antsibull_roles.ROLES))
def lint_collection_extra_docs_files(path_to_collection: str
diff --git a/src/antsibull_docs/semantic_helper.py b/src/antsibull_docs/semantic_helper.py
new file mode 100644
index 00000000..e1dbe399
--- /dev/null
+++ b/src/antsibull_docs/semantic_helper.py
@@ -0,0 +1,87 @@
+# Author: Felix Fontein
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
+# https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2021, Ansible Project
+"""
+Helpers for parsing semantic markup.
+"""
+
+import re
+
+import typing as t
+
+
+_ARRAY_STUB_RE = re.compile(r'\[([^\]]*)\]')
+_FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([a-z]+):(.*)$')
+_IGNORE_MARKER = 'ignore:'
+
+
+def _remove_array_stubs(text: str) -> str:
+ return _ARRAY_STUB_RE.sub('', text)
+
+
+def parse_option(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
+ require_plugin=False
+ ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]:
+ """
+ Given the contents of O(...) / :ansopt:`...` with potential escaping removed,
+ split it into plugin FQCN, plugin type, option link name, option name, and option value.
+ """
+ value = None
+ if '=' in text:
+ text, value = text.split('=', 1)
+ m = _FQCN_TYPE_PREFIX_RE.match(text)
+ if m:
+ plugin_fqcn = m.group(1)
+ plugin_type = m.group(2)
+ text = m.group(3)
+ elif require_plugin:
+ raise ValueError('Cannot extract plugin name and type')
+ elif text.startswith(_IGNORE_MARKER):
+ plugin_fqcn = ''
+ plugin_type = ''
+ text = text[len(_IGNORE_MARKER):]
+ if ':' in text or '#' in text:
+ raise ValueError(f'Invalid option name "{text}"')
+ return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value
+
+
+def parse_return_value(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
+ require_plugin=False
+ ) -> t.Tuple[t.Optional[str], t.Optional[str], str, str, t.Optional[str]]:
+ """
+ Given the contents of RV(...) / :ansretval:`...` with potential escaping removed,
+ split it into plugin FQCN, plugin type, option link name, option name, and option value.
+ """
+ value = None
+ if '=' in text:
+ text, value = text.split('=', 1)
+ m = _FQCN_TYPE_PREFIX_RE.match(text)
+ if m:
+ plugin_fqcn = m.group(1)
+ plugin_type = m.group(2)
+ text = m.group(3)
+ elif require_plugin:
+ raise ValueError('Cannot extract plugin name and type')
+ elif text.startswith(_IGNORE_MARKER):
+ plugin_fqcn = ''
+ plugin_type = ''
+ text = text[len(_IGNORE_MARKER):]
+ if ':' in text or '#' in text:
+ raise ValueError(f'Invalid return value name "{text}"')
+ return plugin_fqcn, plugin_type, _remove_array_stubs(text), text, value
+
+
+def augment_plugin_name_type(text: str, plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str]
+ ) -> str:
+ """
+ Given the text contents of O(...) or RV(...) and a plugin's FQCN and type, insert
+ the FQCN and type if they are not already present.
+ """
+ value = None
+ if '=' in text:
+ text, value = text.split('=', 1)
+ if ':' not in text and plugin_fqcn and plugin_type:
+ text = f'{plugin_fqcn}#{plugin_type}:{text}'
+ return text if value is None else f'{text}={value}'
diff --git a/src/sphinx_antsibull_ext/__init__.py b/src/sphinx_antsibull_ext/__init__.py
index f3753ee4..db5f6479 100644
--- a/src/sphinx_antsibull_ext/__init__.py
+++ b/src/sphinx_antsibull_ext/__init__.py
@@ -14,6 +14,7 @@
from .assets import setup_assets
+from .roles import setup_roles
def setup(app):
@@ -25,6 +26,9 @@ def setup(app):
# Add assets
setup_assets(app)
+ # Add roles
+ setup_roles(app)
+
return dict(
parallel_read_safe=True,
parallel_write_safe=True,
diff --git a/src/sphinx_antsibull_ext/antsibull-minimal.css b/src/sphinx_antsibull_ext/antsibull-minimal.css
index 9434d0d3..f934ea1c 100644
--- a/src/sphinx_antsibull_ext/antsibull-minimal.css
+++ b/src/sphinx_antsibull_ext/antsibull-minimal.css
@@ -1,3 +1,3 @@
@charset "UTF-8";
/* Copyright (c) Ansible and contributors */
-/* GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt) */.ansible-links{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:start;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.ansible-links>a{padding:4px 12px;margin:2px 4px;cursor:pointer;border-radius:3px;background-color:#5bbdbf;color:#fff}.ansible-links>a:active,.ansible-links>a:focus,.ansible-links>a:hover{background-color:#91d9db}.ansible-links>a:focus{outline:3px solid #204748}table.documentation-table{border-bottom:1px solid #000;border-right:1px solid #000}table.documentation-table th{background-color:#6ab0de}table.documentation-table td,table.documentation-table th{padding:4px;border-left:1px solid #000;border-top:1px solid #000}table.documentation-table td.elbow-placeholder{border-top:0;width:30px;min-width:30px}table.documentation-table td{vertical-align:top}table.documentation-table td:first-child{white-space:nowrap}table.documentation-table tr .ansibleOptionLink{display:inline-block;visibility:hidden}table.documentation-table tr .ansibleOptionLink:after{content:"🔗"}table.documentation-table tr:hover .ansibleOptionLink:after{visibility:visible}table.documentation-table tr:nth-child(odd){background-color:#fff}table.documentation-table tr:nth-child(2n){background-color:#e7f2fa}table.ansible-option-table{display:table;border-color:#000!important;height:1px}table.ansible-option-table tr{height:100%}table.ansible-option-table td,table.ansible-option-table th{border-color:#000!important;border-bottom:none!important;vertical-align:top!important}table.ansible-option-table th>p{font-size:medium!important}table.ansible-option-table thead tr{background-color:#6ab0de}table.ansible-option-table tbody .row-odd td{background-color:#fff!important}table.ansible-option-table tbody .row-even td{background-color:#e7f2fa!important}table.ansible-option-table ul>li>p{margin:0!important}table.ansible-option-table ul>li>div[class^=highlight]{margin-bottom:4px!important}table.ansible-option-table p.ansible-option-title{display:inline}table.ansible-option-table .ansible-option-type-line{font-size:small;margin-bottom:0}table.ansible-option-table .ansible-option-elements,table.ansible-option-table .ansible-option-type{color:purple}table.ansible-option-table .ansible-option-required{color:red}table.ansible-option-table .ansible-option-versionadded{font-style:italic;font-size:small;color:#006400}table.ansible-option-table .ansible-option-aliases{color:#006400}table.ansible-option-table .ansible-option-line{margin-top:8px}table.ansible-option-table .ansible-option-choices{font-weight:700}table.ansible-option-table .ansible-option-default{color:#00f}table.ansible-option-table .ansible-option-default-bold{color:#00f;font-weight:700}table.ansible-option-table .ansible-option-returned-bold{font-weight:700}table.ansible-option-table .ansible-option-sample{color:#00f;word-wrap:break-word;word-break:break-all}table.ansible-option-table .ansible-option-sample-bold{color:#000;font-weight:700}table.ansible-option-table .ansible-option-configuration{font-weight:700}table.ansible-option-table .ansibleOptionLink{display:inline-block;visibility:hidden}table.ansible-option-table .ansibleOptionLink:after{content:"🔗"}table.ansible-option-table p{margin:0 0 8px}table.ansible-option-table tr:hover .ansibleOptionLink:after{visibility:visible}table.ansible-option-table td{padding:0!important;white-space:normal}table.ansible-option-table td>div.ansible-option-cell{padding:8px 16px;border-top:1px solid #000}table.ansible-option-table td:first-child{height:inherit;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:first-child>div.ansible-option-cell{height:inherit;-webkit-box-flex:1;-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;white-space:nowrap}table.ansible-option-table .ansible-option-indent{margin-left:2em;border-right:1px solid #000}table.ansible-option-table .ansible-attribute-support-label{display:none}table.ansible-option-table .ansible-attribute-support-label,table.ansible-option-table .ansible-attribute-support-property{font-weight:700}table.ansible-option-table .ansible-attribute-support-none{font-weight:700;color:red}table.ansible-option-table .ansible-attribute-support-partial{font-weight:700;color:#a5a500}table.ansible-option-table .ansible-attribute-support-full{font-weight:700;color:green}table.ansible-option-table .ansible-attribute-details{font-style:italic}@media (max-width:1200px){table.ansible-option-table{display:block;height:unset;border:none!important}table.ansible-option-table thead{display:none}table.ansible-option-table tbody,table.ansible-option-table td,table.ansible-option-table tr{display:block;border:none!important}table.ansible-option-table tbody .row-even td,table.ansible-option-table tbody .row-odd td{background-color:unset!important}table.ansible-option-table td>div.ansible-option-cell{border-top:none}table.ansible-option-table td:first-child>div.ansible-option-cell{background-color:#e7f2fa!important}table.ansible-option-table td:not(:first-child){display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:not(:first-child)>div.ansible-option-cell{margin-left:1em}table.ansible-option-table .ansible-option-indent,table.ansible-option-table .ansible-option-indent-desc{margin-left:1em;border:none;border-right:3px solid #e7f2fa}table.ansible-option-table .ansible-attribute-support-label{display:unset}}
\ No newline at end of file
+/* GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt) */.ansible-links{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:start;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.ansible-links>a{padding:4px 12px;margin:2px 4px;cursor:pointer;border-radius:3px;background-color:#5bbdbf;color:#fff}.ansible-links>a:active,.ansible-links>a:focus,.ansible-links>a:hover{background-color:#91d9db}.ansible-links>a:focus{outline:3px solid #204748}table.documentation-table{border-bottom:1px solid #000;border-right:1px solid #000}table.documentation-table th{background-color:#6ab0de}table.documentation-table td,table.documentation-table th{padding:4px;border-left:1px solid #000;border-top:1px solid #000}table.documentation-table td.elbow-placeholder{border-top:0;width:30px;min-width:30px}table.documentation-table td{vertical-align:top}table.documentation-table td:first-child{white-space:nowrap}table.documentation-table tr .ansibleOptionLink{display:inline-block;visibility:hidden}table.documentation-table tr .ansibleOptionLink:after{content:"🔗"}table.documentation-table tr:hover .ansibleOptionLink:after{visibility:visible}table.documentation-table tr:nth-child(odd){background-color:#fff}table.documentation-table tr:nth-child(2n){background-color:#e7f2fa}table.ansible-option-table{display:table;border-color:#000!important;height:1px}table.ansible-option-table tr{height:100%}table.ansible-option-table td,table.ansible-option-table th{border-color:#000!important;border-bottom:none!important;vertical-align:top!important}table.ansible-option-table th>p{font-size:medium!important}table.ansible-option-table thead tr{background-color:#6ab0de}table.ansible-option-table tbody .row-odd td{background-color:#fff!important}table.ansible-option-table tbody .row-even td{background-color:#e7f2fa!important}table.ansible-option-table ul>li>p{margin:0!important}table.ansible-option-table ul>li>div[class^=highlight]{margin-bottom:4px!important}table.ansible-option-table p.ansible-option-title{display:inline}table.ansible-option-table .ansible-option-type-line{font-size:small;margin-bottom:0}table.ansible-option-table .ansible-option-elements,table.ansible-option-table .ansible-option-type{color:purple}table.ansible-option-table .ansible-option-required{color:red}table.ansible-option-table .ansible-option-versionadded{font-style:italic;font-size:small;color:#006400}table.ansible-option-table .ansible-option-aliases{color:#006400}table.ansible-option-table .ansible-option-line{margin-top:8px}table.ansible-option-table .ansible-option-choices{font-weight:700}table.ansible-option-table .ansible-option-default{color:#00f}table.ansible-option-table .ansible-option-default-bold{color:#00f;font-weight:700}table.ansible-option-table .ansible-option-returned-bold{font-weight:700}table.ansible-option-table .ansible-option-sample{color:#00f;word-wrap:break-word;word-break:break-all}table.ansible-option-table .ansible-option-sample-bold{color:#000;font-weight:700}table.ansible-option-table .ansible-option-configuration{font-weight:700}table.ansible-option-table .ansibleOptionLink{display:inline-block;visibility:hidden}table.ansible-option-table .ansibleOptionLink:after{content:"🔗"}table.ansible-option-table p{margin:0 0 8px}table.ansible-option-table tr:hover .ansibleOptionLink:after{visibility:visible}table.ansible-option-table td{padding:0!important;white-space:normal}table.ansible-option-table td>div.ansible-option-cell{padding:8px 16px;border-top:1px solid #000}table.ansible-option-table td:first-child{height:inherit;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:first-child>div.ansible-option-cell{height:inherit;-webkit-box-flex:1;-webkit-flex:1 0 auto;-ms-flex:1 0 auto;flex:1 0 auto;white-space:nowrap}table.ansible-option-table .ansible-option-indent{margin-left:2em;border-right:1px solid #000}table.ansible-option-table .ansible-attribute-support-label{display:none}table.ansible-option-table .ansible-attribute-support-label,table.ansible-option-table .ansible-attribute-support-property{font-weight:700}table.ansible-option-table .ansible-attribute-support-none{font-weight:700;color:red}table.ansible-option-table .ansible-attribute-support-partial{font-weight:700;color:#a5a500}table.ansible-option-table .ansible-attribute-support-full{font-weight:700;color:green}table.ansible-option-table .ansible-attribute-details{font-style:italic}@media (max-width:1200px){table.ansible-option-table{display:block;height:unset;border:none!important}table.ansible-option-table thead{display:none}table.ansible-option-table tbody,table.ansible-option-table td,table.ansible-option-table tr{display:block;border:none!important}table.ansible-option-table tbody .row-even td,table.ansible-option-table tbody .row-odd td{background-color:unset!important}table.ansible-option-table td>div.ansible-option-cell{border-top:none}table.ansible-option-table td:first-child>div.ansible-option-cell{background-color:#e7f2fa!important}table.ansible-option-table td:not(:first-child){display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}table.ansible-option-table td:not(:first-child)>div.ansible-option-cell{margin-left:1em}table.ansible-option-table .ansible-option-indent,table.ansible-option-table .ansible-option-indent-desc{margin-left:1em;border:none;border-right:3px solid #e7f2fa}table.ansible-option-table .ansible-attribute-support-label{display:unset}}.ansible-option-value a.reference.internal,.ansible-option-value a.reference.internal:hover,.ansible-option a.reference.internal,.ansible-option a.reference.internal:hover,.ansible-return-value a.reference.internal,.ansible-return-value a.reference.internal:hover{color:unset}
\ No newline at end of file
diff --git a/src/sphinx_antsibull_ext/css/antsibull-minimal.scss b/src/sphinx_antsibull_ext/css/antsibull-minimal.scss
index dbdb7b53..e86885fd 100644
--- a/src/sphinx_antsibull_ext/css/antsibull-minimal.scss
+++ b/src/sphinx_antsibull_ext/css/antsibull-minimal.scss
@@ -333,3 +333,13 @@ table.ansible-option-table {
}
}
}
+
+.ansible-option, .ansible-option-value, .ansible-return-value {
+ a.reference.internal {
+ color: unset;
+
+ &:hover {
+ color: unset;
+ }
+ }
+}
diff --git a/src/sphinx_antsibull_ext/roles.py b/src/sphinx_antsibull_ext/roles.py
new file mode 100644
index 00000000..de722594
--- /dev/null
+++ b/src/sphinx_antsibull_ext/roles.py
@@ -0,0 +1,165 @@
+# Author: Felix Fontein
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
+# https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2021, Ansible Project
+'''
+Add roles for semantic markup.
+'''
+
+import typing as t
+
+from docutils import nodes
+from sphinx import addnodes
+
+from antsibull_docs.semantic_helper import parse_option, parse_return_value
+
+
+def _create_option_reference(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
+ option: str) -> t.Optional[str]:
+ if not plugin_fqcn or not plugin_type:
+ return None
+ # TODO: handle role arguments (entrypoint!)
+ ref = option.replace(".", "/")
+ return f'ansible_collections.{plugin_fqcn}_{plugin_type}__parameter-{ref}'
+
+
+def _create_return_value_reference(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
+ return_value: str) -> t.Optional[str]:
+ if not plugin_fqcn or not plugin_type:
+ return None
+ ref = return_value.replace(".", "/")
+ return f'ansible_collections.{plugin_fqcn}_{plugin_type}__return-{ref}'
+
+
+def _create_ref_or_not(create_ref: t.Callable[[t.Optional[str], t.Optional[str], str],
+ t.Optional[str]],
+ plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
+ ref_parameter: str, text: str
+ ) -> t.Tuple[str, t.List[t.Any]]:
+ ref = create_ref(plugin_fqcn, plugin_type, ref_parameter)
+ if ref is None:
+ return text, []
+
+ # The content node will be replaced by Sphinx anyway, so it doesn't matter what kind
+ # of node we are using...
+ content = nodes.literal(text, text)
+
+ options = {
+ 'reftype': 'ref',
+ 'refdomain': 'std',
+ 'refexplicit': True,
+ 'refwarn': True,
+ }
+ refnode = addnodes.pending_xref(text, content, **options)
+ refnode['reftarget'] = ref
+ return '', [refnode]
+
+
+# pylint:disable-next=unused-argument
+def _create_error(rawtext: str, text: str, error: str) -> t.Tuple[t.List[t.Any], t.List[str]]:
+ node = ... # FIXME
+ return [node], []
+
+
+# pylint:disable-next=unused-argument,dangerous-default-value
+def option_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ """Format Ansible option key, or option key-value.
+
+ Returns 2 part tuple containing list of nodes to insert into the
+ document and a list of system messages. Both are allowed to be
+ empty.
+
+ :param name: The role name used in the document.
+ :param rawtext: The entire markup snippet, with role.
+ :param text: The text marked with the role.
+ :param lineno: The line number where rawtext appears in the input.
+ :param inliner: The inliner instance that called us.
+ :param options: Directive options for customization.
+ :param content: The directive content for customization.
+ """
+ classes = []
+ try:
+ plugin_fqcn, plugin_type, option_link, option, value = parse_option(
+ text.replace('\x00', ''), '', '', require_plugin=False)
+ except ValueError as exc:
+ return _create_error(rawtext, text, str(exc))
+ if value is None:
+ text = f'{option}'
+ classes.append('ansible-option')
+ else:
+ text = f'{option}={value}'
+ classes.append('ansible-option-value')
+ text, subnodes = _create_ref_or_not(
+ _create_option_reference, plugin_fqcn, plugin_type, option_link, text)
+ if value is None:
+ content = nodes.strong(rawtext, text, *subnodes)
+ content = nodes.literal(rawtext, '', content, classes=classes)
+ else:
+ content = nodes.literal(rawtext, text, *subnodes, classes=classes)
+ return [content], []
+
+
+# pylint:disable-next=unused-argument,dangerous-default-value
+def value_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ """Format Ansible option value.
+
+ Returns 2 part tuple containing list of nodes to insert into the
+ document and a list of system messages. Both are allowed to be
+ empty.
+
+ :param name: The role name used in the document.
+ :param rawtext: The entire markup snippet, with role.
+ :param text: The text marked with the role.
+ :param lineno: The line number where rawtext appears in the input.
+ :param inliner: The inliner instance that called us.
+ :param options: Directive options for customization.
+ :param content: The directive content for customization.
+ """
+ return [nodes.literal(rawtext, text, classes=['ansible-value'])], []
+
+
+# pylint:disable-next=unused-argument,dangerous-default-value
+def return_value_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ """Format Ansible option value.
+
+ Returns 2 part tuple containing list of nodes to insert into the
+ document and a list of system messages. Both are allowed to be
+ empty.
+
+ :param name: The role name used in the document.
+ :param rawtext: The entire markup snippet, with role.
+ :param text: The text marked with the role.
+ :param lineno: The line number where rawtext appears in the input.
+ :param inliner: The inliner instance that called us.
+ :param options: Directive options for customization.
+ :param content: The directive content for customization.
+ """
+ classes = ['ansible-return-value']
+ try:
+ plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value(
+ text.replace('\x00', ''), '', '', require_plugin=False)
+ except ValueError as exc:
+ return _create_error(rawtext, text, str(exc))
+ if value is None:
+ text = f'{rv}'
+ else:
+ text = f'{rv}={value}'
+ text, subnodes = _create_ref_or_not(
+ _create_return_value_reference, plugin_fqcn, plugin_type, rv_link, text)
+ return [nodes.literal(rawtext, text, *subnodes, classes=classes)], []
+
+
+ROLES = {
+ 'ansopt': option_role,
+ 'ansval': value_role,
+ 'ansretval': return_value_role,
+}
+
+
+def setup_roles(app):
+ '''
+ Setup roles for a Sphinx app object.
+ '''
+ for name, role in ROLES.items():
+ app.add_role(name, role)
diff --git a/stubs/jinja2/utils.pyi b/stubs/jinja2/utils.pyi
new file mode 100644
index 00000000..bd4c6700
--- /dev/null
+++ b/stubs/jinja2/utils.pyi
@@ -0,0 +1,11 @@
+# Copyright (c) Ansible Project
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import typing as t
+
+F = t.TypeVar("F", bound=t.Callable[..., t.Any])
+
+def pass_context(f: F) -> F: ...
+def pass_eval_context(f: F) -> F: ...
+def pass_environment(f: F) -> F: ...
diff --git a/tests/units/test_jinja2.py b/tests/units/test_jinja2.py
index f50a9a4f..810a413e 100644
--- a/tests/units/test_jinja2.py
+++ b/tests/units/test_jinja2.py
@@ -35,7 +35,11 @@
@pytest.mark.parametrize('text, expected', RST_IFY_DATA.items())
def test_rst_ify(text, expected):
- assert rst_ify(text) == expected
+ context = {
+ 'plugin_name': 'foo.bar.baz',
+ 'plugin_type': 'module',
+ }
+ assert rst_ify(context, text) == expected
RST_ESCAPE_DATA = {