diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..648e75e3a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev-v25.2", + "runtimeExecutable": "uv", + "runtimeArgs": [ + "run", + "zensical", + "serve", + "-a", + "localhost:8123" + ], + "cwd": "build/v25.2", + "port": 8123 + }, + { + "name": "serve-dist", + "runtimeExecutable": "uv", + "runtimeArgs": [ + "run", + "python", + "-m", + "http.server", + "8003", + "--directory", + "dist" + ], + "port": 8003 + } + ] +} diff --git a/.gitignore b/.gitignore index 09109dfa2..e347f0b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Pre-commit cache .cache + +# Downloaded binaries (e.g. pypandoc) +*.deb diff --git a/scripts/assets/idf-fields.css b/scripts/assets/idf-fields.css new file mode 100644 index 000000000..1dacfd5e9 --- /dev/null +++ b/scripts/assets/idf-fields.css @@ -0,0 +1,128 @@ +/* Inline pill/badge styling for IDF field metadata. + * Appears directly after #### Field: headings in the IO Reference. + * Metadata is extracted from the EnergyPlus IDD file. + */ + +/* Container */ +.field-pills { + margin: 0.2em 0 0.5em 0; + line-height: 2; +} + +/* Base pill style */ +.field-pill { + display: inline-block; + font-size: 0.75em; + padding: 0.15em 0.55em; + border-radius: 999px; + font-family: var(--md-code-font-family, monospace); + vertical-align: baseline; + white-space: nowrap; +} + +.field-pill .pill-label { + opacity: 0.65; + font-weight: 400; +} + +/* Type pill — primary accent */ +.field-pill.pill-type { + background: var(--md-accent-fg-color); + color: var(--md-accent-bg-color, #fff); + font-weight: 600; +} + +/* Units pill */ +.field-pill.pill-units { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border: 1px solid var(--md-default-fg-color--lightest); +} + +/* Default pill */ +.field-pill.pill-default { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border: 1px solid var(--md-default-fg-color--lightest); +} + +/* Range pill */ +.field-pill.pill-range { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border: 1px solid var(--md-default-fg-color--lightest); +} + +/* Flag pills (Required, Autosizable, etc.) */ +.field-pill.pill-flag { + background: var(--md-code-bg-color); + color: var(--md-default-fg-color--light); + border: 1px solid var(--md-default-fg-color--lightest); + font-style: italic; +} + +.field-pill.pill-required { + background: hsla(15, 80%, 55%, 0.12); + color: hsl(15, 70%, 45%); + border-color: hsla(15, 70%, 55%, 0.3); + font-weight: 600; + font-style: normal; +} + +/* Dark mode override for Required */ +[data-md-color-scheme="slate"] .field-pill.pill-required { + background: hsla(15, 80%, 55%, 0.18); + color: hsl(15, 70%, 65%); + border-color: hsla(15, 70%, 55%, 0.35); +} + +/* Choices row */ +.field-choices { + margin-top: 0.15em; + line-height: 1.9; +} + +.field-choices code.pill-choice { + font-size: 0.8em; + padding: 0.1em 0.4em; + border-radius: 3px; + margin-right: 0.2em; +} + +/* ── Object separator ── */ +hr.idf-object-separator { + border: none; + border-top: 2px solid var(--md-default-fg-color--lightest); + margin: 2.5em 0 1em; +} + +/* ── Sticky object headings ── + * Any h2/h3 immediately after the separator sticks to the top of the + * viewport while the user scrolls through that object's fields. + */ +hr.idf-object-separator + h2, +hr.idf-object-separator + h3 { + position: sticky; + top: 0; + z-index: 2; + background: var(--md-default-bg-color); + padding-top: 0.4em; + padding-bottom: 0.3em; + margin-top: 0; + /* subtle bottom edge so the heading doesn't blend into content */ + box-shadow: 0 1px 0 var(--md-default-fg-color--lightest); +} + +/* When header is hidden on scroll (e.g. Material "header.autohide"), + * adjust sticky offset to account for the header height. */ +[data-md-header="shadow"] hr.idf-object-separator + h2, +[data-md-header="shadow"] hr.idf-object-separator + h3 { + top: 0; +} + +/* When the Material header is visible, offset below it. + * Material's header is ~3.6rem (default). */ +.md-header--shadow ~ .md-container hr.idf-object-separator + h2, +.md-header--shadow ~ .md-container hr.idf-object-separator + h3 { + top: 0; +} diff --git a/scripts/convert.py b/scripts/convert.py index 93372ca48..fcbab0968 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -33,9 +33,10 @@ IMAGE_EXTENSIONS, version_to_title, ) +from scripts.idd_parser import parse_idd from scripts.latex_preprocessor import preprocess from scripts.markdown_postprocessor import postprocess -from scripts.models import ConversionResult, DocSet, DocSetResult, LabelRef, VersionResult +from scripts.models import ConversionResult, DocSet, DocSetResult, IddObject, LabelRef, VersionResult from scripts.nav_generator import extract_heading, generate_nav, parse_input_chain logger = logging.getLogger(__name__) @@ -294,6 +295,7 @@ def convert_tex_file( doc_set_title: str = "", current_md_path: str = "", figure_numbers: list[int] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> ConversionResult: """Convert a single .tex file to Markdown via preprocessing -> Pandoc -> postprocessing.""" warnings: list[str] = [] @@ -362,6 +364,7 @@ def convert_tex_file( rel_depth=rel_depth, current_md_path=current_md_path, figure_numbers=figure_numbers, + idd_index=idd_index, ) # Write output @@ -462,6 +465,7 @@ def _convert_files( label_index: dict[str, LabelRef], max_workers: int, file_figure_numbers: dict[str, list[int]] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> list[tuple[str, ConversionResult]]: """Run file conversions, using a thread pool when *max_workers* > 1.""" if file_figure_numbers is None: @@ -480,6 +484,7 @@ def _convert_files( doc_set_title, current_md_path, file_figure_numbers.get(current_md_path), + idd_index, ): inp for inp, tex_path, output_path, rel_depth, current_md_path in tasks } @@ -499,6 +504,7 @@ def _convert_files( doc_set_title=doc_set_title, current_md_path=current_md_path, figure_numbers=file_figure_numbers.get(current_md_path), + idd_index=idd_index, ), )) return converted @@ -538,6 +544,7 @@ def convert_doc_set( *, max_workers: int = 1, file_figure_numbers: dict[str, list[int]] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> DocSetResult: """Convert all files in a doc set. @@ -553,7 +560,9 @@ def convert_doc_set( tasks = _collect_tasks(inputs, doc_set, output_dir, parent_children, result) # Phase 1: Convert files (parallel when max_workers > 1) - converted = _convert_files(tasks, doc_set.slug, doc_set.title, label_index, max_workers, file_figure_numbers) + converted = _convert_files( + tasks, doc_set.slug, doc_set.title, label_index, max_workers, file_figure_numbers, idd_index + ) # Phase 2: Log results and append TOCs (must happen after files are written) for inp, file_result in converted: @@ -650,7 +659,7 @@ def generate_zensical_config( {"path": "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.js", "async": True}, {"path": "assets/eq-tooltips.js"}, ] - project["extra_css"] = ["assets/eq-tooltips.css"] + project["extra_css"] = ["assets/eq-tooltips.css", "assets/idf-fields.css"] config_path = output_dir / "zensical.toml" config_path.write_text(tomli_w.dumps(config)) @@ -720,11 +729,26 @@ def convert_version( # Build label index across all doc sets label_index, file_figure_numbers = build_label_index(source_dir, doc_sets) + # Parse IDD for structured field metadata (IO Reference only) + idd_index: dict[str, IddObject] | None = None + idd_path = source_dir / "idd" / "Energy+.idd.in" + if idd_path.exists(): + idd_index = parse_idd(idd_path.read_text(errors="replace")) + else: + logger.warning("IDD file not found at %s, field metadata will not be injected", idd_path) + # Convert each doc set for ds in doc_sets: logger.info("Converting doc set: %s", ds.title) + # Only pass IDD index for IO Reference doc set (contains IDF object field docs) + ds_idd = idd_index if ds.slug == "io-reference" else None ds_result = convert_doc_set( - ds, output_dir, label_index, max_workers=max_workers, file_figure_numbers=file_figure_numbers + ds, + output_dir, + label_index, + max_workers=max_workers, + file_figure_numbers=file_figure_numbers, + idd_index=ds_idd, ) result.doc_set_results.append(ds_result) logger.info( diff --git a/scripts/convert_all.py b/scripts/convert_all.py index 9757d65fc..f7ad74ccf 100644 --- a/scripts/convert_all.py +++ b/scripts/convert_all.py @@ -37,7 +37,7 @@ def clone_version(version: str, clone_dir: Path) -> Path: Returns the path to the cloned repo. """ target = clone_dir / version - if target.exists() and (target / "doc").exists(): + if target.exists() and (target / "doc").exists() and (target / "idd").exists(): logger.info("Source for %s already exists, reusing", version) return target @@ -67,7 +67,7 @@ def clone_version(version: str, clone_dir: Path) -> Path: text=True, ) subprocess.run( - ["git", "-C", str(target), "sparse-checkout", "set", "doc"], + ["git", "-C", str(target), "sparse-checkout", "set", "doc", "idd"], check=True, capture_output=True, ) diff --git a/scripts/idd_parser.py b/scripts/idd_parser.py new file mode 100644 index 000000000..4bcced01b --- /dev/null +++ b/scripts/idd_parser.py @@ -0,0 +1,183 @@ +"""Parser for EnergyPlus IDD (Input Data Dictionary) files. + +Extracts structured metadata for each IDF object and its fields, +including types, units, defaults, ranges, choices, and flags. +""" + +from __future__ import annotations + +import logging +import re + +from scripts.models import IddField, IddObject + +logger = logging.getLogger(__name__) + + +def parse_idd(idd_text: str) -> dict[str, IddObject]: + """Parse an Energy+.idd.in file into a dict mapping object names to IddObject. + + Args: + idd_text: Full text content of the IDD file. + + Returns: + Dictionary mapping object names (e.g., "Pump:ConstantSpeed") to their + IddObject definitions with all field metadata. + """ + # Strip comment-only lines (starting with !) but keep inline \-directives + lines = idd_text.splitlines() + cleaned_lines: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("!"): + continue + cleaned_lines.append(line) + + text = "\n".join(cleaned_lines) + + # Split into object blocks. Each object ends with a semicolon on its last field. + # Objects are separated by their name line (a line starting with a letter and ending with comma). + objects = _split_objects(text) + + result: dict[str, IddObject] = {} + for obj_text in objects: + obj = _parse_object(obj_text) + if obj: + result[obj.name] = obj + + logger.info("Parsed %d IDD objects", len(result)) + return result + + +def _split_objects(text: str) -> list[str]: + """Split IDD text into individual object definition blocks.""" + # An object starts with a line like "ObjectName," or "Object:SubName," + # at the beginning of a line (no leading whitespace). + # We split on these boundaries. + object_pattern = re.compile(r"^([A-Z][A-Za-z0-9:_\- ]*),\s*$", re.MULTILINE) + + objects: list[str] = [] + matches = list(object_pattern.finditer(text)) + + for i, match in enumerate(matches): + start = match.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + objects.append(text[start:end].strip()) + + return objects + + +def _parse_object(obj_text: str) -> IddObject | None: + """Parse a single IDD object block into an IddObject.""" + lines = obj_text.split("\n") + if not lines: + return None + + # First line is "ObjectName," + obj_name = lines[0].strip().rstrip(",").strip() + if not obj_name: + return None + + memo_parts: list[str] = [] + fields = _parse_object_fields(lines[1:], memo_parts) + + # Build fields_by_name lookup (case-insensitive on the field name) + fields_by_name: dict[str, IddField] = {f.name.lower(): f for f in fields if f.name} + + return IddObject( + name=obj_name, + memo=" ".join(memo_parts), + fields=fields, + fields_by_name=fields_by_name, + ) + + +def _parse_object_fields(lines: list[str], memo_parts: list[str]) -> list[IddField]: + """Parse field definitions from an object's body lines.""" + fields: list[IddField] = [] + current_field: IddField | None = None + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Object-level directives (before first field) + if stripped.startswith("\\") and current_field is None: + _parse_object_directive(stripped, memo_parts) + continue + + # Field definition line: "A1," or "N2," or "A1;" etc. + field_match = re.match(r"^([AN]\d+)\s*[,;]", stripped) + if field_match: + if current_field is not None: + fields.append(current_field) + current_field = IddField(name="", field_id=field_match.group(1)) + inline = stripped[field_match.end() :].strip() + if inline: + _parse_field_directive(inline, current_field) + continue + + # Field-level directives + if stripped.startswith("\\") and current_field is not None: + _parse_field_directive(stripped, current_field) + + if current_field is not None: + fields.append(current_field) + return fields + + +def _parse_object_directive(text: str, memo_parts: list[str]) -> None: + """Parse an object-level directive like \\memo, \\unique-object, etc.""" + if text.startswith("\\memo"): + memo_parts.append(text[len("\\memo") :].strip()) + + +def _parse_field_directive(text: str, field: IddField) -> None: + """Parse a field-level directive and update the IddField.""" + if text.startswith("\\field"): + field.name = text[len("\\field") :].strip() + elif text.startswith("\\type"): + field.field_type = text[len("\\type") :].strip().lower() + elif text.startswith("\\units") and not text.startswith("\\unitsBasedOnField"): + field.units = text[len("\\units") :].strip() + elif text.startswith("\\ip-units"): + field.ip_units = text[len("\\ip-units") :].strip() + elif text.startswith("\\default"): + field.default = text[len("\\default") :].strip() + elif text.startswith("\\minimum"): + _parse_bound_directive(text, "\\minimum", field, is_min=True) + elif text.startswith("\\maximum"): + _parse_bound_directive(text, "\\maximum", field, is_min=False) + else: + _parse_field_flag_directive(text, field) + + +def _parse_bound_directive(text: str, prefix: str, field: IddField, *, is_min: bool) -> None: + """Parse \\minimum or \\maximum directives, including exclusive variants (> or <).""" + exclusive = len(text) > len(prefix) and text[len(prefix)] in "<>" + directive = prefix + text[len(prefix)] if exclusive else prefix + value = text[len(directive) :].strip() + if is_min: + field.minimum = value + field.minimum_exclusive = exclusive + else: + field.maximum = value + field.maximum_exclusive = exclusive + + +def _parse_field_flag_directive(text: str, field: IddField) -> None: + """Parse flag and list directives (required, autosizable, key, note, etc.).""" + if text.startswith("\\required-field"): + field.required = True + elif text.startswith("\\autosizable"): + field.autosizable = True + elif text.startswith("\\autocalculatable"): + field.autocalculatable = True + elif text.startswith("\\key"): + key_value = text[len("\\key") :].strip() + if key_value: + field.keys.append(key_value) + elif text.startswith("\\note"): + note_text = text[len("\\note") :].strip() + field.notes = f"{field.notes} {note_text}" if field.notes else note_text diff --git a/scripts/latex_preprocessor.py b/scripts/latex_preprocessor.py index 49428468a..67e8a0014 100644 --- a/scripts/latex_preprocessor.py +++ b/scripts/latex_preprocessor.py @@ -146,34 +146,42 @@ def _find_brace_content(text: str, start: int) -> tuple[str, int] | None: } +_BRACKET_MACRO_RE = re.compile(r"\\(?:PB|RB|CB)\{") + + def _expand_all_bracket_macros(text: str) -> str: r"""Expand all ``\PB``, ``\RB``, ``\CB`` macros in *text*, inside-out. When an outer macro wraps inner macros (e.g. ``\PB{a \PB{b}}``) the inner content is recursively expanded first, so the final result contains no bracket macros regardless of nesting depth. + + Uses regex to jump to the next macro occurrence instead of scanning + every character, which is critical for large files. """ result: list[str] = [] - i = 0 - while i < len(text): - matched_macro = None - for macro in _BRACKET_MACROS: - if text[i:].startswith(macro + "{"): - matched_macro = macro - break - if matched_macro: - brace_start = i + len(matched_macro) - found = _find_brace_content(text, brace_start) - if found: - content, end = found - # Recursively expand any bracket macros inside the content - content = _expand_all_bracket_macros(content) - left, right = _BRACKET_MACROS[matched_macro] - result.append(f"{left} {content} {right}") - i = end - continue - result.append(text[i]) - i += 1 + pos = 0 + while True: + m = _BRACKET_MACRO_RE.search(text, pos) + if m is None: + result.append(text[pos:]) + break + # Append everything before this macro + result.append(text[pos : m.start()]) + macro = text[m.start() : m.end() - 1] # e.g. "\\PB" + brace_start = m.end() - 1 # position of the '{' + found = _find_brace_content(text, brace_start) + if found: + content, end = found + # Recursively expand any bracket macros inside the content + content = _expand_all_bracket_macros(content) + left, right = _BRACKET_MACROS[macro] + result.append(f"{left} {content} {right}") + pos = end + else: + # Unbalanced brace — emit macro text literally and continue + result.append(text[m.start() : m.end()]) + pos = m.end() return "".join(result) diff --git a/scripts/markdown_postprocessor.py b/scripts/markdown_postprocessor.py index 2e7b0572b..e3b99b928 100644 --- a/scripts/markdown_postprocessor.py +++ b/scripts/markdown_postprocessor.py @@ -18,7 +18,7 @@ import posixpath import re -from scripts.models import LabelRef +from scripts.models import IddField, IddObject, LabelRef # Map PDF filenames used in \href{...} to their doc-set URL slugs. # These are inter-doc-set cross-references left over from the original @@ -394,6 +394,131 @@ def clean_div_wrappers(text: str) -> str: return "\n".join(result) +_IDD_TYPE_LABELS = { + "real": "Real", + "integer": "Integer", + "alpha": "Alpha", + "choice": "Choice", + "node": "Node", + "object-list": "Object-List", + "external-list": "External-List", +} + + +def _pill(css_class: str, label: str, value: str = "") -> str: + """Build an HTML span pill/badge.""" + if value: + return f'{label} {value}' + return f'{label}' + + +def _format_range(field: IddField) -> str: + """Format min/max range constraints as a readable string.""" + parts: list[str] = [] + if field.minimum: + op = ">" if field.minimum_exclusive else "\u2265" # > or ≥ + parts.append(f"{op}\u2009{field.minimum}") + if field.maximum: + op = "<" if field.maximum_exclusive else "\u2264" # < or ≤ + parts.append(f"{op}\u2009{field.maximum}") + return ", ".join(parts) + + +def _format_field_attrs(field: IddField) -> str: + """Build inline pill/badge HTML for a field's metadata.""" + pills: list[str] = [] + + if field.field_type: + type_label = _IDD_TYPE_LABELS.get(field.field_type, field.field_type.title()) + pills.append(_pill("pill-type", type_label)) + + if field.units: + unit_str = f"{field.units} / {field.ip_units}" if field.ip_units else field.units + pills.append(_pill("pill-units", unit_str)) + + if field.default: + pills.append(_pill("pill-default", "Default:", field.default)) + + range_str = _format_range(field) + if range_str: + pills.append(_pill("pill-range", "Range:", range_str)) + + if field.required: + pills.append(_pill("pill-flag pill-required", "Required")) + if field.autosizable: + pills.append(_pill("pill-flag", "Autosizable")) + if field.autocalculatable: + pills.append(_pill("pill-flag", "Autocalculatable")) + + if not pills: + return "" + + lines = ['
{k}' for k in field.keys)
+ lines.append(f'