Files
stanza/packages/codemods/src/builtins/set-html-attributes.ts
T
jake 678b5bc1af feat: add ui-shadcn-base, ui-shadcn-radix, and ui-tailwind modules; rename styling category to ui
- Rename `styling` → `ui` across the entire codebase (CATEGORIES array, docs, AGENTS.md, CLI tests, resolver, template tests, codemod runner); `ui` is `home: package` so shadcn installs into `packages/ui/` as a workspace dep rather than wiring directly into the app shell
- Add `ui-shadcn-base` (Tailwind v4 / CSS-first, no Radix peer) and `ui-shadcn-radix` (Tailwind v4 + Radix primitives) with full per-framework template sets for Next.js and TanStack Start: `components.json`, `globals.css`, `postcss.config.mjs`, `theme-provider.tsx`, shared `Button` + `utils.ts`; light/dark logos inlined from svgl
- Rename `styling-tailwind` → `ui-tailwind` to match the new category id; update its module definition to `category: "ui"` and `home: package`
- Add four new codemod builtins with tests: `add-array-entry-in-call` (inserts an element into a named array argument of any `CallExpression`), `replace-import` (rewrites a specifier in an existing import declaration), `set-html-attributes` (sets attributes on an HTML/JSX element by tag name or selector), `set-tsconfig-paths` (merges `compilerOptions` entries into a `tsconfig.json` AST node); register all four in the builtins index and export from `@stanza/codemods`
- Add `wrap-root-layout` builtin tests
- Update registry.mdx, concepts.mdx, cli.mdx, getting-started.mdx, agents.mdx, index.mdx, AGENTS.md, README.md, TODO.md, and SKILL.md to reflect the category rename and new modules
2026-05-25 15:26:08 -04:00

201 lines
6.8 KiB
TypeScript

