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.
yarn add -D @spiracss/html-clinpm install -D @spiracss/html-cliCommands
| Command | Description |
|---|---|
spiracss-html-to-scss | Generate SCSS from HTML |
spiracss-html-lint | Validate HTML structure against SpiraCSS rules |
spiracss-html-format | Insert 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
# 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 stdincat file.html | yarn spiracss-html-to-scss --selection --stdin --base-dir src/components/sampleOptions
| Option | Description |
|---|---|
--root | Generate from the root element (treated as the root Block; default) |
--selection | Treat input as a fragment (selection mode) |
--stdin | Read HTML from stdin |
--base-dir PATH | Output directory for SCSS (overrides the input file directory; when using --stdin, defaults to the current directory) |
--dry-run | Print output paths only (no files written) |
--json | Output results as JSON to stdout (no files written; for AI/scripts) |
--ignore-structure-errors | Continue even if structure linting errors occur |
Note:
- Mode is determined by the presence of
--selection, so--rootis kept for backward compatibility (if both are set,--selectionwins) - In
--rootmode, 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 noclass, generation fails. Use this mode for component-level input - In
--selectionmode, if no elements have aclassattribute, 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 missingclasson 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.htmlOutput rules
- Output goes to the same directory as the input file when a path is specified (
--base-diroverrides) --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
# Validate as a root elementyarn spiracss-html-lint --root path/to/file.html
# Validate a fragment from stdincat fragment.html | yarn spiracss-html-lint --selection --stdin
# Output JSONyarn spiracss-html-lint --root path/to/file.html --jsonOptions
| Option | Description |
|---|---|
--root | Treat the root as a single Block (default) |
--selection | Treat input as multiple fragments |
--stdin | Read HTML from stdin |
--json | Output as JSON: { file, mode, ok, errors[] } |
Note:
- In
--rootmode, 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_CLASSis 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--rootis kept for backward compatibility (if both are set,--selectionwins)
Error codes
| Code | Description |
|---|---|
INVALID_BASE_CLASS | Base class violates Block/Element naming rules, or the root has no class, or no eligible elements were found |
MODIFIER_WITHOUT_BASE | Modifier class without a base class |
DISALLOWED_MODIFIER | Modifier class used in data mode (both variant/state are data) |
UTILITY_WITHOUT_BASE | Utility class without a base class |
MULTIPLE_BASE_CLASSES | Multiple base classes found |
ROOT_NOT_BLOCK | Root element is not a Block |
ELEMENT_WITHOUT_BLOCK_ANCESTOR | Element has no Block ancestor |
ELEMENT_PARENT_OF_BLOCK | Element is the parent of a Block |
DISALLOWED_VARIANT_ATTRIBUTE | Variant attributes (e.g. data-variant) used in class mode |
DISALLOWED_STATE_ATTRIBUTE | State attributes (e.g. data-state, aria-*) used in class mode |
INVALID_VARIANT_VALUE | Variant value violates valueNaming in data mode |
INVALID_STATE_VALUE | State value violates valueNaming in data mode |
UNBALANCED_HTML | HTML opening/closing tags are not balanced |
MULTIPLE_ROOT_ELEMENTS | Multiple root elements detected in root mode |
Notes:
- In
--selectionmode, only elements with aclassattribute 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.valueNamingandvariant/state.valueNaming - Classes matching
stylelint.base.external.classes/stylelint.base.external.prefixesare 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=classandstate.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
# Read from file and write to another fileyarn spiracss-html-format path/to/file.html -o formatted.html
# Read from file and output to stdoutyarn spiracss-html-format path/to/file.html
# Read from stdin and write to a filecat file.html | yarn spiracss-html-format --stdin -o formatted.htmlOptions
| Option | Description |
|---|---|
--stdin | Read HTML from stdin |
-o, --output PATH | Output 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):
| Case | Block placeholder | Element placeholder |
|---|---|---|
kebab (default) | block-box | element |
camel | blockBox | element |
pascal | BlockBox | Element |
snake | block_box | element |
Note:
- Element names are always a single word.
elementCase=camelallows only a single lowercase word (e.g.element), andelementCase=pascalallows only a single capitalized word (e.g.Element); internal capitals likebodyText/BodyTextare 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.htmlSupported templates
Note: This section describes SCSS generation.
For placeholder insertion, see the limitations above.
| Template | Support |
|---|---|
| Plain HTML | Fully supported |
| Astro | Frontmatter removed automatically |
| EJS | <% ... %> removed automatically |
| Nunjucks | {{ }} / {% %} / {# #} removed automatically |
| JSX | Extracts static classes from class / className |
| Vue / Svelte | Removes 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>blocksdangerouslySetInnerHTMLattributes- attribute spreads like
{...foo} - template literal interpolations
${...}(static member access or string literals insideclass/classNamemay 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.
blockCase | Block examples | elementCase | Element examples |
|---|---|---|---|
kebab (default) | hero-section, feature-card | kebab (default) | title, body |
camel | heroSection, featureCard | camel | title, body |
pascal | HeroSection, FeatureCard | pascal | Title, Body |
snake | hero_section, feature_card | snake | title, body |
Note:
- Element names are always a single word.
elementCase=camelallows only a single lowercase word (e.g.title), andelementCase=pascalallows only a single capitalized word (e.g.Title); names likebodyText/BodyTextthat 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
-,_, oru-are treated as Modifier/Utility and cannot be base classes (even if they matchcustomPatterns)
Dynamic class limitations
- Static classes inside Vue/Svelte
:classbindings are not extracted (place them inclass="..."instead). In some cases,v-bind:classmay leave:classbehind. - 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"]). WhenjsxClassBindings.memberAccessAllowlistis 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.