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

Add <FaStaticSprite> placeholder to transform icons #138

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Compatibility
- [Usage](#usage)
* [Configuration](#configuration)
* [Template](#template)
* [Static SVG Sprites](#static-svg-sprites)
- [Features](#features)
* [Basic](#basic)
* [Advanced](#advanced)
Expand Down Expand Up @@ -226,6 +227,24 @@ If you want to use an icon from any style other than the default, use `prefix=`.
<FaIcon @icon="square" @prefix="far" />
```

### Static SVG Sprites

In situations where many icons are rendered to the page you can substantially improve performance by rendering
a [static sprite](https://fontawesome.com/how-to-use/on-the-web/advanced/svg-sprites) instead of the full icon component. Any
additional attributes you pass will be included in the `<svg>` element of the output.

```hbs
<FaStaticSprite @icon="coffee" />
<FaStaticSprite @icon="coffee" @prefix="fal" />
<FaStaticSprite @icon="coffee" class="my-custom-class" />
```

Because this transformation happens at build time some invocations will *not work*:
```hbs
{{!-- does not work --}} <FaStaticSprite @icon={{this.iconName}} />
{{!-- does not work --}} {{component 'FaStaticSprite'}}
```

## Features

The following features are available as part of Font Awesome. Note that the syntax is different from our general web-use documentation.
Expand Down
29 changes: 28 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';
var broccoliSource = require('broccoli-source')
var UnwatchedDir = broccoliSource.UnwatchedDir
const Funnel = require('broccoli-funnel');
var MergeTrees = require('broccoli-merge-trees')
var Rollup = require('broccoli-rollup')
var resolve = require('rollup-plugin-node-resolve')
Expand All @@ -13,6 +14,7 @@ var writeFile = require('broccoli-file-creator');
const { config, dom } = require('@fortawesome/fontawesome-svg-core');
const path = require('path');
const findWorkspaceRoot = require('find-yarn-workspace-root');
const FaStaticSpriteTransformPlugin = require('./lib/fa-static-sprite-transform');

module.exports = {
name: require('./package').name,
Expand Down Expand Up @@ -203,6 +205,26 @@ module.exports = {
app.import('vendor/fontawesome.css');
},

treeForPublic: function(parentTree) {
const tree = new Funnel(this._nodeModulesPath, {
include: [
'@fortawesome/fontawesome-free/sprites/*.svg',
'@fortawesome/fontawesome-pro/sprites/*.svg',
],
destDir: 'assets/fa-sprites/',
allowEmpty: true,
getDestinationPath: function (relativePath) {
return path.basename(relativePath);
}
});
const trees = [tree];
if (parentTree) {
trees.push(parentTree);
}

return MergeTrees(trees);
},

/**
* setupPreprocessorRegistry is called before included
* see https://github.com/ember-cli/ember-cli/issues/3701
Expand All @@ -221,6 +243,11 @@ module.exports = {
},
});
}
},

registry.add('htmlbars-ast-plugin', FaStaticSpriteTransformPlugin.instantiate({
options: {
defaultPrefix: this.fontawesomeConfig.defaultPrefix,
},
}));
},
}
113 changes: 113 additions & 0 deletions lib/fa-static-sprite-transform.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* eslint-env node */
'use strict';

const path = require('path');
const { getAbstractIcon } = require('./fontawesome-helpers');

const prefixToSpriteFile = {
fas: 'solid',
far: 'regular',
fal: 'light',
fad: 'duotone',
fab: 'brands',
};

/*
```hbs
<FaStaticIcon @prefix="fas" @icon="coffee" />
```

becomes

```hbs
<svg>
<use xlink:href="fa-solid.svg#coffee"></use>
</svg>
```
*/
module.exports = class FaStaticSpriteTransformPlugin {
constructor(env, options) {
this.syntax = env.syntax;
this.builders = env.syntax.builders;
this.options = options;
this.visitor = this.buildVisitor();
}

static instantiate({ options }) {
return {
name: 'fontawesome-static-sprite-transform',
plugin: env => new this(env, options),
parallelBabel: {
requireFile: __filename,
buildUsing: 'instantiate',
params: { options },
},
baseDir() {
return `${__dirname}/..`;
}
};
}

buildVisitor() {
return {
ElementNode: node => this.transformElementNode(node),
};
}

transformElementNode(node) {
if (node.tag === 'FaStaticSprite') {
const controlAttrs = node.attributes.filter(attr => attr.name.startsWith('@'));
const passedAttributes = node.attributes.filter(attr => {
return attr.name !== 'class' && !controlAttrs.includes(attr)
});
const mappedAttributes = controlAttrs.reduce((obj, attr) => {
obj[attr.name] = attr.value;
return obj;
}, {});
if (!mappedAttributes.hasOwnProperty('@icon')) {
throw new Error(
'<FaStaticSprite /> requires an @icon parameter');
}
const iconName = mappedAttributes['@icon'].chars;
const prefix = mappedAttributes.hasOwnProperty('@prefix') ? mappedAttributes['@prefix'].chars : this.options.defaultPrefix;

//use a set to force uniqueness
const cssClasses = new Set();
const icon = getAbstractIcon(iconName, prefix);
if (!icon) {
throw new Error(`icon "${iconName}" with prefix "${prefix}" could not be found.`);
}
icon.attributes.class.split(' ').forEach(str => cssClasses.add(str));

const passedCssClassAttr = node.attributes.find(attr => attr.name === 'class');
if (passedCssClassAttr) {
//filter out any null/undefined/empty string falsey values
const values = passedCssClassAttr.value.chars.split(' ').filter(Boolean);
values.forEach(str => cssClasses.add(str));
}
const hasTitle = mappedAttributes.hasOwnProperty('@title');

const defaultAttributes = [
{ key: 'class', value: [...cssClasses.values()].join(' ') },
{ key: 'role', value: 'img' },
{ key: 'focusable', value: String(hasTitle) },
{ key: 'aria-hidden', value: String(!hasTitle) },
{ key: 'xmlns', value: icon.attributes.xmlns },
].map(({key, value}) => {
return this.builders.attr(key, this.builders.text(value));
});
const spriteFile = `${prefixToSpriteFile[prefix]}.svg`;
const spritePath = path.join('assets', 'fa-sprites', spriteFile)

const children = [];
if (hasTitle) {
const title = this.builders.text(mappedAttributes['@title'].chars);
children.push(this.builders.element('title', null, null, [title]));
}
const xlink = this.builders.attr('xlink:href', this.builders.text(`${spritePath}#${iconName}`));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without the leading / on ${spritePath} , it is not looking on root url /assets but /user/assets for example

children.push(this.builders.element('use', [xlink]));

return this.builders.element('svg', [...defaultAttributes, ...passedAttributes], null, children);
}
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@
]
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.12.1",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this intentionally added?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's where the sprites seem to live, are they somewhere else? I figured a zero install experience for free icons was better than documenting a separate install step.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, they live there and that's the correct place to get them from. But this would force people who are just using Pro to download the extra package. I also worry about someone who is trying to use an older version for one reason or another (we have those folks who aren't ready to upgrade) This is going to add a competing version of the package that might have to contend with hoisting.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I'll replace with installation instructions.

"@fortawesome/fontawesome-svg-core": "^1.2.0",
"broccoli-file-creator": "^2.1.1",
"broccoli-funnel": "^3.0.2",
"broccoli-merge-trees": "^3.0.2",
"broccoli-plugin": "^3.0.0",
"broccoli-rollup": "^4.1.1",
Expand Down
13 changes: 13 additions & 0 deletions tests/dummy/app/templates/application.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,17 @@
<FaIcon @icon="cog" @pulse={{true}} /><br>
<FaIcon @icon="cog" @pulse={{this.isPulsing}} /> {{input type="checkbox" checked=this.isPulsing}} Pulse it

<h2>Static Sprites</h2>
<FaStaticSprite @icon="coffee" />

<h3>Next to component icons for comparison</h3>
<p>
<FaStaticSprite @icon="square" /><FaIcon @icon="square" /><FaStaticSprite @icon="square" /> <br>
<FaIcon @icon="square" /><FaStaticSprite @icon="square" /><FaIcon @icon="square" />
</p>
<p>
<FaStaticSprite @icon="coffee" /><FaIcon @icon="coffee" /><FaStaticSprite @icon="coffee" /> <br>
<FaIcon @icon="coffee" /><FaStaticSprite @icon="coffee" /><FaIcon @icon="coffee" />
</p>

{{outlet}}
123 changes: 123 additions & 0 deletions tests/integration/components/fa-static-sprite-transform-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';

module('Integration | Transform | <FaStaticSprite>', function(hooks) {
setupRenderingTest(hooks);

test('it renders solid coffee', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="coffee"
@prefix="fas"
/>`);

assert.dom('svg').hasAttribute('role', 'img');
assert.dom('svg').hasAttribute('focusable', 'false');
assert.dom('svg').hasAttribute('aria-hidden', 'true');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-coffee');
assert.dom('svg').hasClass('fa-w-20');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/solid.svg#coffee');
});
test('it renders solid coffee as the default prefix', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="coffee"
/>`);

assert.dom('svg').hasAttribute('role', 'img');
assert.dom('svg').hasAttribute('focusable', 'false');
assert.dom('svg').hasAttribute('aria-hidden', 'true');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-coffee');
assert.dom('svg').hasClass('fa-w-20');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/solid.svg#coffee');
});
test('it renders solid atom', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="atom"
@prefix="fas"
/>`);

assert.dom('svg').hasAttribute('role', 'img');
assert.dom('svg').hasAttribute('focusable', 'false');
assert.dom('svg').hasAttribute('aria-hidden', 'true');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-atom');
assert.dom('svg').hasClass('fa-w-14');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/solid.svg#atom');
});
test('it renders brand fort-awesome', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="fort-awesome"
@prefix="fab"
/>`);

assert.dom('svg').hasAttribute('role', 'img');
assert.dom('svg').hasAttribute('focusable', 'false');
assert.dom('svg').hasAttribute('aria-hidden', 'true');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-fort-awesome');
assert.dom('svg').hasClass('fa-w-16');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/brands.svg#fort-awesome');
});
test('it renders with a title', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="coffee"
@prefix="fas"
@title="some title"
/>`);

assert.dom('svg').hasAttribute('role', 'img');
assert.dom('svg').hasAttribute('focusable', 'true');
assert.dom('svg').hasAttribute('aria-hidden', 'false');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-coffee');
assert.dom('svg').hasClass('fa-w-20');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/solid.svg#coffee');
assert.dom('svg title').exists();
assert.dom('svg title').hasText('some title');
});
test('it renders with custom classes', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="coffee"
@prefix="fas"
class="foo bar foo-bar-baz"
/>`);

assert.dom('svg').hasAttribute('role', 'img');
assert.dom('svg').hasAttribute('focusable', 'false');
assert.dom('svg').hasAttribute('aria-hidden', 'true');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-coffee');
assert.dom('svg').hasClass('fa-w-20');
assert.dom('svg').hasClass('foo');
assert.dom('svg').hasClass('bar');
assert.dom('svg').hasClass('foo-bar-baz');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/solid.svg#coffee');
});
test('passed attributes override defaults', async function (assert) {
await render(hbs`<FaStaticSprite
@icon="coffee"
@prefix="fas"
role="button"
focusable="true"
aria-hidden="false"
/>`);

assert.dom('svg').hasAttribute('role', 'button');
assert.dom('svg').hasAttribute('focusable', 'true');
assert.dom('svg').hasAttribute('aria-hidden', 'false');
assert.dom('svg').hasClass('svg-inline--fa');
assert.dom('svg').hasClass('fa-coffee');
assert.dom('svg').hasClass('fa-w-20');
assert.dom('svg use').exists();
assert.dom('svg use').hasAttribute('xlink:href', 'assets/fa-sprites/solid.svg#coffee');
});
});
Loading