handoff-hubspot
v0.2.1
Published
A CLI toolchain for fetching handoff components and transpiling them into hubspot modules
Downloads
609
Keywords
Readme
Handoff HubSpot Client
A transformer CLI that bridges Handoff design system components and HubSpot CMS modules. It fetches component definitions from the Handoff API, validates them against the HubSpot module specification, and transpiles Handlebars templates into complete, ready-to-deploy HubSpot modules.
How It Works
Handoff publishes design system components as structured JSON payloads that include:
- A Handlebars template (
code) describing the component's markup - A property schema (
properties) describing the editable fields - CSS and JS assets for styles and behavior
- Metadata such as title, description, categories, tags, and versioning
This tool takes those payloads and produces a HubSpot .module folder containing:
| File | Description |
|------|-------------|
| module.html | HubL template transpiled from the Handlebars source |
| module.css | Component CSS (or a blank stub when using the central stylesheet) |
| module.js | Component JavaScript (or a blank stub when using the central bundle) |
| meta.json | HubSpot module metadata (label, content types, categories, tags) |
| fields.json | HubSpot field definitions generated from the property schema |
Pipeline Overview
Handoff API
│
▼
fetchComponent(id) → HandoffComponent JSON
│
▼
validateModule(component) → FieldValidation[] (errors/warnings)
│
▼
transpile(code, props) → HubL string (module.html)
buildFields(properties) → HubSpot field definitions (fields.json)
buildMeta(component) → HubSpot module metadata (meta.json)
│
▼
Write *.module/ folder to diskHandlebars → HubL Transpilation
HubSpot modules use HubL, a Jinja2-like templating language. Handoff components are authored in Handlebars. The transpiler converts each Handlebars construct to its HubL equivalent:
Variables
Handoff components reference component data under the properties namespace. The transpiler rewrites these to HubSpot's module namespace:
{{properties.headline}}becomes:
{{ module.headline }}Conditionals
{{#if properties.show_cta}}
<a href="...">Click</a>
{{/if}}becomes:
{% if module.show_cta %} <a href="...">Click</a> {% endif %}Loops
{{#each properties.items}}
<li>{{this.label}}</li>
{{/each}}becomes:
{% for item_i in module.items %} <li>{{ item_i.label }}</li> {% endfor %}Typed Field Handling
The transpiler is property-schema-aware. The way a variable is output depends on its declared type in the property schema. For example, a button property named cta:
<a href="{{properties.cta.url}}">{{properties.cta.label}}</a>becomes:
<a href="{{ module.cta_url.href|escape_attr }}">{{ module.cta_text }}</a>This is because HubSpot represents button/link URLs as a structured url object, requiring the _url.href accessor and escape_attr filter. The transpiler handles these type-specific rewrites automatically for link, button, breadcrumb, image, url, video_embed, and menu types.
Menus
The {{#field menu}} block generates the HubL menu lookup and iteration boilerplate:
{{#field properties.nav}}
{{#each properties.nav}}
<li>{{this.label}}</li>
{{/each}}
{{/field}}becomes:
{# field properties.nav type="menu" #}
{% set menu_xxxxx = menu(module.nav) %}
{% for item_n in menu_xxxxx.children %} <li>{{ item_n.label }}</li> {% endfor %}
{# end field #}Search Fields
The {{#field search}} block injects the standard HubSpot search context variables (search_page, content_types, etc.) required for site search module patterns.
Fields Generation
Each Handoff property type maps to one or more HubSpot field definitions in fields.json:
| Handoff type | HubSpot field(s) |
|---|---|
| text | text |
| richtext | richtext |
| number | number (with min, max, step) |
| boolean | boolean (checkbox display) |
| select | choice (with choices array) |
| image | image (responsive, lazy-loaded) |
| icon | text |
| link | url field ({id}_url) + text field ({id}_text) |
| button | url field ({id}_url) + text field ({id}_text, labeled "Label") |
| url | url (all link types supported) |
| video_file | file field + text title field ({id}_title) |
| video_embed | text embed URL + text title ({id}_title) + image poster ({id}_poster) |
| menu | menu |
| array | group (with occurrence min/max, children built recursively) |
| object | group (children built recursively) |
Validation
Before transpilation, each component is validated at both the module and field levels.
Module-level checks (errors halt the build unless --force is used):
code,title,tags,categories, andpropertiesare all required- Each category must be one of the allowed HubSpot categories
Field-level checks include both errors (build blockers) and warnings (non-blocking):
- Every field must have a valid
type, aname, and arules.requiredboolean - A
descriptionanddefaultvalue are required (warnings if missing) textandnumberfields must haverules.contentwithmin/maxarrayfields must haverules.content.min/max,items.type, and recursively valid childrenimagefields must haverules.dimensions(withmin.width/min.height) and a default withsrc/altlinkdefaults must includehrefandtext;buttondefaults must includeurlandlabelselectfields must have anoptionsarrayobjectfields must have apropertiesmap
Installation
npm install -g handoff-hubspotRequirements
- Node 20, NPM
- A running Handoff instance (URL to the API)
- A HubSpot account if you intend to deploy the generated modules
Quick Start
Install
npm install -g handoff-hubspotConfigure
handoff-hubspot configThis will prompt you interactively for:
- The URL to your Handoff API (e.g.
https://design.example.com/api/) - Where to save the shared CSS bundle
- Where to save the shared JS bundle
- Whether to use per-module CSS/JS or the central compiled bundles
- Where to write the generated
.modulefolders - A label prefix for modules inside HubSpot (e.g.
"UDS: ") - Optional HTTP Basic Auth credentials if your Handoff instance requires authentication
A
handoff.config.jsonfile is written to the current working directory.- The URL to your Handoff API (e.g.
List available components
handoff-hubspot listFetch and build a single component
handoff-hubspot fetch hero-bannerThe module is written to
{modulesPath}/hero-banner.module/.Fetch and build all components
handoff-hubspot fetch:all
Commands
handoff-hubspot configInteractively create or overwrite handoff.config.json.
handoff-hubspot listList all components available from the Handoff API.
handoff-hubspot docs [component]Open the Handoff documentation page for a component in the browser.
handoff-hubspot stylesFetch the shared CSS bundle (main.css) from the Handoff API and write it to cssPath.
handoff-hubspot scriptsFetch the shared JS bundle (main.js) from the Handoff API and write it to jsPath. Skipped automatically when moduleJS is true.
handoff-hubspot fetch [component]Fetch a single component, validate it, transpile it, and write the .module folder. Use --force (-f) to build even when validation errors are present.
handoff-hubspot validate [component]Fetch a single component and run validation only (no files are written).
handoff-hubspot validate:allFetch and validate every component. Exits with a non-zero code if any component fails validation.
handoff-hubspot fetch:all [--force]Validate and build every component. Without --force, aborts the entire run if validation fails.
Configuration Reference (handoff.config.json)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| url | string | https://localhost:3000/api/ | Base URL of the Handoff API |
| cssPath | string | css/uds.css | Directory for the shared CSS output |
| jsPath | string | js/uds.js | Directory for the shared JS output |
| modulesPath | string | modules | Directory where .module folders are written |
| modulePrefix | string | UDS: | Prefix prepended to each module's label in HubSpot |
| moduleCSS | boolean | true | When true, writes per-module CSS; when false, writes a blank stub |
| moduleJS | boolean | false | When true, writes per-module compiled JS; when false, writes a blank stub |
| username | string | "" | HTTP Basic Auth username (optional) |
| password | string | "" | HTTP Basic Auth password (optional) |
| import | object | (absent) | Per-component-type import rules (see below) |
Import Configuration
The import key controls which components are transpiled and how. It replaces the previous hubdb_mappings, componentJS, and componentCSS top-level keys.
Each key under import corresponds to a Handoff component type (e.g. "element", "block", "data"). The value is either a boolean or an object with per-component overrides:
{
"import": {
"element": false,
"block": {
"accordion": false
},
"data": {
"bar_chart": {
"type": "hubdb",
"target_property": "data",
"mapping_type": "xy"
},
"category_breakdown_chart": {
"type": "hubdb",
"target_property": "data",
"mapping_type": "multi_series"
}
}
}
}Semantics:
| Config value | Effect |
|---|---|
| import.{type}: false | Skip all components of that type |
| import.{type}: true (or key absent) | Import all components of that type normally |
| import.{type}: { id: false } | Import all of that type except those set to false |
| import.{type}: { id: { type: "hubdb", ... } } | Import with HubDB data mapping |
| import.{type}: { id: { js: true } } | Per-component JS override (fetches JS even when moduleJS is false) |
| import.{type}: { id: { css: true } } | Per-component CSS override (fetches CSS even when moduleCSS is false) |
When import is absent entirely, all components are imported normally.
HubDB Data Mappings
When a component entry under import has "type": "hubdb", the build pipeline treats its target_property field as a HubDB-powered data source rather than a static array. Two fields are required:
| Key | Description |
|-----|-------------|
| target_property | The name of the array/object property in the component schema to map (e.g. "data") |
| mapping_type | Either "xy" (two-column x/y data) or "multi_series" (multiple named series with categories) |
What happens at build time:
- A Data Source choice field is auto-generated with two options: "Query Builder" and "Manual Data" (default). This field is always visible in the HubSpot editor and does not depend on any field defined in Handoff.
- A Query Config field group is injected, visible only when "Query Builder" is selected. It contains fields for table selection, column mapping, sorting, limits, and a diagnostic toggle.
- The target array field (e.g.
data) is annotated with a visibility rule so it only appears when "Manual Data" is selected. - The transpiler rewrites all Handlebars references to the target property as
component_dataand prepends HubL code that queries HubDB when in query mode, falling through to the manual array otherwise.
Module Output Structure
Each component produces a folder at {modulesPath}/{component-id}.module/:
hero-banner.module/
├── module.html # HubL template (transpiled from Handlebars)
├── module.css # Component CSS
├── module.js # Component JavaScript
├── meta.json # HubSpot module metadata
└── fields.json # HubSpot field definitionsmodule.html opens with a comment block containing the original component metadata:
{#
title: Hero Banner
description: A full-width hero with headline, body copy, and CTA button
group: Marketing
version: 1.4.2
last_updated: 2026-03-11T00:00:00.000Z
link: https://design.example.com/system/component/hero-banner
#}Navigation components (group "Navigation") are automatically marked as global: true in meta.json.
