{
+ 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..06b9e5b59
--- /dev/null
+++ b/idf-editor/src/idf-editor.css
@@ -0,0 +1,75 @@
+/**
+ * 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;
+}
+
+/* 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..79fdfd185
--- /dev/null
+++ b/idf-editor/src/idf-hover-service.ts
@@ -0,0 +1,250 @@
+/**
+ * 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
+ if (field.choices && field.choices.length > 0) {
+ contents.push({ value: `Choices: ${field.choices.map((c) => `\`${c}\``).join(', ')}` });
+ }
+
+ // 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..893a4344b
--- /dev/null
+++ b/idf-editor/src/main.ts
@@ -0,0 +1,122 @@
+/**
+ * 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;
+
+/**
+ * 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 {
+ // Dispose previous editors (from prior page)
+ if (currentManager) {
+ currentManager.dispose();
+ currentManager = null;
+ }
+
+ // 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;
+
+ try {
+ // 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);
+ }
+}
+
+/**
+ * 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$.subscribe(() => {
+ // Small delay to ensure the DOM is updated
+ requestAnimationFrame(() => 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..0345ef27a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,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..7b5fc3e55
--- /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}.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..fbe926e35
--- /dev/null
+++ b/scripts/assets/idf-editor.js
@@ -0,0 +1,2 @@
+(function(){"use strict";const k=[{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"}],w=[{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"}],m="idf-docs-light",g="idf-docs-dark";function A(e){e.editor.defineTheme(g,{base:"vs-dark",inherit:!0,rules:k,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(m,{base:"vs",inherit:!0,rules:w,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 D(){return document.body.getAttribute("data-md-color-scheme")==="slate"?g:m}const L=60,E=720,M=19,N=20,O="200px",p="https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs",f='',C='';class H{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:O}),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())}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 l;const t=o.split(`
+`).length,r=Math.max(L,Math.min(E,t*M+N)),i=document.createElement("div");i.className="idf-editor-container",i.style.height=`${r}px`,(l=n.parentNode)==null||l.replaceChild(i,n),n.dataset.idfEditor="true";const s=this.monaco.editor.create(i,{value:o,language:"idf",theme:D(),readOnly:!0,domReadOnly:!0,minimap:{enabled:!1},lineNumbers:"on",scrollBeyondLastLine:!1,wordWrap:"off",folding:!1,glyphMargin:!1,lineDecorationsWidth:0,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",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=f,t.addEventListener("click",async()=>{try{const r=o.getValue();await navigator.clipboard.writeText(r),t.innerHTML=C,t.classList.add("copied"),setTimeout(()=>{t.innerHTML=f,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=C,t.classList.add("copied"),setTimeout(()=>{t.innerHTML=f,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:m;this.monaco.editor.setTheme(r)}}),this.themeObserver.observe(document.body,{attributes:!0,attributeFilter:["data-md-color-scheme"]})}}function x(){return new Promise((e,n)=>{const o=window;if(o.monaco){e(o.monaco);return}if(typeof o.require=="function"&&o.require.config){b(e,n);return}const t=document.createElement("script");t.src=`${p}/loader.js`,t.onload=()=>b(e,n),t.onerror=()=>n(new Error("Failed to load Monaco AMD loader")),document.head.appendChild(t)})}function b(e,n){const t=window.require;t.config({paths:{vs:p}}),t(["vs/editor/editor.main"],r=>{e(r)},r=>{n(r)})}const c="idf",I={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:_-]*/},F={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 T(e,n){return e.languages.registerHoverProvider(c,{provideHover(o,t){const r=n();if(!r)return null;const i=z(o,t);if(!i)return null;const s=r.objectTypes[i.className.toLowerCase()];if(!s)return null;if(i.isClassName)return B(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 B(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 P(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})}e.default&&o.push({value:`Default: \`${e.default}\``}),e.choices&&e.choices.length>0&&o.push({value:`Choices: ${e.choices.map(i=>`\`${i}\``).join(", ")}`});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 a=null,d=null;function V(){return a}async function W(){return a||d||(d=(async()=>{try{const e=Z(),n=await fetch(e);return n.ok?(a=await n.json(),console.debug(`[idf-editor] IDD schema loaded: ${a.version}`),a):(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 Z(){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 v=!1,u=null;function _(e){v||(e.languages.register({id:c,extensions:[".idf",".imf"],aliases:["IDF","EnergyPlus IDF","Input Data File"],mimetypes:["text/x-idf"]}),e.languages.setLanguageConfiguration(c,I),e.languages.setMonarchTokensProvider(c,F),A(e),T(e,V),v=!0)}async function h(){u&&(u.dispose(),u=null);const e=document.querySelectorAll("div.language-idf pre > code");if(e.length!==0)try{const n=await x();_(n),W(),u=new H(n),u.initialize(e)}catch(n){console.error("[idf-editor] Failed to initialize:",n)}}function S(){const n=window.document$;return n?(n.subscribe(()=>{requestAnimationFrame(()=>h())}),!0):!1}if(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>h()):h(),!S()){let e=0;const n=setInterval(()=>{e++,(S()||e>50)&&clearInterval(n)},100)}})();
diff --git a/scripts/convert.py b/scripts/convert.py
index 8ca5aa6b0..5edf0cd8f 100644
--- a/scripts/convert.py
+++ b/scripts/convert.py
@@ -33,6 +33,7 @@
IMAGE_EXTENSIONS,
version_to_title,
)
+from scripts.idd_schema_builder import build_compact_schema, find_idd_file
from scripts.latex_preprocessor import preprocess
from scripts.markdown_postprocessor import postprocess
from scripts.models import ConversionResult, DocSet, DocSetResult, LabelRef, VersionResult
@@ -644,13 +645,14 @@ 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
+ # MathJax with equation numbering + equation tooltips + IDF Monaco 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"]
+ project["extra_css"] = ["assets/eq-tooltips.css", "assets/figures.css", "assets/idf-editor.css"]
config_path = output_dir / "zensical.toml"
config_path.write_text(tomli_w.dumps(config))
@@ -740,9 +742,20 @@ 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, IDF editor)
copy_assets(output_dir)
+ # Generate IDD schema JSON for Monaco hover documentation
+ idd_path = find_idd_file(source_dir)
+ if idd_path:
+ schema_output = output_dir / "docs" / "assets" / "idd-schema.json"
+ if build_compact_schema(idd_path, schema_output):
+ logger.info("IDD schema generated for Monaco hover docs")
+ else:
+ logger.warning("Failed to generate IDD schema — hover docs will be unavailable")
+ else:
+ logger.warning("No IDD file found in %s — hover docs will be unavailable", source_dir)
+
# Build site
if not skip_build:
logger.info("Building Zensical site for %s...", version)
diff --git a/scripts/convert_all.py b/scripts/convert_all.py
index 9757d65fc..9609a7522 100644
--- a/scripts/convert_all.py
+++ b/scripts/convert_all.py
@@ -47,7 +47,7 @@ def clone_version(version: str, clone_dir: Path) -> Path:
shutil.rmtree(target)
- logger.info("Cloning EnergyPlus %s (sparse, doc/ only)...", version)
+ logger.info("Cloning EnergyPlus %s (sparse, doc/ + idd/)...", version)
subprocess.run(
[
@@ -66,8 +66,9 @@ def clone_version(version: str, clone_dir: Path) -> Path:
capture_output=True,
text=True,
)
+ # Sparse-checkout doc/ (LaTeX source) and idd/ (IDD for Monaco hover docs)
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_schema_builder.py b/scripts/idd_schema_builder.py
new file mode 100644
index 000000000..68e8e4d6b
--- /dev/null
+++ b/scripts/idd_schema_builder.py
@@ -0,0 +1,373 @@
+"""Build a compact IDD schema JSON for the Monaco hover documentation provider.
+
+Parses the EnergyPlus IDD (Input Data Dictionary) file and produces a compact
+JSON file containing object type definitions, field metadata, and documentation
+text. This JSON is loaded by the browser-side IDF editor to power hover
+tooltips.
+
+The parser handles the standard IDD format:
+
+ \\group Group Name
+
+ ClassName,
+ \\memo Description of the object
+ \\unique-object
+ \\min-fields 5
+ A1 , \\field Field Name
+ \\required-field
+ \\type choice
+ \\key Option1
+ \\key Option2
+ N1 ; \\field Field Name
+ \\type real
+ \\units W
+ \\minimum 0.0
+ \\default 1.0
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import logging
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Data models
+# ---------------------------------------------------------------------------
+
+FIELD_TYPE_MAP: dict[str, str] = {
+ "real": "real",
+ "integer": "integer",
+ "alpha": "alpha",
+ "choice": "choice",
+ "object-list": "object-list",
+ "external-list": "external-list",
+ "node": "node",
+}
+
+
+@dataclass
+class CompactField:
+ id: str
+ name: str = ""
+ type: str = "alpha"
+ required: bool = False
+ default: str | None = None
+ units: str | None = None
+ minimum: float | None = None
+ exclusive_minimum: bool = False
+ maximum: float | None = None
+ exclusive_maximum: bool = False
+ choices: list[str] = field(default_factory=list)
+ memo: str = ""
+ autosizable: bool = False
+ autocalculatable: bool = False
+
+ def to_dict(self) -> dict:
+ d: dict = {"id": self.id, "name": self.name, "type": self.type, "required": self.required, "memo": self.memo}
+ if self.default is not None:
+ d["default"] = self.default
+ if self.units:
+ d["units"] = self.units
+ if self.minimum is not None:
+ d["minimum"] = self.minimum
+ if self.exclusive_minimum:
+ d["exclusiveMinimum"] = True
+ if self.maximum is not None:
+ d["maximum"] = self.maximum
+ if self.exclusive_maximum:
+ d["exclusiveMaximum"] = True
+ if self.choices:
+ d["choices"] = self.choices
+ d["autosizable"] = self.autosizable
+ d["autocalculatable"] = self.autocalculatable
+ return d
+
+
+@dataclass
+class CompactObjectType:
+ name: str
+ group: str = ""
+ memo: str = ""
+ fields: list[CompactField] = field(default_factory=list)
+ min_fields: int = 0
+ is_unique: bool = False
+ is_required: bool = False
+ extensible: int = 0
+
+ def to_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "group": self.group,
+ "memo": self.memo,
+ "fields": [f.to_dict() for f in self.fields],
+ "minFields": self.min_fields,
+ "isUnique": self.is_unique,
+ "isRequired": self.is_required,
+ "extensible": self.extensible,
+ }
+
+
+# ---------------------------------------------------------------------------
+# IDD parser
+# ---------------------------------------------------------------------------
+
+_PROPERTY_RE = re.compile(r"\\(\S+)\s*(.*)")
+_FIELD_ID_RE = re.compile(r"^\s*([AN]\d+)\s*[,;]")
+_CLASS_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9:_ -]*[,;]$")
+
+
+def _strip_comments(line: str) -> str:
+ """Remove inline comments and strip trailing whitespace."""
+ pos = line.find("!")
+ return line[:pos].rstrip() if pos >= 0 else line
+
+
+def _is_class_name_line(stripped: str, raw_line: str) -> bool:
+ """Determine if a line is a class name definition (not a field)."""
+ if raw_line and raw_line[0] in (" ", "\t"):
+ return False
+ return bool(_CLASS_NAME_RE.match(stripped))
+
+
+def _append_memo(existing: str, addition: str) -> str:
+ """Append text to an existing memo string."""
+ return (existing + " " + addition).strip() if existing else addition
+
+
+def _apply_object_property(obj: CompactObjectType, fld: CompactField | None, name: str, value: str) -> bool:
+ """Apply an object-level IDD property. Return True if handled."""
+ if name == "memo":
+ if fld is None:
+ obj.memo = _append_memo(obj.memo, value)
+ else:
+ fld.memo = _append_memo(fld.memo, value)
+ return True
+ if name == "unique-object":
+ obj.is_unique = True
+ return True
+ if name == "required-object":
+ obj.is_required = True
+ return True
+ if name == "min-fields":
+ with contextlib.suppress(ValueError):
+ obj.min_fields = int(value)
+ return True
+ if name == "extensible":
+ with contextlib.suppress(ValueError):
+ obj.extensible = int(value.split(":")[0]) if ":" in value else int(value)
+ return True
+ return False
+
+
+def _set_field_range(fld: CompactField, name: str, value: str) -> None:
+ """Set a min/max range constraint on a field."""
+ with contextlib.suppress(ValueError):
+ if name.startswith("min"):
+ fld.minimum = float(value)
+ fld.exclusive_minimum = name == "minimum>"
+ else:
+ fld.maximum = float(value)
+ fld.exclusive_maximum = name == "maximum<"
+
+
+def _apply_field_property(fld: CompactField, name: str, value: str) -> None:
+ """Apply a field-level IDD property."""
+ # Simple flag properties
+ _FIELD_FLAGS = {"required-field": "required", "autosizable": "autosizable", "autocalculatable": "autocalculatable"}
+ if name in _FIELD_FLAGS:
+ setattr(fld, _FIELD_FLAGS[name], True)
+ return
+
+ # Range properties
+ if name in ("minimum", "minimum>", "maximum", "maximum<"):
+ _set_field_range(fld, name, value)
+ return
+
+ # Value properties
+ if name == "note":
+ fld.memo = _append_memo(fld.memo, value)
+ elif name == "field":
+ fld.name = value
+ elif name == "type":
+ fld.type = FIELD_TYPE_MAP.get(value.lower(), value.lower())
+ elif name == "key":
+ fld.choices.append(value)
+ if fld.type == "alpha":
+ fld.type = "choice"
+ elif name == "units":
+ fld.units = value
+ elif name == "default":
+ fld.default = value
+
+
+def _handle_property(state: dict, stripped: str) -> None:
+ """Parse and apply a property line (\\name value)."""
+ m = _PROPERTY_RE.match(stripped)
+ if not m:
+ return
+ prop_name = m.group(1).lower()
+ prop_value = m.group(2).strip()
+ obj, fld = state["obj"], state["fld"]
+ if (obj is not None and not _apply_object_property(obj, fld, prop_name, prop_value) and fld is not None) or (
+ obj is None and fld is not None
+ ):
+ _apply_field_property(fld, prop_name, prop_value)
+
+
+def _handle_class_name(state: dict, stripped: str) -> None:
+ """Start a new object type definition."""
+ if state["obj"] is not None:
+ state["types"][state["obj"].name.lower()] = state["obj"]
+ class_name = stripped.rstrip(",;").strip()
+ state["obj"] = CompactObjectType(name=class_name, group=state["group"])
+ state["fld"] = None
+
+
+def _handle_field(state: dict, line: str) -> None:
+ """Add a new field definition to the current object.
+
+ IDD fields can have properties on the same line, e.g.:
+ A1 , \\field Name of the Zone
+ We parse the field ID first, then check for inline properties.
+ """
+ field_match = _FIELD_ID_RE.match(line)
+ if not field_match or state["obj"] is None:
+ return
+ field_id = field_match.group(1)
+ field_type = "alpha" if field_id.startswith("A") else "real"
+ state["fld"] = CompactField(id=field_id, type=field_type)
+ state["obj"].fields.append(state["fld"])
+
+ # Check for inline properties after the comma/semicolon (e.g., \field Name)
+ remainder = line[field_match.end() :].strip()
+ if remainder.startswith("\\"):
+ _handle_property(state, remainder)
+
+
+def _process_line(line: str, raw_line: str, state: dict) -> None:
+ """Process a single non-empty, non-comment IDD line."""
+ stripped = line.strip()
+
+ if stripped.startswith("\\group"):
+ state["group"] = stripped[7:].strip()
+ elif stripped.startswith("\\"):
+ _handle_property(state, stripped)
+ elif _is_class_name_line(stripped, raw_line):
+ _handle_class_name(state, stripped)
+ else:
+ _handle_field(state, line)
+
+
+def _iter_idd_lines(text: str) -> list[tuple[str, str]]:
+ """Yield (processed_line, raw_line) tuples, skipping blanks, comments, and header."""
+ result = []
+ in_header = True
+ for raw_line in text.splitlines():
+ line = raw_line.rstrip()
+ if not line.strip():
+ continue
+ if in_header:
+ if line.startswith("!"):
+ continue
+ in_header = False
+ if line.lstrip().startswith("!"):
+ continue
+ line = _strip_comments(line)
+ if line.strip():
+ result.append((line, raw_line))
+ return result
+
+
+def _extract_version(types: dict[str, CompactObjectType]) -> str:
+ """Extract the EnergyPlus version from the parsed Version object."""
+ if "version" not in types:
+ return ""
+ for f in types["version"].fields:
+ if f.default:
+ return f.default
+ return ""
+
+
+def parse_idd(idd_path: Path) -> dict:
+ """Parse an EnergyPlus IDD file and return a compact schema dict.
+
+ Returns a dict with keys ``version`` and ``objectTypes`` (keyed by
+ lowercase class name).
+ """
+ text = idd_path.read_text(encoding="utf-8", errors="replace")
+ state: dict = {"group": "", "obj": None, "fld": None, "types": {}}
+
+ for line, raw_line in _iter_idd_lines(text):
+ _process_line(line, raw_line, state)
+
+ # Save the last object
+ if state["obj"] is not None:
+ state["types"][state["obj"].name.lower()] = state["obj"]
+
+ version = _extract_version(state["types"])
+ return {"version": version, "objectTypes": {k: v.to_dict() for k, v in state["types"].items()}}
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def build_compact_schema(idd_path: Path, output_path: Path) -> bool:
+ """Build a compact IDD schema JSON file from an EnergyPlus IDD file.
+
+ Args:
+ idd_path: Path to the Energy+.idd file.
+ output_path: Path where the compact JSON will be written.
+
+ Returns:
+ True if the schema was built successfully, False otherwise.
+ """
+ if not idd_path.exists():
+ logger.warning("IDD file not found: %s", idd_path)
+ return False
+
+ logger.info("Building compact IDD schema from %s", idd_path)
+ schema = parse_idd(idd_path)
+
+ obj_count = len(schema.get("objectTypes", {}))
+ logger.info("Parsed %d object types (version: %s)", obj_count, schema.get("version", "unknown"))
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(output_path, "w", encoding="utf-8") as f:
+ json.dump(schema, f, separators=(",", ":"))
+
+ size_kb = output_path.stat().st_size / 1024
+ logger.info("Wrote compact schema: %s (%.0f KB)", output_path, size_kb)
+ return True
+
+
+def find_idd_file(source_dir: Path) -> Path | None:
+ """Search for the Energy+.idd file in an EnergyPlus source directory.
+
+ Checks several known locations across different EnergyPlus versions.
+ """
+ candidates = [
+ source_dir / "idd" / "Energy+.idd",
+ source_dir / "idd" / "Energy+.idd.in", # CMake template (source builds)
+ source_dir / "idd" / "V8-9-0-Energy+.idd", # v8.9 format
+ source_dir / "Energy+.idd",
+ ]
+ for candidate in candidates:
+ if candidate.exists():
+ return candidate
+
+ # Glob for any .idd file in idd/ directory
+ idd_dir = source_dir / "idd"
+ if idd_dir.exists():
+ idd_files = list(idd_dir.glob("*Energy+.idd*"))
+ if idd_files:
+ return idd_files[0]
+
+ return None
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/uv.lock b/uv.lock
index 55f4a6408..ca9be2d5f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -71,7 +71,7 @@ wheels = [
[[package]]
name = "idfkit-docs"
version = "0.0.1"
-source = { virtual = "." }
+source = { editable = "." }
dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "tomli-w" },