rolfedh-doc-utils 0.1.21__tar.gz → 0.1.23__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {rolfedh_doc_utils-0.1.21/rolfedh_doc_utils.egg-info → rolfedh_doc_utils-0.1.23}/PKG-INFO +2 -1
  2. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/README.md +1 -0
  3. rolfedh_doc_utils-0.1.23/convert_callouts_to_deflist.py +671 -0
  4. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils_cli.py +5 -0
  5. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/pyproject.toml +3 -2
  6. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23/rolfedh_doc_utils.egg-info}/PKG-INFO +2 -1
  7. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/rolfedh_doc_utils.egg-info/SOURCES.txt +1 -0
  8. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/rolfedh_doc_utils.egg-info/entry_points.txt +1 -0
  9. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/rolfedh_doc_utils.egg-info/top_level.txt +1 -0
  10. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/LICENSE +0 -0
  11. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/archive_unused_files.py +0 -0
  12. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/archive_unused_images.py +0 -0
  13. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/check_scannability.py +0 -0
  14. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/__init__.py +0 -0
  15. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/extract_link_attributes.py +0 -0
  16. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/file_utils.py +0 -0
  17. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/format_asciidoc_spacing.py +0 -0
  18. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/replace_link_attributes.py +0 -0
  19. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/scannability.py +0 -0
  20. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/spinner.py +0 -0
  21. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/topic_map_parser.py +0 -0
  22. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/unused_adoc.py +0 -0
  23. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/unused_attributes.py +0 -0
  24. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/unused_images.py +0 -0
  25. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/validate_links.py +0 -0
  26. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/version.py +0 -0
  27. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/doc_utils/version_check.py +0 -0
  28. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/extract_link_attributes.py +0 -0
  29. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/find_unused_attributes.py +0 -0
  30. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/format_asciidoc_spacing.py +0 -0
  31. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/replace_link_attributes.py +0 -0
  32. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/rolfedh_doc_utils.egg-info/dependency_links.txt +0 -0
  33. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/rolfedh_doc_utils.egg-info/requires.txt +0 -0
  34. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/setup.cfg +0 -0
  35. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/setup.py +0 -0
  36. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_archive_unused_files.py +0 -0
  37. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_archive_unused_images.py +0 -0
  38. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_auto_discovery.py +0 -0
  39. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_check_scannability.py +0 -0
  40. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_cli_entry_points.py +0 -0
  41. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_extract_link_attributes.py +0 -0
  42. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_file_utils.py +0 -0
  43. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_fixture_archive_unused_files.py +0 -0
  44. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_fixture_archive_unused_images.py +0 -0
  45. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_fixture_check_scannability.py +0 -0
  46. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_parse_exclude_list.py +0 -0
  47. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_replace_link_attributes.py +0 -0
  48. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_symlink_handling.py +0 -0
  49. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_topic_map_parser.py +0 -0
  50. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_unused_attributes.py +0 -0
  51. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_validate_links.py +0 -0
  52. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/tests/test_version_check.py +0 -0
  53. {rolfedh_doc_utils-0.1.21 → rolfedh_doc_utils-0.1.23}/validate_links.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rolfedh-doc-utils
3
- Version: 0.1.21
3
+ Version: 0.1.23
4
4
  Summary: CLI tools for AsciiDoc documentation projects
5
5
  Author: Rolfe Dlugy-Hegwer
6
6
  License: MIT License
@@ -102,6 +102,7 @@ doc-utils --version # Show version
102
102
  | **`archive-unused-files`** | Finds and archives unreferenced .adoc files | `archive-unused-files` (preview)<br>`archive-unused-files --archive` (execute) |
103
103
  | **`archive-unused-images`** | Finds and archives unreferenced images | `archive-unused-images` (preview)<br>`archive-unused-images --archive` (execute) |
104
104
  | **`find-unused-attributes`** | Identifies unused attribute definitions | `find-unused-attributes attributes.adoc` |
105
+ | **`convert-callouts-to-deflist`** | Converts callout-style annotations to definition list format | `convert-callouts-to-deflist --dry-run modules/` |
105
106
 
106
107
  ## 📖 Documentation
107
108
 
@@ -69,6 +69,7 @@ doc-utils --version # Show version
69
69
  | **`archive-unused-files`** | Finds and archives unreferenced .adoc files | `archive-unused-files` (preview)<br>`archive-unused-files --archive` (execute) |
