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')
+ })
+})