From bcae52becb9dab28eb0dad917bb1da1094dd781d Mon Sep 17 00:00:00 2001 From: Ben Word Date: Sat, 4 Apr 2026 21:22:32 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20theme.json=20partials=20suppo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 68 ++++++++++ src/theme/index.ts | 28 +++- src/theme/partials.ts | 145 +++++++++++++++++++++ src/types.ts | 9 ++ tests/partials.test.ts | 283 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 src/theme/partials.ts create mode 100644 tests/partials.test.ts diff --git a/README.md b/README.md index c240f9d..8bead8a 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,9 @@ 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", }), @@ -179,6 +182,71 @@ export default defineConfig({ }); ``` +#### 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: diff --git a/src/theme/index.ts b/src/theme/index.ts index e078c84..c0ee17c 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -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"; @@ -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 @@ -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, @@ -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"); @@ -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) { @@ -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, diff --git a/src/theme/partials.ts b/src/theme/partials.ts new file mode 100644 index 0000000..49ec49d --- /dev/null +++ b/src/theme/partials.ts @@ -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, + source: Record, +): Record { + 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, val as Record); + 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 { + 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 | 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): void { + if (partial.styles) { + deepMerge(themeJson as unknown as Record, partial); + } + + if (partial.blocks) { + const styles = ((themeJson as Record).styles ??= {}) as Record< + string, + unknown + >; + const blocks = (styles.blocks ??= {}) as Record; + + deepMerge(blocks, partial.blocks as Record); + } + + if (partial.elements) { + const styles = ((themeJson as Record).styles ??= {}) as Record< + string, + unknown + >; + const elements = (styles.elements ??= {}) as Record; + + deepMerge(elements, partial.elements as Record); + } +} diff --git a/src/types.ts b/src/types.ts index 344bd01..0c612c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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"; diff --git a/tests/partials.test.ts b/tests/partials.test.ts new file mode 100644 index 0000000..b6e1546 --- /dev/null +++ b/tests/partials.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { deepMerge, findPartialFiles, mergePartials } from "../src/theme/partials.js"; +import type { ThemeJson } from "../src/types.js"; + +describe("deepMerge", () => { + it("should merge flat objects", () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + + deepMerge(target, source); + + expect(target).toEqual({ a: 1, b: 3, c: 4 }); + }); + + it("should recursively merge nested objects", () => { + const target = { a: { x: 1, y: 2 }, b: 1 }; + const source = { a: { y: 3, z: 4 } }; + + deepMerge(target, source); + + expect(target).toEqual({ a: { x: 1, y: 3, z: 4 }, b: 1 }); + }); + + it("should overwrite arrays instead of merging", () => { + const target = { a: [1, 2] }; + const source = { a: [3, 4, 5] }; + + deepMerge(target, source); + + expect(target).toEqual({ a: [3, 4, 5] }); + }); + + it("should overwrite primitives with objects", () => { + const target = { a: "string" } as Record; + const source = { a: { nested: true } }; + + deepMerge(target, source); + + expect(target).toEqual({ a: { nested: true } }); + }); + + it("should handle deeply nested structures", () => { + const target = { + styles: { + blocks: { + "core/paragraph": { spacing: { margin: { top: "1rem" } } }, + }, + }, + }; + + const source = { + styles: { + blocks: { + "core/paragraph": { spacing: { margin: { bottom: "2rem" } } }, + "core/heading": { typography: { fontWeight: "600" } }, + }, + }, + }; + + deepMerge(target, source); + + expect(target.styles.blocks["core/paragraph"].spacing.margin).toEqual({ + top: "1rem", + bottom: "2rem", + }); + + expect((target.styles.blocks as Record)["core/heading"]).toEqual({ + typography: { fontWeight: "600" }, + }); + }); +}); + +describe("findPartialFiles", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "partials-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true }); + }); + + it("should find .theme.js files", () => { + fs.writeFileSync(path.join(tmpDir, "button.theme.js"), "export default {}"); + + const files = findPartialFiles(tmpDir); + + expect(files).toHaveLength(1); + expect(files[0]).toContain("button.theme.js"); + }); + + it("should not find .theme.json files", () => { + fs.writeFileSync(path.join(tmpDir, "global.theme.json"), "{}"); + + const files = findPartialFiles(tmpDir); + + expect(files).toHaveLength(0); + }); + + it("should find files in subdirectories", () => { + const sub = path.join(tmpDir, "blocks"); + fs.mkdirSync(sub); + fs.writeFileSync(path.join(sub, "button.theme.js"), "export default {}"); + + const files = findPartialFiles(tmpDir); + + expect(files).toHaveLength(1); + }); + + it("should ignore node_modules", () => { + const nm = path.join(tmpDir, "node_modules", "pkg"); + fs.mkdirSync(nm, { recursive: true }); + fs.writeFileSync(path.join(nm, "something.theme.js"), "export default {}"); + fs.writeFileSync(path.join(tmpDir, "button.theme.js"), "export default {}"); + + const files = findPartialFiles(tmpDir); + + expect(files).toHaveLength(1); + expect(files[0]).toContain("button.theme.js"); + }); + + it("should ignore vendor, dist, and public directories", () => { + for (const dir of ["vendor", "dist", "public"]) { + const d = path.join(tmpDir, dir); + fs.mkdirSync(d); + fs.writeFileSync(path.join(d, "test.theme.js"), "export default {}"); + } + + const files = findPartialFiles(tmpDir); + + expect(files).toHaveLength(0); + }); + + it("should not match non-theme files", () => { + fs.writeFileSync(path.join(tmpDir, "app.js"), "export default {}"); + fs.writeFileSync(path.join(tmpDir, "theme.js"), "export default {}"); + fs.writeFileSync(path.join(tmpDir, "config.json"), "{}"); + + const files = findPartialFiles(tmpDir); + + expect(files).toHaveLength(0); + }); + + it("should return files sorted alphabetically", () => { + fs.writeFileSync(path.join(tmpDir, "c.theme.js"), "export default {}"); + fs.writeFileSync(path.join(tmpDir, "a.theme.js"), "export default {}"); + fs.writeFileSync(path.join(tmpDir, "b.theme.js"), "export default {}"); + + const files = findPartialFiles(tmpDir); + + expect(files.map((f) => path.basename(f))).toEqual([ + "a.theme.js", + "b.theme.js", + "c.theme.js", + ]); + }); +}); + +describe("mergePartials", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "partials-merge-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true }); + }); + + it("should merge shorthand blocks/elements into styles", async () => { + fs.writeFileSync( + path.join(tmpDir, "global.theme.js"), + `export default ${JSON.stringify({ + blocks: { + "core/paragraph": { spacing: { margin: { bottom: "1rem" } } }, + }, + elements: { + h1: { typography: { fontSize: "2rem" } }, + }, + })}`, + ); + + const themeJson = { + settings: { typography: { defaultFontSizes: false, customFontSize: false } }, + } as ThemeJson; + + await mergePartials(themeJson, findPartialFiles(tmpDir)); + + const result = themeJson as unknown as Record; + const styles = result.styles as Record; + + expect(styles.blocks).toEqual({ + "core/paragraph": { spacing: { margin: { bottom: "1rem" } } }, + }); + + expect(styles.elements).toEqual({ + h1: { typography: { fontSize: "2rem" } }, + }); + }); + + it("should merge full styles export at root", async () => { + fs.writeFileSync( + path.join(tmpDir, "button.theme.js"), + `export default ${JSON.stringify({ + styles: { + blocks: { + "core/button": { border: { radius: "0" } }, + }, + }, + })}`, + ); + + const themeJson = { + settings: { typography: { defaultFontSizes: false, customFontSize: false } }, + } as ThemeJson; + + await mergePartials(themeJson, findPartialFiles(tmpDir)); + + const result = themeJson as unknown as Record; + const styles = result.styles as Record; + const blocks = styles.blocks as Record; + + expect(blocks["core/button"]).toEqual({ border: { radius: "0" } }); + }); + + it("should deep merge multiple partials", async () => { + fs.writeFileSync( + path.join(tmpDir, "a.theme.js"), + `export default ${JSON.stringify({ + blocks: { + "core/paragraph": { spacing: { margin: { bottom: "1rem" } } }, + }, + })}`, + ); + + fs.writeFileSync( + path.join(tmpDir, "b.theme.js"), + `export default ${JSON.stringify({ + blocks: { + "core/paragraph": { spacing: { padding: { left: "0.5rem" } } }, + "core/heading": { typography: { fontWeight: "600" } }, + }, + })}`, + ); + + const themeJson = { + settings: { typography: { defaultFontSizes: false, customFontSize: false } }, + } as ThemeJson; + + await mergePartials(themeJson, findPartialFiles(tmpDir)); + + const styles = (themeJson as unknown as Record).styles as Record< + string, + unknown + >; + const blocks = styles.blocks as Record>; + + expect(blocks["core/paragraph"]).toEqual({ + spacing: { + margin: { bottom: "1rem" }, + padding: { left: "0.5rem" }, + }, + }); + + expect(blocks["core/heading"]).toEqual({ + typography: { fontWeight: "600" }, + }); + }); + + it("should handle no partial files gracefully", async () => { + const themeJson = { + settings: { typography: { defaultFontSizes: false, customFontSize: false } }, + } as ThemeJson; + + await mergePartials(themeJson, []); + + expect((themeJson as unknown as Record).styles).toBeUndefined(); + }); +});