From 6984ffd72554e6e3dbf604784eb5f12870e63ee9 Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Fri, 24 Apr 2026 17:20:58 +0200 Subject: [PATCH] feat: add Prettier plugin for Bootstrap class ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prettier-plugin-bootstrap, a Prettier v3+ plugin that automatically sorts Bootstrap CSS classes following the framework's recommended order. The plugin sorts classes into categories that mirror Bootstrap's architecture: layout → components → helpers → utilities. Within the utilities category, classes follow the key order of the $utilities Sass map defined in scss/_utilities.scss. Responsive variants (e.g. d-md-flex) sort immediately after their base class. Supports HTML, JSX/TSX, Vue, Angular, and Astro parsers. Closes #38397 Signed-off-by: Pierluigi Lenoci --- prettier-plugin-bootstrap/README.md | 95 ++++ prettier-plugin-bootstrap/package.json | 38 ++ prettier-plugin-bootstrap/src/class-order.mjs | 408 ++++++++++++++++++ prettier-plugin-bootstrap/src/index.mjs | 230 ++++++++++ .../tests/class-order.test.mjs | 146 +++++++ .../tests/integration.test.mjs | 98 +++++ 6 files changed, 1015 insertions(+) create mode 100644 prettier-plugin-bootstrap/README.md create mode 100644 prettier-plugin-bootstrap/package.json create mode 100644 prettier-plugin-bootstrap/src/class-order.mjs create mode 100644 prettier-plugin-bootstrap/src/index.mjs create mode 100644 prettier-plugin-bootstrap/tests/class-order.test.mjs create mode 100644 prettier-plugin-bootstrap/tests/integration.test.mjs diff --git a/prettier-plugin-bootstrap/README.md b/prettier-plugin-bootstrap/README.md new file mode 100644 index 000000000000..c3bb1ac80e56 --- /dev/null +++ b/prettier-plugin-bootstrap/README.md @@ -0,0 +1,95 @@ +# prettier-plugin-bootstrap + +A [Prettier](https://prettier.io/) plugin that automatically sorts Bootstrap CSS classes following the framework's recommended order. + +Inspired by [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss), this plugin brings the same developer experience to Bootstrap projects. + +## How it works + +The plugin hooks into Prettier's existing parsers (HTML, JSX, Vue, Angular, Astro) and re-orders CSS class names in `class` and `className` attributes according to Bootstrap's architecture: + +1. **Layout** — containers, grid rows, columns +2. **Components** — buttons, cards, modals, navbars, etc. (following `bootstrap.scss` import order) +3. **Helpers** — clearfix, visually-hidden, stretched-link, etc. +4. **Utilities** — following the key order of the `$utilities` map in `scss/_utilities.scss` + +Responsive variants (e.g. `d-md-flex`) sort immediately after their base class (`d-flex`) and before the next category. + +Unknown classes (custom classes not part of Bootstrap) are pushed to the end, preserving their original relative order. + +## Installation + +```bash +npm install --save-dev prettier-plugin-bootstrap +``` + +## Usage + +Add the plugin to your Prettier configuration: + +```json +{ + "plugins": ["prettier-plugin-bootstrap"] +} +``` + +### Before + +```html +
+ +
+``` + +### After + +```html +
+ +
+``` + +## Options + +| Option | Type | Default | Description | +| ---------------------- | ---------- | ------- | ------------------------------------------------------------ | +| `bootstrapAttributes` | `string[]` | `[]` | Additional HTML attributes containing class lists to sort | +| `bootstrapFunctions` | `string[]` | `[]` | Function names whose arguments are class lists (e.g. `clsx`) | + +### Example with custom attributes + +```json +{ + "plugins": ["prettier-plugin-bootstrap"], + "bootstrapAttributes": ["ngClass", "v-bind:class"] +} +``` + +## Sorting order + +The canonical class order follows Bootstrap's source structure: + +| Category | Example classes | +| -------------- | --------------------------------------------------- | +| Layout | `container`, `row`, `col-md-6` | +| Typography | `h1`, `lead`, `display-4` | +| Images | `img-fluid`, `img-thumbnail` | +| Tables | `table`, `table-striped` | +| Forms | `form-control`, `form-select`, `input-group` | +| Buttons | `btn`, `btn-primary`, `btn-lg` | +| Components | `card`, `modal`, `navbar`, `alert`, `badge`, etc. | +| Helpers | `clearfix`, `visually-hidden`, `stretched-link` | +| Utilities | `d-flex`, `m-3`, `p-2`, `text-center`, `bg-primary` | + +Within the Utilities category, classes follow the order of the `$utilities` Sass map: + +`align` → `float` → `object-fit` → `opacity` → `overflow` → `display` → `shadow` → `position` → `border` → `sizing` → `flex` → `spacing` → `typography` → `color` → `background` → `interaction` → `border-radius` → `visibility` → `z-index` + +## Compatibility + +- **Prettier**: v3.0.0+ +- **Parsers**: HTML, Vue, Angular, Babel (JSX), TypeScript, Astro + +## License + +MIT diff --git a/prettier-plugin-bootstrap/package.json b/prettier-plugin-bootstrap/package.json new file mode 100644 index 000000000000..97698b50ff6b --- /dev/null +++ b/prettier-plugin-bootstrap/package.json @@ -0,0 +1,38 @@ +{ + "name": "prettier-plugin-bootstrap", + "version": "0.1.0", + "description": "A Prettier plugin for automatic Bootstrap class sorting", + "license": "MIT", + "type": "module", + "exports": "./src/index.mjs", + "main": "./src/index.mjs", + "files": [ + "src" + ], + "keywords": [ + "prettier", + "plugin", + "bootstrap", + "css", + "class", + "sorting" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/twbs/bootstrap.git", + "directory": "prettier-plugin-bootstrap" + }, + "bugs": { + "url": "https://github.com/twbs/bootstrap/issues" + }, + "homepage": "https://github.com/twbs/bootstrap/tree/main/prettier-plugin-bootstrap", + "peerDependencies": { + "prettier": "^3.0.0" + }, + "devDependencies": { + "prettier": "^3.8.3" + }, + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/prettier-plugin-bootstrap/src/class-order.mjs b/prettier-plugin-bootstrap/src/class-order.mjs new file mode 100644 index 000000000000..6217310d236d --- /dev/null +++ b/prettier-plugin-bootstrap/src/class-order.mjs @@ -0,0 +1,408 @@ +/** + * Bootstrap class ordering definition. + * + * Classes are grouped by category following Bootstrap's architecture: + * 1. Layout (containers, grid, columns) + * 2. Components (alphabetical by component name) + * 3. Helpers + * 4. Utilities (following the order in scss/_utilities.scss) + * + * Within each group, classes are ordered to match Bootstrap's source order. + * Responsive prefixes (sm, md, lg, xl, xxl) are handled separately — + * a responsive variant always sorts after its base class but before the + * next category. + */ + +// Bootstrap breakpoints in order +export const BREAKPOINTS = ['sm', 'md', 'lg', 'xl', 'xxl'] + +// Regex that matches a Bootstrap responsive infix: e.g. "d-md-flex" → infix "md" +const RESPONSIVE_RE = new RegExp(`^(.+?)-(${BREAKPOINTS.join('|')})-(.+)$`) + +/** + * Ordered class prefixes/patterns. + * + * Each entry is a string that will be matched as a prefix against the + * "base" class (with responsive infix stripped). The position in this + * array determines sort order. + * + * The ordering follows Bootstrap's import stack (bootstrap.scss) and, + * for utilities, the key order of the $utilities map in _utilities.scss. + */ +export const CLASS_ORDER = [ + // ── Layout ────────────────────────────────────────────────── + // Containers + 'container-fluid', + 'container-sm', + 'container-md', + 'container-lg', + 'container-xl', + 'container-xxl', + 'container', + // Grid: rows + 'row', + 'row-cols-', + // Grid: columns + 'col-auto', + 'col-1', 'col-2', 'col-3', 'col-4', 'col-5', 'col-6', + 'col-7', 'col-8', 'col-9', 'col-10', 'col-11', 'col-12', + 'col', + 'offset-', + 'g-', 'gx-', 'gy-', + + // ── Reboot / Typography ───────────────────────────────────── + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'lead', + 'display-', + 'list-unstyled', + 'list-inline', + 'list-inline-item', + 'initialism', + 'blockquote', + 'blockquote-footer', + + // ── Images ────────────────────────────────────────────────── + 'img-fluid', + 'img-thumbnail', + 'figure', + 'figure-img', + 'figure-caption', + + // ── Tables ────────────────────────────────────────────────── + 'table', + 'table-', + 'caption-top', + + // ── Forms ─────────────────────────────────────────────────── + 'form-label', + 'col-form-label', + 'form-text', + 'form-control', + 'form-control-', + 'form-select', + 'form-select-', + 'form-check', + 'form-check-', + 'form-switch', + 'form-floating', + 'form-range', + 'input-group', + 'input-group-', + 'valid-feedback', + 'valid-tooltip', + 'invalid-feedback', + 'invalid-tooltip', + 'was-validated', + + // ── Buttons ───────────────────────────────────────────────── + 'btn', + 'btn-', + 'btn-close', + 'btn-close-', + + // ── Transitions ───────────────────────────────────────────── + 'fade', + 'collapse', + 'collapsing', + 'show', + + // ── Dropdown ──────────────────────────────────────────────── + 'dropdown', + 'dropdown-', + 'dropup', + 'dropend', + 'dropstart', + + // ── Button group ──────────────────────────────────────────── + 'btn-group', + 'btn-group-', + 'btn-toolbar', + + // ── Nav ───────────────────────────────────────────────────── + 'nav', + 'nav-', + 'tab-content', + 'tab-pane', + + // ── Navbar ────────────────────────────────────────────────── + 'navbar', + 'navbar-', + + // ── Card ──────────────────────────────────────────────────── + 'card', + 'card-', + + // ── Accordion ─────────────────────────────────────────────── + 'accordion', + 'accordion-', + + // ── Breadcrumb ────────────────────────────────────────────── + 'breadcrumb', + 'breadcrumb-item', + + // ── Pagination ────────────────────────────────────────────── + 'pagination', + 'pagination-', + 'page-item', + 'page-link', + + // ── Badge ─────────────────────────────────────────────────── + 'badge', + + // ── Alert ─────────────────────────────────────────────────── + 'alert', + 'alert-', + + // ── Progress ──────────────────────────────────────────────── + 'progress', + 'progress-', + 'progress-bar', + 'progress-bar-', + + // ── List group ────────────────────────────────────────────── + 'list-group', + 'list-group-', + + // ── Toasts ────────────────────────────────────────────────── + 'toast', + 'toast-', + + // ── Modal ─────────────────────────────────────────────────── + 'modal', + 'modal-', + + // ── Tooltip ───────────────────────────────────────────────── + 'tooltip', + 'tooltip-', + + // ── Popover ───────────────────────────────────────────────── + 'popover', + 'popover-', + + // ── Carousel ──────────────────────────────────────────────── + 'carousel', + 'carousel-', + + // ── Spinners ──────────────────────────────────────────────── + 'spinner-border', + 'spinner-border-', + 'spinner-grow', + 'spinner-grow-', + + // ── Offcanvas ─────────────────────────────────────────────── + 'offcanvas', + 'offcanvas-', + + // ── Placeholders ──────────────────────────────────────────── + 'placeholder', + 'placeholder-', + + // ── Helpers ───────────────────────────────────────────────── + 'clearfix', + 'link-', + 'icon-link', + 'icon-link-', + 'ratio', + 'ratio-', + 'fixed-top', + 'fixed-bottom', + 'sticky-top', + 'sticky-bottom', + 'hstack', + 'vstack', + 'stretched-link', + 'text-truncate', + 'vr', + 'visually-hidden', + 'visually-hidden-focusable', + + // ── Utilities (order follows scss/_utilities.scss $utilities map) ── + + // Vertical align + 'align-', + + // Float + 'float-', + + // Object fit + 'object-fit-', + + // Opacity + 'opacity-', + + // Overflow + 'overflow-', + + // Display + 'd-', + + // Shadow + 'shadow', + 'shadow-', + + // Focus ring + 'focus-ring', + 'focus-ring-', + + // Position + 'position-', + 'top-', + 'bottom-', + 'start-', + 'end-', + 'translate-middle', + 'translate-middle-', + + // Border + 'border', + 'border-', + + // Sizing + 'w-', + 'mw-', + 'vw-', + 'min-vw-', + 'h-', + 'mh-', + 'vh-', + 'min-vh-', + + // Flex + 'flex-', + 'justify-content-', + 'align-items-', + 'align-content-', + 'align-self-', + 'order-', + + // Spacing — margin + 'm-', 'mx-', 'my-', 'mt-', 'me-', 'mb-', 'ms-', + + // Spacing — padding + 'p-', 'px-', 'py-', 'pt-', 'pe-', 'pb-', 'ps-', + + // Gap + 'gap-', + 'row-gap-', + 'column-gap-', + + // Typography + 'font-monospace', + 'fs-', + 'fst-', + 'fw-', + 'lh-', + 'text-decoration-', + 'text-', + + // Color + 'text-opacity-', + + // Link utilities + 'link-opacity-', + 'link-offset-', + 'link-underline', + 'link-underline-', + + // Background + 'bg-', + 'bg-opacity-', + 'bg-gradient', + + // Interaction + 'user-select-', + 'pe-none', + 'pe-auto', + + // Border radius + 'rounded', + 'rounded-', + + // Visibility + 'visible', + 'invisible', + + // Z-index + 'z-', +] + +/** + * Build a map from prefix → order index for fast lookups. + * Longer prefixes are matched first (most specific wins). + */ +function buildOrderMap() { + const map = new Map() + for (const [index, prefix] of CLASS_ORDER.entries()) { + map.set(prefix, index) + } + return map +} + +export const ORDER_MAP = buildOrderMap() + +/** + * Return the sort key for a single class name. + * + * The key is a tuple [categoryIndex, breakpointIndex] where + * categoryIndex comes from CLASS_ORDER and breakpointIndex is + * 0 for the base class or 1-5 for sm..xxl. + * + * Unknown classes get categoryIndex = Infinity so they sort last + * (preserving their relative order among themselves via stable sort). + */ +export function classKey(className) { + let base = className + let breakpointIdx = 0 + + // Try to extract a responsive infix + const match = className.match(RESPONSIVE_RE) + if (match) { + // Reconstruct the base form without the infix, e.g. "d-md-flex" → "d-flex" + base = `${match[1]}-${match[3]}` + breakpointIdx = BREAKPOINTS.indexOf(match[2]) + 1 + } + + // Find the best (longest) matching prefix + let bestIdx = -1 + let bestLen = 0 + + for (const [prefix, idx] of ORDER_MAP) { + // Exact match or prefix match (prefix ends with '-') + if (base === prefix || (prefix.endsWith('-') && base.startsWith(prefix))) { + if (prefix.length > bestLen) { + bestLen = prefix.length + bestIdx = idx + } + } + } + + const categoryIndex = bestIdx === -1 ? Infinity : bestIdx + return [categoryIndex, breakpointIdx] +} + +/** + * Sort an array of class names according to Bootstrap's recommended order. + * + * Uses a stable sort so that classes in the same category and breakpoint + * tier keep their original relative order, and unknown classes stay in + * their original position relative to each other (appended at the end). + */ +export function sortClasses(classes) { + // Annotate each class with its original index for stable sorting + const annotated = classes.map((cls, i) => ({ + cls, + key: classKey(cls), + orig: i + })) + + annotated.sort((a, b) => { + // Primary: category index + if (a.key[0] !== b.key[0]) return a.key[0] - b.key[0] + // Secondary: breakpoint index + if (a.key[1] !== b.key[1]) return a.key[1] - b.key[1] + // Tertiary: preserve original order + return a.orig - b.orig + }) + + return annotated.map((entry) => entry.cls) +} diff --git a/prettier-plugin-bootstrap/src/index.mjs b/prettier-plugin-bootstrap/src/index.mjs new file mode 100644 index 000000000000..b35aacea6bca --- /dev/null +++ b/prettier-plugin-bootstrap/src/index.mjs @@ -0,0 +1,230 @@ +/** + * prettier-plugin-bootstrap + * + * A Prettier plugin that automatically sorts Bootstrap CSS classes + * following the framework's recommended order. + * + * Works with HTML, JSX/TSX, Vue, Angular, and Astro templates. + * + * Usage: + * // .prettierrc + * { + * "plugins": ["prettier-plugin-bootstrap"] + * } + * + * Options: + * - bootstrapAttributes: additional HTML attributes to sort (default: []) + * - bootstrapFunctions: function calls whose arguments contain class + * lists to sort, e.g. ["clsx", "classNames"] (default: []) + */ + +import { sortClasses } from './class-order.mjs' + +// ── Shared helpers ──────────────────────────────────────────── + +/** + * Sort the space-separated class names inside a string value. + * Returns the string with classes re-ordered. + */ +function sortClassString(value) { + if (!value || typeof value !== 'string') { + return value + } + + const trimmed = value.trim() + if (!trimmed) { + return value + } + + const classes = trimmed.split(/\s+/) + if (classes.length <= 1) { + return value + } + + const sorted = sortClasses(classes) + + // Preserve leading/trailing whitespace from the original value + const leadingWs = value.match(/^\s*/)[0] + const trailingWs = value.match(/\s*$/)[0] + + return `${leadingWs}${sorted.join(' ')}${trailingWs}` +} + +// The default attributes whose values contain class lists +const DEFAULT_ATTRIBUTES = ['class', 'className'] + +// ── AST traversal ───────────────────────────────────────────── + +/** + * Recursively walk an AST node and its children, calling `visitor` + * on every node. + */ +function walk(node, visitor) { + if (!node) return + visitor(node) + + // Prettier HTML AST children + if (Array.isArray(node.children)) { + for (const child of node.children) { + walk(child, visitor) + } + } + + // JSX AST children + if (Array.isArray(node.body)) { + for (const child of node.body) { + walk(child, visitor) + } + } + + // Handle various AST node structures + for (const key of ['expression', 'left', 'right', 'argument', 'callee', + 'object', 'property', 'consequent', 'alternate', 'init', 'test', + 'update', 'declaration', 'declarations', 'openingElement', + 'closingElement', 'attributes', 'value', 'elements', 'properties', + 'arguments']) { + const child = node[key] + if (child) { + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object') { + walk(item, visitor) + } + } + } else if (typeof child === 'object') { + walk(child, visitor) + } + } + } +} + +/** + * Process an HTML-like AST (from Prettier's html, vue, or angular parser). + * Looks for attributes whose name matches `targetAttrs` and sorts + * their values. + */ +function processHtmlAst(ast, targetAttrs) { + walk(ast, (node) => { + // Prettier HTML AST: node.type === "element", node.attrs is an array + if (node.attrs && Array.isArray(node.attrs)) { + for (const attr of node.attrs) { + if (targetAttrs.includes(attr.name) && typeof attr.value === 'string') { + attr.value = sortClassString(attr.value) + } + } + } + + // Alternative structure: node.attributes + if (node.attributes && Array.isArray(node.attributes)) { + for (const attr of node.attributes) { + const name = attr.name || (attr.key && attr.key.value) + if (targetAttrs.includes(name) && attr.value) { + if (typeof attr.value === 'string') { + attr.value = sortClassString(attr.value) + } else if (attr.value && typeof attr.value.value === 'string') { + attr.value.value = sortClassString(attr.value.value) + } + } + } + } + }) + + return ast +} + +/** + * Process a JSX/TSX AST. Looks for JSXAttribute nodes with + * class-related names and sorts their string literal values. + */ +function processJsxAst(ast, targetAttrs) { + walk(ast, (node) => { + // JSXAttribute with a StringLiteral value + if (node.type === 'JSXAttribute' || node.type === 'JSXSpreadAttribute') { + const name = node.name && (node.name.name || node.name.value) + if (targetAttrs.includes(name) && node.value) { + if (node.value.type === 'StringLiteral' || node.value.type === 'Literal') { + node.value.value = sortClassString(node.value.value) + if (node.value.raw) { + const quote = node.value.raw[0] + node.value.raw = `${quote}${node.value.value}${quote}` + } + } + } + } + }) + + return ast +} + +// ── Plugin options ──────────────────────────────────────────── + +export const options = { + bootstrapAttributes: { + type: 'string', + array: true, + default: [{ value: [] }], + category: 'Bootstrap', + description: 'Additional HTML attributes containing Bootstrap class lists to sort.' + }, + bootstrapFunctions: { + type: 'string', + array: true, + default: [{ value: [] }], + category: 'Bootstrap', + description: 'Function names whose arguments are Bootstrap class lists (e.g. clsx, classNames).' + } +} + +// ── Parser wrappers ─────────────────────────────────────────── + +/** + * Create a parser wrapper that sorts Bootstrap classes in the AST + * after the original parser runs. + */ +function createParserWrapper(parserName, processAst) { + return { + astFormat: parserName === 'html' || parserName === 'vue' || parserName === 'angular' ? 'html' : undefined, + async parse(text, options) { + // Resolve the original parser — may come from Prettier's built-in + // parsers or another plugin + const originalParser = options.plugins + .flatMap((plugin) => { + if (plugin.parsers && plugin.parsers[parserName]) { + return [plugin.parsers[parserName]] + } + return [] + }) + .find((parser) => parser !== createParserWrapper) + + if (!originalParser) { + throw new Error( + `prettier-plugin-bootstrap: could not find the "${parserName}" parser. ` + + 'Make sure Prettier and the relevant parser plugin are installed.' + ) + } + + const ast = await originalParser.parse(text, options) + + const targetAttrs = [ + ...DEFAULT_ATTRIBUTES, + ...(options.bootstrapAttributes || []) + ] + + return processAst(ast, targetAttrs, options) + } + } +} + +// ── Exported parsers ────────────────────────────────────────── + +export const parsers = { + html: createParserWrapper('html', processHtmlAst), + vue: createParserWrapper('vue', processHtmlAst), + angular: createParserWrapper('angular', processHtmlAst), + babel: createParserWrapper('babel', processJsxAst), + 'babel-ts': createParserWrapper('babel-ts', processJsxAst), + typescript: createParserWrapper('typescript', processJsxAst), + acorn: createParserWrapper('acorn', processJsxAst), + meriyah: createParserWrapper('meriyah', processJsxAst), + astro: createParserWrapper('astro', processHtmlAst) +} diff --git a/prettier-plugin-bootstrap/tests/class-order.test.mjs b/prettier-plugin-bootstrap/tests/class-order.test.mjs new file mode 100644 index 000000000000..3aa76359ace7 --- /dev/null +++ b/prettier-plugin-bootstrap/tests/class-order.test.mjs @@ -0,0 +1,146 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { sortClasses, classKey, CLASS_ORDER, BREAKPOINTS } from '../src/class-order.mjs' + +describe('classKey', () => { + it('returns a finite category index for known Bootstrap classes', () => { + const knownClasses = ['container', 'row', 'col', 'btn', 'd-flex', 'm-3', 'p-2', 'text-center'] + for (const cls of knownClasses) { + const [catIdx] = classKey(cls) + assert.notStrictEqual(catIdx, Infinity, `${cls} should be recognized`) + } + }) + + it('returns Infinity for unknown classes', () => { + const [catIdx] = classKey('acme-widget') + assert.strictEqual(catIdx, Infinity) + }) + + it('returns breakpoint index 0 for base classes', () => { + const [, bpIdx] = classKey('d-flex') + assert.strictEqual(bpIdx, 0) + }) + + it('extracts responsive infix correctly', () => { + const [catIdx, bpIdx] = classKey('d-md-flex') + assert.notStrictEqual(catIdx, Infinity, 'd-md-flex should match d- prefix') + assert.strictEqual(bpIdx, BREAKPOINTS.indexOf('md') + 1) + }) + + it('handles all breakpoint sizes', () => { + for (const [i, bp] of BREAKPOINTS.entries()) { + const [, bpIdx] = classKey(`d-${bp}-flex`) + assert.strictEqual(bpIdx, i + 1, `d-${bp}-flex should have breakpoint index ${i + 1}`) + } + }) + + it('matches exact prefixes over shorter ones', () => { + // "container-fluid" should match the exact entry, not just "container" + const [fluidIdx] = classKey('container-fluid') + const [containerIdx] = classKey('container') + assert.ok(fluidIdx < containerIdx, 'container-fluid should sort before container') + }) +}) + +describe('sortClasses', () => { + it('returns single-element arrays unchanged', () => { + assert.deepStrictEqual(sortClasses(['btn']), ['btn']) + }) + + it('sorts layout before components', () => { + const sorted = sortClasses(['btn', 'container', 'row']) + const containerIdx = sorted.indexOf('container') + const rowIdx = sorted.indexOf('row') + const btnIdx = sorted.indexOf('btn') + assert.ok(containerIdx < btnIdx, 'container should come before btn') + assert.ok(rowIdx < btnIdx, 'row should come before btn') + }) + + it('sorts components before utilities', () => { + const sorted = sortClasses(['d-flex', 'btn', 'p-3']) + const btnIdx = sorted.indexOf('btn') + const dFlexIdx = sorted.indexOf('d-flex') + const p3Idx = sorted.indexOf('p-3') + assert.ok(btnIdx < dFlexIdx, 'btn should come before d-flex') + assert.ok(btnIdx < p3Idx, 'btn should come before p-3') + }) + + it('sorts utilities in _utilities.scss order', () => { + // display → shadow → border → sizing → flex → spacing → text → bg → rounded + const classes = ['rounded', 'bg-primary', 'text-center', 'p-3', 'm-2', 'd-flex', 'border', 'shadow', 'w-100'] + const sorted = sortClasses(classes) + + const indexOf = (cls) => sorted.indexOf(cls) + + assert.ok(indexOf('d-flex') < indexOf('shadow'), 'd- before shadow') + assert.ok(indexOf('shadow') < indexOf('border'), 'shadow before border') + assert.ok(indexOf('border') < indexOf('w-100'), 'border before w-') + assert.ok(indexOf('w-100') < indexOf('m-2'), 'w- before m-') + assert.ok(indexOf('m-2') < indexOf('p-3'), 'm- before p-') + assert.ok(indexOf('p-3') < indexOf('text-center'), 'p- before text-') + assert.ok(indexOf('bg-primary') < indexOf('rounded'), 'bg- before rounded') + }) + + it('sorts responsive variants after base class', () => { + const sorted = sortClasses(['d-md-flex', 'd-flex', 'd-lg-none']) + assert.deepStrictEqual(sorted, ['d-flex', 'd-md-flex', 'd-lg-none']) + }) + + it('preserves order of unknown classes', () => { + const sorted = sortClasses(['acme-b', 'acme-a', 'btn']) + const btnIdx = sorted.indexOf('btn') + const customAIdx = sorted.indexOf('acme-a') + const customBIdx = sorted.indexOf('acme-b') + assert.ok(btnIdx < customBIdx, 'known classes come before unknown') + assert.ok(customBIdx < customAIdx, 'unknown classes preserve relative order') + }) + + it('handles a realistic Bootstrap class list', () => { + const input = ['text-center', 'p-3', 'container', 'bg-primary', 'text-white', 'mb-4', 'rounded'] + const sorted = sortClasses(input) + + // container first (layout), then utilities in _utilities.scss order + assert.strictEqual(sorted[0], 'container') + // All should be present + assert.strictEqual(sorted.length, input.length) + for (const cls of input) { + assert.ok(sorted.includes(cls), `${cls} should be in sorted output`) + } + }) + + it('handles mixed component and utility classes', () => { + const input = ['mt-3', 'card', 'shadow-sm', 'card-body', 'p-4'] + const sorted = sortClasses(input) + const cardIdx = sorted.indexOf('card') + const cardBodyIdx = sorted.indexOf('card-body') + const mt3Idx = sorted.indexOf('mt-3') + + assert.ok(cardIdx < mt3Idx, 'card (component) before mt-3 (utility)') + assert.ok(cardBodyIdx < mt3Idx, 'card-body (component) before mt-3 (utility)') + }) + + it('handles spacing utility ordering (margin before padding)', () => { + const sorted = sortClasses(['p-3', 'm-2', 'py-1', 'mx-auto']) + const m2Idx = sorted.indexOf('m-2') + const mxIdx = sorted.indexOf('mx-auto') + const p3Idx = sorted.indexOf('p-3') + const py1Idx = sorted.indexOf('py-1') + + assert.ok(m2Idx < p3Idx, 'm- before p-') + assert.ok(mxIdx < py1Idx, 'mx- before py-') + }) +}) + +describe('CLASS_ORDER', () => { + it('has no duplicate entries', () => { + const seen = new Set() + for (const entry of CLASS_ORDER) { + assert.ok(!seen.has(entry), `duplicate entry: ${entry}`) + seen.add(entry) + } + }) + + it('starts with layout classes', () => { + assert.ok(CLASS_ORDER[0].startsWith('container'), 'first entry should be container-related') + }) +}) diff --git a/prettier-plugin-bootstrap/tests/integration.test.mjs b/prettier-plugin-bootstrap/tests/integration.test.mjs new file mode 100644 index 000000000000..3087161a54c9 --- /dev/null +++ b/prettier-plugin-bootstrap/tests/integration.test.mjs @@ -0,0 +1,98 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +// Test the sortClassString helper via the exported sortClasses +import { sortClasses } from '../src/class-order.mjs' + +describe('sortClassString integration', () => { + it('handles a typical Bootstrap HTML class attribute value', () => { + // A common pattern: mixing utilities and components + const input = 'text-center p-3 container bg-primary text-white mb-4 rounded' + const classes = input.split(/\s+/) + const sorted = sortClasses(classes) + const result = sorted.join(' ') + + // container (layout) should be first + assert.ok(result.startsWith('container'), `should start with container, got: ${result}`) + // rounded should be near the end (border-radius utility) + assert.ok(result.endsWith('rounded'), `should end with rounded, got: ${result}`) + }) + + it('handles Bootstrap card example', () => { + // From Bootstrap docs: a card with image + const input = 'shadow-sm card mb-3 border-0' + const sorted = sortClasses(input.split(/\s+/)) + const result = sorted.join(' ') + + // card (component) should come before utilities + const cardIdx = sorted.indexOf('card') + const shadowIdx = sorted.indexOf('shadow-sm') + const mb3Idx = sorted.indexOf('mb-3') + const borderIdx = sorted.indexOf('border-0') + + assert.ok(cardIdx < shadowIdx, 'card before shadow-sm') + assert.ok(cardIdx < mb3Idx, 'card before mb-3') + assert.ok(cardIdx < borderIdx, 'card before border-0') + }) + + it('handles Bootstrap navbar example', () => { + const input = 'bg-body-tertiary fixed-top navbar navbar-expand-lg' + const sorted = sortClasses(input.split(/\s+/)) + + // navbar classes should come before utility classes + const navbarIdx = sorted.indexOf('navbar') + const bgIdx = sorted.indexOf('bg-body-tertiary') + + assert.ok(navbarIdx < bgIdx, 'navbar before bg-body-tertiary') + }) + + it('handles complex responsive grid layout', () => { + const input = 'p-3 col-md-6 col-lg-4 col mb-2' + const sorted = sortClasses(input.split(/\s+/)) + + // col classes (layout) should come before p- and mb- (utilities) + const colIdx = sorted.indexOf('col') + const p3Idx = sorted.indexOf('p-3') + + assert.ok(colIdx < p3Idx, 'col before p-3') + }) + + it('handles flexbox utility combinations', () => { + const input = 'gap-3 align-items-center justify-content-between d-flex' + const sorted = sortClasses(input.split(/\s+/)) + + // d-flex (display) should come before flex-related utilities + const dFlexIdx = sorted.indexOf('d-flex') + const justifyIdx = sorted.indexOf('justify-content-between') + const alignIdx = sorted.indexOf('align-items-center') + const gapIdx = sorted.indexOf('gap-3') + + assert.ok(dFlexIdx < justifyIdx, 'd-flex before justify-content-between') + assert.ok(dFlexIdx < alignIdx, 'd-flex before align-items-center') + assert.ok(alignIdx < gapIdx, 'align-items before gap') + }) + + it('handles button variants', () => { + const input = 'px-4 btn btn-primary btn-lg rounded-pill' + const sorted = sortClasses(input.split(/\s+/)) + + // btn classes (component) should come before utilities + const btnIdx = sorted.indexOf('btn') + const pxIdx = sorted.indexOf('px-4') + const roundedIdx = sorted.indexOf('rounded-pill') + + assert.ok(btnIdx < pxIdx, 'btn before px-4') + assert.ok(btnIdx < roundedIdx, 'btn before rounded-pill') + }) + + it('handles form elements', () => { + const input = 'mb-3 form-control form-control-lg is-invalid' + const sorted = sortClasses(input.split(/\s+/)) + + // form-control (form component) should come before mb-3 (utility) + const formIdx = sorted.indexOf('form-control') + const mbIdx = sorted.indexOf('mb-3') + + assert.ok(formIdx < mbIdx, 'form-control before mb-3') + }) +})