Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated macro system #344

Merged
merged 24 commits into from
Feb 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
"request": "launch",
"name": "Run jest tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"cwd": "${workspaceFolder}/packages/core",
"args": ["--runInBand", "--testPathPattern", "tests/template-colocation-plugin.test.js"]
"cwd": "${workspaceFolder}/packages/macros",
"args": ["--runInBand", "--testPathPattern", "tests/babel/get-config.test.js"]
},
{
"type": "node",
Expand Down
2 changes: 1 addition & 1 deletion packages/compat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@types/strip-bom": "^3.0.0",
"ember-cli-htmlbars-inline-precompile": "^2.1.0",
"qunit": "^2.8.0",
"typescript": "~3.2.0"
"typescript": "~3.4.0"
},
"dependencies": {
"@babel/core": "^7.2.2",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"fixturify": "^1.2.0",
"qunit": "^2.8.0",
"tmp": "^0.1.0",
"typescript": "~3.2.0"
"typescript": "~3.4.0"
},
"dependencies": {
"@babel/core": "^7.2.2",
Expand All @@ -52,7 +52,7 @@
"filesize": "^4.1.2",
"fs-extra": "^7.0.1",
"fs-tree-diff": "^2.0.0",
"handlebars": "^4.3.0",
"handlebars": "^4.4.2",
"js-string-escape": "^1.0.1",
"jsdom": "^12.0.0",
"json-stable-stringify": "^1.0.1",
Expand Down
178 changes: 12 additions & 166 deletions packages/macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,160 +10,20 @@ The [Embroider package spec](../../SPEC.md) proposes fixing this by making Ember

This package works in both Embroider builds and normal ember-cli builds, so that addon authors can switch to this newer pattern without disruption.

## Javascript macros
## Examples

- `getOwnConfig()`: a macro that returns arbitrary JSON-serializable configuration that was sent to your package. See "Setting Configuration" for how to get configuration in.
```js
import { dependencySatisfies, macroCondition, failBuild } from '@embroider/macros';

Assuming a config of `{ flavor: 'chocolate' }`, this code:

```js
import { getOwnConfig } from '@embroider/macros';
console.log(getOwnConfig().flavor);
```

Compiles to:

```js
console.log({ flavor: 'chocolate' }.flavor);
```

- `getConfig(packageName)`: like `getOwnConfig`, but will retrieve the configuration that was sent to another package. We will resolve which one based on node_modules resolution rules from your package.

- `dependencySatisfies(packagename, semverRange)`: a macro that compiles to a boolean literal. It will be true if the given package can be resolved (via normal node_modules resolution rules) and meets the stated semver requirement. The package version will be `semver.coerce()`'d first, such that nonstandard versions like "3.9.0-beta.0" will appropriately satisfy constraints like "> 3.8".

Assuming you have `ember-source` 3.9.0 available, this code:

```js
import { dependencySatisfies } from '@embroider/macros';
let hasNativeArrayHelper = dependencySatisfies('ember-source', '>=3.8.0');
```

Compiles to:

```js
let hasNativeArrayHelper = true;
```

* `macroIf(predicate, consequent, alternate)`: a compile time conditional. Lets you choose between two blocks of code and only include one of them. Critically, it will also strip import statements that are used only inside the dead block. The predicate is usually one of the other macros.

This code:

```js
import { dependencySatisfies, macroIf } from '@embroider/macros';
import OldComponent from './old-component';
import NewComponent from './new-component';
export default macroIf(dependencySatisfies('ember-source', '>=3.8.0'), () => NewComponent, () => OldComponent);
```

Will compile to either this:

```js
import NewComponent from './new-component';
export default NewComponent;
```

Or this:

```js
import OldComponent from './old-component';
export default OldComponent;
```

* `failBuild(message, ...params)`: cause a compile-time build failure. Generally only useful if you put it inside a `macroIf`. All the arguments must be statically analyzable, and they get passed to Node's standard `utils.format()`.

```js
import { macroIf, failBuild, dependencySatisfies } from '@embroider/macros';
macroIf(
dependencySatisfies('ember-source', '>=3.8.0'),
() => true,
() => failBuild('You need to have ember-source >= 3.8.0')
);
```

* `importSync(moduleSpecifier)`: exactly like standard ECMA `import()` except instead of returning `Promise<Module>` it returns `Module`. Under Emroider this is interpreted at build-time. Under classic ember-cli it is interpreted at runtime. This exists to provide synchronous & dynamic import. That's not a think ECMA supports, but it's a thing Ember historically has done, so we sometimes need this macro to bridge the worlds.

## Template macros

These are analogous to the Javascript macros, although here (because we don't import them) they are all prefixed with "macro".

- `macroGetOwnConfig`: works like a helper that pulls values out of your config. For example, assuming you have the config:

```json
{
"items": [{ "score": 42 }]
}
```

Then:

```hbs
<SomeComponent @score={{macroGetOwnConfig "items" "0" "score" }} />
{{! ⬆️compiles to ⬇️ }}
<SomeComponent @score={{42}} />
```

If you don't pass any keys, you can get the whole thing (although this makes your template bigger, so use keys when you can):

```hbs
<SomeComponent @config={{macroGetOwnConfig}} />
{{! ⬆️compiles to ⬇️ }}
<SomeComponent @config={{hash items=(array (hash score=42))}} />
```

* `macroGetConfig`: similar to `macroGetOwnConfig`, but takes the name of another package and gets that package's config. We will locate the other package following node_modules rules from your package. Additional extra arguments are treated as property keys just like in the previous examples.

```hbs
<SomeComponent @config={{macroGetConfig "liquid-fire"}} />
```

* `macroDependencySatisfies`

```hbs
<SomeComponent @canAnimate={{macroDependencySatisfies "liquid-fire" "*"}} />
{{! ⬆️compiles to ⬇️ }}
<SomeComponent @canAnimate={{true}} />
```

* `macroIf`: Like Ember's own `if`, this can be used in both block form and expresion form. The block form looks like:

```hbs
{{#macroIf (macroGetOwnConfig "shouldUseThing") }}
<Thing />
{{else}}
<OtherThing />
{{/macroIf}}

{{! ⬆️compiles to ⬇️ }}
<Thing />
```

The expression form looks like:

```hbs
<div class="box {{macroIf (macroGetOwnConfig "extraModeEnabled") extraClass regularClass}}" />
{{! ⬆️compiles to ⬇️ }}
<div class="box {{extraClass}}"/>
```

- `macroMaybeAttrs`: This macro allows you to include or strip HTML attributes themselves (not just change their values). It works like an element modifier:
if (macroCondition(dependencySatisfies('ember-data', '^3.0.0'))) {
} else
```

```hbs
<div {{macroMaybeAttr (macroGetConfig "ember-test-selectors" "enabled") data-test-here data-test-there=42}} >
{{! ⬆️compiles to either this ⬇️ }}
<div data-test-here data-test-there=42 >
{{! or this ⬇️ }}
<div>
```
## The Macros

- `macroFailBuild`: cause a compile-time build failure. Generally only useful if you put it inside a `macroIf`. All the arguments must be statically analyzable, and they get passed to Node's standard `utils.format()`.
### dependencySatisfies

```hbs
{{#macroIf (dependencySatisfies "important-thing" ">= 1.0")}}
<UseThing />
{{else}}
{{macroFailBuild "You need to have import-thing >= 1.0"}}
{{/macroIf}}
```
Tests whether a given dependency is present and satisfies the given semver range.

## Setting Configuration: from an Ember app

Expand All @@ -183,9 +43,9 @@ These are analogous to the Javascript macros, although here (because we don't im
setConfig: {
'some-dependency': {
// config for some-dependency
}
}
}
},
},
},
});
```

Expand All @@ -211,17 +71,3 @@ These are analogous to the Javascript macros, although here (because we don't im
},
};
```

