Skip to content

Instantly share code, notes, and snippets.

@bhaidar
Created March 6, 2026 08:32
Show Gist options
  • Select an option

  • Save bhaidar/462e7d885e47f9a26c5ece3886dd8599 to your computer and use it in GitHub Desktop.

Select an option

Save bhaidar/462e7d885e47f9a26c5ece3886dd8599 to your computer and use it in GitHub Desktop.
Claude Code slash command + Python script to generate Inertia.js docs as Word (.docx) and PDF. Usage: /generate-inertia-docs <version>
name description allowed-tools
generate-inertia-docs
Generate Inertia.js documentation as Word and PDF
Bash

Generate Inertia.js Documentation

Generate Word (.docx) and PDF documentation for the specified Inertia.js version.

Arguments

$ARGUMENTS = The Inertia.js version number (e.g., 1, 2, 3)

Instructions

Run the documentation generator script located in the Inertia.js docs repository. The script reads docs.json for the table of contents and processes all MDX files for the requested version.

  1. Ensure python-docx is installed: pip3 install python-docx 2>/dev/null
  2. Run the generator: python3 /Users/bhaidar/projects/github/inertiajs/docs/generate_docx.py $ARGUMENTS --format both
  3. Report the output file paths and sizes to the user

If the user doesn't provide a version number, ask them which version they want (1, 2, or 3).

The script generates both .docx and .pdf files in the docs repository root directory.

#!/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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment