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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,81 @@ export default defineConfig({
outputPath: "assets/theme.json",
cssFile: "app.css",

// Optional: Directory to scan for .theme.js partials (default: 'resources')
partials: "resources",

// Optional: Legacy Tailwind v3 config path
tailwindConfig: "./tailwind.config.js",
}),
],
});
```

#### Partials

The plugin automatically discovers `*.theme.js` files in the `resources/` directory and deep merges them into the generated `theme.json`. This lets you split your theme styles across multiple files — for example, co-locating block styles with their block templates.

Partials support two export formats:

**Shorthand** — `blocks` and `elements` at the top level are merged into `styles`:

```js
// resources/views/blocks/_global.theme.js
export default {
blocks: {
"core/paragraph": {
spacing: { margin: { bottom: "1rem" } },
},
},
elements: {
h1: {
typography: {
fontSize: "var(--wp--preset--font-size--4-xl)",
fontWeight: "600",
},
},
},
};
```

**Full** — merged at the root level, allowing you to target any part of theme.json:

```js
// resources/views/blocks/button.theme.js
export default {
styles: {
blocks: {
"core/button": {
border: { radius: "0" },
color: {
background: "var(--wp--preset--color--black)",
text: "var(--wp--preset--color--white)",
},
},
},
},
};
```

Files are merged in alphabetical order by path. During development, changes to `.theme.js` files will trigger a rebuild.

You can customize the directory to scan, pass multiple directories, or disable partials entirely:

```js
wordpressThemeJson({
// Custom directory
partials: "src/blocks",

// Multiple directories
partials: ["resources/views/blocks", "resources/styles"],

// Disable
partials: false,
});
```

#### Tailwind CSS Variables

By default, Tailwind v4 will only [generate CSS variables](https://tailwindcss.com/docs/theme#generating-all-css-variables) that are discovered in your source files.

To generate the full default Tailwind color palette into your `theme.json`, you can use the `static` theme option when importing Tailwind:
Expand Down
28 changes: 26 additions & 2 deletions src/theme/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Plugin as VitePlugin } from "vite";
import type { Plugin as VitePlugin, ResolvedConfig } from "vite";
import fs from "fs";
import path from "path";
import type { ThemeJsonConfig, ThemeJson, TailwindConfig } from "../types.js";
Expand All @@ -10,6 +10,7 @@ import { resolveFonts } from "./fonts.js";
import { resolveFontSizes } from "./font-sizes.js";
import { resolveBorderRadii } from "./border-radius.js";
import { buildSettings } from "./settings.js";
import { mergePartials, findPartialFiles, resolvePartialDirs } from "./partials.js";

/**
* Generate a WordPress theme.json from Tailwind CSS
Expand All @@ -25,6 +26,7 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin {
baseThemeJsonPath = "./theme.json",
outputPath = "assets/theme.json",
cssFile = "app.css",
partials: partialsOption = "resources",
shadeLabels,
fontLabels,
fontSizeLabels,
Expand All @@ -33,6 +35,7 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin {

let cssContent: string | null = null;
let resolvedTailwindConfig: TailwindConfig | undefined;
let rootDir: string = process.cwd();

if (tailwindConfig !== undefined && typeof tailwindConfig !== "string") {
throw new Error("tailwindConfig must be a string path or undefined");
Expand All @@ -42,10 +45,21 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin {
name: "wordpress-theme-json",
enforce: "pre",

async configResolved() {
async configResolved(resolvedConfig: ResolvedConfig) {
rootDir = resolvedConfig?.root ?? process.cwd();

if (tailwindConfig) {
resolvedTailwindConfig = await loadTailwindConfig(tailwindConfig);
}

if (partialsOption !== false && resolvedConfig?.command === "serve") {
const partialDirs = resolvePartialDirs(partialsOption, rootDir);
const files = partialDirs.flatMap(findPartialFiles);

for (const file of files) {
resolvedConfig.configFileDependencies.push(file);
}
}
},

transform(code: string, id: string) {
Expand Down Expand Up @@ -116,6 +130,16 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin {

delete themeJson.__preprocessed__;

// Merge partials
if (partialsOption !== false) {
const partialDirs = resolvePartialDirs(partialsOption, rootDir);
const partialFiles = partialDirs.flatMap(findPartialFiles);

if (partialFiles.length > 0) {
await mergePartials(themeJson, partialFiles);
}
}

this.emitFile({
type: "asset",
fileName: outputPath,
Expand Down
145 changes: 145 additions & 0 deletions src/theme/partials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import fs from "fs";
import path from "path";
import type { ThemeJson } from "../types.js";

const IGNORE_DIRS = new Set(["node_modules", "vendor", "dist", "public"]);

/**
* Deep merge source into target, mutating target.
* Objects are recursively merged; arrays and primitives are overwritten.
*/
export function deepMerge(
target: Record<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
for (const key in source) {
const val = source[key];

if (val && typeof val === "object" && !Array.isArray(val)) {
if (!target[key] || typeof target[key] !== "object" || Array.isArray(target[key])) {
target[key] = {};
}

deepMerge(target[key] as Record<string, unknown>, val as Record<string, unknown>);
continue;
}

target[key] = val;
}

return target;
}

/**
* Resolve partial directory paths relative to the project root.
*/
export function resolvePartialDirs(partials: string | string[], rootDir: string): string[] {
const dirs = Array.isArray(partials) ? partials : [partials];

return dirs.map((dir) => path.resolve(rootDir, dir));
}

/**
* Find all *.theme.js and *.theme.json files under a directory,
* skipping node_modules, vendor, dist, and public directories.
*/
export function findPartialFiles(rootDir: string): string[] {
const results: string[] = [];

let entries: fs.Dirent[];

try {
const result = fs.readdirSync(rootDir, { withFileTypes: true, recursive: true });

if (!Array.isArray(result)) {
return results;
}

entries = result;
} catch {
return results;
}

for (const entry of entries) {
if (!entry.isFile()) {
continue;
}

if (!entry.name.endsWith(".theme.js")) {
continue;
}

const rel = path.relative(rootDir, path.join(entry.parentPath, entry.name));
const parts = rel.split(path.sep);

if (parts.some((p) => IGNORE_DIRS.has(p))) {
continue;
}

results.push(path.join(entry.parentPath, entry.name));
}

return results.sort();
}

/**
* Load and merge partial theme files into the theme.json.
*/
export async function mergePartials(themeJson: ThemeJson, files: string[]): Promise<void> {
for (const file of files) {
const partial = await loadPartial(file);

if (!partial) {
continue;
}

applyPartial(themeJson, partial);
}
}

/**
* Load a single .theme.js partial file.
*/
async function loadPartial(file: string): Promise<Record<string, unknown> | null> {
try {
const url = `${path.resolve(file)}?t=${Date.now()}`;
const mod = await import(url);

return mod.default ?? null;
} catch {
return null;
}
}

/**
* Merge a partial into the theme.json.
*
* Supports two export shapes:
* - Full: `{ styles: { blocks, elements } }` — merged at root
* - Shorthand: `{ blocks, elements }` — merged into `styles`
*/
function applyPartial(themeJson: ThemeJson, partial: Record<string, unknown>): void {
if (partial.styles) {
deepMerge(themeJson as unknown as Record<string, unknown>, partial);
}

if (partial.blocks) {
const styles = ((themeJson as Record<string, unknown>).styles ??= {}) as Record<
string,
unknown
>;
const blocks = (styles.blocks ??= {}) as Record<string, unknown>;

deepMerge(blocks, partial.blocks as Record<string, unknown>);
}

if (partial.elements) {
const styles = ((themeJson as Record<string, unknown>).styles ??= {}) as Record<
string,
unknown
>;
const elements = (styles.elements ??= {}) as Record<string, unknown>;

deepMerge(elements, partial.elements as Record<string, unknown>);
}
}
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ export interface ThemeJsonConfig extends ThemeJsonPluginOptions {
* @default 'app.css'
*/
cssFile?: string;

/**
* Directory path(s) to scan for `.theme.js` partial files
* that are deep merged into the generated theme.json.
* Set to `false` to disable.
*
* @default 'resources'
*/
partials?: string | string[] | false;
}

import { SUPPORTED_EXTENSIONS } from "./constants.js";
Expand Down
Loading