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: 42 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <name>` from the project. |
Expand All @@ -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. |
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <patrickcoffey48@gmail.com>",
Expand Down
59 changes: 59 additions & 0 deletions src/schemas/emulsifyProjectConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions src/types/_emulsifyProjectConfig.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand Down
43 changes: 43 additions & 0 deletions src/util/project/getEmulsifyConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -75,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,
Expand Down
8 changes: 8 additions & 0 deletions src/util/project/getEmulsifyConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ async function getProjectConfigValidator(): Promise<ValidateFunction> {

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}`;
}

Expand Down
21 changes: 17 additions & 4 deletions test/e2e/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');",
Expand Down Expand Up @@ -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',
Expand All @@ -258,7 +268,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => {
'--checkout',
'main',
'--platform',
'none',
'wordpress',
'--yes',
]);

Expand All @@ -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,
Expand All @@ -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',
Expand Down
Loading