Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6d2be6a
feat: add clickable BigLadder doc links to Inspector and SimSettings …
Ski90Moo May 24, 2026
38e13fc
fix: address PR #876 review comments
Ski90Moo May 31, 2026
7704312
feat: add 11 missing IDD type URL mappings
Ski90Moo May 31, 2026
4b4f355
feat: add 37 more IDD type URL mappings
Ski90Moo May 31, 2026
7416240
feat: add clickable doc links to sidebar category headers
Ski90Moo May 31, 2026
869fc12
feat: add doc links to Site, Facility, and Life Cycle Costs tabs
Ski90Moo May 31, 2026
241fe39
refactor: replace BIGLADDERSOFTWARE_DOC_BASE_URL macro with accessor …
Ski90Moo May 31, 2026
4641830
chore: add check_doc_urls.py script to verify BigLadder doc link anchors
Ski90Moo May 31, 2026
dda4e28
fix: correct 31 broken/wrong-page doc URL anchors in IddObjectDocUrl.hpp
Ski90Moo May 31, 2026
a1bcc57
fix: resolve final 7 broken anchors in IddObjectDocUrl.hpp
Ski90Moo Jun 1, 2026
6ab361c
style: apply clang-format to PR-touched files
Ski90Moo Jun 1, 2026
2e6866e
fix: address macumber review comments on IddObjectDocUrl and Building…
Ski90Moo Jun 1, 2026
73631da
feat: add iddGroupDocUrl() mapping OpenStudio IDD groups to BigLadder…
Ski90Moo Jun 1, 2026
82421eb
Update src/model_editor/IddObjectDocUrl.hpp
Ski90Moo Jun 3, 2026
ec5050e
refactor: consolidate all hardcoded doc URLs into IddObjectDocUrl.hpp
Ski90Moo Jun 3, 2026
bec4e6b
style: apply clang-format 18.1.3 to fix CI formatting check
Ski90Moo Jun 8, 2026
6b0a468
fix: derive BigLadder doc URL from SDK's EnergyPlus version
Ski90Moo Jun 11, 2026
37e07fe
Merge branch 'develop' into feat/doc-links-inspector
macumber Jun 21, 2026
2e6d7f1
feat: point doc links at a locally-bundled EnergyPlus docs export
Ski90Moo Jun 24, 2026
18d3310
Merge remote-tracking branch 'fork/feat/doc-links-inspector' into fea…
Ski90Moo Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ relwithdebinfo/
profile/
/super-build-shared/
/super-build-static/
/Products/

*.sublime-workspace
*.sublime-project
cmake-build-debug
Expand All @@ -39,4 +41,7 @@ clang_format.patch
conan-cache
.ccache
CMakeUserPresets.json
__pycache__
__pycache__

# Local working notes, not for commit
ENERGYPLUS_DOCS_WORKFLOW.md
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ endif()
# Or it'll download a zip/tar.gz for you
include(FindOpenStudioSDK.cmake)

# Downloads a local copy of the EnergyPlus Input Output Reference HTML documentation,
# so doc links in the app can point at it instead of a live website.
include(FindEnergyPlusDocs.cmake)

###############################################################################
# C O N A N #
###############################################################################
Expand Down Expand Up @@ -788,6 +792,7 @@ endif()
install(DIRECTORY "${openstudio_ROOT_DIR}/Radiance" DESTINATION "." COMPONENT "OpenStudioApp" USE_SOURCE_PERMISSIONS)
install(DIRECTORY "${openstudio_ROOT_DIR}/Ruby" DESTINATION "." COMPONENT "OpenStudioApp" USE_SOURCE_PERMISSIONS)
install(DIRECTORY "${openstudio_ROOT_DIR}/EnergyPlus" DESTINATION "." COMPONENT "OpenStudioApp" USE_SOURCE_PERMISSIONS)
install(DIRECTORY "${ENERGYPLUS_DOCS_DIR}" DESTINATION "EnergyPlus/doc" COMPONENT "OpenStudioApp" USE_SOURCE_PERMISSIONS)
install(DIRECTORY "${openstudio_ROOT_DIR}/Examples" DESTINATION "." COMPONENT "OpenStudioApp" USE_SOURCE_PERMISSIONS)
install(DIRECTORY "${openstudio_ROOT_DIR}/Python" DESTINATION "." COMPONENT "Python" USE_SOURCE_PERMISSIONS)

