Skip to content

Commit da86b23

Browse files
committed
docs(tools/build-docs): render plugin pages in the role-style "definition list" format
Plugin docs were a single Markdown table per section ("Parameter | Type | Required | Default | Choices | Description"). The role READMEs use a different layout that's much easier to read on a wide variable list: each variable becomes its own definition block with the name in backticks as a heading and one bullet per attribute. This commit aligns the auto-generated plugin pages with that role style. Renderer changes * Parameters are now split into "## Mandatory Parameters" and "## Optional Parameters" H2 sections, mirroring the role pattern. * Each parameter renders as `name` * Description (one paragraph, Ansible inline markup converted to Markdown). * Type: String. One of `a`, `b`, `c`. (choices fold into the type line) * Default: `value` Suboptions appear under a "* Subkeys:" bullet, indented exactly like in `roles/example/README.md`: * Subkeys: * `subname`: * Description. * Type: ... * Return values use the same definition-list shape, with multi-line YAML samples emitted as fenced code blocks rather than table cells. * Type labels are mapped to the role-style spelling: `str` -> `String`, `int` / `float` -> `Number`, `bool` -> `Bool`, `list` -> `List`, `dict` -> `Dictionary`, `path` -> `Path`, `raw` -> `Raw`, `json` / `jsonarg` -> `JSON`. Anything else falls back to a capitalised version of the original wire type. * Ansible's inline markup (`C(...)`, `I(...)`, `B(...)`, `M(...)`, `U(...)`, `L(label, url)`, `O(...)`, `V(...)`, `P(...)`, `R(...)`) is converted to Markdown across Synopsis, Notes, Requirements, Authors, and every option / suboption / return description. Same nav structure (Plugins > Modules / Lookup / Filter), no behaviour change for the role / playbook pages.
1 parent a333e16 commit da86b23

1 file changed

Lines changed: 183 additions & 65 deletions

File tree

tools/build-docs

Lines changed: 183 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Run this tool from the repository root directory.
2020

2121
import ast
2222
import os
23+
import re
2324
import sys
2425

2526
import yaml
@@ -130,76 +131,199 @@ def extract_plugin_docs(filepath):
130131
}
131132

132133

134+
_ANSIBLE_DOC_MARKUP = re.compile(r'([CIBLMOPRUV])\(([^)]+)\)')
135+
136+
137+
def _convert_ansible_markup(text):
138+
"""Convert Ansible's `C(foo)` / `I(foo)` / `B(foo)` / `M(module)` / `U(url)` / `L(text,url)`
139+
inline markup to Markdown. Stays close to Ansible's own rendering: code-span for
140+
C / O / V, italic for I, bold for B, plain text for L's display part.
141+
"""
142+
if not text:
143+
return text
144+
def _sub(match):
145+
kind, body = match.group(1), match.group(2)
146+
if kind in ('C', 'O', 'V'):
147+
return f'`{body}`'
148+
if kind == 'I':
149+
return f'*{body}*'
150+
if kind == 'B':
151+
return f'**{body}**'
152+
if kind == 'M':
153+
return f'`{body}`'
154+
if kind == 'U':
155+
return f'<{body}>'
156+
if kind == 'L':
157+
# L(label, url)
158+
if ',' in body:
159+
label, url = (s.strip() for s in body.split(',', 1))
160+
return f'[{label}]({url})'
161+
return body
162+
if kind in ('P', 'R'):
163+
return f'`{body}`'
164+
return match.group(0)
165+
return _ANSIBLE_DOC_MARKUP.sub(_sub, text)
166+
167+
133168
def _flatten_description(value):
134-
"""Description fields can be a string or a list of strings; return a single string."""
169+
"""Description fields can be a string or a list of strings; return a single string
170+
with Ansible inline markup converted to Markdown.
171+
"""
135172
if value is None:
136173
return ''
137174
if isinstance(value, list):
138-
return ' '.join(str(line).strip() for line in value if line is not None)
139-
return str(value).strip()
175+
joined = ' '.join(str(line).strip() for line in value if line is not None)
176+
else:
177+
joined = str(value).strip()
178+
return _convert_ansible_markup(joined)
179+
180+
181+
_TYPE_LABELS = {
182+
'str': 'String',
183+
'int': 'Number',
184+
'float': 'Number',
185+
'bool': 'Bool',
186+
'list': 'List',
187+
'dict': 'Dictionary',
188+
'path': 'Path',
189+
'raw': 'Raw',
190+
'json': 'JSON',
191+
'jsonarg': 'JSON',
192+
}
193+
194+
195+
def _pretty_type(otype):
196+
"""Map Ansible's wire-level type names to the labels the role-style READMEs use
197+
(`str` -> `String`, `int` -> `Number`, etc.).
198+
"""
199+
if isinstance(otype, list):
200+
return ' / '.join(_pretty_type(t) for t in otype)
201+
if not otype:
202+
return ''
203+
key = str(otype).lower()
204+
return _TYPE_LABELS.get(key, str(otype).capitalize())
205+
206+
207+
def _format_default(value):
208+
"""Render an option's default value the same way the role-style READMEs do."""
209+
if value is None:
210+
return None
211+
if isinstance(value, bool):
212+
return f'`{str(value).lower()}`'
213+
if isinstance(value, str) and value == '':
214+
return "`''`"
215+
return f'`{value}`'
140216

