LNXSDK/lib/leenkx_tools/mkdocs/make_node_reference.py

292 lines
11 KiB
Python
Raw Normal View History

2025-01-22 16:18:30 +01:00
"""
Generates the logic nodes reference page for Leenkx3D's wiki:
https://github.com/leenkx3d/leenkx/wiki/reference
USAGE:
First, generate the node screenshots (1). After that, open a
terminal in the folder of this script and execute the following
command (Blender must have the Leenkx add-on activated of course):
path/to/blender.exe -b -P make_node_reference.py"
This will create markdown files containing the reference in the
`/output` folder relative to this script. You can copy the content
from those files into the logic node reference articles. DO NOT
commit the generated files to the leenkx_tools repo!
Todo: Create a GitHub action to automatically update the reference
for each release.
(1) https://github.com/leenkx3d/leenkx_wiki_images/blob/master/logic_nodes/make_screenshots.py
Please also read the usage notes in that file!
"""
import ensurepip
import itertools
import os
import subprocess
import sys
from typing import List
import bpy
from nodeitems_utils import NodeItem
from lnx.logicnode import lnx_nodes
import lnx.props
ensurepip.bootstrap()
os.environ.pop("PIP_REQ_TRACKER", None)
subprocess.check_output([sys.executable, '-m', 'pip', 'install', '--upgrade', 'markdownmaker'])
# If pip wants an update, toggle this flag before execution
UPDATE_PIP = False
if UPDATE_PIP:
subprocess.check_output([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
from markdownmaker.document import Document
from markdownmaker.markdownmaker import *
PY_NODE_DIR = "https://github.com/leenkx3d/leenkx/blob/main/blender/lnx.logicnode/"
HX_NODE_DIR = "https://github.com/leenkx3d/leenkx/blob/main/Sources/leenkx/logicnode/"
IMG_DIR = "https://github.com/leenkx3d/leenkx_wiki_images/raw/master/logic_nodes/"
OUTPUT_DIR = os.path.abspath(__file__)
OUTPUT_DIR = os.path.join(os.path.dirname(OUTPUT_DIR), "output")
def get_anchor(text: str) -> str:
"""Gets the GitHub anchor id for a link."""
return "#" + text.lower().replace(" ", "-")
def make_node_link(nodename: str) -> str:
"""Create a link to a node given by the name of the node"""
return Link(label=InlineCode(nodename), url=get_anchor(nodename))
def get_nodetype(typename: str):
"""Convert the type name to the actual type."""
return bpy.types.bpy_struct.bl_rna_get_subclass_py(typename)
def format_desc(description_text: str, *, indented=False) -> str:
"""Format the raw description string."""
out = ""
# Indentation for list items is 2 spaces per markdown standard in
# this case. For now, sub-lists are indented one level only, more
# is not required currently
line_start = " " if indented else ""
# Whether the last line was empty (ignore multiple empty lines)
last_empty = False
for line in description_text.splitlines():
line = line.strip()
# List item. Explicitly check for space after "-", might be a negative number else
if line.startswith("- "):
out += "\n" + line_start + line
last_empty = False
elif line == "":
if last_empty:
continue
out += "\n"
last_empty = True
else:
if last_empty:
out += "\n" + line_start + line # Create a full empty line above the start of a paragraph
else:
out += " " + line # Combine one paragraph to a single line
last_empty = False
# Remove any left-over whitespace at the beginning/end
return out.strip()
def generate_node_documentation(doc: Document, nodeitem: NodeItem, category: lnx_nodes.LnxNodeCategory):
nodetype = get_nodetype(nodeitem.nodetype)
docstring: str = nodetype.__doc__
if docstring is not None:
doc_parts = docstring.split("@")
# Show docstring until the first "@"
node_description = doc_parts[0].rstrip("\n")
node_description = format_desc(node_description)
deprecation_note = Optional()
doc.add(deprecation_note)
doc.add(Paragraph(node_description))
has_see = False
has_inputs = False
has_outputs = False
has_options = False
see_list = []
input_list = []
output_list = []
option_list = []
for part in doc_parts:
# Reference to other logic nodes
if part.startswith("seeNode "):
if not has_see:
has_see = True
doc.add(Paragraph(Bold("See also:")))
doc.add(UnorderedList(see_list))
see_list.append(Italic(make_node_link(part[8:].rstrip())))
# General references
elif part.startswith("see "):
if not has_see:
has_see = True
doc.add(Paragraph(Bold("See also:")))
doc.add(UnorderedList(see_list))
see_list.append(Italic(part[4:].rstrip()))
# Add node screenshot
image_file = IMG_DIR + category.name.lower() + "/" + nodeitem.nodetype + ".jpg"
doc.add(Image(url=image_file, alt_text=nodeitem.label + " node"))
for part in doc_parts:
# Input sockets
if part.startswith("input "):
if not has_inputs:
has_inputs = True
doc.add(Paragraph(Bold("Inputs:")))
doc.add(UnorderedList(input_list))
socket_name, description = part[6:].split(":", 1)
description = format_desc(description, indented=True)
input_list.append(f"{InlineCode(socket_name)}: {description}")
# Output sockets
elif part.startswith("output "):
if not has_outputs:
has_outputs = True
doc.add(Paragraph(Bold("Outputs:")))
doc.add(UnorderedList(output_list))
socket_name, description = part[7:].split(":", 1)
description = format_desc(description, indented=True)
output_list.append(f"{InlineCode(socket_name)}: {description}")
# Other UI options
elif part.startswith("option "):
if not has_options:
has_options = True
doc.add(Paragraph(Bold("Options:")))
doc.add(UnorderedList(option_list))
option_name, description = part[7:].split(":", 1)
description = format_desc(description, indented=True)
option_list.append(f"{InlineCode(option_name)}: {description}")
elif part.startswith("deprecated "):
alternatives, message = part[11:].split(":", 1)
message = " ".join(message.split()).replace("\n", "")
if not message.endswith(".") and not message == "":
message += "."
links = []
for alternative in alternatives.split(","):
if alternative == "":
continue
links.append(str(make_node_link(alternative)))
if len(links) > 0:
alternatives = f"Please use the following node(s) instead: {', '.join(links)}."
message = alternatives + " " + message
deprecation_note.content = Quote(f"{Bold('DEPRECATED.')} This node is deprecated and will be removed in future versions of Leenkx. {message}")
# Link to sources
node_file_py = "/".join(nodetype.__module__.split(".")[2:]) + ".py"
node_file_hx = nodetype.bl_idname[2:] + ".hx" # Discard LN prefix
pylink = Link(label="Python", url=PY_NODE_DIR + node_file_py)
hxlink = Link(label="Haxe", url=HX_NODE_DIR + node_file_hx)
doc.add(Paragraph(f"{Bold('Sources:')} {pylink} | {hxlink}"))
def build_page(section_name: str = ""):
is_mainpage = section_name == ""
doc = Document()
doc.add(Header("Logic Nodes Reference" + ("" if is_mainpage else f": {Italic(section_name.capitalize())} nodes")))
doc.add(Paragraph(Italic(
"This reference was generated automatically. Please do not edit the"
" page directly, instead change the docstrings of the nodes in their"
f" {Link(label='Python files', url='https://github.com/leenkx3d/leenkx/tree/main/blender/lnx.logicnode')}"
f" or the {Link(label='generator script', url='https://github.com/leenkx3d/leenkx_tools/blob/main/mkdocs/make_node_reference.py')}"
f" and {Link(label='open a pull request', url='https://github.com/leenkx3d/leenkx/wiki/contribute#creating-a-pull-request')}."
" Thank you for contributing!")))
doc.add(Paragraph(Italic(f"This reference was built for {Bold(f'Leenkx {lnx.props.lnx_version}')}.")))
doc.add(HorizontalRule())
with HeaderSubLevel(doc):
# Table of contents
doc.add(Header("Node Categories"))
category_items: List[Node] = []
for section, section_categories in lnx_nodes.category_items.items():
# Ignore empty sections ("default" e.g)
if len(section_categories) > 0:
section_title = Bold(section.capitalize())
if section_name == section:
# Highlight current page
section_title = Italic(section_title)
category_items.append(section_title)
url = f"https://github.com/leenkx3d/leenkx/wiki/reference_{section}"
category_items.append(UnorderedList([Link(c.name, url + get_anchor(c.name)) for c in section_categories]))
doc.add(UnorderedList(category_items))
# Page content
if not is_mainpage:
for category in lnx_nodes.category_items[section_name]:
doc.add(Header(category.name))
if category.description != "":
doc.add(Paragraph(category.description))
with HeaderSubLevel(doc):
# Sort nodes alphabetically and discard section order
iterator = itertools.chain(category.get_all_nodes(), category.deprecated_nodes)
for nodeitem in sorted(iterator, key=lambda n: n.label):
doc.add(Header(nodeitem.label))
generate_node_documentation(doc, nodeitem, category)
filename = "reference.md" if is_mainpage else f"reference_{section_name}.md"
with open(os.path.join(OUTPUT_DIR, filename), "w") as out_file:
out_file.write(doc.write())
def run():
print("Generating documentation...")
if not os.path.exists(OUTPUT_DIR):
os.mkdir(OUTPUT_DIR)
# Main page
build_page()
# Section sub-pages
for section_name in lnx_nodes.category_items.keys():
if section_name == 'default':
continue
build_page(section_name)
if __name__ == "__main__":
run()