Skip to content

Commit 2c8579c

Browse files
committed
Add semantic markup support.
1 parent b6a1c76 commit 2c8579c

File tree

14 files changed

+474
-7
lines changed

14 files changed

+474
-7
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
major_changes:
2+
- Support new semantic markup in documentation (https://github.com/ansible-community/antsibull/pull/281).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ antsibull-changelog = ">= 0.14.0"
4040
asyncio-pool = "*"
4141
docutils = "*"
4242
importlib-metadata = {version = "*", python = "<3.8"}
43-
jinja2 = "*"
43+
jinja2 = ">= 3.0"
4444
# major/minor was introduced here
4545
packaging = ">= 20.0"
4646
perky = "*"

src/antsibull/data/docsite/plugin.rst.j2

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ See Also
213213
{% elif item.module is defined %}
214214
@{ ('M(' + item['module'] + ')') | rst_ify }@
215215
The official documentation on the **@{ item['module'] }@** module.
216+
{% elif item.plugin is defined and item.plugin_type is defined and item.description %}
217+
@{ ('P(' + item['plugin'] + '#' + item['plugin_type'] + ')') | rst_ify }@ @{ item['plugin_type'] }@ plugin
218+
@{ item['description'] | rst_ify }@
219+
{% elif item.plugin is defined and item.plugin_type is defined %}
220+
@{ ('P(' + item['plugin'] + '#' + item['plugin_type'] + ')') | rst_ify }@ @{ item['plugin_type'] }@ plugin
221+
The official documentation on the **@{ item['plugin'] }@** @{ item['plugin_type'] }@ plugin.
216222
{% elif item.name is defined and item.link is defined and item.description %}
217223
`@{ item['name'] }@ <@{ item['link'] }@>`_
218224
@{ item['description'] | rst_ify }@

src/antsibull/data/docsite/role.rst.j2

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ See Also
161161
{% elif item.module is defined %}
162162
@{ ('M(' + item['module'] + ')') | rst_ify }@
163163
The official documentation on the **@{ item['module'] }@** module.
164+
{% elif item.plugin is defined and item.plugin_type is defined and item.description %}
165+
@{ ('P(' + item['plugin'] + '#' + item['plugin_type'] + ')') | rst_ify }@ @{ item['plugin_type'] }@ plugin
166+
@{ item['description'] | rst_ify }@
167+
{% elif item.plugin is defined and item.plugin_type is defined %}
168+
@{ ('P(' + item['plugin'] + '#' + item['plugin_type'] + ')') | rst_ify }@ @{ item['plugin_type'] }@ plugin
169+
The official documentation on the **@{ item['plugin'] }@** @{ item['plugin_type'] }@ plugin.
164170
{% elif item.name is defined and item.link is defined and item.description %}
165171
`@{ item['name'] }@ <@{ item['link'] }@>`_
166172
@{ item['description'] | rst_ify }@

src/antsibull/jinja2/filters.py

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
"""
66

77
import re
8+
from functools import partial
89
from html import escape as html_escape
910
from urllib.parse import quote
1011

1112
import typing as t
1213

13-
from jinja2.runtime import Undefined
14+
from jinja2.runtime import Context, Undefined
15+
from jinja2.utils import pass_context
1416

1517
from ..logging import log
18+
from ..semantic_helper import parse_option, parse_return_value, augment_plugin_name_type
1619

1720

1821
mlog = log.fields(mod=__name__)
@@ -22,32 +25,152 @@
2225
_ITALIC = re.compile(r"\bI\(([^)]+)\)")
2326
_BOLD = re.compile(r"\bB\(([^)]+)\)")
2427
_MODULE = re.compile(r"\bM\(([^).]+)\.([^).]+)\.([^)]+)\)")
28+
_PLUGIN = re.compile(r"\bP\(([^).]+)\.([^).]+)\.([^)]+)#([a-z]+)\)")
2529
_URL = re.compile(r"\bU\(([^)]+)\)")
2630
_LINK = re.compile(r"\bL\(([^)]+), *([^)]+)\)")
2731
_REF = re.compile(r"\bR\(([^)]+), *([^)]+)\)")
2832
_CONST = re.compile(r"\bC\(([^)]+)\)")
33+
_SEM_PARAMETER_STRING = r"\(((?:[^\\)]+|\\.)+)\)"
34+
_SEM_OPTION_NAME = re.compile(r"\bO" + _SEM_PARAMETER_STRING)
35+
_SEM_OPTION_VALUE = re.compile(r"\bV" + _SEM_PARAMETER_STRING)
36+
_SEM_ENV_VARIABLE = re.compile(r"\bE" + _SEM_PARAMETER_STRING)
37+
_SEM_RET_VALUE = re.compile(r"\bRV" + _SEM_PARAMETER_STRING)
2938
_RULER = re.compile(r"\bHORIZONTALLINE\b")
39+
_UNESCAPE = re.compile(r"\\(.)")
3040

3141
_EMAIL_ADDRESS = re.compile(r"(?:<{mail}>|\({mail}\)|{mail})".format(mail=r"[\w.+-]+@[\w.-]+\.\w+"))
3242

3343

34-
def html_ify(text):
44+
def extract_plugin_data(context: Context) -> t.Tuple[t.Optional[str], t.Optional[str]]:
45+
plugin_fqcn = context.get('plugin_name')
46+
plugin_type = context.get('plugin_type')
47+
if plugin_fqcn is None or plugin_type is None:
48+
return None, None
49+
# if plugin_type == 'role':
50+
# entry_point = context.get('entry_point', 'main')
51+
# # FIXME: use entry_point
52+
return plugin_fqcn, plugin_type
53+
54+
55+
def _unescape_sem_value(text: str) -> str:
56+
return _UNESCAPE.sub(r'\1', text)
57+
58+
59+
def _check_plugin(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
60+
matcher: 're.Match') -> None:
61+
if plugin_fqcn is None or plugin_type is None:
62+
raise Exception(f'The markup {matcher.group(0)} cannot be used outside a plugin or role')
63+
64+
65+
def _create_error(text: str, error: str) -> str: # pylint:disable=unused-argument
66+
return '...' # FIXME
67+
68+
69+
def _option_name_html(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
70+
matcher: 're.Match') -> str:
71+
_check_plugin(plugin_fqcn, plugin_type, matcher)
72+
text = _unescape_sem_value(matcher.group(1))
73+
try:
74+
plugin_fqcn, plugin_type, option_link, option, value = parse_option(
75+
text, plugin_fqcn, plugin_type, require_plugin=False)
76+
except ValueError as exc:
77+
return _create_error(text, str(exc))
78+
if value is None:
79+
cls = 'ansible-option'
80+
text = f'{option}'
81+
strong_start = '<strong>'
82+
strong_end = '</strong>'
83+
else:
84+
cls = 'ansible-option-value'
85+
text = f'{option}={value}'
86+
strong_start = ''
87+
strong_end = ''
88+
if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2:
89+
# TODO: handle role arguments (entrypoint!)
90+
namespace, name, plugin = plugin_fqcn.split('.', 2)
91+
url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html'
92+
fragment = f'parameter-{option_link.replace(".", "/")}'
93+
link_start = (
94+
f'<a class="reference internal" href="{url}#{fragment}">'
95+
'<span class="std std-ref"><span class="pre">'
96+
)
97+
link_end = '</span></span></a>'
98+
else:
99+
link_start = ''
100+
link_end = ''
101+
return (
102+
f'<code class="{cls} literal notranslate">'
103+
f'{strong_start}{link_start}{text}{link_end}{strong_end}</code>'
104+
)
105+
106+
107+
def _return_value_html(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
108+
matcher: 're.Match') -> str:
109+
_check_plugin(plugin_fqcn, plugin_type, matcher)
110+
text = _unescape_sem_value(matcher.group(1))
111+
try:
112+
plugin_fqcn, plugin_type, rv_link, rv, value = parse_return_value(
113+
text, plugin_fqcn, plugin_type, require_plugin=False)
114+
except ValueError as exc:
115+
return _create_error(text, str(exc))
116+
cls = 'ansible-return-value'
117+
if value is None:
118+
text = f'{rv}'
119+
else:
120+
text = f'{rv}={value}'
121+
if plugin_fqcn and plugin_type and plugin_fqcn.count('.') >= 2:
122+
namespace, name, plugin = plugin_fqcn.split('.', 2)
123+
url = f'../../{namespace}/{name}/{plugin}_{plugin_type}.html'
124+
fragment = f'return-{rv_link.replace(".", "/")}'
125+
link_start = (
126+
f'<a class="reference internal" href="{url}#{fragment}">'
127+
'<span class="std std-ref"><span class="pre">'
128+
)
129+
link_end = '</span></span></a>'
130+
else:
131+
link_start = ''
132+
link_end = ''
133+
return f'<code class="{cls} literal notranslate">{link_start}{text}{link_end}</code>'
134+
135+
136+
def _value_html(matcher: 're.Match') -> str:
137+
text = _unescape_sem_value(matcher.group(1))
138+
return f'<code class="ansible-value literal notranslate">{text}</code>'
139+
140+
141+
def _env_var_html(matcher: 're.Match') -> str:
142+
text = _unescape_sem_value(matcher.group(1))
143+
return f'<code class="xref std std-envvar literal notranslate">{text}</code>'
144+
145+
146+
@pass_context
147+
def html_ify(context: Context, text: str) -> str:
35148
''' convert symbols like I(this is in italics) to valid HTML '''
36149

37150
flog = mlog.fields(func='html_ify')
38151
flog.fields(text=text).debug('Enter')
39152
_counts = {}
40153

154+
plugin_fqcn, plugin_type = extract_plugin_data(context)
155+
41156
text = html_escape(text)
42157
text, _counts['italic'] = _ITALIC.subn(r"<em>\1</em>", text)
43158
text, _counts['bold'] = _BOLD.subn(r"<b>\1</b>", text)
44159
text, _counts['module'] = _MODULE.subn(
45160
r"<a href='../../\1/\2/\3_module.html' class='module'>\1.\2.\3</a>", text)
161+
text, _counts['plugin'] = _PLUGIN.subn(
162+
r"<a href='../../\1/\2/\3_\4.html' class='module plugin-\4'>\1.\2.\3</span>", text)
46163
text, _counts['url'] = _URL.subn(r"<a href='\1'>\1</a>", text)
47164
text, _counts['ref'] = _REF.subn(r"<span class='module'>\1</span>", text)
48165
text, _counts['link'] = _LINK.subn(r"<a href='\2'>\1</a>", text)
49166
text, _counts['const'] = _CONST.subn(
50167
r"<code class='docutils literal notranslate'>\1</code>", text)
168+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
169+
partial(_option_name_html, plugin_fqcn, plugin_type), text)
170+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_value_html, text)
171+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_env_var_html, text)
172+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
173+
partial(_return_value_html, plugin_fqcn, plugin_type), text)
51174
text, _counts['ruler'] = _RULER.subn(r"<hr/>", text)
52175

53176
text = text.strip()
@@ -95,6 +218,12 @@ def _rst_ify_module(m: 're.Match') -> str:
95218
return f"\\ :ref:`{rst_escape(fqcn)} <ansible_collections.{fqcn}_module>`\\ "
96219

97220

221+
def _rst_ify_plugin(m: 're.Match') -> str:
222+
fqcn = f'{m.group(1)}.{m.group(2)}.{m.group(3)}'
223+
plugin_type = m.group(4)
224+
return f"\\ :ref:`{rst_escape(fqcn)} <ansible_collections.{fqcn}_{plugin_type}>`\\ "
225+
226+
98227
def _escape_url(url: str) -> str:
99228
# We include '<>[]{}' in safe to allow urls such as 'https://<HOST>:[PORT]/v{version}/' to
100229
# remain unmangled by percent encoding
@@ -118,20 +247,56 @@ def _rst_ify_const(m: 're.Match') -> str:
118247
return f"\\ :literal:`{rst_escape(m.group(1), escape_ending_whitespace=True)}`\\ "
119248

120249

121-
def rst_ify(text):
250+
def _rst_ify_option_name(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
251+
m: 're.Match') -> str:
252+
_check_plugin(plugin_fqcn, plugin_type, m)
253+
text = _unescape_sem_value(m.group(1))
254+
text = augment_plugin_name_type(text, plugin_fqcn, plugin_type)
255+
return f"\\ :ansopt:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
256+
257+
258+
def _rst_ify_value(m: 're.Match') -> str:
259+
text = _unescape_sem_value(m.group(1))
260+
return f"\\ :ansval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
261+
262+
263+
def _rst_ify_return_value(plugin_fqcn: t.Optional[str], plugin_type: t.Optional[str],
264+
m: 're.Match') -> str:
265+
_check_plugin(plugin_fqcn, plugin_type, m)
266+
text = _unescape_sem_value(m.group(1))
267+
text = augment_plugin_name_type(text, plugin_fqcn, plugin_type)
268+
return f"\\ :ansretval:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
269+
270+
271+
def _rst_ify_envvar(m: 're.Match') -> str:
272+
text = _unescape_sem_value(m.group(1))
273+
return f"\\ :envvar:`{rst_escape(text, escape_ending_whitespace=True)}`\\ "
274+
275+
276+
@pass_context
277+
def rst_ify(context: Context, text: str) -> str:
122278
''' convert symbols like I(this is in italics) to valid restructured text '''
123279

124280
flog = mlog.fields(func='rst_ify')
125281
flog.fields(text=text).debug('Enter')
126282
_counts = {}
127283

284+
plugin_fqcn, plugin_type = extract_plugin_data(context)
285+
128286
text, _counts['italic'] = _ITALIC.subn(_rst_ify_italic, text)
129287
text, _counts['bold'] = _BOLD.subn(_rst_ify_bold, text)
130288
text, _counts['module'] = _MODULE.subn(_rst_ify_module, text)
289+
text, _counts['plugin'] = _PLUGIN.subn(_rst_ify_plugin, text)
131290
text, _counts['link'] = _LINK.subn(_rst_ify_link, text)
132291
text, _counts['url'] = _URL.subn(_rst_ify_url, text)
133292
text, _counts['ref'] = _REF.subn(_rst_ify_ref, text)
134293
text, _counts['const'] = _CONST.subn(_rst_ify_const, text)
294+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
295+
partial(_rst_ify_option_name, plugin_fqcn, plugin_type), text)
296+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_rst_ify_value, text)
297+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_rst_ify_envvar, text)
298+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
299+
partial(_rst_ify_return_value, plugin_fqcn, plugin_type), text)
135300
text, _counts['ruler'] = _RULER.subn('\n\n.. raw:: html\n\n <hr>\n\n', text)
136301

137302
flog.fields(counts=_counts).info('Number of macros converted to rst equivalents')

src/antsibull/lint_extra_docs.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import docutils.utils
1414
import rstcheck
1515

16+
from docutils.parsers.rst import roles as docutils_roles
17+
18+
from sphinx_antsibull_ext import roles as antsibull_roles
19+
1620
from .extra_docs import (
1721
find_extra_docs,
1822
lint_required_conditions,
@@ -58,6 +62,12 @@ def lint_optional_conditions(content: str, path: str, collection_name: str
5862
return [(result[0], 0, result[1]) for result in results]
5963

6064

65+
def _setup_rstcheck():
66+
'''Make sure that rstcheck knows about our roles.'''
67+
for name, role in antsibull_roles.ROLES.items():
68+
docutils_roles.register_local_role(name, role)
69+
70+
6171
def lint_collection_extra_docs_files(path_to_collection: str
6272
) -> t.List[t.Tuple[str, int, int, str]]:
6373
try:
@@ -69,6 +79,7 @@ def lint_collection_extra_docs_files(path_to_collection: str
6979
result = []
7080
all_labels = set()
7181
docs = find_extra_docs(path_to_collection)
82+
_setup_rstcheck()
7283
for doc in docs:
7384
try:
7485
# Load content

src/antsibull/schemas/docs/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ class SeeAlsoModSchema(BaseModel):
440440
description: str = ""
441441

442442

443+
class SeeAlsoPluginSchema(BaseModel):
444+
plugin: str
445+
plugin_type: str
446+
description: str = ""
447+
448+
443449
class SeeAlsoRefSchema(BaseModel):
444450
description: str
445451
ref: str
@@ -505,7 +511,10 @@ class DocSchema(BaseModel):
505511
filename: str = ''
506512
notes: t.List[str] = []
507513
requirements: t.List[str] = []
508-
seealso: t.List[t.Union[SeeAlsoModSchema, SeeAlsoRefSchema, SeeAlsoLinkSchema]] = []
514+
seealso: t.List[t.Union[SeeAlsoModSchema,
515+
SeeAlsoPluginSchema,
516+
SeeAlsoRefSchema,
517+
SeeAlsoLinkSchema]] = []
509518
todo: t.List[str] = []
510519
version_added: str = 'historical'
511520
version_added_collection: str = COLLECTION_NAME_F

0 commit comments

Comments
 (0)