141217

142-
def _render_options_table(options, level=0):
143-
"""Render an options block as a Markdown table. Suboptions are folded into the
144-
description column as a nested bullet list to keep the table flat.
218+
def _format_type(opt):
219+
"""Render `Type: <type>[. One of <a>, <b>, <c>.]` for an option dict, using the
220+
role-style type labels (`String`, `Number`, `Bool`, `List`, `Dictionary`, ...).
221+
"""
222+
type_str = _pretty_type(opt.get('type', ''))
223+
choices = opt.get('choices') or []
224+
if choices:
225+
choices_str = ', '.join(f'`{c}`' for c in choices)
226+
return f'Type: {type_str}. One of {choices_str}.'
227+
return f'Type: {type_str}.'
228+
229+
230+
def _render_option_block(name, opt, depth=0):
231+
"""Render one option in the LFOps role-style definition format.
232+
233+
At depth=0 (top-level option), the variable name is a bare heading-like
234+
line and the bullets sit at column 0:
235+
236+
`name`
237+
238+
* Description.
239+
* Type: ...
240+
* Default: ...
241+
242+
At depth>=1 (suboption), the name becomes a bullet item indented under
243+
its parent's "Subkeys:" line and its own bullets indent one more level.
244+
The pattern matches `roles/example/README.md`.
245+
"""
246+
opt = opt or {}
247+
if depth == 0:
248+
heading = f'`{name}`'
249+
heading_indent = ''
250+
else:
251+
heading = f'* `{name}`:'
252+
heading_indent = ' ' * (2 * depth - 1)
253+
bullet_indent = ' ' * (2 * depth)
254+
255+
lines = [f'{heading_indent}{heading}', '']
256+
257+
description = _flatten_description(opt.get('description')) or '(no description)'
258+
lines.append(f'{bullet_indent}* {description}')
259+
lines.append(f'{bullet_indent}* {_format_type(opt)}')
260+
261+
default = _format_default(opt.get('default'))
262+
if default is not None:
263+
lines.append(f'{bullet_indent}* Default: {default}')
264+
265+
suboptions = opt.get('suboptions') or {}
266+
if suboptions:
267+
lines.append(f'{bullet_indent}* Subkeys:')
268+
lines.append('')
269+
for sub_name in sorted(suboptions):
270+
lines.extend(_render_option_block(sub_name, suboptions[sub_name], depth + 1))
271+
lines.append('')
272+
return lines
273+
274+
275+
def _render_options_section(options):
276+
"""Render an options block as role-style "Mandatory" / "Optional" sections.
277+
Returns a list of Markdown lines (with H2 headings already emitted).
145278
"""
146279
if not options:
147280
return []
281+
mandatory = [n for n in sorted(options) if (options[n] or {}).get('required')]
282+
optional = [n for n in sorted(options) if not (options[n] or {}).get('required')]
283+
148284
lines = []
149-
indent = ' ' * level
150-
if level == 0:
151-
lines.append('| Parameter | Type | Required | Default | Choices | Description |')
152-
lines.append('|---|---|---|---|---|---|')
153-
for opt_name in sorted(options):
154-
opt = options[opt_name] or {}
155-
otype = opt.get('type', '')
156-
if isinstance(otype, list):
157-
otype = ' / '.join(str(t) for t in otype)
158-
required = 'yes' if opt.get('required') else ''
159-
default = opt.get('default')
160-
if default is None:
161-
default_str = ''
162-
elif isinstance(default, bool):
163-
default_str = str(default).lower()
164-
else:
165-
default_str = f'`{default}`'
166-
choices = opt.get('choices') or []
167-
choices_str = ', '.join(f'`{c}`' for c in choices)
168-
description = _flatten_description(opt.get('description'))
169-
suboptions = opt.get('suboptions') or {}
170-
if suboptions:
171-
sub_bullets = []
172-
for sub_name in sorted(suboptions):
173-
sub = suboptions[sub_name] or {}
174-
sub_desc = _flatten_description(sub.get('description'))
175-
sub_type = sub.get('type', '')
176-
sub_req = ' (required)' if sub.get('required') else ''
177-
sub_bullets.append(
178-
f'<br>&nbsp;&nbsp;&bull; **{sub_name}** ({sub_type}){sub_req}: {sub_desc}'
179-
)
180-
description = description + ''.join(sub_bullets)
181-
lines.append(
182-
f'{indent}| **{opt_name}** | {otype} | {required} | '
183-
f'{default_str} | {choices_str} | {description} |'
184-
)
285+
if mandatory:
286+
lines.append('## Mandatory Parameters')
287+
lines.append('')
288+
for name in mandatory:
289+
lines.extend(_render_option_block(name, options[name]))
290+
if optional:
291+
lines.append('## Optional Parameters')
292+
lines.append('')
293+
for name in optional:
294+
lines.extend(_render_option_block(name, options[name]))
185295
return lines
186296

187297

188-
def _render_return_table(returns):
189-
"""Render the RETURN block as a Markdown table."""
298+
def _render_return_section(returns):
299+
"""Render the RETURN block as a role-style definition list."""
190300
if not returns:
191301
return []
192-
lines = []
193-
lines.append('| Key | Type | Returned | Description |')
194-
lines.append('|---|---|---|---|')
302+
lines = ['## Return Values', '']
195303
for ret_name in sorted(returns):
196304
ret = returns[ret_name] or {}
305+
lines.append(f'`{ret_name}`')
306+
lines.append('')
307+
description = _flatten_description(ret.get('description')) or '(no description)'
308+
lines.append(f'* {description}')
197309
rtype = ret.get('type', '')
198-
returned = ret.get('returned', '')
199-
description = _flatten_description(ret.get('description'))
200-
lines.append(
201-
f'| **{ret_name}** | {rtype} | {returned} | {description} |'
202-
)
310+
if rtype:
311+
lines.append(f'* Type: {_pretty_type(rtype)}.')
312+
returned = ret.get('returned')
313+
if returned:
314+
lines.append(f'* Returned: {returned}.')
315+
sample = ret.get('sample')
316+
if sample is not None:
317+
if isinstance(sample, (dict, list)):
318+
lines.append('* Sample:')
319+
lines.append('')
320+
lines.append(' ```yaml')
321+
for sline in yaml.safe_dump(sample, default_flow_style=False).rstrip().split('\n'):
322+
lines.append(f' {sline}')
323+
lines.append(' ```')
324+
else:
325+
lines.append(f'* Sample: `{sample}`')
326+
lines.append('')
203327
return lines
204328

205329

@@ -221,7 +345,7 @@ def render_plugin_md(kind, name, parsed):
221345
lines = [f'# `{title}`', '']
222346

223347
if short:
224-
lines.append(f'> {short}')
348+
lines.append(f'> {_convert_ansible_markup(short)}')
225349
lines.append('')
226350

227351
if deprecated:
@@ -243,7 +367,7 @@ def render_plugin_md(kind, name, parsed):
243367
if isinstance(description, str):
244368
description = [description]
245369
for paragraph in description:
246-
lines.append(f'* {str(paragraph).strip()}')
370+
lines.append(f'* {_convert_ansible_markup(str(paragraph).strip())}')
247371
lines.append('')
248372

249373
if version_added:
@@ -254,14 +378,11 @@ def render_plugin_md(kind, name, parsed):
254378
lines.append('## Requirements')
255379
lines.append('')
256380
for req in (requirements if isinstance(requirements, list) else [requirements]):
257-
lines.append(f'* {req}')
381+
lines.append(f'* {_convert_ansible_markup(str(req).strip())}')
258382
lines.append('')
259383

260384
if options:
261-
lines.append('## Parameters')
262-
lines.append('')
263-
lines.extend(_render_options_table(options))
264-
lines.append('')
385+
lines.extend(_render_options_section(options))
265386

266387
if parsed.get('examples'):
267388
lines.append('## Examples')
@@ -272,23 +393,20 @@ def render_plugin_md(kind, name, parsed):
272393
lines.append('')
273394

274395
if parsed.get('return'):
275-
lines.append('## Return Values')
276-
lines.append('')
277-
lines.extend(_render_return_table(parsed['return']))
278-
lines.append('')
396+
lines.extend(_render_return_section(parsed['return']))
279397

280398
if notes:
281399
lines.append('## Notes')
282400
lines.append('')
283401
for note in (notes if isinstance(notes, list) else [notes]):
284-
lines.append(f'* {str(note).strip()}')
402+
lines.append(f'* {_convert_ansible_markup(str(note).strip())}')
285403
lines.append('')
286404

287405
if authors:
288406
lines.append('## Authors')
289407
lines.append('')
290408
for author in (authors if isinstance(authors, list) else [authors]):
291-
lines.append(f'* {author}')
409+
lines.append(f'* {_convert_ansible_markup(str(author).strip())}')
292410
lines.append('')
293411

294412
return '\n'.join(lines).rstrip() + '\n'

0 commit comments

Comments
 (0)