## Setting Configuration: Low Level API

Configuration is stored per NPM package, based off their true on-disk locations. So it's possible to configure two independent copies of the same package when they're being consumed by different subsets of the total NPM dependency graph.

Configuration gets set during the build process, from within Node.

The entrypoints to the low level API are:

- `import { MacrosConfig } from '@embroider/macros'`: constructs the shared global object that stores config. It has methods for setting configuration and for retrieving the necessary Babel and HTMLBars plugins that will implment the config. See `macros-config.ts` for details.

```

```
3 changes: 2 additions & 1 deletion packages/macros/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@
"@types/qunit": "^2.5.3",
"@types/resolve": "^0.0.8",
"qunit": "^2.8.0",
"typescript": "~3.2.0"
"typescript": "~3.4.0"
},
"dependencies": {
"@babel/core": "^7.2.2",
"@babel/traverse": "^7.2.4",
"@babel/types": "^7.3.2",
"@embroider/core": "0.13.0",
"lodash": "^4.17.10",
"resolve": "^1.8.1",
"semver": "^5.6.0"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/macros/src/babel/dependency-satisfies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export default function dependencySatisfies(path: NodePath<CallExpression>, stat
let version = packageCache.resolve(packageName.value, us).version;
path.replaceWith(booleanLiteral(satisfies(version, range.value)));
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') {
throw err;
}
path.replaceWith(booleanLiteral(false));
}
}
87 changes: 87 additions & 0 deletions packages/macros/src/babel/each.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { NodePath } from '@babel/traverse';
import evaluate from './evaluate-json';
import { parse } from '@babel/core';
import {
CallExpression,
ForOfStatement,
identifier,
File,
ExpressionStatement,
Identifier,
callExpression,
Expression,
} from '@babel/types';
import error from './error';
import cloneDeep from 'lodash/cloneDeep';
import State from './state';

