Skip to content

HTML CLI

A CLI tool designed primarily for AI agents and automation scripts.
Provides the core logic for SpiraCSS HTML parsing and SCSS generation.

  • Primary use: AI agents (Codex, Claude Code, etc.) call this CLI to generate SCSS from HTML
  • Secondary use: The VS Code extension uses this package internally as its engine

This tool is not designed for direct human use from the terminal.
For manual SCSS generation, use the VS Code extension instead.

Install

Node.js >= 20 is required.

Terminal window
yarn add -D @spiracss/html-cli
Terminal window
npm install -D @spiracss/html-cli

Commands

CommandDescription
spiracss-html-to-scssGenerate SCSS from HTML
spiracss-html-lintValidate HTML structure against SpiraCSS rules
spiracss-html-formatInsert placeholder classes (defaults: block-box / element)

spiracss-html-to-scss

Generates SCSS files from HTML files or templates. Runs structure linting by default and exits on errors (use --ignore-structure-errors to continue).

Basic usage

Terminal window
# Root mode (generate from the root element)
yarn spiracss-html-to-scss --root path/to/file.html
# Selection mode (treat input as a fragment)
yarn spiracss-html-to-scss --selection path/to/fragment.html
# From stdin
cat file.html | yarn spiracss-html-to-scss --selection --stdin --base-dir src/components/sample

Options

OptionDescription
--rootGenerate from the root element (treated as the root Block; default)
--selectionTreat input as a fragment (selection mode)
--stdinRead HTML from stdin
--base-dir PATHOutput directory for SCSS (overrides the input file directory; when using --stdin, defaults to the current directory)
--dry-runPrint output paths only (no files written)
--jsonOutput results as JSON to stdout (no files written; for AI/scripts)
--ignore-structure-errorsContinue even if structure linting errors occur

Note:

  • Mode is determined by the presence of --selection, so --root is kept for backward compatibility (if both are set, --selection wins)
  • In --root mode, a single element from the input is treated as the root, and SCSS is generated for the root and its descendants. If that element has no class, generation fails. Use this mode for component-level input
  • In --selection mode, if no elements have a class attribute, the CLI exits with an error
  • Even with --ignore-structure-errors, generation still fails when required preconditions are not met (no root element, multiple roots in root mode, or missing class on the root)
  • Error message text can change; do not depend on exact strings

Output example

your-component/
├─ hero-section.scss -> root Block
├─ scss/
│ ├─ feature-card.scss -> child Block
│ └─ index.scss -> @use merged automatically
└─ your-template.html

Output rules

  • Output goes to the same directory as the input file when a path is specified (--base-dir overrides)
  • --stdin: --base-dir (defaults to the current directory)
  • Child Blocks go under childScssDir (default: scss)

Generation notes

  • When multiple elements share the same base class, modifier / variant / state / ARIA attributes are deduplicated and merged
  • Reserved keys can be changed via selectorPolicy (e.g. data-theme, data-status, aria-hidden, aria-expanded)
  • In data mode, variants are emitted in the base structure (you may manually move initial interaction values to the interaction section)

spiracss-html-lint

Validates HTML structure against SpiraCSS Block / Element / Modifier rules.

Basic usage

Terminal window
# Validate as a root element
yarn spiracss-html-lint --root path/to/file.html
# Validate a fragment from stdin
cat fragment.html | yarn spiracss-html-lint --selection --stdin
# Output JSON
yarn spiracss-html-lint --root path/to/file.html --json

Options

OptionDescription
--rootTreat the root as a single Block (default)
--selectionTreat input as multiple fragments
--stdinRead HTML from stdin
--jsonOutput as JSON: { file, mode, ok, errors[] }

Note:

  • In --root mode, a single element from the input is treated as the root and the root plus its descendants are validated
  • If the root element has no class, INVALID_BASE_CLASS is reported
  • Use this mode for component-level input. If you have multiple root Blocks, use --selection
  • Mode is determined by the presence of --selection, so --root is kept for backward compatibility (if both are set, --selection wins)

Error codes

CodeDescription
INVALID_BASE_CLASSBase class violates Block/Element naming rules, or the root has no class, or no eligible elements were found
MODIFIER_WITHOUT_BASEModifier class without a base class
DISALLOWED_MODIFIERModifier class used in data mode (both variant/state are data)
UTILITY_WITHOUT_BASEUtility class without a base class
MULTIPLE_BASE_CLASSESMultiple base classes found
ROOT_NOT_BLOCKRoot element is not a Block
ELEMENT_WITHOUT_BLOCK_ANCESTORElement has no Block ancestor
ELEMENT_PARENT_OF_BLOCKElement is the parent of a Block
DISALLOWED_VARIANT_ATTRIBUTEVariant attributes (e.g. data-variant) used in class mode
DISALLOWED_STATE_ATTRIBUTEState attributes (e.g. data-state, aria-*) used in class mode
INVALID_VARIANT_VALUEVariant value violates valueNaming in data mode
INVALID_STATE_VALUEState value violates valueNaming in data mode
UNBALANCED_HTMLHTML opening/closing tags are not balanced
MULTIPLE_ROOT_ELEMENTSMultiple root elements detected in root mode

