{
+ if (cachedSchema) return cachedSchema;
+ if (fetchPromise) return fetchPromise;
+
+ fetchPromise = (async () => {
+ try {
+ // Resolve the schema URL relative to the current page
+ const schemaUrl = resolveSchemaUrl();
+ const response = await fetch(schemaUrl);
+ if (!response.ok) {
+ console.debug(`[idf-editor] IDD schema not available (${response.status}), hover docs disabled`);
+ return null;
+ }
+ cachedSchema = (await response.json()) as CompactIDDSchema;
+ console.debug(`[idf-editor] IDD schema loaded: ${cachedSchema.version}`);
+ return cachedSchema;
+ } catch (error) {
+ console.debug('[idf-editor] Failed to load IDD schema:', error);
+ return null;
+ } finally {
+ fetchPromise = null;
+ }
+ })();
+
+ return fetchPromise;
+}
+
+/**
+ * Clear the cached schema (used when navigating between versions).
+ */
+export function clearSchema(): void {
+ cachedSchema = null;
+ fetchPromise = null;
+}
+
+/**
+ * Resolve the URL for the IDD schema JSON.
+ *
+ * The schema lives alongside the idf-editor.js script in the assets/ directory.
+ * We derive the URL from the script's own src attribute so it works regardless
+ * of the current page path (deep links, multi-version deployment, etc.).
+ */
+function resolveSchemaUrl(): string {
+ // Find our own script tag and resolve relative to it
+ const script = document.querySelector('script[src*="idf-editor"]');
+ if (script) {
+ const scriptSrc = (script as HTMLScriptElement).src;
+ return new URL('idd-schema.json', scriptSrc).href;
+ }
+ // Fallback: absolute path from site root
+ return new URL('/assets/idd-schema.json', window.location.origin).href;
+}
diff --git a/idf-editor/src/idf-editor.css b/idf-editor/src/idf-editor.css
new file mode 100644
index 000000000..a5a68008a
--- /dev/null
+++ b/idf-editor/src/idf-editor.css
@@ -0,0 +1,80 @@
+/**
+ * IDF Editor Styles
+ *
+ * Styles for Monaco editor containers embedded in documentation pages.
+ * Colors and spacing are designed to match Zensical's Material Design theme.
+ */
+
+/* Container for Monaco editor replacing */
+.idf-editor-container {
+ position: relative;
+ border-radius: 0.2rem;
+ overflow: hidden;
+ margin: 1em 0;
+}
+
+/* Copy button */
+.idf-editor-copy {
+ position: absolute;
+ top: 0.5em;
+ right: 0.5em;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2em;
+ height: 2em;
+ padding: 0;
+ border: none;
+ border-radius: 0.2rem;
+ background: transparent;
+ color: var(--md-default-fg-color--lighter, #9e9e9e);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.2s, color 0.2s, background 0.2s;
+}
+
+/* Show copy button on container hover */
+.idf-editor-container:hover .idf-editor-copy {
+ opacity: 1;
+}
+
+.idf-editor-copy:hover {
+ color: var(--md-accent-fg-color, #ffc107);
+ background: var(--md-default-fg-color--lightest, rgba(0, 0, 0, 0.04));
+}
+
+/* Copied state */
+.idf-editor-copy.copied {
+ opacity: 1;
+ color: #4caf50;
+}
+
+/* Loading placeholder while Monaco initializes */
+.idf-editor-loading {
+ background: var(--md-code-bg-color, #f5f5f5);
+ color: var(--md-code-fg-color, #333);
+ padding: 1em;
+ font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, monospace;
+ font-size: 0.85em;
+ white-space: pre;
+ overflow-x: auto;
+ border-radius: 0.2rem;
+ margin: 1em 0;
+}
+
+/* Ensure Monaco editor fills its container */
+.idf-editor-container .monaco-editor {
+ border-radius: 0.2rem;
+}
+
+/* Constrain hover tooltip width — !important needed to override Monaco's inline style */
+.monaco-hover {
+ max-width: min(360px, 90vw) !important;
+}
+
+/* Hide the default Zensical copy button when editor is present */
+.idf-editor-container + .md-clipboard,
+.idf-editor-container .md-clipboard {
+ display: none;
+}
diff --git a/idf-editor/src/idf-hover-service.ts b/idf-editor/src/idf-hover-service.ts
new file mode 100644
index 000000000..cbc42f158
--- /dev/null
+++ b/idf-editor/src/idf-hover-service.ts
@@ -0,0 +1,255 @@
+/**
+ * IDF Hover Documentation Service
+ *
+ * Provides hover tooltips for IDF code blocks, showing IDD schema documentation
+ * when the user hovers over object class names or field values.
+ *
+ * Extracted from the Envelop project (src/editor/idf-language-service.ts),
+ * keeping only the hover-related functionality.
+ */
+
+import type * as Monaco from 'monaco-editor';
+import { IDF_LANGUAGE_ID } from './idf-language';
+import type { CompactIDDSchema, CompactIDDObjectType, CompactIDDField } from './types';
+
+/**
+ * Register hover provider for IDF files.
+ *
+ * @param monaco - The Monaco editor instance
+ * @param getSchema - Function that returns the current IDD schema (or null if not loaded)
+ * @returns A disposable to unregister the provider
+ */
+export function registerHoverProvider(
+ monaco: typeof Monaco,
+ getSchema: () => CompactIDDSchema | null
+): Monaco.IDisposable {
+ return monaco.languages.registerHoverProvider(IDF_LANGUAGE_ID, {
+ provideHover(model, position): Monaco.languages.Hover | null {
+ const schema = getSchema();
+ if (!schema) {
+ return null;
+ }
+
+ const context = getHoverContext(model, position);
+ if (!context) {
+ return null;
+ }
+
+ const objectType = schema.objectTypes[context.className.toLowerCase()];
+ if (!objectType) {
+ return null;
+ }
+
+ // Hovering over class name
+ if (context.isClassName) {
+ return createObjectHover(objectType, position);
+ }
+
+ // Hovering over a field value
+ if (context.fieldIndex !== undefined && context.fieldIndex < objectType.fields.length) {
+ const field = objectType.fields[context.fieldIndex];
+ if (field) {
+ return createFieldHover(field, objectType);
+ }
+ }
+
+ return null;
+ },
+ });
+}
+
+/** Hover context describing what the cursor is over */
+interface HoverContext {
+ className: string;
+ isClassName: boolean;
+ fieldIndex?: number;
+}
+
+/** Get context for hover documentation at the given position */
+function getHoverContext(
+ model: Monaco.editor.ITextModel,
+ position: Monaco.Position
+): HoverContext | null {
+ const lineContent = model.getLineContent(position.lineNumber);
+
+ // Check if we're hovering over a class name
+ const classMatch = lineContent.match(/^([A-Za-z][A-Za-z0-9:_-]*)\s*,/);
+ if (classMatch && classMatch[1]) {
+ const classNameEnd = classMatch[1].length;
+ if (position.column <= classNameEnd + 1) {
+ return { className: classMatch[1], isClassName: true };
+ }
+ }
+
+ // Find the current object
+ const objectContext = findCurrentObject(model, position);
+ if (objectContext) {
+ const fieldIndex = countFieldsSoFar(model, objectContext.startLine, position);
+ return {
+ className: objectContext.className,
+ isClassName: false,
+ fieldIndex,
+ };
+ }
+
+ return null;
+}
+
+/** Find the current object context (class name and start line) */
+function findCurrentObject(
+ model: Monaco.editor.ITextModel,
+ position: Monaco.Position
+): { className: string; startLine: number } | null {
+ for (let line = position.lineNumber; line >= 1; line--) {
+ const lineContent = model.getLineContent(line);
+
+ // Look for class name pattern: "ClassName,"
+ const match = lineContent.match(/^([A-Za-z][A-Za-z0-9:_-]*)\s*,/);
+ if (match && match[1]) {
+ return { className: match[1], startLine: line };
+ }
+
+ // If we hit a semicolon, we've gone past our object
+ if (lineContent.includes(';') && line < position.lineNumber) {
+ break;
+ }
+ }
+
+ return null;
+}
+
+/** Count the number of fields (commas) from object start to current position */
+function countFieldsSoFar(
+ model: Monaco.editor.ITextModel,
+ startLine: number,
+ position: Monaco.Position
+): number {
+ let fieldCount = 0;
+
+ for (let line = startLine; line <= position.lineNumber; line++) {
+ const lineContent = model.getLineContent(line);
+ const endCol = line === position.lineNumber ? position.column - 1 : lineContent.length;
+ const text = lineContent.substring(0, endCol);
+
+ // Remove comments
+ const withoutComments = text.replace(/!.*$/, '');
+
+ // Count commas
+ const commas = (withoutComments.match(/,/g) || []).length;
+ fieldCount += commas;
+
+ // The first comma after class name is field 0's delimiter
+ if (line === startLine && commas > 0) {
+ fieldCount--;
+ }
+ }
+
+ return fieldCount;
+}
+
+/** Create hover content for an object type */
+function createObjectHover(
+ objectType: CompactIDDObjectType,
+ position: Monaco.Position
+): Monaco.languages.Hover {
+ const contents: Monaco.IMarkdownString[] = [];
+
+ // Title
+ contents.push({ value: `**${objectType.name}**` });
+
+ // Group
+ if (objectType.group) {
+ contents.push({ value: `*Group: ${objectType.group}*` });
+ }
+
+ // Memo
+ if (objectType.memo) {
+ contents.push({ value: objectType.memo });
+ }
+
+ // Properties
+ const props: string[] = [];
+ if (objectType.isUnique) props.push('unique-object');
+ if (objectType.isRequired) props.push('required-object');
+ if (objectType.minFields > 0) props.push(`min-fields: ${String(objectType.minFields)}`);
+ if (objectType.extensible > 0) props.push(`extensible: ${String(objectType.extensible)}`);
+
+ if (props.length > 0) {
+ contents.push({ value: `\`${props.join(' | ')}\`` });
+ }
+
+ return {
+ contents,
+ range: {
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: objectType.name.length + 1,
+ },
+ };
+}
+
+/** Create hover content for a field */
+function createFieldHover(
+ field: CompactIDDField,
+ objectType: CompactIDDObjectType
+): Monaco.languages.Hover {
+ const contents: Monaco.IMarkdownString[] = [];
+
+ // Title
+ contents.push({ value: `**${field.name || field.id}** (${objectType.name})` });
+
+ // Type and units
+ let typeInfo = `Type: \`${field.type}\``;
+ if (field.units) {
+ typeInfo += ` | Units: \`${field.units}\``;
+ }
+ contents.push({ value: typeInfo });
+
+ // Memo
+ if (field.memo) {
+ contents.push({ value: field.memo });
+ }
+
+ // Range constraints
+ if (field.minimum !== undefined || field.maximum !== undefined) {
+ let range = 'Range: ';
+ if (field.minimum !== undefined) {
+ range += field.exclusiveMinimum ? `> ${String(field.minimum)}` : `>= ${String(field.minimum)}`;
+ }
+ if (field.minimum !== undefined && field.maximum !== undefined) {
+ range += ' and ';
+ }
+ if (field.maximum !== undefined) {
+ range += field.exclusiveMaximum ? `< ${String(field.maximum)}` : `<= ${String(field.maximum)}`;
+ }
+ contents.push({ value: range });
+ }
+
+ // Default value
+ if (field.default) {
+ contents.push({ value: `Default: \`${field.default}\`` });
+ }
+
+ // Choices — use a compact list when there are many options
+ if (field.choices && field.choices.length > 0) {
+ if (field.choices.length <= 5) {
+ contents.push({ value: `Choices: ${field.choices.map((c) => `\`${c}\``).join(', ')}` });
+ } else {
+ const list = field.choices.map((c) => `- \`${c}\``).join('\n');
+ contents.push({ value: `Choices (${String(field.choices.length)}):\n${list}` });
+ }
+ }
+
+ // Properties
+ const props: string[] = [];
+ if (field.required) props.push('required');
+ if (field.autosizable) props.push('autosizable');
+ if (field.autocalculatable) props.push('autocalculatable');
+
+ if (props.length > 0) {
+ contents.push({ value: `\`${props.join(' | ')}\`` });
+ }
+
+ return { contents };
+}
diff --git a/idf-editor/src/idf-language.ts b/idf-editor/src/idf-language.ts
new file mode 100644
index 000000000..3c64c9eab
--- /dev/null
+++ b/idf-editor/src/idf-language.ts
@@ -0,0 +1,226 @@
+/**
+ * IDF Language Definition for Monaco Editor
+ *
+ * Provides syntax highlighting, tokenization, and language configuration
+ * for EnergyPlus IDF (Input Data File) format.
+ *
+ * Adapted from the Envelop project (src/editor/idf-language.ts).
+ */
+
+import type { languages } from 'monaco-editor';
+
+/** Language ID for IDF files */
+export const IDF_LANGUAGE_ID = 'idf';
+
+/** IDF Language configuration */
+export const idfLanguageConfiguration: languages.LanguageConfiguration = {
+ comments: {
+ lineComment: '!',
+ },
+ brackets: [],
+ autoClosingPairs: [],
+ surroundingPairs: [],
+ folding: {
+ markers: {
+ start: /^!-\s*={3,}\s*ALL OBJECTS IN CLASS:/i,
+ end: /^!-\s*={3,}\s*ALL OBJECTS IN CLASS:/i,
+ },
+ },
+ wordPattern: /[A-Za-z][A-Za-z0-9:_-]*/,
+};
+
+/** Common EnergyPlus object class names for highlighting */
+const COMMON_CLASSES = [
+ 'Version',
+ 'SimulationControl',
+ 'Building',
+ 'Timestep',
+ 'RunPeriod',
+ 'Site:Location',
+ 'SizingPeriod:DesignDay',
+ 'GlobalGeometryRules',
+ 'Zone',
+ 'ZoneList',
+ 'BuildingSurface:Detailed',
+ 'FenestrationSurface:Detailed',
+ 'Wall:Exterior',
+ 'Wall:Interior',
+ 'Roof',
+ 'Floor:GroundContact',
+ 'Window',
+ 'Door',
+ 'Material',
+ 'Material:NoMass',
+ 'Material:AirGap',
+ 'WindowMaterial:SimpleGlazingSystem',
+ 'WindowMaterial:Glazing',
+ 'Construction',
+ 'Schedule:Compact',
+ 'Schedule:Constant',
+ 'Schedule:Day:Interval',
+ 'Schedule:Week:Daily',
+ 'Schedule:Year',
+ 'ScheduleTypeLimits',
+ 'People',
+ 'Lights',
+ 'ElectricEquipment',
+ 'ZoneInfiltration:DesignFlowRate',
+ 'ZoneVentilation:DesignFlowRate',
+ 'Sizing:Zone',
+ 'Sizing:System',
+ 'Sizing:Plant',
+ 'ZoneHVAC:IdealLoadsAirSystem',
+ 'ZoneHVAC:EquipmentList',
+ 'ZoneHVAC:EquipmentConnections',
+ 'ThermostatSetpoint:SingleHeating',
+ 'ThermostatSetpoint:SingleCooling',
+ 'ThermostatSetpoint:DualSetpoint',
+ 'ZoneControl:Thermostat',
+ 'AirLoopHVAC',
+ 'AirLoopHVAC:ZoneSplitter',
+ 'AirLoopHVAC:ZoneMixer',
+ 'Fan:ConstantVolume',
+ 'Fan:VariableVolume',
+ 'Fan:OnOff',
+ 'Coil:Heating:Electric',
+ 'Coil:Heating:Fuel',
+ 'Coil:Heating:Water',
+ 'Coil:Cooling:DX:SingleSpeed',
+ 'Coil:Cooling:DX:TwoSpeed',
+ 'Coil:Cooling:Water',
+ 'Controller:OutdoorAir',
+ 'AirLoopHVAC:OutdoorAirSystem',
+ 'OutdoorAir:Mixer',
+ 'SetpointManager:Scheduled',
+ 'SetpointManager:SingleZone:Reheat',
+ 'PlantLoop',
+ 'Pump:ConstantSpeed',
+ 'Pump:VariableSpeed',
+ 'Boiler:HotWater',
+ 'Chiller:Electric:EIR',
+ 'CoolingTower:SingleSpeed',
+ 'Output:Variable',
+ 'Output:Meter',
+ 'Output:Table:Monthly',
+ 'Output:Table:SummaryReports',
+ 'OutputControl:Table:Style',
+];
+
+/** Keywords used in IDF field values */
+const KEYWORDS = [
+ 'Yes',
+ 'No',
+ 'On',
+ 'Off',
+ 'True',
+ 'False',
+ 'autocalculate',
+ 'autosize',
+ 'Continuous',
+ 'Discrete',
+ 'Any Number',
+ 'Hourly',
+ 'Timestep',
+ 'Daily',
+ 'Monthly',
+ 'RunPeriod',
+ 'Annual',
+ 'SummerDesignDay',
+ 'WinterDesignDay',
+ 'Sunday',
+ 'Monday',
+ 'Tuesday',
+ 'Wednesday',
+ 'Thursday',
+ 'Friday',
+ 'Saturday',
+ 'Holiday',
+ 'CustomDay1',
+ 'CustomDay2',
+ 'AllDays',
+ 'Weekdays',
+ 'Weekends',
+ 'AllOtherDays',
+];
+
+/** IDF Monarch tokenizer definition */
+export const idfTokensProvider: languages.IMonarchLanguage = {
+ defaultToken: 'invalid',
+ tokenPostfix: '.idf',
+
+ // Case insensitive
+ ignoreCase: true,
+
+ // Common class names
+ classes: COMMON_CLASSES,
+
+ // Keywords
+ keywords: KEYWORDS,
+
+ // Operators and delimiters
+ operators: [',', ';'],
+
+ // Number patterns
+ digits: /\d+/,
+ floatDigits: /\d*\.\d+([eE][+-]?\d+)?/,
+
+ tokenizer: {
+ root: [
+ // Whitespace
+ { include: '@whitespace' },
+
+ // Comments (must come before other rules)
+ [/!-.*$/, 'comment.doc'],
+ [/!.*$/, 'comment'],
+
+ // Class names (at start of line or after semicolon)
+ [
+ /^([A-Za-z][A-Za-z0-9:_-]*)\s*(,)/,
+ [
+ {
+ cases: {
+ '@classes': 'type.identifier',
+ '@default': 'type',
+ },
+ },
+ 'delimiter',
+ ],
+ ],
+
+ // Field values
+ { include: '@fieldValue' },
+
+ // Delimiters
+ [/[,]/, 'delimiter'],
+ [/[;]/, 'delimiter.semicolon'],
+ ],
+
+ whitespace: [[/[ \t\r\n]+/, 'white']],
+
+ fieldValue: [
+ // Numbers (including scientific notation)
+ [/-?\d*\.\d+([eE][+-]?\d+)?/, 'number.float'],
+ [/-?\d+([eE][+-]?\d+)?/, 'number'],
+
+ // Keywords
+ [
+ /[A-Za-z][A-Za-z0-9_-]*/,
+ {
+ cases: {
+ '@keywords': 'keyword',
+ '@default': 'string',
+ },
+ },
+ ],
+
+ // Wildcards and special values
+ [/\*/, 'constant'],
+
+ // Time/date patterns (e.g., "Through: 12/31")
+ [/Through:\s*\d+\/\d+/, 'string.date'],
+ [/For:\s*[A-Za-z,\s]+/, 'string.date'],
+ [/Until:\s*\d+:\d+/, 'string.date'],
+ [/Interpolate:\s*[A-Za-z]+/, 'string.date'],
+ ],
+ },
+};
diff --git a/idf-editor/src/idf-themes.ts b/idf-editor/src/idf-themes.ts
new file mode 100644
index 000000000..022efd35b
--- /dev/null
+++ b/idf-editor/src/idf-themes.ts
@@ -0,0 +1,93 @@
+/**
+ * Monaco Editor Themes for IDF Documentation
+ *
+ * Light and dark themes adapted from the Envelop project, with colors
+ * tuned to match Zensical's Material Design palette (teal/amber with
+ * default and slate color schemes).
+ */
+
+/** IDF theme token rules for dark mode */
+const idfDarkThemeRules: { token: string; foreground?: string; fontStyle?: string }[] = [
+ { token: 'comment', foreground: '6A9955' },
+ { token: 'comment.doc', foreground: '6A9955', fontStyle: 'italic' },
+ { token: 'type', foreground: '4EC9B0' },
+ { token: 'type.identifier', foreground: '4EC9B0', fontStyle: 'bold' },
+ { token: 'keyword', foreground: '569CD6' },
+ { token: 'number', foreground: 'B5CEA8' },
+ { token: 'number.float', foreground: 'B5CEA8' },
+ { token: 'string', foreground: 'CE9178' },
+ { token: 'string.date', foreground: 'DCDCAA' },
+ { token: 'delimiter', foreground: 'D4D4D4' },
+ { token: 'delimiter.semicolon', foreground: 'D4D4D4', fontStyle: 'bold' },
+ { token: 'constant', foreground: '4FC1FF' },
+];
+
+/** IDF theme token rules for light mode */
+const idfLightThemeRules: { token: string; foreground?: string; fontStyle?: string }[] = [
+ { token: 'comment', foreground: '008000' },
+ { token: 'comment.doc', foreground: '008000', fontStyle: 'italic' },
+ { token: 'type', foreground: '267F99' },
+ { token: 'type.identifier', foreground: '267F99', fontStyle: 'bold' },
+ { token: 'keyword', foreground: '0000FF' },
+ { token: 'number', foreground: '098658' },
+ { token: 'number.float', foreground: '098658' },
+ { token: 'string', foreground: 'A31515' },
+ { token: 'string.date', foreground: '795E26' },
+ { token: 'delimiter', foreground: '000000' },
+ { token: 'delimiter.semicolon', foreground: '000000', fontStyle: 'bold' },
+ { token: 'constant', foreground: '0070C1' },
+];
+
+/** Theme name constants */
+export const THEME_LIGHT = 'idf-docs-light';
+export const THEME_DARK = 'idf-docs-dark';
+
+/**
+ * Register both light and dark themes with Monaco.
+ *
+ * Background and widget colors are tuned to match Zensical's Material Design
+ * theme: "default" scheme for light, "slate" scheme for dark.
+ */
+export function registerIDFThemes(monaco: { editor: { defineTheme: Function } }): void {
+ monaco.editor.defineTheme(THEME_DARK, {
+ base: 'vs-dark',
+ inherit: true,
+ rules: idfDarkThemeRules,
+ colors: {
+ 'editor.background': '#212121', // Matches Zensical slate code bg
+ 'editor.foreground': '#e2e8f0',
+ 'editor.lineHighlightBackground': '#2d2d2d',
+ 'editor.selectionBackground': '#264f78',
+ 'editorLineNumber.foreground': '#6b7280',
+ 'editorCursor.foreground': '#e2e8f0',
+ 'editorWidget.background': '#2d2d2d',
+ 'editorWidget.border': '#404040',
+ 'editorHoverWidget.background': '#2d2d2d',
+ 'editorHoverWidget.border': '#404040',
+ },
+ });
+
+ monaco.editor.defineTheme(THEME_LIGHT, {
+ base: 'vs',
+ inherit: true,
+ rules: idfLightThemeRules,
+ colors: {
+ 'editor.background': '#f5f5f5', // Matches Zensical default code bg
+ 'editor.foreground': '#1a202c',
+ 'editor.lineHighlightBackground': '#f0f0f0',
+ 'editor.selectionBackground': '#c8e1ff',
+ 'editorLineNumber.foreground': '#a0aec0',
+ 'editorCursor.foreground': '#1a202c',
+ 'editorWidget.background': '#ffffff',
+ 'editorWidget.border': '#e2e8f0',
+ 'editorHoverWidget.background': '#ffffff',
+ 'editorHoverWidget.border': '#e2e8f0',
+ },
+ });
+}
+
+/** Get the appropriate theme name based on Zensical's color scheme */
+export function getCurrentTheme(): string {
+ const scheme = document.body.getAttribute('data-md-color-scheme');
+ return scheme === 'slate' ? THEME_DARK : THEME_LIGHT;
+}
diff --git a/idf-editor/src/main.ts b/idf-editor/src/main.ts
new file mode 100644
index 000000000..c210da8b0
--- /dev/null
+++ b/idf-editor/src/main.ts
@@ -0,0 +1,152 @@
+/**
+ * IDF Editor — Main Entry Point
+ *
+ * Scans documentation pages for IDF code blocks and progressively replaces
+ * them with rich Monaco editor instances featuring syntax highlighting,
+ * hover documentation, and code folding.
+ *
+ * Monaco is loaded lazily from CDN only when IDF code blocks are present.
+ */
+
+import './idf-editor.css';
+import { EditorManager, loadMonacoFromCDN } from './editor-manager';
+import { idfLanguageConfiguration, idfTokensProvider, IDF_LANGUAGE_ID } from './idf-language';
+import { registerIDFThemes } from './idf-themes';
+import { registerHoverProvider } from './idf-hover-service';
+import { getSchema, loadSchema } from './idd-schema-loader';
+import type * as Monaco from 'monaco-editor';
+
+/** Whether the IDF language has been registered (once globally) */
+let languageRegistered = false;
+
+/** Current editor manager (recreated on each page navigation) */
+let currentManager: EditorManager | null = null;
+
+/** Whether initPage() is currently running (prevents concurrent execution) */
+let initInProgress = false;
+
+/**
+ * Register the IDF language, themes, and providers with Monaco.
+ * Only done once globally (Monaco language registration is persistent).
+ */
+function registerLanguage(monaco: typeof Monaco): void {
+ if (languageRegistered) return;
+
+ // Register language
+ monaco.languages.register({
+ id: IDF_LANGUAGE_ID,
+ extensions: ['.idf', '.imf'],
+ aliases: ['IDF', 'EnergyPlus IDF', 'Input Data File'],
+ mimetypes: ['text/x-idf'],
+ });
+ monaco.languages.setLanguageConfiguration(IDF_LANGUAGE_ID, idfLanguageConfiguration);
+ monaco.languages.setMonarchTokensProvider(IDF_LANGUAGE_ID, idfTokensProvider);
+
+ // Register themes
+ registerIDFThemes(monaco);
+
+ // Register hover provider (schema may not be loaded yet; getSchema returns null until it is)
+ registerHoverProvider(monaco, getSchema);
+
+ languageRegistered = true;
+}
+
+/**
+ * Initialize editors for the current page.
+ * Called on initial load and after each instant navigation.
+ */
+async function initPage(): Promise {
+ // Prevent concurrent execution (e.g. document$ ReplaySubject firing
+ // while the initial initPage() is still loading Monaco from CDN).
+ if (initInProgress) return;
+ initInProgress = true;
+
+ try {
+ // Dispose previous editors (from prior page)
+ if (currentManager) {
+ currentManager.dispose();
+ currentManager = null;
+ }
+
+ // Skip Monaco on narrow viewports — touch devices lack hover and the
+ // editors need horizontal space. Pygments static highlighting remains.
+ if (window.innerWidth < 768) return;
+
+ // Check if there are IDF code blocks on this page.
+ // Zensical/pymdownx puts the language class on a wrapper , not on
.
+ // Structure:
+ const codeBlocks = document.querySelectorAll('div.language-idf pre > code');
+ if (codeBlocks.length === 0) return;
+
+ // Load Monaco from CDN (cached after first load)
+ const monaco = await loadMonacoFromCDN();
+
+ // Register language and providers (once)
+ registerLanguage(monaco);
+
+ // Start loading IDD schema in the background (for hover docs)
+ loadSchema();
+
+ // Create editor manager and initialize editors
+ currentManager = new EditorManager(monaco);
+ currentManager.initialize(codeBlocks);
+ } catch (error) {
+ console.error('[idf-editor] Failed to initialize:', error);
+ } finally {
+ initInProgress = false;
+ }
+}
+
+/**
+ * Hook into Zensical's instant navigation system.
+ * The document$ observable emits on each page navigation.
+ */
+function hookInstantNav(): boolean {
+ const win = window as Record;
+ const document$ = win.document$ as { subscribe: (fn: () => void) => void } | undefined;
+
+ if (!document$) return false;
+
+ // document$ is a ReplaySubject — it replays the last value on subscribe.
+ // Skip that initial emission since initPage() already handles the first load.
+ let firstEmission = true;
+
+ document$.subscribe(() => {
+ if (firstEmission) {
+ firstEmission = false;
+ return;
+ }
+
+ // Small delay to ensure the DOM is updated
+ requestAnimationFrame(() => {
+ // If our editors/pending blocks are still in the live DOM, Zensical
+ // did NOT replace the content (e.g. same-page anchor scroll).
+ // Re-initializing would destroy the working editors for nothing.
+ if (currentManager && currentManager.isStillInDOM()) return;
+
+ initPage();
+ });
+ });
+
+ return true;
+}
+
+// --- Bootstrap ---
+
+// Initialize on page load
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => initPage());
+} else {
+ initPage();
+}
+
+// Hook into instant navigation (may not be available immediately)
+if (!hookInstantNav()) {
+ let attempts = 0;
+ const interval = setInterval(() => {
+ attempts++;
+ if (hookInstantNav() || attempts > 50) {
+ clearInterval(interval);
+ }
+ }, 100);
+}
diff --git a/idf-editor/src/types.ts b/idf-editor/src/types.ts
new file mode 100644
index 000000000..bd4a1fd10
--- /dev/null
+++ b/idf-editor/src/types.ts
@@ -0,0 +1,77 @@
+/**
+ * Compact IDD Schema Types for Browser
+ *
+ * These are simplified versions of the Envelop project's IDD types,
+ * optimized for the hover documentation use case. They use plain
+ * objects/Records instead of Maps for JSON serialization.
+ */
+
+/** Field type enumeration */
+export type IDDFieldType =
+ | 'real'
+ | 'integer'
+ | 'alpha'
+ | 'choice'
+ | 'object-list'
+ | 'external-list'
+ | 'node';
+
+/** Compact field definition for hover docs */
+export interface CompactIDDField {
+ /** Field identifier (A1, A2, N1, N2, etc.) */
+ id: string;
+ /** Field name from \field tag */
+ name: string;
+ /** Data type */
+ type: IDDFieldType;
+ /** Whether this field is required */
+ required: boolean;
+ /** Default value */
+ default?: string;
+ /** Unit specification (e.g., "m", "W", "degC") */
+ units?: string;
+ /** Minimum value */
+ minimum?: number;
+ /** Whether minimum is exclusive */
+ exclusiveMinimum?: boolean;
+ /** Maximum value */
+ maximum?: number;
+ /** Whether maximum is exclusive */
+ exclusiveMaximum?: boolean;
+ /** Valid choices for 'choice' type fields */
+ choices?: string[];
+ /** Documentation text */
+ memo: string;
+ /** Whether this field can be autosized */
+ autosizable: boolean;
+ /** Whether this field can be autocalculated */
+ autocalculatable: boolean;
+}
+
+/** Compact object type definition for hover docs */
+export interface CompactIDDObjectType {
+ /** Object class name (e.g., "Building", "Zone") */
+ name: string;
+ /** Group this object belongs to */
+ group: string;
+ /** Documentation from \memo tags */
+ memo: string;
+ /** Field definitions */
+ fields: CompactIDDField[];
+ /** Minimum number of fields required */
+ minFields: number;
+ /** Only one instance allowed */
+ isUnique: boolean;
+ /** Must exist in every model */
+ isRequired: boolean;
+ /** Number of fields in extensible group */
+ extensible: number;
+}
+
+/** The complete compact IDD schema */
+export interface CompactIDDSchema {
+ /** EnergyPlus version */
+ version: string;
+ /** Object types keyed by lowercase name */
+ objectTypes: Record;
+}
diff --git a/idf-editor/tsconfig.json b/idf-editor/tsconfig.json
new file mode 100644
index 000000000..039320107
--- /dev/null
+++ b/idf-editor/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "declaration": false,
+ "sourceMap": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/idf-editor/vite.config.ts b/idf-editor/vite.config.ts
new file mode 100644
index 000000000..782156a81
--- /dev/null
+++ b/idf-editor/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite';
+import path from 'path';
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: path.resolve(__dirname, 'src/main.ts'),
+ name: 'IDFEditor',
+ formats: ['iife'],
+ fileName: () => 'idf-editor.js',
+ },
+ outDir: path.resolve(__dirname, 'dist'),
+ cssFileName: 'idf-editor',
+ minify: 'esbuild',
+ sourcemap: false,
+ rollupOptions: {
+ output: {
+ assetFileNames: 'idf-editor.[ext]',
+ },
+ },
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
index 6a6eb064e..f8d61ca10 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,6 +8,7 @@ requires-python = ">=3.10,<4.0"
dependencies = [
"tomli_w>=1.0",
"tomli>=2.0; python_version < '3.11'",
+ "idfkit>=0.3.0",
]
[project.urls]
@@ -24,6 +25,16 @@ dev = [
"tomli_w>=1.0",
]
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["scripts"]
+
+[project.entry-points."pygments.lexers"]
+idf = "scripts.pygments_idf_lexer:IDFLexer"
+
[tool.ruff]
target-version = "py310"
line-length = 120
diff --git a/scripts/assets/idf-editor.css b/scripts/assets/idf-editor.css
new file mode 100644
index 000000000..956fcd1d9
--- /dev/null
+++ b/scripts/assets/idf-editor.css
@@ -0,0 +1 @@
+.idf-editor-container{position:relative;border-radius:.2rem;overflow:hidden;margin:1em 0}.idf-editor-copy{position:absolute;top:.5em;right:.5em;z-index:10;display:flex;align-items:center;justify-content:center;width:2em;height:2em;padding:0;border:none;border-radius:.2rem;background:transparent;color:var(--md-default-fg-color--lighter, #9e9e9e);cursor:pointer;opacity:0;transition:opacity .2s,color .2s,background .2s}.idf-editor-container:hover .idf-editor-copy{opacity:1}.idf-editor-copy:hover{color:var(--md-accent-fg-color, #ffc107);background:var(--md-default-fg-color--lightest, rgba(0, 0, 0, .04))}.idf-editor-copy.copied{opacity:1;color:#4caf50}.idf-editor-loading{background:var(--md-code-bg-color, #f5f5f5);color:var(--md-code-fg-color, #333);padding:1em;font-family:JetBrains Mono,Fira Code,ui-monospace,SFMono-Regular,monospace;font-size:.85em;white-space:pre;overflow-x:auto;border-radius:.2rem;margin:1em 0}.idf-editor-container .monaco-editor{border-radius:.2rem}.monaco-hover{max-width:min(360px,90vw)!important}.idf-editor-container+.md-clipboard,.idf-editor-container .md-clipboard{display:none}
diff --git a/scripts/assets/idf-editor.js b/scripts/assets/idf-editor.js
new file mode 100644
index 000000000..cb5e78654
--- /dev/null
+++ b/scripts/assets/idf-editor.js
@@ -0,0 +1,4 @@
+(function(){"use strict";const A=[{token:"comment",foreground:"6A9955"},{token:"comment.doc",foreground:"6A9955",fontStyle:"italic"},{token:"type",foreground:"4EC9B0"},{token:"type.identifier",foreground:"4EC9B0",fontStyle:"bold"},{token:"keyword",foreground:"569CD6"},{token:"number",foreground:"B5CEA8"},{token:"number.float",foreground:"B5CEA8"},{token:"string",foreground:"CE9178"},{token:"string.date",foreground:"DCDCAA"},{token:"delimiter",foreground:"D4D4D4"},{token:"delimiter.semicolon",foreground:"D4D4D4",fontStyle:"bold"},{token:"constant",foreground:"4FC1FF"}],D=[{token:"comment",foreground:"008000"},{token:"comment.doc",foreground:"008000",fontStyle:"italic"},{token:"type",foreground:"267F99"},{token:"type.identifier",foreground:"267F99",fontStyle:"bold"},{token:"keyword",foreground:"0000FF"},{token:"number",foreground:"098658"},{token:"number.float",foreground:"098658"},{token:"string",foreground:"A31515"},{token:"string.date",foreground:"795E26"},{token:"delimiter",foreground:"000000"},{token:"delimiter.semicolon",foreground:"000000",fontStyle:"bold"},{token:"constant",foreground:"0070C1"}],f="idf-docs-light",g="idf-docs-dark";function L(e){e.editor.defineTheme(g,{base:"vs-dark",inherit:!0,rules:A,colors:{"editor.background":"#212121","editor.foreground":"#e2e8f0","editor.lineHighlightBackground":"#2d2d2d","editor.selectionBackground":"#264f78","editorLineNumber.foreground":"#6b7280","editorCursor.foreground":"#e2e8f0","editorWidget.background":"#2d2d2d","editorWidget.border":"#404040","editorHoverWidget.background":"#2d2d2d","editorHoverWidget.border":"#404040"}}),e.editor.defineTheme(f,{base:"vs",inherit:!0,rules:D,colors:{"editor.background":"#f5f5f5","editor.foreground":"#1a202c","editor.lineHighlightBackground":"#f0f0f0","editor.selectionBackground":"#c8e1ff","editorLineNumber.foreground":"#a0aec0","editorCursor.foreground":"#1a202c","editorWidget.background":"#ffffff","editorWidget.border":"#e2e8f0","editorHoverWidget.background":"#ffffff","editorHoverWidget.border":"#e2e8f0"}})}function E(){return document.body.getAttribute("data-md-color-scheme")==="slate"?g:f}const M=60,N=720,O=19,I=20,H="200px",v="https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs",h='',b='';class x{constructor(n){this.editors=[],this.observer=null,this.themeObserver=null,this.pendingBlocks=new Map,this.monaco=n}initialize(n){n.length!==0&&(this.observer=new IntersectionObserver(o=>this.handleIntersection(o),{rootMargin:H}),n.forEach(o=>{const t=o.parentElement;if(!t||t.tagName!=="PRE")return;const r=t.parentElement;if(!r||r.dataset.idfEditor==="true")return;const i=o.textContent||"";this.pendingBlocks.set(r,{wrapper:r,code:i}),this.observer.observe(r)}),this.watchThemeChanges())}isStillInDOM(){for(const{container:n}of this.editors)if(document.contains(n))return!0;for(const[n]of this.pendingBlocks)if(document.contains(n))return!0;return!1}dispose(){var n,o;for(const{editor:t}of this.editors)t.dispose();this.editors=[],(n=this.observer)==null||n.disconnect(),this.observer=null,(o=this.themeObserver)==null||o.disconnect(),this.themeObserver=null,this.pendingBlocks.clear()}handleIntersection(n){var o;for(const t of n){if(!t.isIntersecting)continue;const r=this.pendingBlocks.get(t.target);r&&((o=this.observer)==null||o.unobserve(t.target),this.pendingBlocks.delete(t.target),this.createEditor(r.wrapper,r.code))}}createEditor(n,o){var u;const t=o.split(`
+`).length,r=Math.max(M,Math.min(N,t*O+I)),i=document.createElement("div");i.className="idf-editor-container",i.style.height=`${r}px`,(u=n.parentNode)==null||u.replaceChild(i,n),n.dataset.idfEditor="true";const s=this.monaco.editor.create(i,{value:o,language:"idf",theme:E(),readOnly:!0,domReadOnly:!0,minimap:{enabled:!1},lineNumbers:"on",scrollBeyondLastLine:!1,wordWrap:"off",folding:!1,glyphMargin:!1,lineDecorationsWidth:8,lineNumbersMinChars:3,renderLineHighlight:"none",overviewRulerLanes:0,hideCursorInOverviewRuler:!0,overviewRulerBorder:!1,scrollbar:{vertical:"auto",horizontal:"auto",verticalScrollbarSize:8,horizontalScrollbarSize:8},fontSize:13,fontFamily:"'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, monospace",padding:{top:8,bottom:8},automaticLayout:!0,contextmenu:!1,links:!1,renderValidationDecorations:"off",fixedOverflowWidgets:!0,accessibilitySupport:"off",ariaLabel:"EnergyPlus IDF code example"});this.addCopyButton(i,s),this.editors.push({editor:s,container:i})}addCopyButton(n,o){const t=document.createElement("button");t.className="idf-editor-copy",t.title="Copy to clipboard",t.innerHTML=h,t.addEventListener("click",async()=>{try{const r=o.getValue();await navigator.clipboard.writeText(r),t.innerHTML=b,t.classList.add("copied"),setTimeout(()=>{t.innerHTML=h,t.classList.remove("copied")},2e3)}catch{const r=document.createElement("textarea");r.value=o.getValue(),r.style.position="fixed",r.style.opacity="0",document.body.appendChild(r),r.select(),document.execCommand("copy"),document.body.removeChild(r),t.innerHTML=b,t.classList.add("copied"),setTimeout(()=>{t.innerHTML=h,t.classList.remove("copied")},2e3)}}),n.appendChild(t)}watchThemeChanges(){this.themeObserver=new MutationObserver(n=>{for(const o of n)if(o.attributeName==="data-md-color-scheme"){const r=document.body.getAttribute("data-md-color-scheme")==="slate"?g:f;this.monaco.editor.setTheme(r)}}),this.themeObserver.observe(document.body,{attributes:!0,attributeFilter:["data-md-color-scheme"]})}}let c=null;function F(){return c||(c=new Promise((e,n)=>{const o=window;if(o.monaco){e(o.monaco);return}if(typeof o.require=="function"&&o.require.config){S(e,n);return}const t=document.createElement("script");t.src=`${v}/loader.js`,t.onload=()=>S(e,n),t.onerror=()=>n(new Error("Failed to load Monaco AMD loader")),document.head.appendChild(t)}),c)}function S(e,n){const t=window.require;t.config({paths:{vs:v}}),t(["vs/editor/editor.main"],r=>{e(r)},r=>{n(r)})}const m="idf",T={comments:{lineComment:"!"},brackets:[],autoClosingPairs:[],surroundingPairs:[],folding:{markers:{start:/^!-\s*={3,}\s*ALL OBJECTS IN CLASS:/i,end:/^!-\s*={3,}\s*ALL OBJECTS IN CLASS:/i}},wordPattern:/[A-Za-z][A-Za-z0-9:_-]*/},z={defaultToken:"invalid",tokenPostfix:".idf",ignoreCase:!0,classes:["Version","SimulationControl","Building","Timestep","RunPeriod","Site:Location","SizingPeriod:DesignDay","GlobalGeometryRules","Zone","ZoneList","BuildingSurface:Detailed","FenestrationSurface:Detailed","Wall:Exterior","Wall:Interior","Roof","Floor:GroundContact","Window","Door","Material","Material:NoMass","Material:AirGap","WindowMaterial:SimpleGlazingSystem","WindowMaterial:Glazing","Construction","Schedule:Compact","Schedule:Constant","Schedule:Day:Interval","Schedule:Week:Daily","Schedule:Year","ScheduleTypeLimits","People","Lights","ElectricEquipment","ZoneInfiltration:DesignFlowRate","ZoneVentilation:DesignFlowRate","Sizing:Zone","Sizing:System","Sizing:Plant","ZoneHVAC:IdealLoadsAirSystem","ZoneHVAC:EquipmentList","ZoneHVAC:EquipmentConnections","ThermostatSetpoint:SingleHeating","ThermostatSetpoint:SingleCooling","ThermostatSetpoint:DualSetpoint","ZoneControl:Thermostat","AirLoopHVAC","AirLoopHVAC:ZoneSplitter","AirLoopHVAC:ZoneMixer","Fan:ConstantVolume","Fan:VariableVolume","Fan:OnOff","Coil:Heating:Electric","Coil:Heating:Fuel","Coil:Heating:Water","Coil:Cooling:DX:SingleSpeed","Coil:Cooling:DX:TwoSpeed","Coil:Cooling:Water","Controller:OutdoorAir","AirLoopHVAC:OutdoorAirSystem","OutdoorAir:Mixer","SetpointManager:Scheduled","SetpointManager:SingleZone:Reheat","PlantLoop","Pump:ConstantSpeed","Pump:VariableSpeed","Boiler:HotWater","Chiller:Electric:EIR","CoolingTower:SingleSpeed","Output:Variable","Output:Meter","Output:Table:Monthly","Output:Table:SummaryReports","OutputControl:Table:Style"],keywords:["Yes","No","On","Off","True","False","autocalculate","autosize","Continuous","Discrete","Any Number","Hourly","Timestep","Daily","Monthly","RunPeriod","Annual","SummerDesignDay","WinterDesignDay","Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Holiday","CustomDay1","CustomDay2","AllDays","Weekdays","Weekends","AllOtherDays"],operators:[",",";"],digits:/\d+/,floatDigits:/\d*\.\d+([eE][+-]?\d+)?/,tokenizer:{root:[{include:"@whitespace"},[/!-.*$/,"comment.doc"],[/!.*$/,"comment"],[/^([A-Za-z][A-Za-z0-9:_-]*)\s*(,)/,[{cases:{"@classes":"type.identifier","@default":"type"}},"delimiter"]],{include:"@fieldValue"},[/[,]/,"delimiter"],[/[;]/,"delimiter.semicolon"]],whitespace:[[/[ \t\r\n]+/,"white"]],fieldValue:[[/-?\d*\.\d+([eE][+-]?\d+)?/,"number.float"],[/-?\d+([eE][+-]?\d+)?/,"number"],[/[A-Za-z][A-Za-z0-9_-]*/,{cases:{"@keywords":"keyword","@default":"string"}}],[/\*/,"constant"],[/Through:\s*\d+\/\d+/,"string.date"],[/For:\s*[A-Za-z,\s]+/,"string.date"],[/Until:\s*\d+:\d+/,"string.date"],[/Interpolate:\s*[A-Za-z]+/,"string.date"]]}};function $(e,n){return e.languages.registerHoverProvider(m,{provideHover(o,t){const r=n();if(!r)return null;const i=R(o,t);if(!i)return null;const s=r.objectTypes[i.className.toLowerCase()];if(!s)return null;if(i.isClassName)return W(s,t);if(i.fieldIndex!==void 0&&i.fieldIndex=1;o--){const t=e.getLineContent(o),r=t.match(/^([A-Za-z][A-Za-z0-9:_-]*)\s*,/);if(r&&r[1])return{className:r[1],startLine:o};if(t.includes(";")&&o0&&t--}return t}function W(e,n){const o=[];o.push({value:`**${e.name}**`}),e.group&&o.push({value:`*Group: ${e.group}*`}),e.memo&&o.push({value:e.memo});const t=[];return e.isUnique&&t.push("unique-object"),e.isRequired&&t.push("required-object"),e.minFields>0&&t.push(`min-fields: ${String(e.minFields)}`),e.extensible>0&&t.push(`extensible: ${String(e.extensible)}`),t.length>0&&o.push({value:`\`${t.join(" | ")}\``}),{contents:o,range:{startLineNumber:n.lineNumber,startColumn:1,endLineNumber:n.lineNumber,endColumn:e.name.length+1}}}function V(e,n){const o=[];o.push({value:`**${e.name||e.id}** (${n.name})`});let t=`Type: \`${e.type}\``;if(e.units&&(t+=` | Units: \`${e.units}\``),o.push({value:t}),e.memo&&o.push({value:e.memo}),e.minimum!==void 0||e.maximum!==void 0){let i="Range: ";e.minimum!==void 0&&(i+=e.exclusiveMinimum?`> ${String(e.minimum)}`:`>= ${String(e.minimum)}`),e.minimum!==void 0&&e.maximum!==void 0&&(i+=" and "),e.maximum!==void 0&&(i+=e.exclusiveMaximum?`< ${String(e.maximum)}`:`<= ${String(e.maximum)}`),o.push({value:i})}if(e.default&&o.push({value:`Default: \`${e.default}\``}),e.choices&&e.choices.length>0)if(e.choices.length<=5)o.push({value:`Choices: ${e.choices.map(i=>`\`${i}\``).join(", ")}`});else{const i=e.choices.map(s=>`- \`${s}\``).join(`
+`);o.push({value:`Choices (${String(e.choices.length)}):
+${i}`})}const r=[];return e.required&&r.push("required"),e.autosizable&&r.push("autosizable"),e.autocalculatable&&r.push("autocalculatable"),r.length>0&&o.push({value:`\`${r.join(" | ")}\``}),{contents:o}}let l=null,d=null;function Z(){return l}async function _(){return l||d||(d=(async()=>{try{const e=q(),n=await fetch(e);return n.ok?(l=await n.json(),console.debug(`[idf-editor] IDD schema loaded: ${l.version}`),l):(console.debug(`[idf-editor] IDD schema not available (${n.status}), hover docs disabled`),null)}catch(e){return console.debug("[idf-editor] Failed to load IDD schema:",e),null}finally{d=null}})(),d)}function q(){const e=document.querySelector('script[src*="idf-editor"]');if(e){const n=e.src;return new URL("idd-schema.json",n).href}return new URL("/assets/idd-schema.json",window.location.origin).href}let y=!1,a=null,p=!1;function G(e){y||(e.languages.register({id:m,extensions:[".idf",".imf"],aliases:["IDF","EnergyPlus IDF","Input Data File"],mimetypes:["text/x-idf"]}),e.languages.setLanguageConfiguration(m,T),e.languages.setMonarchTokensProvider(m,z),L(e),$(e,Z),y=!0)}async function C(){if(!p){p=!0;try{if(a&&(a.dispose(),a=null),window.innerWidth<768)return;const e=document.querySelectorAll("div.language-idf pre > code");if(e.length===0)return;const n=await F();G(n),_(),a=new x(n),a.initialize(e)}catch(e){console.error("[idf-editor] Failed to initialize:",e)}finally{p=!1}}}function w(){const n=window.document$;if(!n)return!1;let o=!0;return n.subscribe(()=>{if(o){o=!1;return}requestAnimationFrame(()=>{a&&a.isStillInDOM()||C()})}),!0}if(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>C()):C(),!w()){let e=0;const n=setInterval(()=>{e++,(w()||e>50)&&clearInterval(n)},100)}})();
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/assets/theme-overrides.css b/scripts/assets/theme-overrides.css
new file mode 100644
index 000000000..83ae1093f
--- /dev/null
+++ b/scripts/assets/theme-overrides.css
@@ -0,0 +1,53 @@
+/**
+ * Zensical / Material Theme Overrides
+ *
+ * Fixes for theme features that don't work correctly out of the box.
+ */
+
+/* ---------------------------------------------------------------------------
+ * Mobile TOC — show "On this page" in the hamburger drawer
+ * -------------------------------------------------------------------------*/
+
+/*
+ * Zensical hides the TOC toggle and its nav in the primary (mobile) sidebar:
+ * .md-nav--primary .md-nav__link[for="__toc"],
+ * .md-nav--primary .md-nav__link[for="__toc"] ~ .md-nav { display: none }
+ *
+ * Override on mobile so users can access the page TOC from the hamburger menu.
+ * The label (page title repeated as a TOC toggle) stays hidden to avoid
+ * duplication — only the TOC nav itself is shown.
+ */
+@media screen and (max-width: 76.1875em) {
+ .md-nav--primary .md-nav__link[for="__toc"] ~ .md-nav {
+ display: block !important;
+ visibility: visible !important;
+ opacity: 1 !important;
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ * Collapsible TOC sub-trees (desktop right-side + mobile drawer)
+ * -------------------------------------------------------------------------*/
+
+/*
+ * Object names (H2) stay visible; their children (Inputs / Field: …)
+ * collapse by default and expand when the object is the active anchor.
+ * This keeps the list scannable while preserving deep-link navigation.
+ *
+ * Requires toc.follow in the theme features list so that
+ * md-nav__link--active is set on the currently-visible heading.
+ */
+
+/* Collapse nested lists under each H2 in the TOC */
+.md-nav--secondary > .md-nav__list > .md-nav__item > .md-nav {
+ max-height: 0;
+ overflow: hidden;
+ opacity: 0;
+ transition: max-height 0.25s ease, opacity 0.2s ease;
+}
+
+/* Expand when the H2 anchor is active (toc.follow sets md-nav__link--active) */
+.md-nav--secondary > .md-nav__list > .md-nav__item > .md-nav__link--active ~ .md-nav {
+ max-height: 500px; /* large enough for any field list */
+ opacity: 1;
+}
diff --git a/scripts/config.py b/scripts/config.py
index 0ebce9e9d..99e6774f2 100644
--- a/scripts/config.py
+++ b/scripts/config.py
@@ -5,7 +5,7 @@
# Target EnergyPlus versions to convert (oldest to newest)
TARGET_VERSIONS: list[str] = [
"v8.9.0",
- "v9.0.0",
+ "v9.0.1",
"v9.1.0",
"v9.2.0",
"v9.3.0",
diff --git a/scripts/convert.py b/scripts/convert.py
index 8ca5aa6b0..90fddd027 100644
--- a/scripts/convert.py
+++ b/scripts/convert.py
@@ -37,6 +37,7 @@
from scripts.markdown_postprocessor import postprocess
from scripts.models import ConversionResult, DocSet, DocSetResult, LabelRef, VersionResult
from scripts.nav_generator import extract_heading, generate_nav, parse_input_chain
+from scripts.schema_utils import DocObjectInfo, build_object_index, serialize_for_monaco
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,
+ object_index: dict[str, DocObjectInfo] | 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,
+ object_index=object_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,
+ object_index: dict[str, DocObjectInfo] | 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),
+ object_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),
+ object_index=object_index,
),
))
return converted
@@ -538,6 +544,7 @@ def convert_doc_set(
*,
max_workers: int = 1,
file_figure_numbers: dict[str, list[int]] | None = None,
+ object_index: dict[str, DocObjectInfo] | 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, object_index
+ )
# Phase 2: Log results and append TOCs (must happen after files are written)
for inp, file_result in converted:
@@ -644,13 +653,57 @@ def generate_zensical_config(
# Tags for cmd-k search filtering
extra["tags"] = {ds.title: ds.slug for ds in doc_sets}
- # MathJax with equation numbering + equation tooltips
+ # Markdown extensions — specify explicitly so we don't lose Zensical's
+ # defaults (superfences, highlight, etc.) when adding our overrides.
+ # Omitting this from zensical.toml would work for `make docs-test`, but
+ # since we need toc_depth=4 and arithmatex, we must list everything.
+ project["markdown_extensions"] = {
+ "abbr": {},
+ "admonition": {},
+ "attr_list": {},
+ "def_list": {},
+ "footnotes": {},
+ "md_in_html": {},
+ "toc": {"permalink": True, "toc_depth": 4},
+ "pymdownx.arithmatex": {"generic": True},
+ "pymdownx.betterem": {},
+ "pymdownx.caret": {},
+ "pymdownx.details": {},
+ "pymdownx.highlight": {
+ "anchor_linenums": True,
+ "line_spans": "__span",
+ "pygments_lang_class": True,
+ },
+ "pymdownx.inlinehilite": {},
+ "pymdownx.keys": {},
+ "pymdownx.magiclink": {},
+ "pymdownx.mark": {},
+ "pymdownx.smartsymbols": {},
+ "pymdownx.superfences": {
+ "custom_fences": [{"name": "mermaid", "class": "mermaid"}],
+ },
+ "pymdownx.tabbed": {
+ "alternate_style": True,
+ "combine_header_slug": True,
+ },
+ "pymdownx.tasklist": {"custom_checkbox": True},
+ "pymdownx.tilde": {},
+ }
+
+ # MathJax with equation numbering + equation tooltips + IDF editor
project["extra_javascript"] = [
{"path": "assets/mathjax-config.js"},
{"path": "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.js", "async": True},
{"path": "assets/eq-tooltips.js"},
+ {"path": "assets/idf-editor.js", "defer": True},
+ ]
+ project["extra_css"] = [
+ "assets/eq-tooltips.css",
+ "assets/figures.css",
+ "assets/idf-fields.css",
+ "assets/idf-editor.css",
+ "assets/theme-overrides.css",
]
- project["extra_css"] = ["assets/eq-tooltips.css", "assets/figures.css"]
config_path = output_dir / "zensical.toml"
config_path.write_text(tomli_w.dumps(config))
@@ -720,11 +773,27 @@ def convert_version(
# Build label index across all doc sets
label_index, file_figure_numbers = build_label_index(source_dir, doc_sets)
+ # Load epJSON schema from idfkit for structured field metadata and hover docs
+ object_index: dict[str, DocObjectInfo] | None = None
+ try:
+ object_index = build_object_index(version)
+ except Exception:
+ logger.warning(
+ "Failed to load epJSON schema for %s, field metadata will not be available", version, exc_info=True
+ )
+
# Convert each doc set
for ds in doc_sets:
logger.info("Converting doc set: %s", ds.title)
+ # Only pass object index for IO Reference doc set (contains IDF object field docs)
+ ds_object_index = object_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,
+ object_index=ds_object_index,
)
result.doc_set_results.append(ds_result)
logger.info(
@@ -740,9 +809,14 @@ def convert_version(
# Generate zensical config
generate_zensical_config(version, doc_sets, output_dir)
- # Copy static assets (MathJax config, equation tooltips)
+ # Copy static assets (MathJax config, equation tooltips, editor bundle)
copy_assets(output_dir)
+ # Generate compact JSON schema for Monaco hover documentation
+ if object_index:
+ schema_output = output_dir / "docs" / "assets" / "idd-schema.json"
+ serialize_for_monaco(object_index, version, schema_output)
+
# Build site
if not skip_build:
logger.info("Building Zensical site for %s...", version)
diff --git a/scripts/latex_preprocessor.py b/scripts/latex_preprocessor.py
index 49428468a..722cd4874 100644
--- a/scripts/latex_preprocessor.py
+++ b/scripts/latex_preprocessor.py
@@ -145,6 +145,8 @@ def _find_brace_content(text: str, start: int) -> tuple[str, int] | None:
"\\CB": ("\\left\\{", "\\right\\}"),
}
+_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.
@@ -152,28 +154,33 @@ def _expand_all_bracket_macros(text: str) -> str:
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..43f4f705f 100644
--- a/scripts/markdown_postprocessor.py
+++ b/scripts/markdown_postprocessor.py
@@ -19,6 +19,7 @@
import re
from scripts.models import LabelRef
+from scripts.schema_utils import DocFieldInfo, DocObjectInfo
# 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 +395,127 @@ def clean_div_wrappers(text: str) -> str:
return "\n".join(result)
+_IDD_TYPE_LABELS: dict[str, str] = {
+ "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:
+ """Create a single HTML pill span."""
+ if value:
+ return f'{label} {value}'
+ return f'{label}'
+
+
+def _format_range(field: DocFieldInfo) -> str:
+ """Format min/max constraints as a compact range string."""
+ parts: list[str] = []
+ if field.minimum:
+ op = ">" if field.minimum_exclusive else "\u2265"
+ parts.append(f"{op} {field.minimum}")
+ if field.maximum:
+ op = "<" if field.maximum_exclusive else "\u2264"
+ parts.append(f"{op} {field.maximum}")
+ return ", ".join(parts)
+
+
+def _collect_field_pills(field: DocFieldInfo) -> list[str]:
+ """Collect the ordered list of HTML pill spans for a field."""
+ pills: list[str] = []
+
+ type_label = _IDD_TYPE_LABELS.get(field.field_type)
+ if type_label:
+ pills.append(_pill("pill-type", type_label))
+
+ if field.units:
+ units_text = field.units
+ if field.ip_units:
+ units_text += f" ({field.ip_units})"
+ pills.append(_pill("pill-units", "Units:", units_text))
+
+ 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))
+
+ # Flag pills
+ for flag, css, label in [
+ (field.required, "pill-required pill-flag", "Required"),
+ (field.autosizable, "pill-flag", "Autosizable"),
+ (field.autocalculatable, "pill-flag", "Autocalculatable"),
+ ]:
+ if flag:
+ pills.append(_pill(css, label))
+
+ return pills
+
+
+def _format_field_attrs(field: DocFieldInfo) -> str:
+ """Build the HTML pill badges for a single field."""
+ pills = _collect_field_pills(field)
+
+ result_parts: list[str] = []
+ if pills:
+ result_parts.append(f'{" ".join(pills)}
')
+
+ if field.keys:
+ choices_html = " ".join(f'{k}' for k in field.keys)
+ result_parts.append(f'{choices_html}
')
+
+ return "\n".join(result_parts)
+
+
+def inject_field_metadata(text: str, object_index: dict[str, DocObjectInfo]) -> str:
+ """Inject structured metadata blocks after ``#### Field:`` headings using schema data.
+
+ Scans through the markdown line by line, tracks the current IDD object based
+ on h1-h3 headings, and for each ``#### Field: `` heading, looks up the
+ corresponding field metadata and appends inline HTML pills.
+ """
+ lines = text.split("\n")
+ result: list[str] = []
+ current_object: DocObjectInfo | None = None
+
+ heading_re = re.compile(r"^(#{1,3})\s+(.+)$")
+ field_re = re.compile(r"^####\s+Field:\s*(.+)$")
+
+ for line in lines:
+ result.append(line)
+
+ # Track current IDD object from h1-h3 headings
+ h_match = heading_re.match(line)
+ if h_match:
+ heading_text = h_match.group(2).strip()
+ # Strip Pandoc attributes like {#id .class}
+ heading_text = re.sub(r"\s*\{[#.][^}]*\}", "", heading_text).strip()
+ if heading_text in object_index:
+ current_object = object_index[heading_text]
+
+ # Inject pills after #### Field: headings
+ if current_object:
+ f_match = field_re.match(line)
+ if f_match:
+ field_name = f_match.group(1).strip()
+ # Strip Pandoc attributes
+ field_name = re.sub(r"\s*\{[#.][^}]*\}", "", field_name).strip()
+ idd_field = current_object.fields_by_display_name.get(field_name.lower())
+ if idd_field:
+ pills_html = _format_field_attrs(idd_field)
+ if pills_html:
+ result.append("")
+ result.append(pills_html)
+
+ return "\n".join(result)
+
+
def postprocess(
text: str,
title: str | None = None,
@@ -403,6 +525,7 @@ def postprocess(
rel_depth: int = 0,
current_md_path: str = "",
figure_numbers: list[int] | None = None,
+ object_index: dict[str, DocObjectInfo] | None = None,
) -> str:
"""Apply all postprocessing transformations in the correct order."""
if label_index is None:
@@ -424,6 +547,8 @@ def postprocess(
text = fix_heading_dashes(text)
text = clean_empty_links(text)
text = clean_div_wrappers(text)
+ if object_index:
+ text = inject_field_metadata(text, object_index)
text = add_front_matter(text, title, doc_set_title=doc_set_title)
return text
diff --git a/scripts/pygments_idf_lexer.py b/scripts/pygments_idf_lexer.py
new file mode 100644
index 000000000..8985b9665
--- /dev/null
+++ b/scripts/pygments_idf_lexer.py
@@ -0,0 +1,53 @@
+"""Minimal Pygments lexer for EnergyPlus IDF files.
+
+Registers the ``idf`` language name with Pygments so that fenced code blocks
+tagged as ``idf`` in Markdown are rendered with the correct ``language-idf``
+CSS class instead of falling back to ``language-text``.
+
+The actual syntax highlighting in the browser is handled by the Monaco-based
+IDF editor bundle (``idf-editor/``), so this lexer only needs to provide basic
+token classification — enough for Pygments to produce reasonable colour output
+as a static fallback.
+"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+from pygments.lexer import RegexLexer, bygroups
+from pygments.token import Comment, Keyword, Name, Number, Punctuation, String, Text
+
+
+class IDFLexer(RegexLexer):
+ """Pygments lexer for EnergyPlus Input Data Files (.idf)."""
+
+ name = "IDF"
+ aliases: ClassVar[list[str]] = ["idf"]
+ filenames: ClassVar[list[str]] = ["*.idf"]
+ mimetypes: ClassVar[list[str]] = ["text/x-idf"]
+
+ tokens: ClassVar[dict] = {
+ "root": [
+ # Comments: everything after '!'
+ (r"!.*$", Comment.Single),
+ # Object terminator
+ (r";", Punctuation),
+ # Field separator
+ (r",", Punctuation),
+ # Numeric values (integer and float, with optional sign)
+ (r"-?\d+\.\d*([eE][+-]?\d+)?", Number.Float),
+ (r"-?\d+([eE][+-]?\d+)?", Number.Integer),
+ # Special keywords
+ (
+ r"\b(autosize|autocalculate|yes|no)\b",
+ Keyword,
+ ),
+ # Object name (first non-whitespace token on a line before comma/semicolon)
+ # This matches EnergyPlus class names like "Zone," or "Building,"
+ (r"^([A-Za-z][A-Za-z0-9:_ -]*)(,)", bygroups(Name.Class, Punctuation)),
+ # Field values (general text)
+ (r"[^\s,;!]+", String),
+ # Whitespace
+ (r"\s+", Text),
+ ],
+ }
diff --git a/scripts/schema_utils.py b/scripts/schema_utils.py
new file mode 100644
index 000000000..3adffce01
--- /dev/null
+++ b/scripts/schema_utils.py
@@ -0,0 +1,355 @@
+"""Utilities for loading EnergyPlus schema metadata from idfkit.
+
+Provides unified data structures and functions that serve both:
+- Inline field metadata pills in the IO Reference (build-time HTML injection)
+- Monaco editor hover documentation (runtime JSON loaded by the browser)
+
+Replaces custom IDD parsers by delegating to idfkit's bundled epJSON schemas.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from idfkit import get_schema
+from idfkit.schema import EpJSONSchema
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Data classes
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class DocFieldInfo:
+ """Unified field metadata for documentation rendering.
+
+ Populated from idfkit's epJSON schema. Serves both the inline pill
+ generator (markdown_postprocessor) and the Monaco hover JSON serialiser.
+ """
+
+ name: str # Human-readable display name (e.g. "Thermal Absorptance")
+ snake_name: str # Schema key (e.g. "thermal_absorptance")
+ field_id: str = "" # Reconstructed IDD id (e.g. "A1", "N2")
+ field_type: str = "" # IDD-style: real, integer, alpha, choice, object-list, external-list
+ units: str = ""
+ ip_units: str = ""
+ default: str = ""
+ minimum: str = ""
+ minimum_exclusive: bool = False
+ maximum: str = ""
+ maximum_exclusive: bool = False
+ required: bool = False
+ autosizable: bool = False
+ autocalculatable: bool = False
+ keys: list[str] = field(default_factory=list)
+ notes: str = ""
+
+
+@dataclass
+class DocObjectInfo:
+ """Object-level schema metadata with field lookup by display name."""
+
+ name: str # Original case (e.g. "Pump:ConstantSpeed")
+ group: str = ""
+ memo: str = ""
+ fields: list[DocFieldInfo] = field(default_factory=list)
+ fields_by_display_name: dict[str, DocFieldInfo] = field(default_factory=dict, repr=False)
+ min_fields: int = 0
+ is_unique: bool = False
+ is_required: bool = False
+ extensible_size: int = 0
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+
+def _version_tag_to_tuple(version_tag: str) -> tuple[int, int, int]:
+ """Convert ``'v25.2.0'`` to ``(25, 2, 0)``."""
+ parts = version_tag.lstrip("v").split(".")
+ return (int(parts[0]), int(parts[1]), int(parts[2]))
+
+
+def _detect_auto_flags(field_schema: dict[str, Any]) -> tuple[bool, bool]:
+ """Return ``(autosizable, autocalculatable)`` from *anyOf* patterns."""
+ autosizable = False
+ autocalculatable = False
+ for sub in field_schema.get("anyOf", []):
+ if sub.get("type") == "string":
+ enum_vals = sub.get("enum", [])
+ if "Autosize" in enum_vals:
+ autosizable = True
+ if "Autocalculate" in enum_vals:
+ autocalculatable = True
+ return autosizable, autocalculatable
+
+
+def _resolve_anyof_type(any_of: list[dict[str, Any]]) -> str | None:
+ """Resolve an IDD type from an ``anyOf`` schema (autosizable/autocalculatable)."""
+ for sub in any_of:
+ if sub.get("type") in ("number", "integer"):
+ return "integer" if sub["type"] == "integer" else "real"
+ if any(sub.get("enum") for sub in any_of if sub.get("type") == "string"):
+ return "choice"
+ return None
+
+
+def _resolve_string_subtype(field_schema: dict[str, Any]) -> str:
+ """Classify a JSON ``"string"`` field into an IDD type."""
+ if "object_list" in field_schema:
+ return "object-list"
+ if field_schema.get("data_type") == "external_list":
+ return "external-list"
+ return "alpha"
+
+
+_JSON_TYPE_MAP: dict[str, str] = {"number": "real", "integer": "integer"}
+
+
+def _resolve_idd_type(field_schema: dict[str, Any]) -> str:
+ """Map an epJSON field schema to an IDD-style type string."""
+ if "enum" in field_schema:
+ return "choice"
+
+ any_of: list[dict[str, Any]] = field_schema.get("anyOf", [])
+ if any_of:
+ return _resolve_anyof_type(any_of) or "alpha"
+
+ json_type = field_schema.get("type", "string")
+ if json_type in _JSON_TYPE_MAP:
+ return _JSON_TYPE_MAP[json_type]
+ if json_type == "string":
+ return _resolve_string_subtype(field_schema)
+ return "alpha"
+
+
+def _compute_field_ids(
+ field_names: list[str],
+ field_info: dict[str, Any],
+) -> dict[str, str]:
+ """Reconstruct IDD field IDs (A1, N1, ...) from legacy_idd metadata."""
+ alpha_count = 0
+ numeric_count = 0
+ ids: dict[str, str] = {}
+ for name in field_names:
+ ft = field_info.get(name, {}).get("field_type", "a")
+ if ft == "a":
+ alpha_count += 1
+ ids[name] = f"A{alpha_count}"
+ else:
+ numeric_count += 1
+ ids[name] = f"N{numeric_count}"
+ return ids
+
+
+_CHOICE_EXCLUDE = {"", "Autosize", "Autocalculate"}
+
+
+def _extract_choices(field_schema: dict[str, Any]) -> list[str]:
+ """Extract enum/choice values, filtering out blanks and Autosize/Autocalculate markers."""
+ raw: list[str] = list(field_schema.get("enum", []))
+ if not raw:
+ for sub in field_schema.get("anyOf", []):
+ if sub.get("type") == "string" and "enum" in sub:
+ raw.extend(sub["enum"])
+ return [v for v in raw if v not in _CHOICE_EXCLUDE]
+
+
+def _build_doc_field(
+ snake_name: str,
+ schema: EpJSONSchema,
+ obj_type: str,
+ field_info_map: dict[str, Any],
+ field_ids: dict[str, str],
+ required_set: set[str],
+) -> DocFieldInfo:
+ """Build a single ``DocFieldInfo`` from the epJSON schema."""
+ field_schema: dict[str, Any] = schema.get_field_schema(obj_type, snake_name) or {}
+ fi_entry = field_info_map.get(snake_name, {})
+ display_name: str = fi_entry.get("field_name", snake_name)
+
+ autosizable, autocalculatable = _detect_auto_flags(field_schema)
+ idd_type = _resolve_idd_type(field_schema)
+
+ # Bounds
+ minimum_val = ""
+ minimum_exclusive = False
+ maximum_val = ""
+ maximum_exclusive = False
+
+ if "exclusiveMinimum" in field_schema:
+ minimum_val = str(field_schema["exclusiveMinimum"])
+ minimum_exclusive = True
+ elif "minimum" in field_schema:
+ minimum_val = str(field_schema["minimum"])
+
+ if "exclusiveMaximum" in field_schema:
+ maximum_val = str(field_schema["exclusiveMaximum"])
+ maximum_exclusive = True
+ elif "maximum" in field_schema:
+ maximum_val = str(field_schema["maximum"])
+
+ # Default
+ default_val = ""
+ if "default" in field_schema:
+ default_val = str(field_schema["default"])
+
+ return DocFieldInfo(
+ name=display_name,
+ snake_name=snake_name,
+ field_id=field_ids.get(snake_name, ""),
+ field_type=idd_type,
+ units=field_schema.get("units", ""),
+ ip_units=field_schema.get("ip-units", ""),
+ default=default_val,
+ minimum=minimum_val,
+ minimum_exclusive=minimum_exclusive,
+ maximum=maximum_val,
+ maximum_exclusive=maximum_exclusive,
+ required=snake_name in required_set,
+ autosizable=autosizable,
+ autocalculatable=autocalculatable,
+ keys=_extract_choices(field_schema),
+ notes=field_schema.get("note", ""),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def build_object_index(version_tag: str) -> dict[str, DocObjectInfo]:
+ """Load the epJSON schema for *version_tag* and build the full object index.
+
+ Args:
+ version_tag: Version string such as ``"v25.2.0"``.
+
+ Returns:
+ Mapping of object type names (original case) to :class:`DocObjectInfo`.
+ """
+ version_tuple = _version_tag_to_tuple(version_tag)
+ schema = get_schema(version_tuple)
+ logger.info(
+ "Loaded epJSON schema for %s (%d object types)",
+ version_tag,
+ len(schema),
+ )
+
+ index: dict[str, DocObjectInfo] = {}
+
+ for obj_type in schema.object_types:
+ obj_schema = schema.get_object_schema(obj_type)
+ if not obj_schema:
+ continue
+
+ legacy: dict[str, Any] = obj_schema.get("legacy_idd", {})
+ field_info_map: dict[str, Any] = legacy.get("field_info", {})
+ all_field_names: list[str] = legacy.get("fields", [])
+
+ # Required fields
+ inner = schema.get_inner_schema(obj_type)
+ required_set: set[str] = set(inner.get("required", [])) if inner else set()
+
+ # Reconstruct IDD field IDs
+ field_ids = _compute_field_ids(all_field_names, field_info_map)
+
+ # Build per-field metadata
+ doc_fields: list[DocFieldInfo] = []
+ fields_by_display: dict[str, DocFieldInfo] = {}
+
+ for snake_name in all_field_names:
+ doc_field = _build_doc_field(snake_name, schema, obj_type, field_info_map, field_ids, required_set)
+ doc_fields.append(doc_field)
+ fields_by_display[doc_field.name.lower()] = doc_field
+
+ # Object-level properties
+ index[obj_type] = DocObjectInfo(
+ name=obj_type,
+ group=obj_schema.get("group", ""),
+ memo=obj_schema.get("memo", ""),
+ fields=doc_fields,
+ fields_by_display_name=fields_by_display,
+ min_fields=obj_schema.get("min_fields", 0),
+ is_unique=obj_schema.get("maxProperties", 0) == 1,
+ is_required=False,
+ extensible_size=obj_schema.get("extensible_size", 0),
+ )
+
+ return index
+
+
+def serialize_for_monaco(
+ object_index: dict[str, DocObjectInfo],
+ version_tag: str,
+ output_path: Path,
+) -> bool:
+ """Serialize the object index to compact JSON for the Monaco hover provider.
+
+ Produces the ``CompactIDDSchema`` format expected by
+ ``idf-editor/src/types.ts``.
+ """
+ object_types: dict[str, dict[str, Any]] = {}
+
+ for _key, obj in object_index.items():
+ fields_json: list[dict[str, Any]] = []
+ for f in obj.fields:
+ fd: dict[str, Any] = {
+ "id": f.field_id,
+ "name": f.name,
+ "type": f.field_type,
+ "required": f.required,
+ "memo": f.notes,
+ "autosizable": f.autosizable,
+ "autocalculatable": f.autocalculatable,
+ }
+ if f.default:
+ fd["default"] = f.default
+ if f.units:
+ fd["units"] = f.units
+ if f.minimum:
+ fd["minimum"] = float(f.minimum)
+ if f.minimum_exclusive:
+ fd["exclusiveMinimum"] = True
+ if f.maximum:
+ fd["maximum"] = float(f.maximum)
+ if f.maximum_exclusive:
+ fd["exclusiveMaximum"] = True
+ if f.keys:
+ fd["choices"] = f.keys
+ fields_json.append(fd)
+
+ object_types[obj.name.lower()] = {
+ "name": obj.name,
+ "group": obj.group,
+ "memo": obj.memo,
+ "fields": fields_json,
+ "minFields": obj.min_fields,
+ "isUnique": obj.is_unique,
+ "isRequired": obj.is_required,
+ "extensible": obj.extensible_size,
+ }
+
+ schema_json: dict[str, Any] = {
+ "version": version_tag.lstrip("v"),
+ "objectTypes": object_types,
+ }
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(json.dumps(schema_json, separators=(",", ":")))
+
+ logger.info(
+ "Wrote Monaco schema JSON (%d object types, %.1f KB) to %s",
+ len(object_types),
+ output_path.stat().st_size / 1024,
+ output_path,
+ )
+ return True
diff --git a/uv.lock b/uv.lock
index 55f4a6408..747a1da8f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -68,11 +68,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
]
+[[package]]
+name = "idfkit"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dd/0a/35fc74570f424dee8edd89d608bdf80452130e70b7815b8ef924370e083a/idfkit-0.3.0.tar.gz", hash = "sha256:cdb3ba19b4d5c6ac68d9bd7c8681824f89296cae27a6309b4d24a0ba3849d6f8", size = 12872869, upload-time = "2026-02-26T22:22:56.442Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/1c/629da6c4041095662d001c4e336d2c4070585beeddca1a0a42c126c3c6dd/idfkit-0.3.0-py3-none-any.whl", hash = "sha256:e29c02b263111e37a277e75f636316d041ff4e2015695fa17e0a24f5346e66ab", size = 12275791, upload-time = "2026-02-26T22:22:53.994Z" },
+]
+
[[package]]
name = "idfkit-docs"
version = "0.0.1"
-source = { virtual = "." }
+source = { editable = "." }
dependencies = [
+ { name = "idfkit" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "tomli-w" },
]
@@ -88,6 +98,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "idfkit", specifier = ">=0.3.0" },
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" },
{ name = "tomli-w", specifier = ">=1.0" },
]
diff --git a/zensical.toml b/zensical.toml
index c14ff9f7b..fe87fe827 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -19,6 +19,7 @@ features = [
"navigation.footer",
"navigation.tracking",
"content.code.copy",
+ "toc.follow",
]
[project.theme.icon]