import path from "node:path";
import {
type Codemod,
type JsxAttribute,
type JsxOpeningElement,
type SourceFile,
SyntaxKind,
} from "../index";
/**
* Set attributes on the file's root `<html>` JSX element. Used by UI modules
* to add `suppressHydrationWarning` and apply theme/font classes during
* setup — the canonical example is shadcn's `<html lang="en"
* suppressHydrationWarning className="antialiased font-sans">`.
*
* Three attribute shapes are supported:
* - **`boolean: true`** — emit a bare attribute (`suppressHydrationWarning`)
* - **`value: string`** — emit a string literal (`lang="en"`). For
* `name: "className"` the value is *merged* into any existing className —
* duplicates are skipped (token-wise), order is preserved.
* - **`expression: string`** — emit a JSX expression container with the raw
* code inside (`className={cn("a", "b")}`). For `className` this *replaces*
* any existing className (no automatic merge possible at the AST level).
*
* Idempotent: a no-op when every requested attribute is already satisfied.
*/
export type SetHtmlAttributesArgs = {
/** Path to the file, relative to `base`. */
file: string;
/**
* Where `file` resolves against:
* - `"repo"`: `ctx.projectRoot`
* - `"app"` (default): `ctx.appRoot`
* - `"package:<dir>"`: `<projectRoot>/packages/<dir>/`
*/
base?: "app" | "repo" | `package:${string}`;
/** Attributes to set/merge. Each entry uses exactly one of `boolean | value | expression`. */
attributes: Array<
| { name: string; boolean: true }
| { name: string; value: string }
| { name: string; expression: string }
>;
/**
* Region key segment used to track ownership. Defaults to
* `html.attributes.<comma-joined-names>`.
*/
regionKey?: string;
};
const setHtmlAttributes: Codemod<SetHtmlAttributesArgs> = {
id: "set-html-attributes",
description: "Set or merge attributes on the file's root `<html>` JSX element.",
apply(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const opening = findHtmlOpening(sf);
if (!opening) {
throw new Error(
`set-html-attributes: ${rel} has no top-level \`<html …>\` element. ` +
`This codemod targets the root layout file.`,
);
}
let changed = false;
for (const attr of args.attributes) {
if (applyOne(opening, attr)) changed = true;
}
ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: changed ? [rel] : [] };
},
revert(ctx, args) {
const abs = resolveFilePath(ctx, args);
const rel = path.relative(ctx.projectRoot, abs);
const sf = ctx.project().addSourceFileAtPath(abs);
const opening = findHtmlOpening(sf);
let changed = false;
if (opening) {
for (const attr of args.attributes) {
if (revertOne(opening, attr)) changed = true;
}
}
ctx.releaseRegion(rel, regionKeyFor(args));
return { touchedFiles: changed ? [rel] : [] };
},
};
function resolveFilePath(
ctx: { projectRoot: string; appRoot: string },
args: SetHtmlAttributesArgs,
): string {
const base = args.base ?? "app";
if (base.startsWith("package:")) {
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
}
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
}
function regionKeyFor(args: SetHtmlAttributesArgs): string {
if (args.regionKey) return args.regionKey;
return `html.attributes.${args.attributes.map((a) => a.name).join(",")}`;
}
/** Find the first `<html …>` opening element in the file. */
function findHtmlOpening(sf: SourceFile): JsxOpeningElement | undefined {
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) {
if (node.getTagNameNode().getText() === "html") return node;
}
return undefined;
}
function getAttribute(opening: JsxOpeningElement, name: string): JsxAttribute | undefined {
for (const a of opening.getAttributes()) {
const attr = a.asKind(SyntaxKind.JsxAttribute);
if (attr && attr.getNameNode().getText() === name) return attr;
}
return undefined;
}
/** Returns `true` when the element text was modified. */
function applyOne(
opening: JsxOpeningElement,
attr: SetHtmlAttributesArgs["attributes"][number],
): boolean {
const existing = getAttribute(opening, attr.name);
if ("boolean" in attr) {
if (existing) return false; // present in any form → no change
opening.addAttribute({ name: attr.name });
return true;
}
if ("value" in attr) {
if (attr.name === "className" && existing) {
// Merge tokens into the existing string literal (only — bail on expr form).
const init = existing.getInitializer();
if (!init || init.getKind() !== SyntaxKind.StringLiteral) return false;
const current: string[] = init.getText().slice(1, -1).split(/\s+/).filter(Boolean);
const incoming: string[] = attr.value.split(/\s+/).filter(Boolean);
const additions = incoming.filter((t: string) => !current.includes(t));
if (additions.length === 0) return false;
init.replaceWithText(JSON.stringify([...current, ...additions].join(" ")));
return true;
}
if (existing) {
const init = existing.getInitializer();
if (init && init.getText() === JSON.stringify(attr.value)) return false;
existing.setInitializer(JSON.stringify(attr.value));
return true;
}
opening.addAttribute({ name: attr.name, initializer: JSON.stringify(attr.value) });
return true;
}
// `expression`
const expr = `{${attr.expression}}`;
if (existing) {
const init = existing.getInitializer();
if (init && init.getText() === expr) return false;
existing.setInitializer(expr);
return true;
}
opening.addAttribute({ name: attr.name, initializer: expr });
return true;
}
function revertOne(
opening: JsxOpeningElement,
attr: SetHtmlAttributesArgs["attributes"][number],
): boolean {
const existing = getAttribute(opening, attr.name);
if (!existing) return false;
// For className value-merge we can only safely remove tokens we know about.
if ("value" in attr && attr.name === "className") {
const init = existing.getInitializer();
if (init && init.getKind() === SyntaxKind.StringLiteral) {
const current: string[] = init.getText().slice(1, -1).split(/\s+/).filter(Boolean);
const toRemove = new Set<string>(attr.value.split(/\s+/).filter(Boolean));
const remaining = current.filter((t: string) => !toRemove.has(t));
if (remaining.length === 0) {
existing.remove();
} else if (remaining.length !== current.length) {
init.replaceWithText(JSON.stringify(remaining.join(" ")));
}
return true;
}
}
existing.remove();
return true;
}
export default setHtmlAttributes;