Notes:

  • In --selection mode, only elements with a class attribute are validated (if none, INVALID_BASE_CLASS)
  • data-* / aria-* are validated only when they match the reserved keys (variant.dataKeys / state.dataKey / state.ariaKeys)
  • Data value naming is validated against selectorPolicy.valueNaming and variant/state.valueNaming
  • Classes matching stylelint.base.external.classes / stylelint.base.external.prefixes are treated as external (excluded from base checks)
  • If the first token is an external class and a non-external class appears later, it results in INVALID_BASE_CLASS (Block/Element must come first). Elements with only external classes are allowed
  • When variant.mode=class and state.mode=class, state cannot be separated, so all modifiers are treated as variants
  • Error message text can change; do not depend on exact strings

spiracss-html-format

Formats HTML by inserting placeholder classes to normalize SpiraCSS structure.

Basic usage

Terminal window
# Read from file and write to another file
yarn spiracss-html-format path/to/file.html -o formatted.html
# Read from file and output to stdout
yarn spiracss-html-format path/to/file.html
# Read from stdin and write to a file
cat file.html | yarn spiracss-html-format --stdin -o formatted.html

Options

OptionDescription
--stdinRead HTML from stdin
-o, --output PATHOutput file path (defaults to stdout)

The output attribute follows htmlFormat.classAttribute in spiracss.config.js (default: class).
Set htmlFormat.classAttribute to className if you want className output.
Internally, class / className are temporarily converted to data-spiracss-classname and restored on output.

Inserted classes

Recursively walks all descendants and normalizes them into a Block > Element structure.

  • Elements with children -> prepend a Block placeholder
  • Leaf elements -> prepend an Element placeholder
  • Elements with an existing Block name -> keep the name and process descendants
  • Elements with an Element name but with children -> convert to Block form (e.g. title -> title-box)

Note: For the root element, Element -> Block conversion is not performed; instead, a Block placeholder is prepended (e.g. block-box title), even for names that are neither Block nor Element.

Placeholder names change based on blockCase / elementCase (configured independently):

CaseBlock placeholderElement placeholder
kebab (default)block-boxelement
camelblockBoxelement
pascalBlockBoxElement
snakeblock_boxelement

Note:

  • Element names are always a single word. elementCase=camel allows only a single lowercase word (e.g. element), and elementCase=pascal allows only a single capitalized word (e.g. Element); internal capitals like bodyText / BodyText are not allowed
  • If you set customPatterns, ensure the placeholders (block-box / element) still match your naming rules

Limitations