Expand Down
113 changes: 113 additions & 0 deletions FindEnergyPlusDocs.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Downloads a pre-built HTML export of EnergyPlus's Input Output Reference documentation
# and stages it for installation alongside the application, so doc links can point at a
# local copy instead of a live website.
#
# This is a stopgap: the export is produced manually today, not by a reproducible pipeline
# we control. Bump ENERGYPLUS_DOCS_VERSION/_URL/_EXPECTED_MD5 together whenever a new export
# is published for a newer bundled EnergyPlus version.

set(ENERGYPLUS_DOCS_VERSION "25.2.0")
set(ENERGYPLUS_DOCS_URL "https://drive.google.com/uc?export=download&id=1nUXQjdpX_AlCqA121Rnedr8edpS2lie5")
set(ENERGYPLUS_DOCS_EXPECTED_MD5 "f13e0a862e6a956f716d78def055a804")

set(ENERGYPLUS_DOCS_ARCHIVE_DIR "${PROJECT_BINARY_DIR}/EnergyPlusDocsArchive")
set(ENERGYPLUS_DOCS_ARCHIVE_NAME "EnergyPlus-docs-${ENERGYPLUS_DOCS_VERSION}.tar.gz")
set(ENERGYPLUS_DOCS_ARCHIVE_PATH "${ENERGYPLUS_DOCS_ARCHIVE_DIR}/${ENERGYPLUS_DOCS_ARCHIVE_NAME}")

# Where the extracted, installable HTML ends up. Kept as a cache variable so the rest of
# the build (the install() rule, and the .cxx.in path baked in for build-tree runs) can
# refer to it without re-deriving it.
set(ENERGYPLUS_DOCS_DIR "${PROJECT_BINARY_DIR}/EnergyPlus/doc/input-output-reference"
CACHE PATH "Directory containing the extracted EnergyPlus Input Output Reference HTML" FORCE)

file(MAKE_DIRECTORY "${ENERGYPLUS_DOCS_ARCHIVE_DIR}")

set(ENERGYPLUS_DOCS_HASH "")
if(EXISTS "${ENERGYPLUS_DOCS_ARCHIVE_PATH}")
file(MD5 "${ENERGYPLUS_DOCS_ARCHIVE_PATH}" ENERGYPLUS_DOCS_HASH)
endif()

set(ENERGYPLUS_DOCS_NEEDS_EXTRACT FALSE)

if(NOT EXISTS "${ENERGYPLUS_DOCS_ARCHIVE_PATH}" OR NOT "${ENERGYPLUS_DOCS_HASH}" MATCHES "${ENERGYPLUS_DOCS_EXPECTED_MD5}")
if(NOT EXISTS "${ENERGYPLUS_DOCS_ARCHIVE_PATH}")
message(STATUS "EnergyPlus docs archive doesn't exist at \"${ENERGYPLUS_DOCS_ARCHIVE_PATH}\"")
else()
message(STATUS
"Existing EnergyPlus docs archive md5sum HASH mismatch\n"
" for file: ${ENERGYPLUS_DOCS_ARCHIVE_PATH}\n"
" expected hash: [${ENERGYPLUS_DOCS_EXPECTED_MD5}]\n"
" actual hash: [${ENERGYPLUS_DOCS_HASH}]\n"
)
endif()

