|
#!/usr/bin/env python3 |
|
"""Generate Word and/or PDF documents from Inertia.js MDX documentation.""" |
|
|
|
import argparse |
|
import json |
|
import re |
|
import os |
|
import subprocess |
|
import sys |
|
from docx import Document |
|
from docx.shared import Pt, Cm, RGBColor |
|
from docx.enum.text import WD_ALIGN_PARAGRAPH |
|
from docx.enum.style import WD_STYLE_TYPE |
|
from docx.enum.table import WD_TABLE_ALIGNMENT |
|
from docx.oxml.ns import nsdecls |
|
from docx.oxml import parse_xml |
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
|
|
|
|
|
def load_version_config(version): |
|
"""Load pages and section groups from docs.json for a given version.""" |
|
docs_path = os.path.join(BASE_DIR, 'docs.json') |
|
with open(docs_path, 'r', encoding='utf-8') as f: |
|
docs = json.load(f) |
|
|
|
version_key = f"{version}.x" |
|
pages = [] |
|
section_groups = {} |
|
|
|
for ver in docs['navigation']['versions']: |
|
if ver['version'] == version_key: |
|
for group in ver['groups']: |
|
group_name = group['group'] |
|
for idx, page in enumerate(group['pages']): |
|
pages.append(page) |
|
if idx == 0: |
|
section_groups[page] = group_name |
|
break |
|
else: |
|
available = [v['version'] for v in docs['navigation']['versions']] |
|
print(f"Error: Version '{version_key}' not found. Available: {', '.join(available)}") |
|
sys.exit(1) |
|
|
|
return pages, section_groups |
|
|
|
|
|
def setup_styles(doc): |
|
"""Set up document styles.""" |
|
style = doc.styles['Normal'] |
|
font = style.font |
|
font.name = 'Calibri' |
|
font.size = Pt(11) |
|
font.color.rgb = RGBColor(0x33, 0x33, 0x33) |
|
style.paragraph_format.space_after = Pt(6) |
|
style.paragraph_format.line_spacing = 1.15 |
|
|
|
title_style = doc.styles['Title'] |
|
title_style.font.size = Pt(36) |
|
title_style.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
title_style.font.bold = True |
|
title_style.paragraph_format.space_after = Pt(4) |
|
|
|
h1 = doc.styles['Heading 1'] |
|
h1.font.size = Pt(28) |
|
h1.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
h1.font.bold = True |
|
h1.paragraph_format.space_before = Pt(24) |
|
h1.paragraph_format.space_after = Pt(12) |
|
h1.paragraph_format.page_break_before = True |
|
|
|
h2 = doc.styles['Heading 2'] |
|
h2.font.size = Pt(22) |
|
h2.font.color.rgb = RGBColor(0x1E, 0x40, 0xAF) |
|
h2.font.bold = True |
|
h2.paragraph_format.space_before = Pt(18) |
|
h2.paragraph_format.space_after = Pt(8) |
|
|
|
h3 = doc.styles['Heading 3'] |
|
h3.font.size = Pt(16) |
|
h3.font.color.rgb = RGBColor(0x1E, 0x3A, 0x8A) |
|
h3.font.bold = True |
|
h3.paragraph_format.space_before = Pt(14) |
|
h3.paragraph_format.space_after = Pt(6) |
|
|
|
h4 = doc.styles['Heading 4'] |
|
h4.font.size = Pt(13) |
|
h4.font.color.rgb = RGBColor(0x1E, 0x3A, 0x8A) |
|
h4.font.bold = True |
|
h4.paragraph_format.space_before = Pt(10) |
|
h4.paragraph_format.space_after = Pt(4) |
|
|
|
if 'Code Block' not in [s.name for s in doc.styles]: |
|
cs = doc.styles.add_style('Code Block', WD_STYLE_TYPE.PARAGRAPH) |
|
cs.font.name = 'Consolas' |
|
cs.font.size = Pt(9) |
|
cs.font.color.rgb = RGBColor(0xE8, 0xE8, 0xE8) |
|
cs.paragraph_format.space_before = Pt(2) |
|
cs.paragraph_format.space_after = Pt(2) |
|
cs.paragraph_format.line_spacing = 1.0 |
|
cs.paragraph_format.left_indent = Cm(0.5) |
|
|
|
if 'Code Label' not in [s.name for s in doc.styles]: |
|
cl = doc.styles.add_style('Code Label', WD_STYLE_TYPE.PARAGRAPH) |
|
cl.font.name = 'Calibri' |
|
cl.font.size = Pt(9) |
|
cl.font.bold = True |
|
cl.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) |
|
cl.paragraph_format.space_before = Pt(8) |
|
cl.paragraph_format.space_after = Pt(0) |
|
cl.paragraph_format.left_indent = Cm(0.5) |
|
|
|
if 'Callout' not in [s.name for s in doc.styles]: |
|
co = doc.styles.add_style('Callout', WD_STYLE_TYPE.PARAGRAPH) |
|
co.font.name = 'Calibri' |
|
co.font.size = Pt(10) |
|
co.font.color.rgb = RGBColor(0x33, 0x33, 0x33) |
|
co.paragraph_format.space_before = Pt(6) |
|
co.paragraph_format.space_after = Pt(6) |
|
co.paragraph_format.left_indent = Cm(1) |
|
|
|
if 'Block Quote' not in [s.name for s in doc.styles]: |
|
bq = doc.styles.add_style('Block Quote', WD_STYLE_TYPE.PARAGRAPH) |
|
bq.font.name = 'Calibri' |
|
bq.font.size = Pt(11) |
|
bq.font.italic = True |
|
bq.font.color.rgb = RGBColor(0x55, 0x55, 0x55) |
|
bq.paragraph_format.left_indent = Cm(1) |
|
bq.paragraph_format.space_before = Pt(6) |
|
bq.paragraph_format.space_after = Pt(6) |
|
|
|
if 'List Bullet' in [s.name for s in doc.styles]: |
|
lb = doc.styles['List Bullet'] |
|
lb.font.name = 'Calibri' |
|
lb.font.size = Pt(11) |
|
|
|
if 'Step Title' not in [s.name for s in doc.styles]: |
|
st = doc.styles.add_style('Step Title', WD_STYLE_TYPE.PARAGRAPH) |
|
st.font.name = 'Calibri' |
|
st.font.size = Pt(12) |
|
st.font.bold = True |
|
st.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
st.paragraph_format.space_before = Pt(10) |
|
st.paragraph_format.space_after = Pt(4) |
|
|
|
return doc |
|
|
|
|
|
def add_shaded_background(paragraph, bg_color): |
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{bg_color}"/>') |
|
paragraph._element.get_or_add_pPr().append(shading) |
|
|
|
|
|
def add_code_block(doc, code_text, language="", label=""): |
|
if label: |
|
label_para = doc.add_paragraph(style='Code Label') |
|
label_para.add_run(f" {label}") |
|
add_shaded_background(label_para, "2D2D2D") |
|
|
|
lines = code_text.split('\n') |
|
while lines and not lines[0].strip(): |
|
lines.pop(0) |
|
while lines and not lines[-1].strip(): |
|
lines.pop() |
|
|
|
for line in lines: |
|
display_line = re.sub(r'\s*//\s*\[!code\s*[+-]+(?::\d+)?\]', '', line) |
|
para = doc.add_paragraph(style='Code Block') |
|
add_shaded_background(para, "1E1E1E") |
|
run = para.add_run(display_line if display_line else " ") |
|
run.font.name = 'Consolas' |
|
run.font.size = Pt(9) |
|
|
|
if '// [!code ++]' in line or '// [!code ++:' in line: |
|
run.font.color.rgb = RGBColor(0x6A, 0xCB, 0x6A) |
|
elif '// [!code --]' in line or '// [!code --:' in line: |
|
run.font.color.rgb = RGBColor(0xF8, 0x72, 0x72) |
|
else: |
|
run.font.color.rgb = RGBColor(0xD4, 0xD4, 0xD4) |
|
|
|
spacer = doc.add_paragraph() |
|
spacer.paragraph_format.space_before = Pt(0) |
|
spacer.paragraph_format.space_after = Pt(4) |
|
spacer.paragraph_format.line_spacing = 0.5 |
|
|
|
|
|
def add_inline_formatting(paragraph, text): |
|
if not text: |
|
return |
|
|
|
pattern = re.compile( |
|
r'(`[^`]+`)' |
|
r'|(\*\*\*[^*]+\*\*\*)' |
|
r'|(\*\*[^*]+\*\*)' |
|
r'|(\*[^*]+\*)' |
|
r'|(\[([^\]]+)\]\(([^)]+)\))' |
|
r'|(~~[^~]+~~)' |
|
) |
|
|
|
pos = 0 |
|
for match in pattern.finditer(text): |
|
if match.start() > pos: |
|
paragraph.add_run(text[pos:match.start()]) |
|
|
|
if match.group(1): |
|
run = paragraph.add_run(match.group(1)[1:-1]) |
|
run.font.name = 'Consolas' |
|
run.font.size = Pt(9.5) |
|
run.font.color.rgb = RGBColor(0xC7, 0x25, 0x4E) |
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="F5F5F5"/>') |
|
run._element.get_or_add_rPr().append(shading) |
|
elif match.group(2): |
|
run = paragraph.add_run(match.group(2)[3:-3]) |
|
run.bold = True |
|
run.italic = True |
|
elif match.group(3): |
|
run = paragraph.add_run(match.group(3)[2:-2]) |
|
run.bold = True |
|
elif match.group(4): |
|
run = paragraph.add_run(match.group(4)[1:-1]) |
|
run.italic = True |
|
elif match.group(5): |
|
run = paragraph.add_run(match.group(6)) |
|
run.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
run.underline = True |
|
elif match.group(8): |
|
run = paragraph.add_run(match.group(8)[2:-2]) |
|
run.font.strike = True |
|
|
|
pos = match.end() |
|
|
|
if pos < len(text): |
|
paragraph.add_run(text[pos:]) |
|
|
|
|
|
def add_callout(doc, callout_type, content): |
|
colors = { |
|
'Warning': ('FFF3CD', '856404'), |
|
'Tip': ('D1ECF1', '0C5460'), |
|
'Note': ('E2E3E5', '383D41'), |
|
'Info': ('CCE5FF', '004085'), |
|
} |
|
bg_color, text_color = colors.get(callout_type, ('E2E3E5', '383D41')) |
|
|
|
para = doc.add_paragraph(style='Callout') |
|
add_shaded_background(para, bg_color) |
|
label_run = para.add_run(f"{callout_type.upper()}: ") |
|
label_run.bold = True |
|
label_run.font.color.rgb = RGBColor(int(text_color[:2], 16), int(text_color[2:4], 16), int(text_color[4:], 16)) |
|
add_inline_formatting(para, content.strip()) |
|
|
|
|
|
def add_table(doc, header_row, data_rows): |
|
if not header_row: |
|
return |
|
|
|
num_cols = len(header_row) |
|
table = doc.add_table(rows=1, cols=num_cols) |
|
table.style = 'Table Grid' |
|
table.alignment = WD_TABLE_ALIGNMENT.LEFT |
|
|
|
hdr_cells = table.rows[0].cells |
|
for i, header in enumerate(header_row): |
|
if i < len(hdr_cells): |
|
hdr_cells[i].text = '' |
|
p = hdr_cells[i].paragraphs[0] |
|
run = p.add_run(header.strip()) |
|
run.bold = True |
|
run.font.size = Pt(10) |
|
run.font.name = 'Calibri' |
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="E8F0FE"/>') |
|
hdr_cells[i]._element.get_or_add_tcPr().append(shading) |
|
|
|
for row_data in data_rows: |
|
row = table.add_row() |
|
for i, cell_text in enumerate(row_data): |
|
if i < num_cols: |
|
row.cells[i].text = '' |
|
p = row.cells[i].paragraphs[0] |
|
add_inline_formatting(p, cell_text.strip()) |
|
if p.runs: |
|
p.runs[0].font.size = Pt(10) |
|
|
|
doc.add_paragraph() |
|
|
|
|
|
def parse_table_row(line): |
|
line = line.strip() |
|
if line.startswith('|'): |
|
line = line[1:] |
|
if line.endswith('|'): |
|
line = line[:-1] |
|
return [cell.strip() for cell in line.split('|')] |
|
|
|
|
|
def is_separator_row(line): |
|
cleaned = line.strip().replace('|', '').replace('-', '').replace(':', '').replace(' ', '') |
|
return len(cleaned) == 0 and '---' in line |
|
|
|
|
|
def extract_frontmatter_title(content): |
|
match = re.match(r'^---\s*\n(.*?)\n---', content, re.DOTALL) |
|
if match: |
|
title_match = re.search(r'title:\s*(.+)', match.group(1)) |
|
if title_match: |
|
return title_match.group(1).strip().strip('"').strip("'") |
|
return None |
|
|
|
|
|
def strip_frontmatter(content): |
|
return re.sub(r'^---\s*\n.*?\n---\s*\n', '', content, count=1, flags=re.DOTALL) |
|
|
|
|
|
def strip_imports(content): |
|
return re.sub(r'^import\s+.*$', '', content, flags=re.MULTILINE) |
|
|
|
|
|
def clean_jsx_tags(text): |
|
text = text.replace('{" "}', ' ') |
|
text = re.sub(r'<V3BetaWarning\s*/>', '', text) |
|
return text |
|
|
|
|
|
def get_beta_warning_text(version): |
|
snippet_path = os.path.join(BASE_DIR, 'snippets', 'v3-beta-warning.mdx') |
|
if version == 3 and os.path.exists(snippet_path): |
|
with open(snippet_path, 'r') as f: |
|
content = f.read() |
|
match = re.search(r'<Warning>(.+?)</Warning>', content, re.DOTALL) |
|
if match: |
|
# Strip markdown links to plain text |
|
text = match.group(1).strip() |
|
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) |
|
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) |
|
return text |
|
return None |
|
|
|
|
|
def process_mdx_file(doc, filepath, page_key, section_groups, beta_warning): |
|
with open(filepath, 'r', encoding='utf-8') as f: |
|
content = f.read() |
|
|
|
title = extract_frontmatter_title(content) |
|
content = strip_frontmatter(content) |
|
content = strip_imports(content) |
|
content = clean_jsx_tags(content) |
|
|
|
if page_key in section_groups: |
|
doc.add_heading(section_groups[page_key], level=1) |
|
|
|
if title: |
|
doc.add_heading(title, level=2) |
|
|
|
if beta_warning: |
|
add_callout(doc, 'Warning', beta_warning) |
|
|
|
lines = content.split('\n') |
|
i = 0 |
|
in_code_block = False |
|
code_block_lines = [] |
|
code_lang = "" |
|
code_label = "" |
|
in_table = False |
|
table_header = None |
|
table_rows = [] |
|
in_callout = False |
|
callout_type = "" |
|
callout_content = "" |
|
step_num = [0] |
|
list_items = [] |
|
in_list = False |
|
ordered_list = False |
|
|
|
def flush_list(): |
|
nonlocal list_items, in_list, ordered_list |
|
if not list_items: |
|
return |
|
for item in list_items: |
|
style = 'List Number' if ordered_list else 'List Bullet' |
|
p = doc.add_paragraph(style=style) |
|
add_inline_formatting(p, item) |
|
list_items = [] |
|
in_list = False |
|
|
|
while i < len(lines): |
|
line = lines[i] |
|
|
|
if re.match(r'^\s*</?(?:CodeGroup|ClientSpecific|Columns|Steps)(?:\s[^>]*)?\s*/?>\s*$', line): |
|
i += 1 |
|
continue |
|
|
|
if re.match(r'^\s*<(?:V3BetaWarning)\s*/>\s*$', line): |
|
i += 1 |
|
continue |
|
|
|
if re.match(r'^\s*</?(?:VueSpecific|ReactSpecific|SvelteSpecific)>\s*$', line): |
|
i += 1 |
|
continue |
|
|
|
# Code block start |
|
if line.strip().startswith('```') and not in_code_block: |
|
flush_list() |
|
in_code_block = True |
|
code_block_lines = [] |
|
header = line.strip()[3:].strip() |
|
code_lang = "" |
|
code_label = "" |
|
if header: |
|
parts = header.split(' ', 1) |
|
code_lang = parts[0] if parts else "" |
|
label_match = re.search(r'(\w+)\s+icon=', header) |
|
if label_match: |
|
code_label = label_match.group(1) |
|
if code_lang: |
|
code_label = f"{code_label} ({code_lang})" |
|
elif len(parts) > 1 and 'icon=' not in parts[1]: |
|
code_label = parts[1].strip() |
|
else: |
|
code_label = code_lang if code_lang else "" |
|
if code_lang == 'mermaid': |
|
code_label = "Diagram" |
|
i += 1 |
|
continue |
|
|
|
# Code block end |
|
if line.strip() == '```' and in_code_block: |
|
in_code_block = False |
|
add_code_block(doc, '\n'.join(code_block_lines), code_lang, code_label) |
|
code_block_lines = [] |
|
i += 1 |
|
continue |
|
|
|
if in_code_block: |
|
code_block_lines.append(line) |
|
i += 1 |
|
continue |
|
|
|
# Multi-line callout |
|
callout_match = re.match(r'^\s*<(Warning|Tip|Note|Info)>\s*$', line) |
|
if callout_match: |
|
flush_list() |
|
in_callout = True |
|
callout_type = callout_match.group(1) |
|
callout_content = "" |
|
i += 1 |
|
continue |
|
|
|
# Single-line callout |
|
callout_single = re.match(r'^\s*<(Warning|Tip|Note|Info)>(.+?)</\1>\s*$', line) |
|
if callout_single: |
|
flush_list() |
|
add_callout(doc, callout_single.group(1), callout_single.group(2)) |
|
i += 1 |
|
continue |
|
|
|
if in_callout: |
|
if re.match(rf'^\s*</{callout_type}>\s*$', line): |
|
in_callout = False |
|
add_callout(doc, callout_type, callout_content) |
|
callout_content = "" |
|
i += 1 |
|
continue |
|
callout_content += line + "\n" |
|
i += 1 |
|
continue |
|
|
|
# Step |
|
step_match = re.match(r'^\s*<Step\s+title="([^"]+)"', line) |
|
if step_match: |
|
flush_list() |
|
step_num[0] += 1 |
|
p = doc.add_paragraph(style='Step Title') |
|
p.add_run(f"Step {step_num[0]}: {step_match.group(1)}") |
|
i += 1 |
|
continue |
|
|
|
if re.match(r'^\s*</Step>\s*$', line): |
|
i += 1 |
|
continue |
|
|
|
# Card |
|
if re.match(r'^\s*<Card\s+', line): |
|
flush_list() |
|
card_text = line |
|
while '>' not in card_text or (card_text.count('<') > card_text.count('>')): |
|
i += 1 |
|
if i < len(lines): |
|
card_text += '\n' + lines[i] |
|
else: |
|
break |
|
|
|
title_m = re.search(r'title="([^"]+)"', card_text) |
|
|
|
if '/>' in card_text: |
|
if title_m: |
|
p = doc.add_paragraph() |
|
run = p.add_run(title_m.group(1)) |
|
run.bold = True |
|
run.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
i += 1 |
|
continue |
|
|
|
card_content = "" |
|
i += 1 |
|
while i < len(lines): |
|
if re.match(r'^\s*</Card>\s*$', lines[i]): |
|
break |
|
card_content += lines[i] + "\n" |
|
i += 1 |
|
|
|
if title_m: |
|
p = doc.add_paragraph() |
|
run = p.add_run(title_m.group(1)) |
|
run.bold = True |
|
run.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
if card_content.strip(): |
|
p2 = doc.add_paragraph() |
|
add_inline_formatting(p2, card_content.strip()) |
|
i += 1 |
|
continue |
|
|
|
# ParamField |
|
if re.match(r'^\s*<ParamField\s+', line): |
|
flush_list() |
|
param_text = line |
|
while '>' not in line or not line.strip().endswith('>'): |
|
if '>' in param_text: |
|
break |
|
i += 1 |
|
if i < len(lines): |
|
param_text += ' ' + lines[i] |
|
line = lines[i] |
|
else: |
|
break |
|
|
|
header_m = re.search(r'header="([^"]+)"', param_text) |
|
body_m = re.search(r'body="([^"]+)"', param_text) |
|
type_m = re.search(r'type="([^"]+)"', param_text) |
|
param_name = (header_m or body_m).group(1) if (header_m or body_m) else "" |
|
param_type = type_m.group(1) if type_m else "" |
|
|
|
param_content = "" |
|
i += 1 |
|
while i < len(lines): |
|
if re.match(r'^\s*</ParamField>\s*$', lines[i]): |
|
break |
|
param_content += lines[i] + "\n" |
|
i += 1 |
|
|
|
p = doc.add_paragraph() |
|
if param_name: |
|
run = p.add_run(param_name) |
|
run.font.name = 'Consolas' |
|
run.font.size = Pt(10) |
|
run.bold = True |
|
run.font.color.rgb = RGBColor(0xC7, 0x25, 0x4E) |
|
if param_type: |
|
run = p.add_run(f" ({param_type})") |
|
run.font.size = Pt(10) |
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) |
|
if param_content.strip(): |
|
p2 = doc.add_paragraph() |
|
p2.paragraph_format.left_indent = Cm(1) |
|
add_inline_formatting(p2, param_content.strip()) |
|
i += 1 |
|
continue |
|
|
|
# Video (single-line) |
|
video_match = re.match(r'^\s*<[Vv]ideo\s+.*?src="([^"]+)".*?/?>\s*$', line) |
|
if video_match: |
|
flush_list() |
|
p = doc.add_paragraph() |
|
run = p.add_run(f"[Video: {video_match.group(1)}]") |
|
run.italic = True |
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) |
|
i += 1 |
|
continue |
|
|
|
# Video (multi-line) |
|
if re.match(r'^\s*<video\s+', line) and '/>' not in line and '</video>' not in line: |
|
flush_list() |
|
video_text = line |
|
while i < len(lines) - 1 and '</video>' not in video_text: |
|
i += 1 |
|
video_text += lines[i] |
|
src_m = re.search(r'src="([^"]+)"', video_text) |
|
if src_m: |
|
p = doc.add_paragraph() |
|
run = p.add_run(f"[Video: {src_m.group(1)}]") |
|
run.italic = True |
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) |
|
i += 1 |
|
continue |
|
|
|
# Skip misc JSX tags |
|
if re.match(r'^\s*</?(?:div|span|button|Accordion|AccordionGroup|InfiniteScroll|Deferred|WhenVisible|Form|Link)(?:\s[^>]*)?\s*/?>\s*$', line): |
|
i += 1 |
|
continue |
|
|
|
# Table |
|
if '|' in line and not line.strip().startswith('<'): |
|
cells = parse_table_row(line) |
|
if len(cells) >= 2: |
|
if not in_table: |
|
flush_list() |
|
in_table = True |
|
table_header = cells |
|
i += 1 |
|
continue |
|
elif is_separator_row(line): |
|
i += 1 |
|
continue |
|
else: |
|
table_rows.append(cells) |
|
i += 1 |
|
continue |
|
|
|
if in_table and ('|' not in line or line.strip().startswith('<')): |
|
add_table(doc, table_header, table_rows) |
|
in_table = False |
|
table_header = None |
|
table_rows = [] |
|
|
|
# Headings |
|
heading_match = re.match(r'^(#{2,5})\s+(.+)', line) |
|
if heading_match: |
|
flush_list() |
|
level = len(heading_match.group(1)) |
|
doc.add_heading(heading_match.group(2).strip(), level=min(level + 1, 4)) |
|
step_num[0] = 0 |
|
i += 1 |
|
continue |
|
|
|
if re.match(r'^---+\s*$', line): |
|
i += 1 |
|
continue |
|
|
|
# Blockquote |
|
if line.strip().startswith('>'): |
|
flush_list() |
|
quote_text = line.strip()[1:].strip() |
|
while i + 1 < len(lines) and lines[i + 1].strip().startswith('>'): |
|
i += 1 |
|
quote_text += ' ' + lines[i].strip()[1:].strip() |
|
p = doc.add_paragraph(style='Block Quote') |
|
add_inline_formatting(p, quote_text) |
|
i += 1 |
|
continue |
|
|
|
# Unordered list |
|
list_match = re.match(r'^(\s*)[-*]\s+(.+)', line) |
|
if list_match: |
|
if not in_list: |
|
in_list = True |
|
ordered_list = False |
|
list_items.append(list_match.group(2)) |
|
i += 1 |
|
continue |
|
|
|
# Ordered list |
|
ol_match = re.match(r'^(\s*)\d+\.\s+(.+)', line) |
|
if ol_match: |
|
if not in_list: |
|
in_list = True |
|
ordered_list = True |
|
list_items.append(ol_match.group(2)) |
|
i += 1 |
|
continue |
|
|
|
if in_list and not re.match(r'^\s*[-*]\s+', line) and not re.match(r'^\s*\d+\.\s+', line): |
|
flush_list() |
|
|
|
if not line.strip(): |
|
i += 1 |
|
continue |
|
|
|
if re.match(r'^\s*</?[A-Z]', line.strip()): |
|
i += 1 |
|
continue |
|
|
|
# Paragraph |
|
text = line.strip() |
|
text = re.sub(r'</?(?:Vue|React|Svelte)Specific>', '', text) |
|
text = re.sub(r'</?ClientSpecific>', '', text) |
|
text = re.sub(r'\{"\s*"\}', ' ', text) |
|
|
|
if text and not text.startswith('<') and not text.startswith('</'): |
|
para_text = text |
|
while i + 1 < len(lines): |
|
next_line = lines[i + 1].strip() |
|
if (not next_line or |
|
next_line.startswith('#') or |
|
next_line.startswith('```') or |
|
next_line.startswith('|') or |
|
next_line.startswith('-') or |
|
next_line.startswith('*') or |
|
re.match(r'^\d+\.', next_line) or |
|
next_line.startswith('>') or |
|
next_line.startswith('<') or |
|
next_line.startswith('---')): |
|
break |
|
clean_next = re.sub(r'</?(?:Vue|React|Svelte|Client)Specific>', '', next_line) |
|
clean_next = re.sub(r'\{"\s*"\}', ' ', clean_next) |
|
para_text += ' ' + clean_next |
|
i += 1 |
|
p = doc.add_paragraph() |
|
add_inline_formatting(p, para_text) |
|
|
|
i += 1 |
|
|
|
if in_table: |
|
add_table(doc, table_header, table_rows) |
|
flush_list() |
|
|
|
|
|
def add_cover_page(doc, version): |
|
for _ in range(6): |
|
doc.add_paragraph() |
|
|
|
p = doc.add_paragraph() |
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
|
run = p.add_run("Inertia.js") |
|
run.font.size = Pt(48) |
|
run.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
run.bold = True |
|
|
|
p = doc.add_paragraph() |
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
|
run = p.add_run(f"Version {version}.x Documentation") |
|
run.font.size = Pt(24) |
|
run.font.color.rgb = RGBColor(0x64, 0x74, 0x8B) |
|
|
|
doc.add_paragraph() |
|
p = doc.add_paragraph() |
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
|
run = p.add_run("The Modern Monolith") |
|
run.font.size = Pt(16) |
|
run.font.color.rgb = RGBColor(0x94, 0xA3, 0xB8) |
|
run.italic = True |
|
|
|
for _ in range(4): |
|
doc.add_paragraph() |
|
|
|
p = doc.add_paragraph() |
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
|
run = p.add_run("Build modern single-page applications using classic server-side routing and controllers.") |
|
run.font.size = Pt(12) |
|
run.font.color.rgb = RGBColor(0x64, 0x74, 0x8B) |
|
|
|
doc.add_paragraph() |
|
p = doc.add_paragraph() |
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER |
|
run = p.add_run("https://inertiajs.com") |
|
run.font.size = Pt(11) |
|
run.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
|
|
doc.add_page_break() |
|
|
|
|
|
def add_toc(doc, pages, section_groups): |
|
doc.add_heading("Table of Contents", level=1) |
|
|
|
for page_key in pages: |
|
filepath = os.path.join(BASE_DIR, page_key + '.mdx') |
|
if not os.path.exists(filepath): |
|
continue |
|
|
|
with open(filepath, 'r', encoding='utf-8') as f: |
|
content = f.read() |
|
|
|
title = extract_frontmatter_title(content) |
|
if not title: |
|
continue |
|
|
|
if page_key in section_groups: |
|
p = doc.add_paragraph() |
|
p.paragraph_format.space_before = Pt(10) |
|
p.paragraph_format.space_after = Pt(2) |
|
run = p.add_run(section_groups[page_key]) |
|
run.bold = True |
|
run.font.size = Pt(13) |
|
run.font.color.rgb = RGBColor(0x25, 0x63, 0xEB) |
|
|
|
p = doc.add_paragraph() |
|
p.paragraph_format.left_indent = Cm(1) |
|
p.paragraph_format.space_before = Pt(1) |
|
p.paragraph_format.space_after = Pt(1) |
|
run = p.add_run(f" {title}") |
|
run.font.size = Pt(11) |
|
run.font.color.rgb = RGBColor(0x33, 0x33, 0x33) |
|
|
|
doc.add_page_break() |
|
|
|
|
|
def convert_to_pdf(docx_path, pdf_path): |
|
"""Convert docx to PDF using pandoc + xelatex, or pandoc alone.""" |
|
# Try pandoc with xelatex (best quality) |
|
try: |
|
result = subprocess.run( |
|
['pandoc', docx_path, '-o', pdf_path, |
|
'--pdf-engine=xelatex', |
|
'-V', 'geometry:margin=2.5cm', |
|
'-V', 'mainfont:Helvetica', |
|
'-V', 'monofont:Menlo'], |
|
capture_output=True, text=True, timeout=120 |
|
) |
|
if result.returncode == 0 and os.path.exists(pdf_path): |
|
return True |
|
print(f" pandoc+xelatex warning: {result.stderr[:200]}") |
|
except (FileNotFoundError, subprocess.TimeoutExpired): |
|
pass |
|
|
|
# Fallback: pandoc with default engine |
|
try: |
|
result = subprocess.run( |
|
['pandoc', docx_path, '-o', pdf_path], |
|
capture_output=True, text=True, timeout=120 |
|
) |
|
if result.returncode == 0 and os.path.exists(pdf_path): |
|
return True |
|
print(f" pandoc warning: {result.stderr[:200]}") |
|
except (FileNotFoundError, subprocess.TimeoutExpired): |
|
pass |
|
|
|
# Fallback: LibreOffice |
|
try: |
|
out_dir = os.path.dirname(pdf_path) |
|
result = subprocess.run( |
|
['soffice', '--headless', '--convert-to', 'pdf', '--outdir', out_dir, docx_path], |
|
capture_output=True, text=True, timeout=120 |
|
) |
|
if result.returncode == 0: |
|
return True |
|
except FileNotFoundError: |
|
pass |
|
|
|
return False |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser(description='Generate Inertia.js documentation as Word/PDF') |
|
parser.add_argument('version', type=int, help='Inertia.js version number (e.g., 1, 2, 3)') |
|
parser.add_argument('--format', choices=['docx', 'pdf', 'both'], default='both', |
|
help='Output format (default: both)') |
|
parser.add_argument('--output-dir', default=BASE_DIR, help='Output directory') |
|
args = parser.parse_args() |
|
|
|
version = args.version |
|
pages, section_groups = load_version_config(version) |
|
|
|
print(f"Generating Inertia.js v{version} documentation...") |
|
print(f"Found {len(pages)} pages across {len(section_groups)} sections") |
|
|
|
doc = Document() |
|
section = doc.sections[0] |
|
section.page_width = Cm(21) |
|
section.page_height = Cm(29.7) |
|
section.top_margin = Cm(2.5) |
|
section.bottom_margin = Cm(2.5) |
|
section.left_margin = Cm(2.5) |
|
section.right_margin = Cm(2.5) |
|
|
|
setup_styles(doc) |
|
add_cover_page(doc, version) |
|
add_toc(doc, pages, section_groups) |
|
|
|
beta_warning = get_beta_warning_text(version) |
|
|
|
for page_key in pages: |
|
filepath = os.path.join(BASE_DIR, page_key + '.mdx') |
|
if not os.path.exists(filepath): |
|
print(f" Warning: {filepath} not found, skipping") |
|
continue |
|
print(f" Processing: {page_key}") |
|
process_mdx_file(doc, filepath, page_key, section_groups, beta_warning) |
|
|
|
# Save docx |
|
docx_name = f"Inertia.js_v{version}_Documentation.docx" |
|
docx_path = os.path.join(args.output_dir, docx_name) |
|
|
|
if args.format in ('docx', 'both'): |
|
doc.save(docx_path) |
|
print(f"\nWord document saved: {docx_path}") |
|
|
|
# Save PDF |
|
if args.format in ('pdf', 'both'): |
|
pdf_name = f"Inertia.js_v{version}_Documentation.pdf" |
|
pdf_path = os.path.join(args.output_dir, pdf_name) |
|
|
|
# Always need the docx first for conversion |
|
if args.format == 'pdf': |
|
tmp_docx = docx_path + '.tmp' |
|
doc.save(tmp_docx) |
|
source = tmp_docx |
|
else: |
|
source = docx_path |
|
|
|
print("\nConverting to PDF...") |
|
if convert_to_pdf(source, pdf_path): |
|
print(f"PDF saved: {pdf_path}") |
|
else: |
|
print("PDF conversion failed. Install pandoc + xelatex or LibreOffice for PDF support.") |
|
|
|
if args.format == 'pdf' and os.path.exists(tmp_docx): |
|
os.remove(tmp_docx) |
|
|
|
print("\nDone!") |
|
|
|
|
|
if __name__ == '__main__': |
|
main() |