rolfedh-doc-utils 0.1.37__py3-none-any.whl → 0.1.39__py3-none-any.whl

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.
@@ -0,0 +1,708 @@
1
+ """
2
+ Convert FreeMarker-templated AsciiDoc to standard AsciiDoc.
3
+
4
+ Core logic for converting Keycloak-style FreeMarker template markup in AsciiDoc
5
+ files to standard AsciiDoc format. This module handles:
6
+
7
+ - Removing FreeMarker import statements (<#import ...>)
8
+ - Converting <@tmpl.guide> and <@template.guide> blocks to AsciiDoc title/summary
9
+ - Removing closing </@tmpl.guide> and </@template.guide> tags
10
+ - Converting <@links.*> macros to AsciiDoc xref cross-references
11
+ - Converting <@kc.*> command macros to code blocks
12
+ - Removing or preserving <@profile.*> conditional blocks
13
+ - Removing <@opts.*> option macros (build-time generated content)
14
+ - Removing <@features.table> macros (build-time generated content)
15
+ - Handling <#noparse> blocks (preserving content, removing tags)
16
+
17
+ PATTERNS THAT CANNOT BE RELIABLY CONVERTED (require manual review):
18
+ - <#list> loops: Dynamic content generation, no static equivalent
19
+ - <#if>/<#else> conditionals: Logic-based content, context-dependent
20
+ - <#assign> variables: Build-time variable assignment
21
+ - <@features.table ctx.*/>: Dynamic feature tables generated at build time
22
+ - Index files with <#list ctx.guides>: Auto-generated navigation
23
+ """
24
+
25
+ import re
26
+ from pathlib import Path
27
+ from typing import List, Tuple, Optional, Dict
28
+ from dataclasses import dataclass, field
29
+
30
+
31
+ # Mapping of link types to their base paths
32
+ # These are the guide categories used in Keycloak docs
33
+ LINK_TYPE_PATHS: Dict[str, str] = {
34
+ 'server': 'server',
35
+ 'ha': 'high-availability',
36
+ 'gettingstarted': 'getting-started',
37
+ 'operator': 'operator',
38
+ 'migration': 'migration',
39
+ 'securing': 'securing-apps',
40
+ 'securingapps': 'securing-apps', # Alternative spelling used in some files
41
+ 'observability': 'observability',
42
+ }
43
+
44
+ # Mapping of kc command types to their shell commands
45
+ KC_COMMAND_MAP: Dict[str, str] = {
46
+ 'start': 'bin/kc.sh start',
47
+ 'startdev': 'bin/kc.sh start-dev',
48
+ 'build': 'bin/kc.sh build',
49
+ 'export': 'bin/kc.sh export',
50
+ 'import': 'bin/kc.sh import',
51
+ 'admin': 'bin/kcadm.sh',
52
+ 'bootstrapadmin': 'bin/kc.sh bootstrap-admin',
53
+ 'updatecompatibility': 'bin/kc.sh update-compatibility',
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class ConversionStats:
59
+ """Statistics for a conversion operation."""
60
+ imports_removed: int = 0
61
+ guide_blocks_converted: int = 0
62
+ closing_tags_removed: int = 0
63
+ link_macros_converted: int = 0
64
+ kc_macros_converted: int = 0
65
+ profile_blocks_handled: int = 0
66
+ noparse_blocks_handled: int = 0
67
+ opts_macros_removed: int = 0
68
+ features_macros_removed: int = 0
69
+ other_macros_removed: int = 0
70
+ directives_marked: int = 0
71
+
72
+
73
+ @dataclass
74
+ class ConversionResult:
75
+ """Result of converting a single file."""
76
+ file_path: Path
77
+ changes_made: bool
78
+ stats: ConversionStats = field(default_factory=ConversionStats)
79
+ messages: List[str] = field(default_factory=list)
80
+
81
+
82
+ def extract_guide_attributes(content: str) -> Optional[Tuple[str, str, int, int]]:
83
+ """
84
+ Extract title and summary from a <@tmpl.guide> or <@template.guide> block.
85
+
86
+ Args:
87
+ content: The file content to parse
88
+
89
+ Returns:
90
+ Tuple of (title, summary, start_index, end_index) or None if not found
91
+ """
92
+ # Match both <@tmpl.guide> and <@template.guide> variants
93
+ pattern = r'<@(?:tmpl|template)\.guide\s*\n?((?:[^>]|\n)*?)>'
94
+
95
+ match = re.search(pattern, content)
96
+ if not match:
97
+ return None
98
+
99
+ block_content = match.group(1)
100
+ start_idx = match.start()
101
+ end_idx = match.end()
102
+
103
+ # Extract title attribute
104
+ title_match = re.search(r'title\s*=\s*"([^"]*)"', block_content)
105
+ title = title_match.group(1) if title_match else ""
106
+
107
+ # Extract summary attribute
108
+ summary_match = re.search(r'summary\s*=\s*"([^"]*)"', block_content)
109
+ summary = summary_match.group(1) if summary_match else ""
110
+
111
+ return (title, summary, start_idx, end_idx)
112
+
113
+
114
+ def remove_freemarker_imports(content: str) -> Tuple[str, int]:
115
+ """
116
+ Remove FreeMarker import statements from content.
117
+
118
+ Args:
119
+ content: The file content
120
+
121
+ Returns:
122
+ Tuple of (modified content, count of imports removed)
123
+ """
124
+ # Match lines that are FreeMarker imports: <#import "..." as ...>
125
+ import_pattern = r'^<#import\s+"[^"]+"\s+as\s+\w+>\s*\n?'
126
+
127
+ count = len(re.findall(import_pattern, content, re.MULTILINE))
128
+ new_content = re.sub(import_pattern, '', content, flags=re.MULTILINE)
129
+
130
+ return (new_content, count)
131
+
132
+
133
+ def convert_guide_block(content: str) -> Tuple[str, bool]:
134
+ """
135
+ Convert <@tmpl.guide> or <@template.guide> block to standard AsciiDoc title and summary.
136
+
137
+ Args:
138
+ content: The file content
139
+
140
+ Returns:
141
+ Tuple of (modified content, whether conversion was made)
142
+ """
143
+ result = extract_guide_attributes(content)
144
+ if not result:
145
+ return (content, False)
146
+
147
+ title, summary, start_idx, end_idx = result
148
+
149
+ # Build the AsciiDoc replacement
150
+ replacement_parts = []
151
+
152
+ if title:
153
+ replacement_parts.append(f"= {title}")
154
+ replacement_parts.append("") # Blank line after title
155
+
156
+ if summary:
157
+ replacement_parts.append(summary)
158
+ replacement_parts.append("") # Blank line after summary
159
+
160
+ replacement = "\n".join(replacement_parts)
161
+
162
+ # Replace the guide block with the AsciiDoc equivalent
163
+ new_content = content[:start_idx] + replacement + content[end_idx:]
164
+
165
+ # Clean up any excessive blank lines at the start
166
+ new_content = re.sub(r'^\n+', '', new_content)
167
+
168
+ return (new_content, True)
169
+
170
+
171
+ def remove_closing_guide_tag(content: str) -> Tuple[str, bool]:
172
+ """
173
+ Remove the closing </@tmpl.guide> or </@template.guide> tag.
174
+
175
+ Args:
176
+ content: The file content
177
+
178
+ Returns:
179
+ Tuple of (modified content, whether tag was removed)
180
+ """
181
+ # Match both variants
182
+ pattern = r'\n*</@(?:tmpl|template)\.guide>\s*\n*'
183
+ if re.search(pattern, content):
184
+ new_content = re.sub(pattern, '\n', content)
185
+ return (new_content, True)
186
+ return (content, False)
187
+
188
+
189
+ def convert_link_macros(content: str, base_path: str = '') -> Tuple[str, int]:
190
+ """
191
+ Convert <@links.*> macros to AsciiDoc xref cross-references.
192
+
193
+ Converts patterns like:
194
+ - <@links.server id="hostname"/> -> xref:server/hostname.adoc[]
195
+ - <@links.ha id="intro" anchor="section"/> -> xref:high-availability/intro.adoc#section[]
196
+ - <@links.securingapps id="client-registration"/> -> xref:securing-apps/client-registration.adoc[]
197
+
198
+ Args:
199
+ content: The file content
200
+ base_path: Optional base path prefix for xref links
201
+
202
+ Returns:
203
+ Tuple of (modified content, count of macros converted)
204
+ """
205
+ count = 0
206
+
207
+ def replace_link(match):
208
+ nonlocal count
209
+ count += 1
210
+
211
+ link_type = match.group(1) # e.g., 'server', 'ha', 'gettingstarted', 'securingapps'
212
+ link_id = match.group(2) # e.g., 'hostname', 'introduction'
213
+ anchor = match.group(4) if match.group(4) else '' # Optional anchor
214
+
215
+ # Map link type to directory path
216
+ dir_path = LINK_TYPE_PATHS.get(link_type, link_type)
217
+
218
+ # Build the file path
219
+ file_path = f"{link_id}.adoc"
220
+
221
+ # Build the xref
222
+ if base_path:
223
+ xref_path = f"{base_path}/{dir_path}/{file_path}"
224
+ else:
225
+ xref_path = f"{dir_path}/{file_path}"
226
+
227
+ if anchor:
228
+ xref_path += f"#{anchor}"
229
+
230
+ # Return xref with empty link text (will use document title)
231
+ return f"xref:{xref_path}[]"
232
+
233
+ # Pattern to match link macros:
234
+ # <@links.TYPE id="ID"/> or <@links.TYPE id="ID" anchor="ANCHOR"/>
235
+ # Also handles extra whitespace variations
236
+ link_pattern = r'<@links\.(\w+)\s+id="([^"]+)"(\s+anchor="([^"]+)")?\s*/>'
237
+
238
+ new_content = re.sub(link_pattern, replace_link, content)
239
+
240
+ return (new_content, count)
241
+
242
+
243
+ def convert_kc_macros(content: str) -> Tuple[str, int]:
244
+ """
245
+ Convert <@kc.*> command macros to AsciiDoc code blocks.
246
+
247
+ Converts patterns like:
248
+ - <@kc.start parameters="--hostname x"/> -> [source,bash]\n----\nbin/kc.sh start --hostname x\n----
249
+ - <@kc.build parameters="--db postgres"/> -> [source,bash]\n----\nbin/kc.sh build --db postgres\n----
250
+ - <@kc.export parameters="--dir <dir>"/> -> [source,bash]\n----\nbin/kc.sh export --dir <dir>\n----
251
+ - <@kc.import parameters="--file <file>"/> -> [source,bash]\n----\nbin/kc.sh import --file <file>\n----
252
+
253
+ Args:
254
+ content: The file content
255
+
256
+ Returns:
257
+ Tuple of (modified content, count of macros converted)
258
+ """
259
+ count = 0
260
+
261
+ def replace_kc_macro(match):
262
+ nonlocal count
263
+ count += 1
264
+
265
+ command = match.group(1) # e.g., 'start', 'build', 'admin', 'export', 'import'
266
+ parameters = match.group(2) # e.g., '--hostname my.keycloak.org'
267
+
268
+ # Get the base command from the map, or use generic kc.sh
269
+ if command in KC_COMMAND_MAP:
270
+ base_cmd = KC_COMMAND_MAP[command]
271
+ else:
272
+ # Generic kc command
273
+ base_cmd = f"bin/kc.sh {command}"
274
+
275
+ # Build the full command line
276
+ if parameters:
277
+ cmd_line = f"{base_cmd} {parameters}".strip()
278
+ else:
279
+ cmd_line = base_cmd
280
+
281
+ # Return as a code block
282
+ return f"[source,bash]\n----\n{cmd_line}\n----"
283
+
284
+ # Pattern to match kc macros: <@kc.COMMAND parameters="PARAMS"/>
285
+ kc_pattern = r'<@kc\.(\w+)\s+parameters="([^"]*)"\s*/>'
286
+
287
+ new_content = re.sub(kc_pattern, replace_kc_macro, content)
288
+
289
+ return (new_content, count)
290
+
291
+
292
+ def handle_profile_blocks(content: str, keep_community: bool = True) -> Tuple[str, int]:
293
+ """
294
+ Handle <@profile.*> conditional blocks.
295
+
296
+ These blocks wrap content that should only appear in specific editions
297
+ (community vs product). By default, we keep community content and remove
298
+ product-specific content.
299
+
300
+ Args:
301
+ content: The file content
302
+ keep_community: If True, keep community content; if False, keep product content
303
+
304
+ Returns:
305
+ Tuple of (modified content, count of blocks handled)
306
+ """
307
+ count = 0
308
+
309
+ # First, handle ifCommunity blocks
310
+ community_pattern = r'<@profile\.ifCommunity>\s*\n?(.*?)</@profile\.ifCommunity>\s*\n?'
311
+
312
+ def handle_community(match):
313
+ nonlocal count
314
+ count += 1
315
+ if keep_community:
316
+ # Keep the content, remove the tags
317
+ return match.group(1)
318
+ else:
319
+ # Remove the entire block
320
+ return ''
321
+
322
+ content = re.sub(community_pattern, handle_community, content, flags=re.DOTALL)
323
+
324
+ # Handle ifProduct blocks
325
+ product_pattern = r'<@profile\.ifProduct>\s*\n?(.*?)</@profile\.ifProduct>\s*\n?'
326
+
327
+ def handle_product(match):
328
+ nonlocal count
329
+ count += 1
330
+ if not keep_community:
331
+ # Keep the content, remove the tags
332
+ return match.group(1)
333
+ else:
334
+ # Remove the entire block
335
+ return ''
336
+
337
+ content = re.sub(product_pattern, handle_product, content, flags=re.DOTALL)
338
+
339
+ return (content, count)
340
+
341
+
342
+ def handle_noparse_blocks(content: str) -> Tuple[str, int]:
343
+ """
344
+ Handle <#noparse> blocks by removing the tags but keeping the content.
345
+
346
+ These blocks are used to escape FreeMarker syntax in code examples.
347
+ The content inside should be preserved as-is.
348
+
349
+ Args:
350
+ content: The file content
351
+
352
+ Returns:
353
+ Tuple of (modified content, count of blocks handled)
354
+ """
355
+ count = 0
356
+
357
+ # Pattern for noparse blocks: <#noparse>...</#noparse>
358
+ noparse_pattern = r'<#noparse>\s*\n?(.*?)</#noparse>\s*\n?'
359
+
360
+ def handle_noparse(match):
361
+ nonlocal count
362
+ count += 1
363
+ # Keep the content, remove the tags
364
+ return match.group(1)
365
+
366
+ content = re.sub(noparse_pattern, handle_noparse, content, flags=re.DOTALL)
367
+
368
+ return (content, count)
369
+
370
+
371
+ def remove_opts_macros(content: str) -> Tuple[str, int]:
372
+ """
373
+ Remove <@opts.*> option macros.
374
+
375
+ These macros generate option documentation at build time and have no
376
+ meaningful conversion to static AsciiDoc. They are replaced with a
377
+ placeholder comment.
378
+
379
+ Args:
380
+ content: The file content
381
+
382
+ Returns:
383
+ Tuple of (modified content, count of macros removed)
384
+ """
385
+ count = 0
386
+
387
+ # Pattern for self-closing opts macros: <@opts.expectedValues option="..."/>
388
+ self_closing_pattern = r'<@opts\.\w+[^/>]*\/>'
389
+
390
+ def replace_self_closing(match):
391
+ nonlocal count
392
+ count += 1
393
+ return '// Configuration options are documented in the all-config guide'
394
+
395
+ content = re.sub(self_closing_pattern, replace_self_closing, content)
396
+
397
+ # Pattern for block opts macros: <@opts.list ...>...</@opts.list>
398
+ block_pattern = r'<@opts\.\w+[^>]*>.*?</@opts\.\w+>'
399
+
400
+ def replace_block(match):
401
+ nonlocal count
402
+ count += 1
403
+ return '// Configuration options are documented in the all-config guide'
404
+
405
+ content = re.sub(block_pattern, replace_block, content, flags=re.DOTALL)
406
+
407
+ return (content, count)
408
+
409
+
410
+ def remove_features_macros(content: str) -> Tuple[str, int]:
411
+ """
412
+ Remove <@features.table> macros.
413
+
414
+ These macros generate feature tables at build time and cannot be
415
+ converted to static content.
416
+
417
+ Args:
418
+ content: The file content
419
+
420
+ Returns:
421
+ Tuple of (modified content, count of macros removed)
422
+ """
423
+ count = 0
424
+
425
+ # Pattern for features.table macros: <@features.table ctx.features.supported/>
426
+ features_pattern = r'<@features\.table[^/>]*\/>'
427
+
428
+ def replace_features(match):
429
+ nonlocal count
430
+ count += 1
431
+ return '// Feature table is generated at build time - see the features guide'
432
+
433
+ content = re.sub(features_pattern, replace_features, content)
434
+
435
+ return (content, count)
436
+
437
+
438
+ def mark_unconvertible_directives(content: str) -> Tuple[str, int]:
439
+ """
440
+ Mark FreeMarker directives that cannot be reliably converted.
441
+
442
+ These include:
443
+ - <#list> loops
444
+ - <#if>/<#else> conditionals
445
+ - <#assign> variables
446
+
447
+ Args:
448
+ content: The file content
449
+
450
+ Returns:
451
+ Tuple of (modified content, count of directives marked)
452
+ """
453
+ count = 0
454
+
455
+ # Pattern for list blocks: <#list ...>...</#list>
456
+ list_pattern = r'(<#list\s+[^>]+>)(.*?)(</#list>)'
457
+
458
+ def mark_list(match):
459
+ nonlocal count
460
+ count += 1
461
+ opening = match.group(1)
462
+ inner = match.group(2)
463
+ closing = match.group(3)
464
+ return f"// TODO: Manual conversion required - FreeMarker list loop\n// {opening}\n{inner}// {closing}"
465
+
466
+ content = re.sub(list_pattern, mark_list, content, flags=re.DOTALL)
467
+
468
+ # Pattern for if/else blocks: <#if ...>...</#if>
469
+ if_pattern = r'(<#if\s+[^>]+>)(.*?)(</#if>)'
470
+
471
+ def mark_if(match):
472
+ nonlocal count
473
+ count += 1
474
+ opening = match.group(1)
475
+ inner = match.group(2)
476
+ closing = match.group(3)
477
+ return f"// TODO: Manual conversion required - FreeMarker conditional\n// {opening}\n{inner}// {closing}"
478
+
479
+ content = re.sub(if_pattern, mark_if, content, flags=re.DOTALL)
480
+
481
+ # Pattern for standalone else: <#else>
482
+ else_pattern = r'<#else>'
483
+
484
+ def mark_else(match):
485
+ nonlocal count
486
+ count += 1
487
+ return '// <#else>'
488
+
489
+ content = re.sub(else_pattern, mark_else, content)
490
+
491
+ # Pattern for assign: <#assign ...>
492
+ assign_pattern = r'<#assign\s+[^>]+>'
493
+
494
+ def mark_assign(match):
495
+ nonlocal count
496
+ count += 1
497
+ return f"// TODO: Manual conversion required - {match.group(0)}"
498
+
499
+ content = re.sub(assign_pattern, mark_assign, content)
500
+
501
+ return (content, count)
502
+
503
+
504
+ def remove_remaining_macros(content: str) -> Tuple[str, int]:
505
+ """
506
+ Remove any remaining FreeMarker macros that weren't handled by specific converters.
507
+
508
+ Args:
509
+ content: The file content
510
+
511
+ Returns:
512
+ Tuple of (modified content, count of macros removed)
513
+ """
514
+ count = 0
515
+
516
+ # Self-closing macros: <@something .../>
517
+ self_closing = r'<@\w+\.[^/>]+/>'
518
+ self_closing_count = len(re.findall(self_closing, content))
519
+ if self_closing_count > 0:
520
+ content = re.sub(self_closing, '// TODO: Unconverted FreeMarker macro', content)
521
+ count += self_closing_count
522
+
523
+ # Block macros: <@something>...</@something>
524
+ block_pattern = r'<@\w+[^>]*>.*?</@\w+[^>]*>'
525
+ block_count = len(re.findall(block_pattern, content, re.DOTALL))
526
+ if block_count > 0:
527
+ content = re.sub(block_pattern, '// TODO: Unconverted FreeMarker macro', content, flags=re.DOTALL)
528
+ count += block_count
529
+
530
+ return (content, count)
531
+
532
+
533
+ def process_file(
534
+ file_path: Path,
535
+ dry_run: bool = False,
536
+ verbose: bool = False,
537
+ convert_all: bool = True,
538
+ keep_community: bool = True,
539
+ base_path: str = ''
540
+ ) -> ConversionResult:
541
+ """
542
+ Process a single AsciiDoc file to convert FreeMarker markup.
543
+
544
+ Args:
545
+ file_path: Path to the file to process
546
+ dry_run: If True, show what would be changed without modifying
547
+ verbose: If True, show detailed output
548
+ convert_all: If True, convert all FreeMarker macros (not just structure)
549
+ keep_community: If True, keep community content in profile blocks
550
+ base_path: Optional base path prefix for xref links
551
+
552
+ Returns:
553
+ ConversionResult with details of changes made
554
+ """
555
+ messages = []
556
+ stats = ConversionStats()
557
+
558
+ if verbose:
559
+ messages.append(f"Processing: {file_path}")
560
+
561
+ try:
562
+ with open(file_path, 'r', encoding='utf-8') as f:
563
+ original_content = f.read()
564
+ except (IOError, UnicodeDecodeError) as e:
565
+ raise IOError(f"Error reading {file_path}: {e}")
566
+
567
+ content = original_content
568
+
569
+ # Step 1: Remove FreeMarker imports
570
+ content, count = remove_freemarker_imports(content)
571
+ stats.imports_removed = count
572
+ if count > 0 and verbose:
573
+ messages.append(f" Removed {count} FreeMarker import(s)")
574
+
575
+ # Step 2: Convert guide block (both tmpl.guide and template.guide)
576
+ content, converted = convert_guide_block(content)
577
+ if converted:
578
+ stats.guide_blocks_converted = 1
579
+ if verbose:
580
+ messages.append(" Converted guide block to AsciiDoc title/summary")
581
+
582
+ # Step 3: Remove closing tag
583
+ content, removed = remove_closing_guide_tag(content)
584
+ if removed:
585
+ stats.closing_tags_removed = 1
586
+ if verbose:
587
+ messages.append(" Removed closing guide tag")
588
+
589
+ # Step 4: Convert inline macros if requested
590
+ if convert_all:
591
+ # Handle noparse blocks first (preserve content)
592
+ content, count = handle_noparse_blocks(content)
593
+ stats.noparse_blocks_handled = count
594
+ if count > 0 and verbose:
595
+ messages.append(f" Processed {count} <#noparse> block(s)")
596
+
597
+ # Convert link macros to xrefs
598
+ content, count = convert_link_macros(content, base_path)
599
+ stats.link_macros_converted = count
600
+ if count > 0 and verbose:
601
+ messages.append(f" Converted {count} <@links.*> macro(s) to xref")
602
+
603
+ # Convert kc command macros to code blocks
604
+ content, count = convert_kc_macros(content)
605
+ stats.kc_macros_converted = count
606
+ if count > 0 and verbose:
607
+ messages.append(f" Converted {count} <@kc.*> macro(s) to code blocks")
608
+
609
+ # Handle profile conditional blocks
610
+ content, count = handle_profile_blocks(content, keep_community)
611
+ stats.profile_blocks_handled = count
612
+ if count > 0 and verbose:
613
+ messages.append(f" Processed {count} <@profile.*> block(s)")
614
+
615
+ # Remove opts macros
616
+ content, count = remove_opts_macros(content)
617
+ stats.opts_macros_removed = count
618
+ if count > 0 and verbose:
619
+ messages.append(f" Removed {count} <@opts.*> macro(s)")
620
+
621
+ # Remove features macros
622
+ content, count = remove_features_macros(content)
623
+ stats.features_macros_removed = count
624
+ if count > 0 and verbose:
625
+ messages.append(f" Removed {count} <@features.*> macro(s)")
626
+
627
+ # Mark unconvertible directives
628
+ content, count = mark_unconvertible_directives(content)
629
+ stats.directives_marked = count
630
+ if count > 0 and verbose:
631
+ messages.append(f" Marked {count} FreeMarker directive(s) for manual review")
632
+
633
+ # Remove any remaining macros
634
+ content, count = remove_remaining_macros(content)
635
+ stats.other_macros_removed = count
636
+ if count > 0 and verbose:
637
+ messages.append(f" Marked {count} other macro(s) for review")
638
+
639
+ # Determine if changes were made
640
+ changes_made = content != original_content
641
+
642
+ # Write changes if not dry run
643
+ if changes_made and not dry_run:
644
+ try:
645
+ with open(file_path, 'w', encoding='utf-8') as f:
646
+ f.write(content)
647
+ except IOError as e:
648
+ raise IOError(f"Error writing {file_path}: {e}")
649
+
650
+ if not changes_made and verbose:
651
+ messages.append(" No FreeMarker markup found")
652
+
653
+ return ConversionResult(
654
+ file_path=file_path,
655
+ changes_made=changes_made,
656
+ stats=stats,
657
+ messages=messages
658
+ )
659
+
660
+
661
+ def find_adoc_files(path: Path) -> List[Path]:
662
+ """
663
+ Find all .adoc files in the given path.
664
+
665
+ Args:
666
+ path: File or directory to search
667
+
668
+ Returns:
669
+ List of Path objects for .adoc files
670
+ """
671
+ adoc_files = []
672
+
673
+ if path.is_file():
674
+ if path.suffix == '.adoc':
675
+ adoc_files.append(path)
676
+ elif path.is_dir():
677
+ # Use os.walk to avoid following symlinks
678
+ import os
679
+ for root, dirs, files in os.walk(path, followlinks=False):
680
+ # Skip hidden directories
681
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
682
+ for file in files:
683
+ if file.endswith('.adoc'):
684
+ adoc_files.append(Path(root) / file)
685
+
686
+ return sorted(adoc_files)
687
+
688
+
689
+ def has_freemarker_content(file_path: Path) -> bool:
690
+ """
691
+ Check if a file contains FreeMarker markup.
692
+
693
+ Args:
694
+ file_path: Path to the file to check
695
+
696
+ Returns:
697
+ True if file contains FreeMarker markup
698
+ """
699
+ try:
700
+ with open(file_path, 'r', encoding='utf-8') as f:
701
+ content = f.read()
702
+ return bool(
703
+ re.search(r'<#import', content) or
704
+ re.search(r'<#\w+', content) or # Any FreeMarker directive
705
+ re.search(r'<@\w+\.', content) # Any FreeMarker macro
706
+ )
707
+ except (IOError, UnicodeDecodeError):
708
+ return False