message(STATUS "Downloading EnergyPlus docs: ${ENERGYPLUS_DOCS_URL}")
file(DOWNLOAD "${ENERGYPLUS_DOCS_URL}" "${ENERGYPLUS_DOCS_ARCHIVE_PATH}"
SHOW_PROGRESS
INACTIVITY_TIMEOUT 900 # 15-min timeout
STATUS ENERGYPLUS_DOCS_DOWNLOAD_STATUS
)
list(GET ENERGYPLUS_DOCS_DOWNLOAD_STATUS 0 ENERGYPLUS_DOCS_DOWNLOAD_STATUS_CODE)
list(GET ENERGYPLUS_DOCS_DOWNLOAD_STATUS 1 ENERGYPLUS_DOCS_DOWNLOAD_ERROR_MSG)

if(ENERGYPLUS_DOCS_DOWNLOAD_STATUS_CODE)
message(FATAL_ERROR
"Download of EnergyPlus docs from ${ENERGYPLUS_DOCS_URL} failed: "
"status code = ${ENERGYPLUS_DOCS_DOWNLOAD_STATUS_CODE}, message = ${ENERGYPLUS_DOCS_DOWNLOAD_ERROR_MSG}"
)
endif()

file(MD5 "${ENERGYPLUS_DOCS_ARCHIVE_PATH}" ENERGYPLUS_DOCS_HASH)
if(NOT "${ENERGYPLUS_DOCS_HASH}" MATCHES "${ENERGYPLUS_DOCS_EXPECTED_MD5}")
message(FATAL_ERROR
"Download of EnergyPlus docs seemed to have worked, but archive md5sum HASH mismatch\n"
" for file: ${ENERGYPLUS_DOCS_ARCHIVE_PATH}\n"
" from URL: ${ENERGYPLUS_DOCS_URL}\n"
" expected hash: [${ENERGYPLUS_DOCS_EXPECTED_MD5}]\n"
" actual hash: [${ENERGYPLUS_DOCS_HASH}]\n"
)
endif()

message(STATUS "Download of EnergyPlus docs succeeded")
set(ENERGYPLUS_DOCS_NEEDS_EXTRACT TRUE)
endif()

if(ENERGYPLUS_DOCS_NEEDS_EXTRACT OR NOT EXISTS "${ENERGYPLUS_DOCS_DIR}")
if(EXISTS "${ENERGYPLUS_DOCS_DIR}")
file(REMOVE_RECURSE "${ENERGYPLUS_DOCS_DIR}")
endif()

set(ENERGYPLUS_DOCS_EXTRACT_STAGING "${ENERGYPLUS_DOCS_ARCHIVE_DIR}/extracted")
file(REMOVE_RECURSE "${ENERGYPLUS_DOCS_EXTRACT_STAGING}")
file(MAKE_DIRECTORY "${ENERGYPLUS_DOCS_EXTRACT_STAGING}")

execute_process(
COMMAND ${CMAKE_COMMAND} -E tar xfz "${ENERGYPLUS_DOCS_ARCHIVE_PATH}"
WORKING_DIRECTORY "${ENERGYPLUS_DOCS_EXTRACT_STAGING}"
RESULT_VARIABLE ENERGYPLUS_DOCS_EXTRACT_RESULT
)
if(NOT ENERGYPLUS_DOCS_EXTRACT_RESULT EQUAL 0)
message(FATAL_ERROR "Failed to extract ${ENERGYPLUS_DOCS_ARCHIVE_PATH}")
endif()

