Skip to content

Commit 13c8cd1

Browse files
skovhusclaude
andcommitted
feat(adapter): add markerFile option for custom sidecar path
Allow adapters to redirect defineMarker() sidecar declarations to a shared file instead of generating one per source file. Test fixtures now use a single markers.stylex.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43cfdde commit 13c8cd1

34 files changed

+158
-64
lines changed

scripts/regenerate-test-case-outputs.mts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ async function listFixtureNames(): Promise<Array<{ name: string; ext: string }>>
8181
.sort((a, b) => a.name.localeCompare(b.name));
8282
}
8383

84+
// Accumulate all sidecar files across fixtures for merging (shared marker files)
85+
const allSidecarFiles = new Map<string, string>();
86+
8487
async function updateFixture(name: string, ext: string) {
8588
const inputPath = join(testCasesDir, `${name}.input.${ext}`);
8689
const outputPath = join(testCasesDir, `${name}.output.${ext}`);
@@ -107,9 +110,24 @@ async function updateFixture(name: string, ext: string) {
107110
const out = result || input;
108111
await writeFile(outputPath, await normalizeCode(out, ext), "utf-8");
109112

110-
// Write sidecar .stylex.ts files (defineMarker declarations)
113+
// Accumulate sidecar files for merging after all fixtures are processed
111114
for (const [sidecarPath, content] of sidecarFiles) {
112-
await writeFile(sidecarPath, content, "utf-8");
115+
const existing = allSidecarFiles.get(sidecarPath);
116+
if (existing) {
117+
// Merge: extract new marker lines and append any that don't already exist
118+
const markerLineRe = /^export const \w+ = stylex\.defineMarker\(\);$/gm;
119+
const newMarkers = [...content.matchAll(markerLineRe)].map((m) => m[0]);
120+
const markersToAdd = newMarkers.filter((line) => !existing.includes(line));
121+
if (markersToAdd.length > 0) {
122+
const trailingNewline = existing.endsWith("\n") ? "" : "\n";
123+
allSidecarFiles.set(
124+
sidecarPath,
125+
existing + trailingNewline + markersToAdd.join("\n") + "\n",
126+
);
127+
}
128+
} else {
129+
allSidecarFiles.set(sidecarPath, content);
130+
}
113131
}
114132

115133
return outputPath;
@@ -140,3 +158,8 @@ const targetFixtures = (() => {
140158
for (const { name, ext } of targetFixtures) {
141159
await updateFixture(name, ext);
142160
}
161+
162+
// Write accumulated sidecar files (merged across all fixtures)
163+
for (const [sidecarPath, content] of allSidecarFiles) {
164+
await writeFile(sidecarPath, content, "utf-8");
165+
}

src/__tests__/fixture-adapters.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ export const fixtureAdapter = defineAdapter({
3434
// Emit sx={} JSX attributes instead of {...stylex.props()} spreads (StyleX ≥0.18)
3535
useSxProp: true,
3636

37+
// Write all defineMarker() declarations to a single shared sidecar file
38+
markerFile: () => ({ kind: "specifier", value: "./markers.stylex" }),
39+
3740
// Configure external interface for exported components
3841
externalInterface(ctx): ExternalInterfaceResult {
3942
// Enable external styles + polymorphic `as` prop for test cases that need both

src/__tests__/transform.test.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -406,14 +406,19 @@ describe("transform", () => {
406406
const normalizedExpected = await normalizeCode(output, outputPath);
407407
expect(normalizedResult).toEqual(normalizedExpected);
408408

409-
// Compare sidecar .stylex.ts content if the test case has one
410-
const sidecarPath = inputPath.replace(/\.\w+$/, ".stylex.ts");
411-
if (existsSync(sidecarPath)) {
412-
const expectedSidecar = readFileSync(sidecarPath, "utf-8");
413-
expect(diagnostics.sidecarContent).toBeDefined();
414-
expect(diagnostics.sidecarContent).toEqual(expectedSidecar);
415-
} else {
416-
expect(diagnostics.sidecarContent).toBeUndefined();
409+
// Verify sidecar marker content: all marker declarations should be present in the shared markers file
410+
if (diagnostics.sidecarContent) {
411+
const sharedMarkersPath = join(testCasesDir, "markers.stylex.ts");
412+
const sharedMarkers = readFileSync(sharedMarkersPath, "utf-8");
413+
const markerLines = diagnostics.sidecarContent
414+
.split("\n")
415+
.filter((line) => line.startsWith("export const"));
416+
expect(markerLines.length).toBeGreaterThan(0);
417+
for (const line of markerLines) {
418+
expect(sharedMarkers).toContain(line);
419+
}
420+
// Verify sidecarFilePath points to the shared markers file
421+
expect(diagnostics.sidecarFilePath).toBe(sharedMarkersPath);
417422
}
418423
});
419424
});

src/adapter.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,15 @@ export type ExternalInterfaceResult = {
547547
spreadProps?: boolean;
548548
};
549549

550+
// ────────────────────────────────────────────────────────────────────────────
551+
// Marker File Configuration
552+
// ────────────────────────────────────────────────────────────────────────────
553+
554+
export interface MarkerFileContext {
555+
/** Absolute path of the file being transformed */
556+
filePath: string;
557+
}
558+
550559
// ────────────────────────────────────────────────────────────────────────────
551560
// Style Merger Configuration
552561
// ────────────────────────────────────────────────────────────────────────────
@@ -736,6 +745,23 @@ export interface Adapter {
736745
* @default false
737746
*/
738747
usePhysicalProperties?: boolean;
748+
749+
/**
750+
* Optional function to customize where marker sidecar files (`stylex.defineMarker()`)
751+
* are written. By default, markers are placed in a `.stylex.ts` file next to the source.
752+
*
753+
* When provided, the function receives the source file path and returns an `ImportSource`
754+
* that determines both the import path in the transformed file and the file path where
755+
* markers are written.
756+
*
757+
* Example:
758+
* ```typescript
759+
* markerFile(ctx) {
760+
* return { kind: "absolutePath", value: "/path/to/shared/markers.stylex.ts" };
761+
* }
762+
* ```
763+
*/
764+
markerFile?: (context: MarkerFileContext) => ImportSource;
739765
}
740766

741767
// ────────────────────────────────────────────────────────────────────────────
@@ -770,6 +796,7 @@ export interface AdapterInput {
770796
themeHook?: Adapter["themeHook"];
771797
useSxProp: Adapter["useSxProp"];
772798
usePhysicalProperties?: Adapter["usePhysicalProperties"];
799+
markerFile?: Adapter["markerFile"];
773800
}
774801

775802
// ────────────────────────────────────────────────────────────────────────────

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
* Core concepts: adapter configuration and transform execution.
44
*/
55
export { defineAdapter } from "./adapter.js";
6-
export type { AdapterInput } from "./adapter.js";
6+
export type { AdapterInput, ImportSource, MarkerFileContext } from "./adapter.js";
77
export { runTransform } from "./run.js";

src/internal/public-api-validation.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,20 @@ function assertAdapterShape(candidate: unknown, where: string, allowAutoExtIf: b
212212
});
213213
}
214214

215+
// Validate markerFile (optional function)
216+
const markerFile = obj?.markerFile;
217+
if (markerFile !== undefined && markerFile !== null && typeof markerFile !== "function") {
218+
throw new Error(
219+
[
220+
`${where}: adapter.markerFile must be a function when provided.`,
221+
`Received: markerFile=${describeValue(markerFile)}`,
222+
"",
223+
"Expected signature:",
224+
' markerFile(ctx: { filePath: string }) => { kind: "specifier" | "absolutePath", value: string }',
225+
].join("\n"),
226+
);
227+
}
228+
215229
// Validate themeHook config (null/undefined or object with functionName/importSource)
216230
const themeHook = obj?.themeHook;
217231
if (themeHook !== null && themeHook !== undefined) {

src/internal/transform-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export class TransformContext {
107107
ancestorAttrsByStyleKey?: Map<string, Set<string>>;
108108
/** Content for the sidecar .stylex.ts file (defineMarker declarations), populated by emitStylesStep */
109109
sidecarStylexContent?: string;
110+
/** Absolute file path for the sidecar file, when adapter.markerFile provides a custom location */
111+
sidecarFilePath?: string;
110112
/** Bridge components emitted for unconverted consumer selectors. */
111113
bridgeResults?: import("./transform-types.js").BridgeComponentResult[];
112114
/** Transient prop renames for exported components (for consumer patching). */

src/internal/transform-steps/emit-styles.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* Step: emit stylex.create objects and resolver imports.
33
* Core concepts: style emission and import aliasing.
44
*/
5-
import { basename } from "node:path";
5+
import { basename, dirname, join, relative, sep } from "node:path";
6+
import type { ImportSource } from "../../adapter.js";
67
import { emitStylesAndImports } from "../emit-styles.js";
78
import { CONTINUE, type StepResult } from "../transform-types.js";
89
import { TransformContext } from "../transform-context.js";
@@ -112,9 +113,17 @@ function emitDefineMarkerDeclarations(
112113
.join("\n");
113114
ctx.sidecarStylexContent = `import * as stylex from "@stylexjs/stylex";\n\n${markerDecls}\n`;
114115

115-
// Derive sidecar import path from file basename
116-
const fileBase = basename(ctx.file.path).replace(/\.\w+$/, "");
117-
const sidecarImportPath = `./${fileBase}.stylex`;
116+
// Determine sidecar import path — use adapter.markerFile if provided, otherwise derive from basename
117+
let sidecarImportPath: string;
118+
const adapterMarkerFile = ctx.adapter.markerFile;
119+
if (adapterMarkerFile) {
120+
const importSource = adapterMarkerFile({ filePath: ctx.file.path });
121+
sidecarImportPath = importSourceToModuleSpecifier(importSource, ctx.file.path);
122+
ctx.sidecarFilePath = importSourceToAbsolutePath(importSource, ctx.file.path);
123+
} else {
124+
const fileBase = basename(ctx.file.path).replace(/\.\w+$/, "");
125+
sidecarImportPath = `./${fileBase}.stylex`;
126+
}
118127

119128
// Insert `import { XMarker, ... } from "./file.stylex"` after existing imports
120129
const importDecl = j.importDeclaration(
@@ -131,3 +140,33 @@ function emitDefineMarkerDeclarations(
131140
const insertAt = lastImportIdx >= 0 ? lastImportIdx + 1 : 0;
132141
programBody.splice(insertAt, 0, importDecl as unknown as { type?: string });
133142
}
143+
144+
/** Convert an ImportSource to a module specifier string for use in import declarations. */
145+
function importSourceToModuleSpecifier(source: ImportSource, filePath: string): string {
146+
if (source.kind === "specifier") {
147+
return source.value;
148+
}
149+
// absolutePath → relative module specifier from current file
150+
const baseDir = dirname(filePath);
151+
let rel = relative(baseDir, source.value).split(sep).join("/");
152+
// Strip .ts/.tsx extension for module specifier
153+
rel = rel.replace(/\.tsx?$/, "");
154+
if (!rel.startsWith(".")) {
155+
rel = `./${rel}`;
156+
}
157+
return rel;
158+
}
159+
160+
/** Resolve an ImportSource to an absolute file path for writing the sidecar file. */
161+
function importSourceToAbsolutePath(source: ImportSource, filePath: string): string {
162+
if (source.kind === "absolutePath") {
163+
return source.value;
164+
}
165+
// specifier → resolve relative to source file directory, append .ts if no real file extension
166+
const baseDir = dirname(filePath);
167+
let resolved = join(baseDir, source.value);
168+
if (!/\.[jt]sx?$/.test(resolved)) {
169+
resolved += ".ts";
170+
}
171+
return resolved;
172+
}

src/internal/transform-steps/finalize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function finalize(ctx: TransformContext): TransformResult {
5252
code,
5353
warnings: ctx.warnings,
5454
sidecarContent: ctx.sidecarStylexContent,
55+
sidecarFilePath: ctx.sidecarFilePath,
5556
bridgeResults: ctx.bridgeResults,
5657
transientPropRenames: ctx.transientPropRenames,
5758
};

src/internal/transform-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface TransformResult {
2020
warnings: WarningLog[];
2121
/** Content for the sidecar .stylex.ts file (defineMarker declarations). Undefined when no markers needed. */
2222
sidecarContent?: string;
23+
/** Absolute file path for the sidecar file, when adapter.markerFile provides a custom location. */
24+
sidecarFilePath?: string;
2325
/** Bridge components emitted for unconverted consumer selectors. */
2426
bridgeResults?: BridgeComponentResult[];
2527
/** Transient prop renames for exported components, keyed by export name. */

0 commit comments

Comments
 (0)