70
70
  | **`archive-unused-images`** | Finds and archives unreferenced images | `archive-unused-images` (preview)<br>`archive-unused-images --archive` (execute) |
71
71
  | **`find-unused-attributes`** | Identifies unused attribute definitions | `find-unused-attributes attributes.adoc` |
72
+ | **`convert-callouts-to-deflist`** | Converts callout-style annotations to definition list format | `convert-callouts-to-deflist --dry-run modules/` |
72
73
 
73
74
  ## 📖 Documentation
74
75
 
@@ -0,0 +1,671 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ convert-callouts-to-deflist - Convert AsciiDoc callouts to definition list format
4
+
5
+ Converts code blocks with callout-style annotations (<1>, <2>, etc.) to cleaner
6
+ definition list format with "where:" prefix.
7
+
8
+ This tool automatically scans all .adoc files in the current directory (recursively)
9
+ by default, or you can specify a specific file or directory.
10
+ """
11
+
12
+ import re
13
+ import sys
14
+ import argparse
15
+ from pathlib import Path
16
+ from typing import List, Dict, Tuple, Optional
17
+ from dataclasses import dataclass
18
+
19
+
20
+ # Colors for output
21
+ class Colors:
22
+ RED = '\033[0;31m'
23
+ GREEN = '\033[0;32m'
24
+ YELLOW = '\033[1;33m'
25
+ NC = '\033[0m' # No Color
26
+
27
+
28
+ def print_colored(message: str, color: str = Colors.NC) -> None:
29
+ """Print message with color"""
30
+ print(f"{color}{message}{Colors.NC}")
31
+
32
+
33
+ @dataclass
34
+ class Callout:
35
+ """Represents a callout with its number and explanation text."""
36
+ number: int
37
+ lines: List[str] # List of lines to preserve formatting
38
+ is_optional: bool = False
39
+
40
+
41
+ @dataclass
42
+ class CalloutGroup:
43
+ """Represents one or more callouts that share the same code line."""
44
+ code_line: str # The actual code line (without callouts)
45
+ callout_numbers: List[int] # List of callout numbers on this line
46
+
47
+
48
+ @dataclass
49
+ class CodeBlock:
50
+ """Represents a code block with its content and metadata."""
51
+ start_line: int
52
+ end_line: int
53
+ delimiter: str
54
+ content: List[str]
55
+ language: Optional[str] = None
56
+
57
+
58
+ class CalloutConverter:
59
+ """Converts callout-style documentation to definition list format."""
60
+
61
+ # Pattern for code block start: [source,language] or [source] with optional attributes
62
+ # Matches: [source], [source,java], [source,java,subs="..."], [source,java,options="..."], etc.
63
+ CODE_BLOCK_START = re.compile(r'^\[source(?:,\s*(\w+))?(?:[,\s]+[^\]]+)?\]')
64
+
65
+ # Pattern for callout number in code block (can appear multiple times per line)
66
+ CALLOUT_IN_CODE = re.compile(r'<(\d+)>')
67
+
68
+ # Pattern for callout explanation line: <1> Explanation text
69
+ CALLOUT_EXPLANATION = re.compile(r'^<(\d+)>\s+(.+)$')
70
+
71
+ # Pattern to detect user-replaceable values in angle brackets
72
+ # Excludes heredoc syntax (<<) and comparison operators
73
+ USER_VALUE_PATTERN = re.compile(r'(?<!<)<([a-zA-Z][^>]*)>')
74
+
75
+ def __init__(self, dry_run: bool = False, verbose: bool = False):
76
+ self.dry_run = dry_run
77
+ self.verbose = verbose
78
+ self.changes_made = 0
79
+ self.warnings = [] # Collect warnings for summary
80
+
81
+ def log(self, message: str):
82
+ """Print message if verbose mode is enabled."""
83
+ if self.verbose:
84
+ print(f"[INFO] {message}")
85
+
86
+ def find_code_blocks(self, lines: List[str]) -> List[CodeBlock]:
87
+ """Find all code blocks in the document."""
88
+ blocks = []
89
+ i = 0
90
+
91
+ while i < len(lines):
92
+ # Check for [source] prefix first
93
+ match = self.CODE_BLOCK_START.match(lines[i])
94
+ if match:
95
+ language = match.group(1)
96
+ start = i
97
+ i += 1
98
+
99
+ # Find the delimiter line (---- or ....)
100
+ if i < len(lines) and lines[i].strip() in ['----', '....']:
101
+ delimiter = lines[i].strip()
102
+ i += 1
103
+ content_start = i
104
+
105
+ # Find the closing delimiter
106
+ while i < len(lines):
107
+ if lines[i].strip() == delimiter:
108
+ content = lines[content_start:i]
109
+ blocks.append(CodeBlock(
110
+ start_line=start,
111
+ end_line=i,
112
+ delimiter=delimiter,
113
+ content=content,
114
+ language=language
115
+ ))
116
+ break
117
+ i += 1
118
+ # Check for plain delimited blocks without [source] prefix
119
+ elif lines[i].strip() in ['----', '....']:
120
+ delimiter = lines[i].strip()
121
+ start = i
122
+ i += 1
123
+ content_start = i
124
+
125
+ # Find the closing delimiter
126
+ while i < len(lines):
127
+ if lines[i].strip() == delimiter:
128
+ content = lines[content_start:i]
129
+ # Only add if block contains callouts
130
+ if any(self.CALLOUT_IN_CODE.search(line) for line in content):
131
+ blocks.append(CodeBlock(
132
+ start_line=start,
133
+ end_line=i,
134
+ delimiter=delimiter,
135
+ content=content,
136
+ language=None
137
+ ))
138
+ break
139
+ i += 1
140
+ i += 1
141
+
142
+ return blocks
143
+
144
+ def extract_callouts_from_code(self, content: List[str]) -> List[CalloutGroup]:
145
+ """
146
+ Extract callout numbers from code block content.
147
+ Returns list of CalloutGroups, where each group contains:
148
+ - The code line (with user-replaceable value if found, or full line)
149
+ - List of callout numbers on that line
150
+
151
+ Multiple callouts on the same line are grouped together to be merged
152
+ in the definition list.
153
+ """
154
+ groups = []
155
+
156
+ for line in content:
157
+ # Look for all callout numbers on this line
158
+ callout_matches = list(self.CALLOUT_IN_CODE.finditer(line))
159
+ if callout_matches:
160
+ # Remove all callouts from the line to get the actual code
161
+ line_without_callouts = self.CALLOUT_IN_CODE.sub('', line).strip()
162
+
163
+ # Find all angle-bracket enclosed values
164
+ user_values = self.USER_VALUE_PATTERN.findall(line_without_callouts)
165
+
166
+ # Determine what to use as the code line term
167
+ if user_values:
168
+ # Use the rightmost (closest to the callout) user value
169
+ code_line = user_values[-1]
170
+ else:
171
+ # No angle-bracket value found - use the actual code line
172
+ code_line = line_without_callouts
173
+
174
+ # Collect all callout numbers on this line
175
+ callout_nums = [int(m.group(1)) for m in callout_matches]
176
+
177
+ groups.append(CalloutGroup(
178
+ code_line=code_line,
179
+ callout_numbers=callout_nums
180
+ ))
181
+
182
+ return groups
183
+
184
+ def extract_callout_explanations(self, lines: List[str], start_line: int) -> Tuple[Dict[int, Callout], int]:
185
+ """
186
+ Extract callout explanations following a code block.
187
+ Returns dict of callouts and the line number where explanations end.
188
+ """
189
+ explanations = {}
190
+ i = start_line + 1 # Start after the closing delimiter
191
+
192
+ # Skip blank lines and continuation markers (+)
193
+ while i < len(lines) and (not lines[i].strip() or lines[i].strip() == '+'):
194
+ i += 1
195
+
196
+ # Collect consecutive callout explanation lines
197
+ while i < len(lines):
198
+ match = self.CALLOUT_EXPLANATION.match(lines[i])
199
+ if match:
200
+ num = int(match.group(1))
201
+ first_line = match.group(2).strip()
202
+ explanation_lines = [first_line]
203
+ i += 1
204
+
205
+ # Collect continuation lines (lines that don't start with a new callout)
206
+ # Continue until we hit a blank line, a new callout, or certain patterns
207
+ while i < len(lines):
208
+ line = lines[i]
209
+ # Stop if we hit a blank line, new callout, or list start marker
210
+ if not line.strip() or self.CALLOUT_EXPLANATION.match(line) or line.startswith('[start='):
211
+ break
212
+ # Add continuation line preserving original formatting
213
+ explanation_lines.append(line)
214
+ i += 1
215
+
216
+ # Check if marked as optional (only in first line)
217
+ is_optional = False
218
+ if first_line.lower().startswith('optional.') or first_line.lower().startswith('optional:'):
219
+ is_optional = True
220
+ # Remove "Optional." or "Optional:" from first line
221
+ explanation_lines[0] = first_line[9:].strip()
222
+ elif '(Optional)' in first_line or '(optional)' in first_line:
223
+ is_optional = True
224
+ explanation_lines[0] = re.sub(r'\s*\(optional\)\s*', ' ', first_line, flags=re.IGNORECASE).strip()
225
+
226
+ explanations[num] = Callout(num, explanation_lines, is_optional)
227
+ else:
228
+ break
229
+
230
+ return explanations, i - 1
231
+
232
+ def validate_callouts(self, callout_groups: List[CalloutGroup], explanations: Dict[int, Callout],
233
+ input_file: Path = None, block_start: int = None, block_end: int = None) -> bool:
234
+ """
235
+ Validate that callout numbers in code match explanation numbers.
236
+ Returns True if valid, False otherwise.
237
+ """
238
+ # Extract all callout numbers from groups
239
+ code_nums = set()
240
+ for group in callout_groups:
241
+ code_nums.update(group.callout_numbers)
242
+
243
+ explanation_nums = set(explanations.keys())
244
+
245
+ if code_nums != explanation_nums:
246
+ # Format warning message with file and line numbers
247
+ if input_file and block_start is not None and block_end is not None:
248
+ # Line numbers are 1-indexed for display
249
+ line_range = f"{block_start + 1}-{block_end + 1}"
250
+ warning_msg = (
251
+ f"WARNING: {input_file.name} lines {line_range}: Callout mismatch: "
252
+ f"code has {sorted(code_nums)}, explanations have {sorted(explanation_nums)}"
253
+ )
254
+ print_colored(warning_msg, Colors.YELLOW)
255
+ # Store warning for summary
256
+ self.warnings.append(warning_msg)
257
+ else:
258
+ self.log(f"Callout mismatch: code has {code_nums}, explanations have {explanation_nums}")
259
+ return False
260
+
261
+ return True
262
+
263
+ def remove_callouts_from_code(self, content: List[str]) -> List[str]:
264
+ """Remove callout numbers from code block content (handles multiple callouts per line)."""
265
+ cleaned = []
266
+ for line in content:
267
+ # Remove all callout numbers and trailing whitespace
268
+ cleaned.append(self.CALLOUT_IN_CODE.sub('', line).rstrip())
269
+ return cleaned
270
+
271
+ def create_definition_list(self, callout_groups: List[CalloutGroup], explanations: Dict[int, Callout]) -> List[str]:
272
+ """
273
+ Create definition list from callout groups and explanations.
274
+
275
+ For callouts with user-replaceable values in angle brackets, uses those.
276
+ For callouts without values, uses the actual code line as the term.
277
+
278
+ When multiple callouts share the same code line (same group), their
279
+ explanations are merged using AsciiDoc list continuation (+).
280
+ """
281
+ lines = ['\nwhere:']
282
+
283
+ # Process each group (which may contain one or more callouts)
284
+ for group in callout_groups:
285
+ code_line = group.code_line
286
+ callout_nums = group.callout_numbers
287
+
288
+ # Check if this is a user-replaceable value (contains angle brackets but not heredoc)
289
+ # User values are single words/phrases in angle brackets like <my-value>
290
+ user_values = self.USER_VALUE_PATTERN.findall(code_line)
291
+
292
+ if user_values and len(user_values) == 1 and len(code_line) < 100:
293
+ # This looks like a user-replaceable value placeholder
294
+ # Format the value (ensure it has angle brackets)
295
+ user_value = user_values[0]
296
+ if not user_value.startswith('<'):
297
+ user_value = f'<{user_value}>'
298
+ if not user_value.endswith('>'):
299
+ user_value = f'{user_value}>'
300
+ term = f'`{user_value}`'
301
+ else:
302
+ # This is a code line - use it as-is in backticks
303
+ term = f'`{code_line}`'
304
+
305
+ # Add blank line before each term
306
+ lines.append('')
307
+ lines.append(f'{term}::')
308
+
309
+ # Add explanations for all callouts in this group
310
+ for idx, callout_num in enumerate(callout_nums):
311
+ explanation = explanations[callout_num]
312
+
313
+ # If this is not the first explanation in the group, add continuation marker
314
+ if idx > 0:
315
+ lines.append('+')
316
+
317
+ # Add explanation lines, prepending "Optional. " to first line if needed
318
+ for line_idx, line in enumerate(explanation.lines):
319
+ if line_idx == 0 and explanation.is_optional:
320
+ lines.append(f'Optional. {line}')
321
+ else:
322
+ lines.append(line)
323
+
324
+ return lines
325
+
326
+ def convert_file(self, input_file: Path) -> Tuple[int, bool]:
327
+ """
328
+ Convert callouts in a file to definition list format.
329
+ Returns tuple of (number of conversions, whether file was modified).
330
+ """
331
+ # Read input file
332
+ try:
333
+ with open(input_file, 'r', encoding='utf-8') as f:
334
+ lines = [line.rstrip('\n') for line in f]
335
+ except Exception as e:
336
+ print_colored(f"Error reading {input_file}: {e}", Colors.RED)
337
+ return 0, False
338
+
339
+ self.log(f"Processing {input_file} ({len(lines)} lines)")
340
+
341
+ # Find all code blocks
342
+ blocks = self.find_code_blocks(lines)
343
+ self.log(f"Found {len(blocks)} code blocks")
344
+
345
+ if not blocks:
346
+ return 0, False
347
+
348
+ # Process blocks in reverse order to maintain line numbers
349
+ new_lines = lines.copy()
350
+ conversions = 0
351
+
352
+ for block in reversed(blocks):
353
+ # Extract callouts from code (returns list of CalloutGroups)
354
+ callout_groups = self.extract_callouts_from_code(block.content)
355
+
356
+ if not callout_groups:
357
+ self.log(f"No callouts in block at line {block.start_line + 1}")
358
+ continue
359
+
360
+ # Extract all callout numbers for logging
361
+ all_callout_nums = []
362
+ for group in callout_groups:
363
+ all_callout_nums.extend(group.callout_numbers)
364
+
365
+ self.log(f"Block at line {block.start_line + 1} has callouts: {all_callout_nums}")
366
+
367
+ # Extract explanations
368
+ explanations, explanation_end = self.extract_callout_explanations(new_lines, block.end_line)
369
+
370
+ if not explanations:
371
+ self.log(f"No explanations found after block at line {block.start_line + 1}")
372
+ continue
373
+
374
+ # Validate callouts match
375
+ if not self.validate_callouts(callout_groups, explanations, input_file, block.start_line, block.end_line):
376
+ continue
377
+
378
+ self.log(f"Converting block at line {block.start_line + 1}")
379
+
380
+ # Remove callouts from code
381
+ cleaned_content = self.remove_callouts_from_code(block.content)
382
+
383
+ # Create definition list
384
+ def_list = self.create_definition_list(callout_groups, explanations)
385
+
386
+ # Replace in document
387
+ # 1. Update code block content
388
+ # Check if block has [source] prefix by checking if start_line contains [source]
389
+ has_source_prefix = self.CODE_BLOCK_START.match(new_lines[block.start_line])
390
+ if has_source_prefix:
391
+ content_start = block.start_line + 2 # After [source] and ----
392
+ else:
393
+ content_start = block.start_line + 1 # After ---- only
394
+ content_end = block.end_line
395
+
396
+ # 2. Remove old callout explanations
397
+ explanation_start = block.end_line + 1
398
+ while explanation_start < len(new_lines) and not new_lines[explanation_start].strip():
399
+ explanation_start += 1
400
+
401
+ # Build the new section
402
+ new_section = (
403
+ new_lines[:content_start] +
404
+ cleaned_content +
405
+ [new_lines[content_end]] + # Keep closing delimiter
406
+ def_list +
407
+ new_lines[explanation_end + 1:]
408
+ )
409
+
410
+ new_lines = new_section
411
+ conversions += 1
412
+ self.changes_made += 1
413
+
414
+ # Write output
415
+ if conversions > 0 and not self.dry_run:
416
+ try:
417
+ with open(input_file, 'w', encoding='utf-8') as f:
418
+ f.write('\n'.join(new_lines) + '\n')
419
+ self.log(f"Wrote {input_file}")
420
+ except Exception as e:
421
+ print_colored(f"Error writing {input_file}: {e}", Colors.RED)
422
+ return 0, False
423
+
424
+ return conversions, conversions > 0
425
+
426
+
427
+ def find_adoc_files(path: Path, exclude_dirs: List[str] = None, exclude_files: List[str] = None) -> List[Path]:
428
+ """
429
+ Find all .adoc files in the given path.
430
+
431
+ Args:
432
+ path: Path to search (file or directory)
433
+ exclude_dirs: List of directory patterns to exclude
434
+ exclude_files: List of file patterns to exclude
435
+
436
+ Returns:
437
+ List of Path objects for .adoc files
438
+ """
439
+ adoc_files = []
440
+ exclude_dirs = exclude_dirs or []
441
+ exclude_files = exclude_files or []
442
+
443
+ # Always exclude .vale directory by default (Vale linter fixtures)
444
+ if '.vale' not in exclude_dirs:
445
+ exclude_dirs.append('.vale')
446
+
447
+ if path.is_file():
448
+ if path.suffix == '.adoc':
449
+ # Check if file should be excluded
450
+ if not any(excl in str(path) for excl in exclude_files):
451
+ adoc_files.append(path)
452
+ elif path.is_dir():
453
+ # Recursively find all .adoc files
454
+ for adoc_file in path.rglob('*.adoc'):
455
+ # Check if in excluded directory
456
+ if any(excl in str(adoc_file) for excl in exclude_dirs):
457
+ continue
458
+ # Check if file should be excluded
459
+ if any(excl in str(adoc_file) for excl in exclude_files):
460
+ continue
461
+ adoc_files.append(adoc_file)
462
+
463
+ return sorted(adoc_files)
464
+
465
+
466
+ def load_exclusion_list(exclusion_file: Path) -> Tuple[List[str], List[str]]:
467
+ """
468
+ Load exclusion list from file.
469
+ Returns tuple of (excluded_dirs, excluded_files).
470
+ """
471
+ excluded_dirs = []
472
+ excluded_files = []
473
+
474
+ try:
475
+ with open(exclusion_file, 'r') as f:
476
+ for line in f:
477
+ line = line.strip()
478
+ # Skip comments and empty lines
479
+ if not line or line.startswith('#'):
480
+ continue
481
+
482
+ # If it ends with /, it's a directory
483
+ if line.endswith('/'):
484
+ excluded_dirs.append(line.rstrip('/'))
485
+ else:
486
+ # Could be file or directory - check if it has extension
487
+ if '.' in Path(line).name:
488
+ excluded_files.append(line)
489
+ else:
490
+ excluded_dirs.append(line)
491
+ except Exception as e:
492
+ print_colored(f"Warning: Could not read exclusion file {exclusion_file}: {e}", Colors.YELLOW)
493
+
494
+ return excluded_dirs, excluded_files
495
+
496
+
497
+ def main():
498
+ """Main entry point"""
499
+ parser = argparse.ArgumentParser(
500
+ description='Convert AsciiDoc callouts to definition list format',
501
+ formatter_class=argparse.RawDescriptionHelpFormatter,
502
+ epilog="""
503
+ Convert AsciiDoc callout-style documentation to definition list format.
504
+
505
+ This script identifies code blocks with callout numbers (<1>, <2>, etc.) and their
506
+ corresponding explanation lines, then converts them to a cleaner definition list format
507
+ with "where:" prefix.
508
+
509
+ Examples:
510
+ %(prog)s # Process all .adoc files in current directory
511
+ %(prog)s modules/ # Process all .adoc files in modules/
512
+ %(prog)s assemblies/my-guide.adoc # Process single file
513
+ %(prog)s --dry-run modules/ # Preview changes without modifying
514
+ %(prog)s --exclude-dir .vale modules/ # Exclude .vale directory
515
+
516
+ Example transformation:
517
+ FROM:
518
+ [source,yaml]
519
+ ----
520
+ name: <my-secret> <1>
521
+ key: <my-key> <2>
522
+ ----
523
+ <1> Secret name
524
+ <2> Key value
525
+
526
+ TO:
527
+ [source,yaml]
528
+ ----
529
+ name: <my-secret>
530
+ key: <my-key>
531
+ ----
532
+
533
+ where:
534
+
535
+ `<my-secret>`::
536
+ Secret name
537
+
538
+ `<my-key>`::
539
+ Key value
540
+ """
541
+ )
542
+
543
+ parser.add_argument(
544
+ 'path',
545
+ nargs='?',
546
+ default='.',
547
+ help='File or directory to process (default: current directory)'
548
+ )
549
+ parser.add_argument(
550
+ '-n', '--dry-run',
551
+ action='store_true',
552
+ help='Show what would be changed without modifying files'
553
+ )
554
+ parser.add_argument(
555
+ '-v', '--verbose',
556
+ action='store_true',
557
+ help='Enable verbose output'
558
+ )
559
+ parser.add_argument(
560
+ '--exclude-dir',
561
+ action='append',
562
+ dest='exclude_dirs',
563
+ default=[],
564
+ help='Directory to exclude (can be used multiple times)'
565
+ )
566
+ parser.add_argument(
567
+ '--exclude-file',
568
+ action='append',
569
+ dest='exclude_files',
570
+ default=[],
571
+ help='File to exclude (can be used multiple times)'
572
+ )
573
+ parser.add_argument(
574
+ '--exclude-list',
575
+ type=Path,
576
+ help='Path to file containing directories/files to exclude, one per line'
577
+ )
578
+
579
+ args = parser.parse_args()
580
+
581
+ # Load exclusion list if provided
582
+ if args.exclude_list:
583
+ if args.exclude_list.exists():
584
+ excluded_dirs, excluded_files = load_exclusion_list(args.exclude_list)
585
+ args.exclude_dirs.extend(excluded_dirs)
586
+ args.exclude_files.extend(excluded_files)
587
+ else:
588
+ print_colored(f"Warning: Exclusion list file not found: {args.exclude_list}", Colors.YELLOW)
589
+
590
+ # Convert path to Path object
591
+ target_path = Path(args.path)
592
+
593
+ # Check if path exists
594
+ if not target_path.exists():
595
+ print_colored(f"Error: Path does not exist: {target_path}", Colors.RED)
596
+ sys.exit(1)
597
+
598
+ # Display dry-run mode message
599
+ if args.dry_run:
600
+ print_colored("DRY RUN MODE - No files will be modified", Colors.YELLOW)
601
+
602
+ # Find all AsciiDoc files
603
+ adoc_files = find_adoc_files(target_path, args.exclude_dirs, args.exclude_files)
604
+
605
+ if not adoc_files:
606
+ if target_path.is_file():
607
+ print_colored(f"Warning: {target_path} is not an AsciiDoc file (.adoc)", Colors.YELLOW)
608
+ else:
609
+ print(f"No AsciiDoc files found in {target_path}")
610
+ print("Processed 0 AsciiDoc file(s)")
611
+ return
612
+
613
+ print(f"Found {len(adoc_files)} AsciiDoc file(s) to process")
614
+
615
+ # Create converter
616
+ converter = CalloutConverter(dry_run=args.dry_run, verbose=args.verbose)
617
+
618
+ # Process each file
619
+ files_processed = 0
620
+ files_modified = 0
621
+ total_conversions = 0
622
+
623
+ for file_path in adoc_files:
624
+ try:
625
+ conversions, modified = converter.convert_file(file_path)
626
+
627
+ if modified:
628
+ files_modified += 1
629
+ total_conversions += conversions
630
+ if args.dry_run:
631
+ print_colored(f"Would modify: {file_path} ({conversions} code block(s))", Colors.YELLOW)
632
+ else:
633
+ print_colored(f"Modified: {file_path} ({conversions} code block(s))", Colors.GREEN)
634
+ elif args.verbose:
635
+ print(f" No callouts found in: {file_path}")
636
+
637
+ files_processed += 1
638
+
639
+ except KeyboardInterrupt:
640
+ print_colored("\nOperation cancelled by user", Colors.YELLOW)
641
+ sys.exit(1)
642
+ except Exception as e:
643
+ print_colored(f"Unexpected error processing {file_path}: {e}", Colors.RED)
644
+ if args.verbose:
645
+ import traceback
646
+ traceback.print_exc()
647
+
648
+ # Summary
649
+ print(f"\nProcessed {files_processed} AsciiDoc file(s)")
650
+ if args.dry_run and files_modified > 0:
651
+ print(f"Would modify {files_modified} file(s) with {total_conversions} code block conversion(s)")
652
+ elif files_modified > 0:
653
+ print_colored(f"Modified {files_modified} file(s) with {total_conversions} code block conversion(s)", Colors.GREEN)
654
+ else:
655
+ print("No files with callouts to convert")
656
+
657
+ # Display warning summary if any warnings were collected
658
+ if converter.warnings:
659
+ print_colored(f"\n⚠️ {len(converter.warnings)} Warning(s):", Colors.YELLOW)
660
+ for warning in converter.warnings:
661
+ print_colored(f" {warning}", Colors.YELLOW)
662
+ print()
663
+
664
+ if args.dry_run and files_modified > 0:
665
+ print_colored("DRY RUN - No files were modified", Colors.YELLOW)
666
+
667
+ return 0 if files_processed >= 0 else 1
668
+
669
+
670
+ if __name__ == '__main__':
671
+ sys.exit(main())
@@ -53,6 +53,11 @@ TOOLS = [
53
53
  'description': 'Identifies unused attribute definitions in AsciiDoc files',
54
54
  'example': 'find-unused-attributes # auto-discovers attributes files'
55
55
  },
56
+ {
57
+ 'name': 'convert-callouts-to-deflist',
58
+ 'description': 'Converts callout-style annotations to definition list format',
59
+ 'example': 'convert-callouts-to-deflist --dry-run modules/'
60
+ },
56
61
  ]
57
62
 
58
63
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rolfedh-doc-utils"
7
- version = "0.1.21"
7
+ version = "0.1.23"
8
8
  description = "CLI tools for AsciiDoc documentation projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -24,10 +24,11 @@ format-asciidoc-spacing = "format_asciidoc_spacing:main"
24
24
  replace-link-attributes = "replace_link_attributes:main"
25
25
  extract-link-attributes = "extract_link_attributes:main"
26
26
  validate-links = "validate_links:main"
27
+ convert-callouts-to-deflist = "convert_callouts_to_deflist:main"
27
28
 
28
29
  [tool.setuptools.packages.find]
29
30
  where = ["."]
30
31
  include = ["doc_utils*"]
31
32
 
32
33
  [tool.setuptools]
33
- py-modules = ["doc_utils_cli", "find_unused_attributes", "check_scannability", "archive_unused_files", "archive_unused_images", "format_asciidoc_spacing", "replace_link_attributes", "extract_link_attributes", "validate_links"]
34
+ py-modules = ["doc_utils_cli", "find_unused_attributes", "check_scannability", "archive_unused_files", "archive_unused_images", "format_asciidoc_spacing", "replace_link_attributes", "extract_link_attributes", "validate_links", "convert_callouts_to_deflist"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rolfedh-doc-utils
3
- Version: 0.1.21
3
+ Version: 0.1.23
4
4
  Summary: CLI tools for AsciiDoc documentation projects
5
5
  Author: Rolfe Dlugy-Hegwer
6
6
  License: MIT License
@@ -102,6 +102,7 @@ doc-utils --version # Show version
102
102
  | **`archive-unused-files`** | Finds and archives unreferenced .adoc files | `archive-unused-files` (preview)<br>`archive-unused-files --archive` (execute) |
103
103
  | **`archive-unused-images`** | Finds and archives unreferenced images | `archive-unused-images` (preview)<br>`archive-unused-images --archive` (execute) |
104
104
  | **`find-unused-attributes`** | Identifies unused attribute definitions | `find-unused-attributes attributes.adoc` |
105
+ | **`convert-callouts-to-deflist`** | Converts callout-style annotations to definition list format | `convert-callouts-to-deflist --dry-run modules/` |
105
106
 
106
107
  ## 📖 Documentation
107
108
 
@@ -3,6 +3,7 @@ README.md
3
3
  archive_unused_files.py
4
4
  archive_unused_images.py
5
5
  check_scannability.py
6
+ convert_callouts_to_deflist.py
6
7
  doc_utils_cli.py
7
8
  extract_link_attributes.py
8
9
  find_unused_attributes.py
@@ -2,6 +2,7 @@
2
2
  archive-unused-files = archive_unused_files:main
3
3
  archive-unused-images = archive_unused_images:main
4
4
  check-scannability = check_scannability:main
5
+ convert-callouts-to-deflist = convert_callouts_to_deflist:main
5
6
  doc-utils = doc_utils_cli:main
6
7
  extract-link-attributes = extract_link_attributes:main
7
8
  find-unused-attributes = find_unused_attributes:main
@@ -1,6 +1,7 @@
1
1
  archive_unused_files
2
2
  archive_unused_images
3
3
  check_scannability
4
+ convert_callouts_to_deflist
4
5
  doc_utils
5
6
  doc_utils_cli
6
7
  extract_link_attributes