# The archive wraps everything in a single top-level directory; find it regardless of name.
file(GLOB ENERGYPLUS_DOCS_EXTRACTED_SUBDIRS LIST_DIRECTORIES TRUE "${ENERGYPLUS_DOCS_EXTRACT_STAGING}/*")
list(LENGTH ENERGYPLUS_DOCS_EXTRACTED_SUBDIRS ENERGYPLUS_DOCS_EXTRACTED_SUBDIRS_COUNT)
if(NOT ENERGYPLUS_DOCS_EXTRACTED_SUBDIRS_COUNT EQUAL 1)
message(FATAL_ERROR "Expected exactly one top-level directory in ${ENERGYPLUS_DOCS_ARCHIVE_NAME}, found ${ENERGYPLUS_DOCS_EXTRACTED_SUBDIRS_COUNT}")
endif()
list(GET ENERGYPLUS_DOCS_EXTRACTED_SUBDIRS 0 ENERGYPLUS_DOCS_EXTRACTED_SUBDIR)

# Drop macOS AppleDouble resource-fork files left over from how the export was archived.
file(GLOB_RECURSE ENERGYPLUS_DOCS_APPLEDOUBLE_FILES "${ENERGYPLUS_DOCS_EXTRACTED_SUBDIR}/._*")
if(ENERGYPLUS_DOCS_APPLEDOUBLE_FILES)
file(REMOVE ${ENERGYPLUS_DOCS_APPLEDOUBLE_FILES})
endif()

get_filename_component(ENERGYPLUS_DOCS_DIR_PARENT "${ENERGYPLUS_DOCS_DIR}" DIRECTORY)
file(MAKE_DIRECTORY "${ENERGYPLUS_DOCS_DIR_PARENT}")
file(RENAME "${ENERGYPLUS_DOCS_EXTRACTED_SUBDIR}" "${ENERGYPLUS_DOCS_DIR}")
file(REMOVE_RECURSE "${ENERGYPLUS_DOCS_EXTRACT_STAGING}")

message(STATUS "EnergyPlus docs extracted to ${ENERGYPLUS_DOCS_DIR}")
endif()
198 changes: 198 additions & 0 deletions scripts/check_doc_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
check_doc_urls.py - Verify the local EnergyPlus Input Output Reference doc links in OpenStudioApp source.

Scans IddObjectDocUrl.hpp for page#anchor references, and checks that every referenced page
exists and every anchor referenced actually has a matching id= in that page's HTML.

Usage:
python scripts/check_doc_urls.py [--repo-root PATH] [--docs-dir PATH]

The documentation directory is auto-detected by searching for EnergyPlus/doc/input-output-reference
under --repo-root (covers build-tree layouts), or pass --docs-dir to point at it directly
(this is the same directory openstudio::energyPlusDocDirectory() resolves to at runtime).

