From 4c228fd9e5e24a36853b93e5149eddb316c6045a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 00:24:18 -0500 Subject: [PATCH 1/3] fix(config): accept shared project settings --- docs/configuration.md | 43 +++++++++++++++- src/schemas/emulsifyProjectConfig.json | 59 ++++++++++++++++++++++ src/types/_emulsifyProjectConfig.d.ts | 46 +++++++++++++++++ src/util/project/getEmulsifyConfig.test.ts | 26 ++++++++++ test/e2e/cli.test.mjs | 21 ++++++-- 5 files changed, 190 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6322efc..a9c5e3e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,6 +28,42 @@ Emulsify CLI stores project state in `project.emulsify.json`. Commands search fo `project.platform` never uses compatibility expressions. Values such as `drupal || wordpress` are valid only on system variant `platform` fields. +Starter hooks may add optional project metadata that records platform behavior and generated-project lineage: + +| Field | Type | Description | +| ----------------------------------- | -------- | --------------------------------------------------------------------------------------------- | +| `project.singleDirectoryComponents` | Boolean | Enables Drupal Single Directory Component output mirroring when used with the Drupal adapter. | +| `project.generatedFrom` | String | Emulsify source project that generated this project, such as `emulsify-wordpress`. | +| `project.generatedFromVersion` | String | Version of the source project used to generate this project. | +| `project.description` | String | Human-facing description retained by generated-project tooling. | +| `project.assetRoots` | String[] | Compatibility alias for additional asset roots. Prefer the top-level `assets.roots` field. | + +These fields are optional, but generated starters may rely on them for upgrades and support diagnostics. Preserve them when editing a generated project. + +## Asset Configuration + +Emulsify Core discovers assets in the default `./assets` and `./src/assets` directories. Projects can add asset roots and control asset output with the optional top-level `assets` section: + +```json +{ + "assets": { + "roots": ["./design-system/assets", "./prototype-assets"], + "rebase": true, + "selfContainedOutput": true + } +} +``` + +| Field | Type | Default | Description | +| ---------------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------- | +| `assets.roots` | String[] | `[]` | Additional project-relative asset directories. Configured roots take precedence over the default roots. | +| `assets.rebase` | Boolean | `true` | Resolves asset aliases, repairs unresolved CSS asset URLs, and calculates emitted paths. | +| `assets.selfContainedOutput` | Boolean | `true` | Keeps project assets under `dist/assets`. Set to `false` only when the complete project asset tree is deployed. | + +Asset roots are resolved relative to the project root. Emulsify Core ignores paths outside the project and reports them through its audit command. + +For compatibility with earlier project-structure work, Emulsify Core also accepts string arrays at `project.assetRoots` and `projectStructure.assetRoots`. It combines those roots with `assets.roots`; new configuration should use `assets.roots`. The `projectStructure` compatibility object supports only `assetRoots`. + ## System And Variant Configuration `emulsify system install` adds system and variant sections. @@ -109,7 +145,7 @@ Common validation issues: | Issue | Fix | | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Unknown top-level properties | Remove properties that are not defined by the schema. | +| Unknown properties | Remove properties that are not defined by the schema. | | Missing `project`, `project.platform`, `project.name`, or `project.machineName` | Re-run init or restore the required fields. | | Missing `starter.repository` | Restore the starter repository field. | | Component commands fail because `system` or `variant` is missing | Run `emulsify system install ` from the project. | @@ -125,3 +161,8 @@ Manual edits are safe when they preserve the schema and match the installed syst | `system.repository` | Component commands parse the system name from this URL and expect a `.git` suffix. | | `variant.structureImplementations` | Install and create destinations are calculated from these directories. | | `project.platform` | System install selects variants from this concrete value. Existing system and variant config may no longer match. Do not change it to a `\|\|` expression. | +| `project.generatedFrom*` | Generated-project upgrade and support tooling uses these values to identify the source release. | +| `assets.roots` | Roots must stay inside the project and affect Storybook, Vite, Twig asset lookup, and audit behavior. | +| Legacy `assetRoots` aliases | `project.assetRoots` and `projectStructure.assetRoots` remain supported, but new configuration should use `assets.roots`. | +| `assets.rebase` | Disabling rebasing also disables Emulsify's CSS asset alias normalization and URL repair pipeline. | +| `assets.selfContainedOutput` | `false` requires deploying the complete project asset tree alongside build output. | diff --git a/src/schemas/emulsifyProjectConfig.json b/src/schemas/emulsifyProjectConfig.json index e75d762..d7d5c0f 100644 --- a/src/schemas/emulsifyProjectConfig.json +++ b/src/schemas/emulsifyProjectConfig.json @@ -19,6 +19,29 @@ "machineName": { "type": "string", "description": "Machine-friendly name of the project, such as 'carmen-sandiego'." + }, + "singleDirectoryComponents": { + "type": "boolean", + "description": "Whether Drupal Single Directory Component output mirroring is enabled." + }, + "generatedFrom": { + "type": "string", + "description": "Name of the Emulsify project that generated this project." + }, + "generatedFromVersion": { + "type": "string", + "description": "Version of the Emulsify project that generated this project." + }, + "description": { + "type": "string", + "description": "Human-facing description of the generated project." + }, + "assetRoots": { + "type": "array", + "description": "Compatibility alias for additional project-relative asset directories. Prefer the top-level assets.roots property.", + "items": { + "type": "string" + } } }, "additionalProperties": false, @@ -36,6 +59,42 @@ "additionalProperties": false, "required": ["repository"] }, + "projectStructure": { + "type": "object", + "description": "Compatibility configuration for Emulsify Core project structure. Prefer the top-level assets property for asset settings.", + "properties": { + "assetRoots": { + "type": "array", + "description": "Compatibility alias for additional project-relative asset directories. Prefer assets.roots.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "assets": { + "type": "object", + "description": "Configures Emulsify Core asset discovery and output behavior.", + "properties": { + "roots": { + "type": "array", + "description": "Additional project-relative directories containing static assets.", + "items": { + "type": "string" + } + }, + "rebase": { + "type": "boolean", + "description": "Whether Emulsify Core resolves asset aliases and repairs unresolved CSS asset URLs." + }, + "selfContainedOutput": { + "type": "boolean", + "description": "Whether project assets remain under the build output so it can be deployed independently." + } + }, + "additionalProperties": false + }, "system": { "type": "object", "description": "Contains information about the Emulsify system this project is utilizing", diff --git a/src/types/_emulsifyProjectConfig.d.ts b/src/types/_emulsifyProjectConfig.d.ts index cdc8d21..1bbb0cd 100644 --- a/src/types/_emulsifyProjectConfig.d.ts +++ b/src/types/_emulsifyProjectConfig.d.ts @@ -36,6 +36,26 @@ export interface EmulsifyProjectConfiguration { * Machine-friendly name of the project, such as 'carmen-sandiego'. */ machineName: string; + /** + * Whether Drupal Single Directory Component output mirroring is enabled. + */ + singleDirectoryComponents?: boolean; + /** + * Name of the Emulsify project that generated this project. + */ + generatedFrom?: string; + /** + * Version of the Emulsify project that generated this project. + */ + generatedFromVersion?: string; + /** + * Human-facing description of the generated project. + */ + description?: string; + /** + * Compatibility alias for additional project-relative asset directories. Prefer the top-level assets.roots property. + */ + assetRoots?: string[]; }; /** * Contains information about the Emulsify starter this project is based upon @@ -46,6 +66,32 @@ export interface EmulsifyProjectConfiguration { */ repository: string; }; + /** + * Compatibility configuration for Emulsify Core project structure. Prefer the top-level assets property for asset settings. + */ + projectStructure?: { + /** + * Compatibility alias for additional project-relative asset directories. Prefer assets.roots. + */ + assetRoots?: string[]; + }; + /** + * Configures Emulsify Core asset discovery and output behavior. + */ + assets?: { + /** + * Additional project-relative directories containing static assets. + */ + roots?: string[]; + /** + * Whether Emulsify Core resolves asset aliases and repairs unresolved CSS asset URLs. + */ + rebase?: boolean; + /** + * Whether project assets remain under the build output so it can be deployed independently. + */ + selfContainedOutput?: boolean; + }; /** * Contains information about the Emulsify system this project is utilizing */ diff --git a/src/util/project/getEmulsifyConfig.test.ts b/src/util/project/getEmulsifyConfig.test.ts index 0ec1c4c..5da8066 100644 --- a/src/util/project/getEmulsifyConfig.test.ts +++ b/src/util/project/getEmulsifyConfig.test.ts @@ -44,6 +44,32 @@ describe('getEmulsifyConfig', () => { await expect(getEmulsifyConfig()).resolves.toEqual(wordpressProjectConfig); }); + it('accepts shared starter and Emulsify Core configuration fields', async () => { + const sharedProjectConfig = { + ...projectConfig, + project: { + ...projectConfig.project, + platform: 'drupal', + singleDirectoryComponents: true, + generatedFrom: 'emulsify-drupal', + generatedFromVersion: '7.2.1', + description: 'A generated Drupal theme.', + assetRoots: ['./legacy-project-assets'], + }, + projectStructure: { + assetRoots: ['./legacy-structure-assets'], + }, + assets: { + roots: ['./design-system/assets'], + rebase: false, + selfContainedOutput: false, + }, + }; + loadJsonFileMock.mockResolvedValueOnce(sharedProjectConfig); + + await expect(getEmulsifyConfig()).resolves.toEqual(sharedProjectConfig); + }); + it('returns void if no Emulsify config file is found within the users cwd', async () => { findFileMock.mockReturnValueOnce(undefined); await expect(getEmulsifyConfig()).resolves.toBe(undefined); diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index aa13421..3db0a36 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -154,6 +154,16 @@ describe('built Emulsify CLI', { concurrency: false }, () => { }, }, }), + '.cli/init.js': [ + "import { readFileSync, writeFileSync } from 'node:fs';", + "const configUrl = new URL('../project.emulsify.json', import.meta.url);", + "const config = JSON.parse(readFileSync(configUrl, 'utf8'));", + "config.project.generatedFrom = 'emulsify-wordpress';", + "config.project.generatedFromVersion = '2.0.0';", + "config.project.description = 'A generated WordPress child theme.';", + 'writeFileSync(configUrl, `${JSON.stringify(config, null, 2)}\\n`);', + '', + ].join('\n'), '.cli/systemInstall.js': [ "import { writeFileSync } from 'node:fs';", "writeFileSync(new URL('../system-install-hook-ran.txt', import.meta.url), 'ran\\n');", @@ -246,7 +256,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); }); - test('initializes a project from a local starter repository', () => { + test('initializes a WordPress project with starter hook metadata', () => { const result = runCli(tempRoot, [ 'init', 'Fixture Project', @@ -258,7 +268,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { '--checkout', 'main', '--platform', - 'none', + 'wordpress', '--yes', ]); @@ -271,9 +281,12 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ), { project: { - platform: 'none', + platform: 'wordpress', name: 'Fixture Project', machineName: 'fixture-project', + generatedFrom: 'emulsify-wordpress', + generatedFromVersion: '2.0.0', + description: 'A generated WordPress child theme.', }, starter: { repository: starterRepository, @@ -288,7 +301,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(existsSync(join(projectRoot, '.git')), false); }); - test('installs an exact variant from a local system repository', () => { + test('loads starter hook metadata when installing a system', () => { const result = runCli(projectRoot, [ 'system', 'install', From 002455a8270d27e425a1150c116652f660c4766c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 00:24:33 -0500 Subject: [PATCH 2/3] fix(config): name unsupported properties --- src/util/project/getEmulsifyConfig.test.ts | 17 +++++++++++++++++ src/util/project/getEmulsifyConfig.ts | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/src/util/project/getEmulsifyConfig.test.ts b/src/util/project/getEmulsifyConfig.test.ts index 5da8066..e906cd2 100644 --- a/src/util/project/getEmulsifyConfig.test.ts +++ b/src/util/project/getEmulsifyConfig.test.ts @@ -101,6 +101,23 @@ describe('getEmulsifyConfig', () => { ); }); + it('names every unknown project property in schema validation errors', async () => { + loadJsonFileMock.mockResolvedValueOnce({ + ...projectConfig, + project: { + ...projectConfig.project, + generatedForm: 'emulsify-wordpress', + generatedFromVerison: '2.0.0', + descripton: 'A generated WordPress child theme.', + }, + }); + + await expect(getEmulsifyConfig()).rejects.toMatchObject({ + message: + 'Invalid Emulsify project configuration in "/projects/project.emulsify.json": /project must NOT have additional property "generatedForm"; /project must NOT have additional property "generatedFromVerison"; /project must NOT have additional property "descripton"', + }); + }); + it('accepts variant platform compatibility expressions', async () => { const expressionConfig = { ...projectConfig, diff --git a/src/util/project/getEmulsifyConfig.ts b/src/util/project/getEmulsifyConfig.ts index a5b6def..499142f 100644 --- a/src/util/project/getEmulsifyConfig.ts +++ b/src/util/project/getEmulsifyConfig.ts @@ -25,6 +25,14 @@ async function getProjectConfigValidator(): Promise { function formatProjectConfigError(error: ErrorObject): string { const location = error.instancePath || '/'; + + if ( + error.keyword === 'additionalProperties' && + typeof error.params.additionalProperty === 'string' + ) { + return `${location} must NOT have additional property ${JSON.stringify(error.params.additionalProperty)}`; + } + return `${location} ${error.message}`; } From e6d01bdb04a7354d01a4583ae06d87f49bad01e4 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sun, 30 Aug 2026 00:39:45 -0500 Subject: [PATCH 3/3] chore: version bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6dcc8d..6e42f66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@emulsify/cli", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.3.0", + "version": "2.3.1", "license": "GPL-2.0", "dependencies": { "@inquirer/prompts": "^8.7.0", diff --git a/package.json b/package.json index 65e37f2..5c89e63 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.3.0", + "version": "2.3.1", "description": "Command line interface for Emulsify", "repository": "git@github.com:emulsify-ds/emulsify-cli.git", "author": "Patrick Coffey ",