rolfedh-doc-utils 0.1.38__py3-none-any.whl → 0.1.40__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.
- convert_freemarker_to_asciidoc.py +288 -0
- doc_utils/convert_freemarker_to_asciidoc.py +708 -0
- doc_utils/duplicate_content.py +409 -0
- doc_utils/duplicate_includes.py +347 -0
- doc_utils/inventory_conditionals.py +164 -0
- doc_utils/unused_attributes.py +48 -0
- doc_utils/version.py +1 -1
- find_duplicate_content.py +209 -0
- find_duplicate_includes.py +198 -0
- find_unused_attributes.py +16 -1
- inventory_conditionals.py +53 -0
- {rolfedh_doc_utils-0.1.38.dist-info → rolfedh_doc_utils-0.1.40.dist-info}/METADATA +2 -1
- {rolfedh_doc_utils-0.1.38.dist-info → rolfedh_doc_utils-0.1.40.dist-info}/RECORD +17 -9
- {rolfedh_doc_utils-0.1.38.dist-info → rolfedh_doc_utils-0.1.40.dist-info}/WHEEL +1 -1
- {rolfedh_doc_utils-0.1.38.dist-info → rolfedh_doc_utils-0.1.40.dist-info}/entry_points.txt +4 -0
- {rolfedh_doc_utils-0.1.38.dist-info → rolfedh_doc_utils-0.1.40.dist-info}/top_level.txt +4 -0
- {rolfedh_doc_utils-0.1.38.dist-info → rolfedh_doc_utils-0.1.40.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|