Exit codes:
0 All URLs valid
1 One or more broken/missing pages or anchors found
2 Usage / dependency error
"""

import argparse
import re
import sys
from collections import defaultdict
from html.parser import HTMLParser
from pathlib import Path

# ---------------------------------------------------------------------------
# Files to scan and the regex pattern that extracts page#anchor fragments from them
# ---------------------------------------------------------------------------

# Matches values in the IddObjectDocUrl.hpp urlMap and groupMap:
# {"OS:Something", "1.5-group-foo.html#anchor"},
# {"OpenStudio Group Name", "1.5-group-foo.html"},
IDDOBJECTDOCURL_PATTERN = re.compile(
r'"(?:OS:|OpenStudio |Solar |Electric |Energy |User |Python |Airflow)[^"]*"\s*,\s*"([^"]+\.html(?:#[^"]*)?)"'
)

SOURCE_FILES = [
"src/model_editor/IddObjectDocUrl.hpp",
]

# Where to look for the extracted documentation, relative to repo root.
DOCS_DIR_GLOBS = [
"EnergyPlus/doc/input-output-reference",
"build*/EnergyPlus/doc/input-output-reference",
]


# ---------------------------------------------------------------------------
# HTML parser that collects all id= attributes
# ---------------------------------------------------------------------------

class AnchorCollector(HTMLParser):
def __init__(self):
super().__init__()
self.ids = set()

def handle_starttag(self, tag, attrs):
for name, value in attrs:
if name == "id" and value:
self.ids.add(value)


# ---------------------------------------------------------------------------
# Docs directory detection
# ---------------------------------------------------------------------------

def find_docs_dir(repo_root: Path) -> Path | None:
for pattern in DOCS_DIR_GLOBS:
matches = sorted(repo_root.glob(pattern))
if matches:
return matches[0]
return None


# ---------------------------------------------------------------------------
# URL extraction
# ---------------------------------------------------------------------------

def extract_fragments(repo_root: Path):
"""
Returns a dict: page_name -> list of (anchor_or_None, source_file, line_no)
"""
results = defaultdict(list)

for rel_path in SOURCE_FILES:
src = repo_root / rel_path
if not src.exists():
print(f" WARNING: {rel_path} not found, skipping", file=sys.stderr)
continue

text = src.read_text(encoding="utf-8")

for lineno, line in enumerate(text.splitlines(), 1):
for m in IDDOBJECTDOCURL_PATTERN.finditer(line):
fragment = m.group(1)
if "#" in fragment:
page_name, anchor = fragment.split("#", 1)
else:
page_name, anchor = fragment, None
results[page_name].append((anchor, rel_path, lineno))

return results


# ---------------------------------------------------------------------------
# Page reading with simple cache
# ---------------------------------------------------------------------------

def read_anchors(path: Path) -> set | None:
"""Read a local HTML file and return the set of id= values, or None on error."""
try:
html = path.read_text(encoding="utf-8", errors="replace")
parser = AnchorCollector()
parser.feed(html)
return parser.ids
except OSError as e:
print(f" ERROR reading {path}: {e}", file=sys.stderr)
return None


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repo-root",
default=".",
help="Path to the OpenStudioApplication repo root (default: current directory)",
)
parser.add_argument(
"--docs-dir",
help="Path to the extracted EnergyPlus Input Output Reference HTML (default: auto-detect under --repo-root)",
)
args = parser.parse_args()

repo_root = Path(args.repo_root).resolve()
print(f"Scanning repo: {repo_root}")

docs_dir = Path(args.docs_dir).resolve() if args.docs_dir else find_docs_dir(repo_root)
if docs_dir is None or not docs_dir.is_dir():
print(
"ERROR: Could not find the EnergyPlus docs directory "
f"(looked for {DOCS_DIR_GLOBS} under {repo_root}).\n"
"Configure/build the project first, or pass --docs-dir explicitly.",
file=sys.stderr,
)
sys.exit(2)

print(f"Using EnergyPlus docs directory: {docs_dir}")

fragments = extract_fragments(repo_root)
if not fragments:
print("No URLs found — check SOURCE_FILES list.", file=sys.stderr)
sys.exit(2)

print(f"\nFound {sum(len(v) for v in fragments.values())} URL references across {len(fragments)} unique pages.\n")

failures = []
page_cache = {}

for page_name in sorted(fragments):
page_path = docs_dir / page_name
print(f"Checking: {page_path}")
if page_name not in page_cache:
if not page_path.exists():
page_cache[page_name] = None
else:
page_cache[page_name] = read_anchors(page_path)

page_ids = page_cache[page_name]

for anchor, src_file, lineno in fragments[page_name]:
if page_ids is None:
failures.append((src_file, lineno, page_name, anchor, "page not found"))
elif anchor and anchor not in page_ids:
failures.append((src_file, lineno, page_name, anchor, "anchor not found in page"))
else:
status = "OK" if anchor else "OK (no anchor)"
print(f" {status}: #{anchor or ''}")

print()
if failures:
print(f"FAILURES ({len(failures)}):")
for src_file, lineno, page_name, anchor, reason in failures:
print(f" {src_file}:{lineno} #{anchor} -> {reason}")
print(f" {page_name}#{anchor or ''}")
sys.exit(1)
else:
print("All URLs OK.")
sys.exit(0)


if __name__ == "__main__":
main()
Loading
Loading