export type EachPath = NodePath<ForOfStatement> & {
get(right: 'right'): NodePath<CallExpression>;
};

export function isEachPath(path: NodePath<ForOfStatement>): path is EachPath {
let right = path.get('right');
if (right.isCallExpression()) {
let callee = right.get('callee');
if (callee.referencesImport('@embroider/macros', 'each')) {
return true;
}
}
return false;
}

export function prepareEachPath(path: EachPath, state: State) {
let args = path.get('right').get('arguments');
if (args.length !== 1) {
throw error(path, `the each() macro accepts exactly one argument, you passed ${args.length}`);
}

let left = path.get('left');
if (!left.isVariableDeclaration() || left.get('declarations').length !== 1) {
throw error(left, `the each() macro doesn't support this syntax`);
}

let body = path.get('body');
let varName = (left.get('declarations')[0].get('id') as NodePath<Identifier>).node.name;
let nameRefs = body.scope.getBinding(varName)!.referencePaths;

state.pendingEachMacros.push({
body: path.get('body'),
nameRefs,
arg: args[0] as NodePath<Expression>,
});

path.replaceWith(callExpression(identifier('_eachMacroPlaceholder_'), [args[0].node]));
}

export function finishEachPath(path: NodePath<CallExpression>, state: State) {
let resumed = state.pendingEachMacros.pop()!;
let [arrayPath] = path.get('arguments');
let array = evaluate(arrayPath);
if (!array.confident) {
throw error(resumed.arg, `the argument to the each() macro must be statically known`);
}

if (!Array.isArray(array.value)) {
throw error(resumed.arg, `the argument to the each() macro must be an array`);
}

for (let element of array.value) {
let literalElement = asLiteral(element);
for (let target of resumed.nameRefs) {
target.replaceWith(literalElement);
}
path.insertBefore(cloneDeep(resumed.body.node));
}
path.remove();
}

function asLiteral(value: unknown | undefined) {
if (typeof value === 'undefined') {
return identifier('undefined');
}
let ast = parse(`a(${JSON.stringify(value)})`, {}) as File;
let statement = ast.program.body[0] as ExpressionStatement;
let expression = statement.expression as CallExpression;
return expression.arguments[0];
}
Loading