Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,26 @@ const css = Css.mx2.black.$;
const css = { marginLeft: "ml2", marginRight: "mr2", color: "black" };
```

And then these object literals are used by the `css` property to assign the `className` and `style` props:
When every value in the expression is static (no runtime variables or conditionals), the plugin resolves the class names at build time with zero runtime overhead:

```tsx
// Input
return <div css={Css.black.$}>content</div>;
return <div css={Css.df.aic.black.$}>content</div>;
// Build-time output — no runtime call, just a plain className
return <div className="df aic black">content</div>;
```

When the expression contains dynamic values, a lightweight `trussProps` runtime helper is used:

```tsx
// Input
return <div css={Css.mt(someValue).black.$}>content</div>;
// Build-time output
return <div {...trussProps({ color: "black" })}>content</div>;
// Runtime value
return <div className="black">content</div>;
return (
<div {...trussProps({ marginTop: ["mt_var", { "--marginTop": __maybeInc(someValue) }], color: "black" })}>
content
</div>
);
```

## Installation
Expand Down Expand Up @@ -377,28 +388,28 @@ Where `sm` resolves from the breakpoints you define in `truss-config.ts`.

The available pseudo-class modifiers are:

| Modifier | CSS Pseudo-Class |
| --- | --- |
| `onHover` | `:hover` |
| `onFocus` | `:focus` |
| Modifier | CSS Pseudo-Class |
| ---------------- | ---------------- |
| `onHover` | `:hover` |
| `onFocus` | `:focus` |
| `onFocusVisible` | `:focus-visible` |
| `onFocusWithin` | `:focus-within` |
| `onActive` | `:active` |
| `onDisabled` | `:disabled` |
| `ifFirstOfType` | `:first-of-type` |
| `ifLastOfType` | `:last-of-type` |
| `onFocusWithin` | `:focus-within` |
| `onActive` | `:active` |
| `onDisabled` | `:disabled` |
| `ifFirstOfType` | `:first-of-type` |
| `ifLastOfType` | `:last-of-type` |

For arbitrary pseudo-selectors not covered above, use `when`:

```tsx
// Simple pseudo-selector
<div css={Css.when(":hover:not(:disabled)").black.$} />
<div css={Css.when(":hover:not(:disabled)").black.$} />;

// Marker-based relationship (react to an ancestor's hover)
const row = Css.newMarker();
<tr css={Css.markerOf(row).$}>
<td css={Css.when(row, "ancestor", ":hover").blue.$}>...</td>
</tr>
</tr>;
```

## Custom Selectors with `.css.ts` and `Css.className(...)`
Expand Down
3 changes: 1 addition & 2 deletions packages/truss/src/plugin/esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ describe("trussEsbuildPlugin", () => {
expect(result!.loader).toBe("tsx");
expect(n(result!.contents)).toBe(
n(`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ display: "df" })} />;
const el = <div className="df" />;
`),
);
});
Expand Down
6 changes: 2 additions & 4 deletions packages/truss/src/plugin/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ describe("trussPlugin", () => {
// Then it is rewritten
expect(n(result?.code ?? "")).toBe(
n(`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ display: "df" })} />;
const el = <div className="df" />;
`),
);
});
Expand Down Expand Up @@ -73,8 +72,7 @@ describe("trussPlugin", () => {
// Then it gets transformed
expect(n(result?.code ?? "")).toBe(
n(`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ display: "df" })} />;
const el = <div className="df" />;
`),
);
});
Expand Down
47 changes: 45 additions & 2 deletions packages/truss/src/plugin/rewrite-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,18 @@ export function rewriteExpressionSites(options: RewriteSitesOptions): void {
const line = site.path.node.loc?.start.line ?? null;

if (cssAttrPath) {
// JSX css= attribute → spread trussProps or mergeProps
cssAttrPath.replaceWith(t.jsxSpreadAttribute(buildCssSpreadExpression(cssAttrPath, styleHash, line, options)));
// JSX css= attribute → static className when possible, otherwise spread trussProps/mergeProps
if (
!options.debug &&
isFullyStaticStyleHash(styleHash) &&
!hasExistingAttribute(cssAttrPath, "className") &&
!hasExistingAttribute(cssAttrPath, "style")
) {
const classNames = extractStaticClassNames(styleHash);
cssAttrPath.replaceWith(t.jsxAttribute(t.jsxIdentifier("className"), t.stringLiteral(classNames)));
} else {
cssAttrPath.replaceWith(t.jsxSpreadAttribute(buildCssSpreadExpression(cssAttrPath, styleHash, line, options)));
}
} else {
// Non-JSX position → plain object expression with optional debug info
if (options.debug && line !== null) {
Expand Down Expand Up @@ -624,3 +634,36 @@ function extractSiblingClassName(callPath: NodePath<t.CallExpression>): t.Expres
function isMatchingPropertyName(key: t.Expression | t.Identifier | t.PrivateName, name: string): boolean {
return (t.isIdentifier(key) && key.name === name) || (t.isStringLiteral(key) && key.value === name);
}

// ---------------------------------------------------------------------------
// Static style hash detection
// ---------------------------------------------------------------------------

/** Check whether a style hash has only static string values (no spreads, no tuples). */
function isFullyStaticStyleHash(hash: t.ObjectExpression): boolean {
for (const prop of hash.properties) {
if (!t.isObjectProperty(prop)) return false;
if (!t.isStringLiteral(prop.value)) return false;
}
return true;
}

/** Extract all static class names from a fully-static style hash, joined with spaces. */
function extractStaticClassNames(hash: t.ObjectExpression): string {
const classNames: string[] = [];
for (const prop of hash.properties) {
if (t.isObjectProperty(prop) && t.isStringLiteral(prop.value)) {
classNames.push(prop.value.value);
}
}
return classNames.join(" ");
}

/** Check whether a sibling JSX attribute exists without removing it. */
function hasExistingAttribute(path: NodePath<t.JSXAttribute>, attrName: string): boolean {
const openingElement = path.parentPath;
if (!openingElement || !openingElement.isJSXOpeningElement()) return false;
return openingElement.node.attributes.some((attr) => {
return t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: attrName });
});
}
26 changes: 10 additions & 16 deletions packages/truss/src/plugin/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ describe("transform", () => {
import { keepMe } from "./other";
import { Css } from "./Css";

const el = <div css={Css.black.$} />;
const el = <div css={Css.black.$} className="extra" />;
const value = keepMe();
`,
"test.tsx",
mapping,
)?.code;

