Documentation Index
Fetch the complete documentation index at: https://mintlify.com/davidmenlop/HubSpot-Form-builder/llms.txt
Use this file to discover all available pages before exploring further.
Overview
The module generation system transforms the form schema and layout state into a complete HubSpot CMS module. It generates five files that are packaged into a ZIP file for upload to HubSpot.
Generated Files
- fields.json - Module field definitions (CMS configuration)
- module.html - HubL template with form markup
- module.css - Form styles
- module.js - Multi-step navigation and validation logic
- meta.json - Module metadata
Architecture
┌──────────────┐ ┌──────────────┐
│ FormSchema │ │ LayoutState │
└──────┬───────┘ └──────┬───────┘
│ │
└────────┬───────────┘
│
v
┌────────────────┐
│ exportModule() │
└────────┬───────┘
│
v
┌───────────────────────┐
│ Module Generators │
├───────────────────────┤
│ • fieldsJsonGenerator │
│ • moduleHtmlGenerator │
│ • moduleCssGenerator │
│ • moduleJsGenerator │
└───────────┬───────────┘
│
v
┌──────────────┐
│ JSZip │
└──────┬───────┘
│
v
┌──────────────┐
│ module.zip │
└──────────────┘
Export Module Orchestrator
File: frontend/src/utils/exportModule.ts
Orchestrates the generation and packaging process.
import JSZip from 'jszip';
import type { FormSchema, LayoutState } from 'shared';
import {
generateFieldsJson,
generateModuleHtml,
generateModuleCss,
generateModuleJs,
} from './moduleGenerators';
function sanitizeModuleName(name: string): string {
return name
.trim()
.replace(/[^a-zA-Z0-9-_\s]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
function generateMetaJson(schema: FormSchema) {
const moduleId = crypto.randomUUID();
return {
label: schema.name,
icon: 'form',
css_assets: [],
external_dependencies: [],
extra_body_html: '',
global: false,
help_text: '',
host_template_types: ['PAGE', 'BLOG_POST', 'BLOG_LISTING'],
js_assets: [],
module_id: moduleId,
other_assets: [],
smart_type: 'NOT_SMART',
tags: [],
is_available_for_new_content: true,
};
}
export async function generateModule(schema: FormSchema, layout: LayoutState): Promise<Blob> {
const zip = new JSZip();
const moduleName = sanitizeModuleName(schema.name);
const moduleFolder = zip.folder(`${moduleName}.module`);
if (!moduleFolder) {
throw new Error('Failed to create module folder in ZIP');
}
// Generate all files
const fieldsJson = generateFieldsJson(schema, layout);
const moduleHtml = generateModuleHtml();
const moduleCss = generateModuleCss();
const moduleJs = generateModuleJs();
const metaJson = generateMetaJson(schema);
// Add files to ZIP
moduleFolder.file('fields.json', JSON.stringify(fieldsJson, null, 2));
moduleFolder.file('module.html', moduleHtml);
moduleFolder.file('module.css', moduleCss);
moduleFolder.file('module.js', moduleJs);
moduleFolder.file('meta.json', JSON.stringify(metaJson, null, 2));
// Generate blob
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
return blob;
}
export function downloadModule(blob: Blob, moduleName: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${sanitizeModuleName(moduleName)}.zip`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 100);
}
Generator: fields.json
File: frontend/src/utils/moduleGenerators/fieldsJsonGenerator.ts
Generates the HubSpot module field definitions that appear in the CMS editor.
Field Type Mapping
function mapFieldType(schemaType: string, hasMultipleOptions: boolean): FieldType {
const type = schemaType.toLowerCase();
switch (type) {
case 'email': return 'email';
case 'phone':
case 'tel': return 'phone_number';
case 'textarea': return 'rich_text';
case 'select':
case 'dropdown': return 'dropdown';
case 'checkbox': return hasMultipleOptions ? 'checkbox' : 'boolean';
case 'radio': return 'radio';
case 'text':
case 'number':
default: return 'text';
}
}
Structure
The fields.json file defines:
-
Module metadata fields:
sr_module_id - Optional module ID
class - Optional CSS class
style_group - Style configuration group
-
Steps group (repeatable):
tab_label - Step label (e.g., “STEP 1”)
tab_name - Step title
field_group - Nested group for field rows (repeatable)
field - Individual fields (up to 3 per group)
field_type - Field type (text, email, dropdown, etc.)
field_label - Display label
form_property - HubSpot form field name
required - Is field required
choices - Options for select/radio/checkbox
buttons - Button text configuration
-
HubSpot form field:
form - Hidden HubSpot form for submission
Example Output Structure
[
{
"id": "steps_group",
"name": "steps",
"label": "Steps",
"type": "group",
"occurrence": {
"min": 1,
"max": null,
"default": 2
},
"children": [
{
"id": "tab_label",
"name": "tab_label",
"label": "Label",
"type": "text",
"default": "STEP 1"
},
{
"id": "field_group",
"name": "field_group",
"label": "Field Group",
"type": "group",
"occurrence": { "min": null, "max": null },
"children": [
{
"id": "field",
"name": "field",
"label": "Field",
"type": "group",
"occurrence": { "min": 1, "max": 3 },
"children": [
{
"id": "field_type",
"name": "field_type",
"label": "Field Type",
"type": "choice",
"choices": [
["text", "Text"],
["email", "Email"],
["phone_number", "Phone number"]
]
}
]
}
]
}
],
"default": [
{
"tab_label": "STEP 1",
"tab_name": "Personal Info",
"field_group": [
{
"field": [
{
"field_type": "text",
"field_label": "First Name",
"form_property": "firstname",
"required": true
}
]
}
]
}
]
}
]
Generator: module.html
File: frontend/src/utils/moduleGenerators/moduleHtmlGenerator.ts
Generates the HubL template with form markup.
Key Features
- HubL templating - Uses Jinja-like syntax
- Multi-step tabs - ARIA-compliant tab navigation
- Field groups - Up to 3 fields per row (grid layout)
- Conditional logic - Support for field visibility rules
- Accessibility - Proper ARIA attributes and labels
- Hidden HubSpot form - Collects data and submits to HubSpot
Template Structure
<div class="multistep-form" id="{{ name }}_{{ id }}">
<!-- Step tabs -->
<div class="multistep-form__tabs" role="tablist">
{% for step in module.steps %}
<button role="tab" aria-selected="{{ 'true' if loop.first else 'false' }}">
<span class="step">{{ step.tab_label }}</span>
{{ step.tab_name }}
</button>
{% endfor %}
</div>
<!-- Form panels -->
<div class="multistep-form__form">
{% for step in module.steps %}
<section role="tabpanel" {{ "hidden" if not loop.first }}>
<!-- Field groups -->
{% for field_group in step.field_group %}
<div class="field-group field-group--cols-{{ field_group.field|length }}">
{% for field in field_group.field %}
<!-- Field HTML -->
{% endfor %}
</div>
{% endfor %}
<!-- Navigation buttons -->
<div class="form-actions">
{% if not loop.first %}
<button type="button" data-action="prev">{{ step.buttons.previous }}</button>
{% endif %}
{% if loop.last %}
<button type="submit">{{ step.buttons.next }}</button>
{% else %}
<button type="button" data-action="next">{{ step.buttons.next }}</button>
{% endif %}
</div>
</section>
{% endfor %}
<!-- Hidden HubSpot form -->
<div class="multistep-form__hs-form" hidden>
{% form form_to_use="{{ module.form.form_id }}" %}
</div>
</div>
</div>
Field Rendering Examples
Text Input:
<input
type="text"
id="{{ field.form_property }}"
name="{{ field.form_property }}"
class="field-input"
{{ " required" if field.required }}
placeholder="{{ field.placeholder }}">
Dropdown:
<select id="{{ field.form_property }}" name="{{ field.form_property }}">
<option value="">Select an option</option>
{% for choice in field.choices %}
<option value="{{ choice.value }}">{{ choice.text }}</option>
{% endfor %}
</select>
Radio Group:
<fieldset>
<legend>{{ field.field_label }}</legend>
<div class="field-choices">
{% for choice in field.choices %}
<label class="choice-label">
<input type="radio" name="{{ field.form_property }}" value="{{ choice.value }}">
<span>{{ choice.text }}</span>
</label>
{% endfor %}
</div>
</fieldset>
Generator: module.css
File: frontend/src/utils/moduleGenerators/moduleCssGenerator.ts
Generates the CSS styles for the form.
Key Features
- Modern CSS - Flexbox and Grid layout
- Responsive design - Mobile-first approach
- Accessibility - Focus indicators and ARIA states
- Customizable - CSS custom properties for theming
- Isolated styles - BEM-like naming convention
Style Categories
-
Layout:
- Container max-width and padding
- Tab navigation layout
- Form panel layout
- Field group grid (2 or 3 columns)
-
Typography:
- Font family and sizes
- Label styles
- Required indicator
- Error messages
-
Form Controls:
- Input fields (text, email, tel)
- Select dropdowns
- Textareas
- Radio buttons and checkboxes
-
Interactive States:
- Focus states
- Hover states
- Active/selected states
- Disabled states
- Error states
-
Buttons:
- Primary (submit/next)
- Secondary (back)
- Hover effects
- Disabled state
Example Styles
/* Tab navigation */
.multistep-form__tabs {
display: flex;
gap: 8px;
margin-bottom: 32px;
border-bottom: 2px solid #e2e8f0;
}
.multistep-form__tabs-item[aria-selected="true"] {
color: #0c63ff;
border-bottom-color: #0c63ff;
font-weight: 600;
}
/* Field groups - responsive grid */
.field-group--grid {
display: grid;
gap: 16px;
}
.field-group--cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.field-group--cols-3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 640px) {
.field-group--grid {
grid-template-columns: 1fr;
}
}
/* Input fields */
.field-input {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid #cbd5e1;
border-radius: 6px;
transition: all 0.2s ease;
}
.field-input:focus {
outline: none;
border-color: #0c63ff;
box-shadow: 0 0 0 3px rgba(12, 99, 255, 0.1);
}
.field-input[aria-invalid="true"] {
border-color: #ef4444;
}
Generator: module.js
File: frontend/src/utils/moduleGenerators/moduleJsGenerator.ts
Generates the JavaScript for multi-step navigation, validation, and HubSpot form integration.
Key Features
-
Multi-step navigation:
- Next/Previous buttons
- Step validation before navigation
- Tab click navigation
- Keyboard navigation (arrow keys)
-
Form validation:
- Required field validation
- Email format validation
- Phone number format validation
- Custom error messages
- Real-time validation on blur
-
HubSpot integration:
- Syncs data to hidden HubSpot form
- Submits to HubSpot on final step
- Handles form callbacks
-
Accessibility:
- ARIA attributes management
- Keyboard navigation
- Focus management
- Screen reader support
Core Functions
Validation:
const isValidEmail = (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val);
const isValidPhone = (val) => /^[\+]?[(]?[0-9]{1,3}[)]?[-\s\.]?[0-9]{1,4}[-\s\.]?[0-9]{1,4}[-\s\.]?[0-9]{1,9}$/.test(val);
const validateField = (field) => {
const val = field.value.trim();
// Required check
if (val === '' && field.required) {
return { isValid: false, message: 'This field is required.' };
}
// Email validation
if (field.type === 'email' && val !== '' && !isValidEmail(val)) {
return { isValid: false, message: 'Please provide a valid email address.' };
}
// Phone validation
if (field.type === 'tel' && val !== '' && !isValidPhone(val)) {
return { isValid: false, message: 'Please provide a valid phone number.' };
}
return { isValid: true };
};
Step Validation:
const validateStep = (stepIndex) => {
const panel = tabPanels[stepIndex];
const fields = panel.querySelectorAll('input, select, textarea, fieldset');
const invalidFields = [...fields].filter((field) => !isValid(field));
return new Promise((resolve, reject) => {
if (invalidFields.length === 0) {
resolve();
} else {
reject(invalidFields);
}
});
};
Navigation:
function activateTab(index) {
// Deactivate all tabs
tabItems.forEach((tab) => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
});
tabPanels.forEach((panel) => {
panel.setAttribute('hidden', '');
});
// Activate target tab
currentStep = index;
tabItems[index].setAttribute('aria-selected', 'true');
tabItems[index].removeAttribute('tabindex');
tabPanels[index].removeAttribute('hidden');
// Scroll to top
multistepForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
HubSpot Form Sync:
function handlePrefill() {
const hsForm = document.querySelector('.multistep-form__hs-form');
if (!hsForm) return;
const formFields = document.querySelectorAll('.multistep-form__form input, select, textarea');
formFields.forEach((field) => {
if (!field.name) return;
const hsInputs = hsForm.querySelectorAll(`[name="${field.name}"]`);
hsInputs.forEach((hsInput) => {
if (field.type === 'checkbox' || field.type === 'radio') {
if (hsInput.value === field.value) {
hsInput.checked = field.checked;
}
} else {
hsInput.value = field.value;
}
// Trigger change event
hsInput.dispatchEvent(new Event('change', { bubbles: true }));
});
});
}
Usage Example
In the frontend App component:
import { generateModule, downloadModule } from './utils/exportModule';
function App() {
const [generatedModule, setGeneratedModule] = useState<Blob | null>(null);
const handleGenerateModule = async () => {
if (!schema || !layout) return;
try {
const blob = await generateModule(schema, layout);
setGeneratedModule(blob);
} catch (error) {
console.error('Failed to generate module:', error);
}
};
const handleDownloadModule = () => {
if (!generatedModule || !schema) return;
downloadModule(generatedModule, schema.name);
};
return (
<div>
<button onClick={handleGenerateModule}>Generate Module</button>
{generatedModule && (
<button onClick={handleDownloadModule}>Download Module</button>
)}
</div>
);
}
Output Structure
The generated ZIP file has this structure:
contact-form.zip
└── contact-form.module/
├── fields.json (Module field definitions)
├── module.html (HubL template)
├── module.css (Styles)
├── module.js (JavaScript logic)
└── meta.json (Module metadata)
Installing in HubSpot
- Download the generated ZIP file
- Go to HubSpot Design Manager
- Navigate to File > Upload Files
- Upload the ZIP file
- Extract the module
- Use the module in pages or templates