Skip to content

Commit 12ff4e6

Browse files
committed
Add semantic markup support.
1 parent cf76b34 commit 12ff4e6

File tree

14 files changed

+475
-7
lines changed

14 files changed

+475
-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-docs/pull/4).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ ansible-pygments = "*"
3434
antsibull-core = ">= 1.0.0, < 2.0.0"
3535
asyncio-pool = "*"
3636
docutils = "*"
37-
jinja2 = "*"
37+
jinja2 = ">= 3.0"
3838
rstcheck = "^3"
3939
sphinx = "*"
4040

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,12 @@ See Also
286286
{% elif item.module is defined %}
287287
@{ ('M(' + item['module'] + ')') | rst_ify }@
288288
The official documentation on the **@{ item['module'] }@** module.
289+
{% elif item.plugin is defined and item.plugin_type is defined and item.description %}
290+
@{ ('P(' + item['plugin'] + '#' + item['plugin_type'] + ')') | rst_ify }@ @{ item['plugin_type'] }@ plugin
291+
@{ item['description'] | rst_ify }@
292+
{% elif item.plugin is defined and item.plugin_type is defined %}
293+
@{ ('P(' + item['plugin'] + '#' + item['plugin_type'] + ')') | rst_ify }@ @{ item['plugin_type'] }@ plugin
294+
The official documentation on the **@{ item['plugin'] }@** @{ item['plugin_type'] }@ plugin.
289295
{% elif item.name is defined and item.link is defined and item.description %}
290296
`@{ item['name'] }@ <@{ item['link'] }@>`_
291297
@{ item['description'] | rst_ify }@

src/antsibull_docs/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_docs/jinja2/filters.py

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
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 antsibull_core.logging import log
1618

19+
from ..semantic_helper import parse_option, parse_return_value, augment_plugin_name_type
20+
1721

1822
mlog = log.fields(mod=__name__)
1923

@@ -22,32 +26,152 @@
2226
_ITALIC = re.compile(r"\bI\(([^)]+)\)")
2327
_BOLD = re.compile(r"\bB\(([^)]+)\)")
2428
_MODULE = re.compile(r"\bM\(([^).]+)\.([^).]+)\.([^)]+)\)")
29+
_PLUGIN = re.compile(r"\bP\(([^).]+)\.([^).]+)\.([^)]+)#([a-z]+)\)")
2530
_URL = re.compile(r"\bU\(([^)]+)\)")
2631
_LINK = re.compile(r"\bL\(([^)]+), *([^)]+)\)")
2732
_REF = re.compile(r"\bR\(([^)]+), *([^)]+)\)")
2833
_CONST = re.compile(r"\bC\(([^)]+)\)")
34+
_SEM_PARAMETER_STRING = r"\(((?:[^\\)]+|\\.)+)\)"
35+
_SEM_OPTION_NAME = re.compile(r"\bO" + _SEM_PARAMETER_STRING)
36+
_SEM_OPTION_VALUE = re.compile(r"\bV" + _SEM_PARAMETER_STRING)
37+
_SEM_ENV_VARIABLE = re.compile(r"\bE" + _SEM_PARAMETER_STRING)
38+
_SEM_RET_VALUE = re.compile(r"\bRV" + _SEM_PARAMETER_STRING)
2939
_RULER = re.compile(r"\bHORIZONTALLINE\b")
40+
_UNESCAPE = re.compile(r"\\(.)")
3041

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

3344

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

37151
flog = mlog.fields(func='html_ify')
38152
flog.fields(text=text).debug('Enter')
39153
_counts = {}
40154

155+
plugin_fqcn, plugin_type = extract_plugin_data(context)
156+
41157
text = html_escape(text)
42158
text, _counts['italic'] = _ITALIC.subn(r"<em>\1</em>", text)
43159
text, _counts['bold'] = _BOLD.subn(r"<b>\1</b>", text)
44160
text, _counts['module'] = _MODULE.subn(
45161
r"<a href='../../\1/\2/\3_module.html' class='module'>\1.\2.\3</a>", text)
162+
text, _counts['plugin'] = _PLUGIN.subn(
163+
r"<a href='../../\1/\2/\3_\4.html' class='module plugin-\4'>\1.\2.\3</span>", text)
46164
text, _counts['url'] = _URL.subn(r"<a href='\1'>\1</a>", text)
47165
text, _counts['ref'] = _REF.subn(r"<span class='module'>\1</span>", text)
48166
text, _counts['link'] = _LINK.subn(r"<a href='\2'>\1</a>", text)
49167
text, _counts['const'] = _CONST.subn(
50168
r"<code class='docutils literal notranslate'>\1</code>", text)
169+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
170+
partial(_option_name_html, plugin_fqcn, plugin_type), text)
171+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_value_html, text)
172+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_env_var_html, text)
173+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
174+
partial(_return_value_html, plugin_fqcn, plugin_type), text)
51175
text, _counts['ruler'] = _RULER.subn(r"<hr/>", text)
52176

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

97221

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

120250

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

124281
flog = mlog.fields(func='rst_ify')
125282
flog.fields(text=text).debug('Enter')
126283
_counts = {}
127284

285+
plugin_fqcn, plugin_type = extract_plugin_data(context)
286+
128287
text, _counts['italic'] = _ITALIC.subn(_rst_ify_italic, text)
129288
text, _counts['bold'] = _BOLD.subn(_rst_ify_bold, text)
130289
text, _counts['module'] = _MODULE.subn(_rst_ify_module, text)
290+
text, _counts['plugin'] = _PLUGIN.subn(_rst_ify_plugin, text)
131291
text, _counts['link'] = _LINK.subn(_rst_ify_link, text)
132292
text, _counts['url'] = _URL.subn(_rst_ify_url, text)
133293
text, _counts['ref'] = _REF.subn(_rst_ify_ref, text)
134294
text, _counts['const'] = _CONST.subn(_rst_ify_const, text)
295+
text, _counts['option-name'] = _SEM_OPTION_NAME.subn(
296+
partial(_rst_ify_option_name, plugin_fqcn, plugin_type), text)
297+
text, _counts['option-value'] = _SEM_OPTION_VALUE.subn(_rst_ify_value, text)
298+
text, _counts['environment-var'] = _SEM_ENV_VARIABLE.subn(_rst_ify_envvar, text)
299+
text, _counts['return-value'] = _SEM_RET_VALUE.subn(
300+
partial(_rst_ify_return_value, plugin_fqcn, plugin_type), text)
135301
text, _counts['ruler'] = _RULER.subn('\n\n.. raw:: html\n\n <hr>\n\n', text)
136302

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

src/antsibull_docs/lint_extra_docs.py

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

16+
from docutils.parsers.rst import roles as docutils_roles
17+
1618
from antsibull_core.yaml import load_yaml_file
1719

20+
from sphinx_antsibull_ext import roles as antsibull_roles
21+
1822
from .extra_docs import (
1923
find_extra_docs,
2024
lint_required_conditions,
@@ -59,6 +63,12 @@ def lint_optional_conditions(content: str, path: str, collection_name: str
5963
return [(result[0], 0, result[1]) for result in results]
6064

6165

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

src/antsibull_docs/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)