expect(lineOf(output!, 'import { trussProps } from "@homebound/truss/runtime";')).toBe(2);
expect(lineOf(output!, 'import { mergeProps } from "@homebound/truss/runtime";')).toBe(2);
expect(lineOf(output!, "const el =")).toBe(4);
});

Expand Down Expand Up @@ -90,8 +90,7 @@ describe("transform", () => {
const el = <div css={Css.df.$} />;
`).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ display: "df" })} />;
const el = <div className="df" />;
`,
`
.df {
Expand All @@ -107,8 +106,7 @@ describe("transform", () => {
const el = <div css={Css.df.aic.black.$} />;
`).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ display: "df", alignItems: "aic", color: "black" })} />;
const el = <div className="df aic black" />;
`,
`
.aic {
Expand Down Expand Up @@ -525,9 +523,8 @@ describe("transform", () => {
`,
).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const a = <div {...trussProps({ display: "df" })} />;
const b = <div {...trussProps({ display: "df", alignItems: "aic" })} />;
const a = <div className="df" />;
const b = <div className="df aic" />;
`,
`
.aic {
Expand Down Expand Up @@ -1330,7 +1327,7 @@ describe("transform", () => {
import { getFromAnotherFile } from "./other";
import { trussProps } from "@homebound/truss/runtime";
function Example({ param, content }) {
return <div {...trussProps(getFromAnotherFile(param))}><span {...trussProps({ color: "blue" })}>{content}</span></div>;
return <div {...trussProps(getFromAnotherFile(param))}><span className="blue">{content}</span></div>;
}
`,
`
Expand Down Expand Up @@ -2050,8 +2047,7 @@ describe("transform", () => {
const el = <div css={Css.marker.$} />;
`).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ __marker: "_mrk" })} />;
const el = <div className="_mrk" />;
`,
``,
);
Expand Down Expand Up @@ -2644,8 +2640,7 @@ describe("transform", () => {
const el = <div css={Css.ifSm.df.$} />;
`).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ display: "sm_df" })} />;
const el = <div className="sm_df" />;
`,
`
@media screen and (max-width: 599px) {
Expand Down Expand Up @@ -2846,8 +2841,7 @@ describe("transform", () => {

expectTrussTransform(code).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const el = <div {...trussProps({ marginTop: "mt2", transition: "transition_all_240ms" })} />;
const el = <div className="mt2 transition_all_240ms" />;
`,
`
.transition_all_240ms {
Expand Down
Loading