HTML that includes template syntax (EJS <% %>, Nunjucks {{ }} / {% %} / {# #}, Astro frontmatter, etc.) is skipped.
These constructs can be broken by HTML parsing, so only use static HTML fragments.

For placeholder insertion, JSX class / className are supported only when the binding is static: string literals, template literals whose ${} segments are static member access or string literals, or member access like styles.foo / styles['foo'] / styles["foo"]. If jsxClassBindings.memberAccessAllowlist is set, only the listed base identifiers are treated as class sources, and chained access like styles.layout.hero is treated as dynamic. If any dynamic binding is present (conditions, props such as props.className, function calls, non-static ${} segments), the entire HTML is skipped. When formatting JSX/TSX, bindings are normalized to plain class strings for placeholder insertion, so treat the output as scaffolding (do not overwrite CSS Modules sources).

When template syntax is detected:

  • Stdout mode (no -o): output the original HTML and emit a warning to stderr
  • File output mode (-o): skip writing the file and emit a warning to stderr (prevents mtime changes)
  • Behavior is undefined when fragment HTML is missing closing tags for <template> / <textarea> (no explicit detection is performed)

Conversion spec

Conversion example

<!-- Selected HTML -->
<section class="hero-section">
<h1 class="title">Welcome</h1>
<p class="body">Introduction text...</p>
<div class="feature-card">
<h2 class="heading">Feature</h2>
</div>
<div class="cta-box">
<a class="link" href="#">Learn more</a>
</div>
</section>

Generates SpiraCSS-style SCSS:

hero-section.scss (root Block):

@use "@styles/partials/global" as *;
@use "sass:meta";
// @assets/css/index.scss
.hero-section {
@include meta.load-css("scss");
@include breakpoint-up(md) {
// layout mixin
}
> .title {
@include breakpoint-up(md) {
// layout mixin
}
}
> .body {
@include breakpoint-up(md) {
// layout mixin
}
}
> .feature-card {
// @rel/scss/feature-card.scss
@include breakpoint-up(md) {
// child component layout
}
}
> .cta-box {
// @rel/scss/cta-box.scss
@include breakpoint-up(md) {
// child component layout
}
}
// --shared ----------------------------------------
// --interaction -----------------------------------
// @at-root & {
// }
}

scss/feature-card.scss (child Block):

@use "@styles/partials/global" as *;
// @rel/../hero-section.scss
.feature-card {
@include breakpoint-up(md) {
// layout mixin
}
> .heading {
@include breakpoint-up(md) {
// layout mixin
}
}
// --shared ----------------------------------------
// --interaction -----------------------------------
// @at-root & {
// }
}

scss/cta-box.scss (child Block):

@use "@styles/partials/global" as *;
// @rel/../hero-section.scss
.cta-box {
@include breakpoint-up(md) {
// layout mixin
}
> .link {
@include breakpoint-up(md) {
// layout mixin
}
}
// --shared ----------------------------------------
// --interaction -----------------------------------
// @at-root & {
// }
}

scss/index.scss (child Block index file):

@use "cta-box";
@use "feature-card";
your-component/
├─ hero-section.scss -> root Block
├─ scss/
│ ├─ cta-box.scss -> child Block
│ ├─ feature-card.scss -> child Block
│ └─ index.scss -> auto-merged via @use
└─ your-template.html

Supported templates

Note: This section describes SCSS generation.
For placeholder insertion, see the limitations above.

TemplateSupport
Plain HTMLFully supported
AstroFrontmatter removed automatically
EJS<% ... %> removed automatically
Nunjucks{{ }} / {% %} / {# #} removed automatically
JSXExtracts static classes from class / className
Vue / SvelteRemoves attributes like v-* / :prop (in some cases, v-bind:class may leave :class behind)

Template syntax is stripped using regular expressions.

  • <%...%> (EJS)
  • {{...}} / {%...%} / {#...#} (Nunjucks)
  • JSX comments {/*...*/}
  • JSX fragments <>...</>
  • <script>...</script> / <style>...</style> blocks
  • dangerouslySetInnerHTML attributes
  • attribute spreads like {...foo}
  • template literal interpolations ${...} (static member access or string literals inside class / className may still be extracted)

Generic {...} patterns are not removed, and static class names are extracted from class / className.

Block/Element detection

The first token in the class attribute is treated as the base class.
It is treated as a Block only when the first token matches blockCase (or customPatterns.block).
Put the class you want to treat as the Block/Element first.

blockCaseBlock exampleselementCaseElement examples
kebab (default)hero-section, feature-cardkebab (default)title, body
camelheroSection, featureCardcameltitle, body
pascalHeroSection, FeatureCardpascalTitle, Body
snakehero_section, feature_cardsnaketitle, body

Note:

  • Element names are always a single word. elementCase=camel allows only a single lowercase word (e.g. title), and elementCase=pascal allows only a single capitalized word (e.g. Title); names like bodyText / BodyText that contain multiple words are not allowed
  • If you set customPatterns, those patterns take priority
  • When using customPatterns, ensure placeholders (block-box / element) still match your naming rules
  • Classes starting with -, _, or u- are treated as Modifier/Utility and cannot be base classes (even if they match customPatterns)

Dynamic class limitations

  • Static classes inside Vue/Svelte :class bindings are not extracted (place them in class="..." instead). In some cases, v-bind:class may leave :class behind.
  • Dynamically appearing class names are not tracked
  • JSX class / className: SCSS generation extracts static class names from string literals, template literal static parts plus string literals or member access inside ${}, and member access (e.g. styles.foo, styles['foo'], styles["foo"]). When jsxClassBindings.memberAccessAllowlist is set, only the listed base identifiers are treated as class sources; chained access (e.g. styles.layout.hero) is treated as dynamic. Dynamic expressions are dropped.
  • Placeholder insertion skips JSX bindings that include dynamic expressions (e.g. props.className, foo && 'bar', non-static ${} segments).

Configuration

All CLI commands read spiracss.config.js. See spiracss.config.js for details. If package.json has "type": "module", use export default instead of module.exports.

Because the HTML CLI is a CJS bundle, it cannot load ESM config when Node is launched with --disallow-code-generation-from-strings. If possible, switch to module.exports or remove the flag. In "type": "module" projects, spiracss.config.js cannot be CJS, so you effectively need to remove the flag (no automatic .cjs / .mjs discovery).

If spiracss.config.js exists but cannot be loaded, the CLI exits with an error (check the config syntax and type: "module").

See spiracss.config.js for full configuration options.