├── .editorconfig
├── .gitignore
├── .istanbul.yml
├── .npmignore
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── src
├── plugin.spec.ts
└── plugin.ts
├── tsconfig.json
└── tslint.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 |
9 | [{package.json,*.yml}]
10 | indent_style = space
11 | indent_size = 2
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .nyc_output/
3 | .vscode/
4 | coverage/
5 | dist/
6 | node_modules/
7 |
--------------------------------------------------------------------------------
/.istanbul.yml:
--------------------------------------------------------------------------------
1 | instrumentation:
2 | include-all-sources: true
3 | check:
4 | global:
5 | statements: 100
6 | lines: 100
7 | branches: 100
8 | functions: 100
9 | each:
10 | statements: 100
11 | lines: 100
12 | branches: 100
13 | functions: 100
14 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .*
2 | *.map
3 | *.spec.*
4 | .nyc_output/
5 | .vscode/
6 | coverage/
7 | src/
8 | tsconfig.json
9 | tslint.json
10 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "node"
5 | - "6"
6 |
7 | after_success:
8 | - npm install codecov
9 | - npm run codecov
10 |
11 | notifications:
12 | email:
13 | on_success: change
14 | on_failure: always
15 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 1.2.0
2 | - Upgrade to Postcss 6 (still works with PostCSS 5).
3 |
4 | ## 1.1.0
5 | - Introduce `ignoreDeclarations` option.
6 |
7 | ## 1.0.0
8 | - Support PostCSS 5.x.
9 |
10 | ## 0.0.1
11 | - Support PostCSS 4.x.
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Jed Mao
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # postcss-font-pack
2 |
3 |
6 |
7 | [](https://www.npmjs.org/package/postcss-font-pack)
8 | [](https://www.npmjs.org/package/postcss-font-pack)
9 | [](https://travis-ci.org/jedmao/postcss-font-pack)
10 | [](https://codecov.io/gh/jedmao/postcss-font-pack)
11 | [](https://gemnasium.com/github.com/jedmao/postcss-font-pack)
12 |
13 | [](https://nodei.co/npm/postcss-font-pack/)
14 |
15 | [PostCSS](https://github.com/postcss/postcss) plugin to simplify font declarations by validating only configured font packs are used, adding fallbacks and transpiling human-readable font declaration values into valid CSS.
16 |
17 | ## Introduction
18 |
19 | Dealing with fonts can be a pain, especially on teams where not everybody knows where to find the exact fonts they are allowed to use. As a result, mistakes are made, inconsistencies are introduced and maintenance becomes a nightmare. PostCSS Font Pack aims to solve this problem with configuration.
20 |
21 | ## Configuration
22 |
23 | Let's start with the following assumptions:
24 | - We're using "Times New Roman" because it's a commonly used [web safe font](http://www.w3schools.com/cssref/css_websafe_fonts.asp). It also illustrates how to use fonts that need quotes in this plugin.
25 | - We've installed [Roboto](http://www.fontsquirrel.com/fonts/roboto?q%5Bterm%5D=roboto&q%5Bsearch_check%5D=Y) and already setup its [@font-face](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face).
26 |
27 | These fonts can be defined in JSON format. You might call it `font-packs.json`:
28 |
29 | ```json
30 | {
31 | "times": {
32 | "family": ["'Times New Roman'", "Times", "serif"],
33 | "propGroups": [
34 | {},
35 | {
36 | "weight": ["bold", 700]
37 | }
38 | ]
39 | },
40 | "roboto": {
41 | "family": ["Roboto", "Arial", "sans-serif"],
42 | "propGroups": [
43 | {
44 | "style": "italic",
45 | "weight": ["light", 300],
46 | "stretch": "condensed",
47 | "variant": "small-caps"
48 | }
49 | ]
50 | }
51 | }
52 | ```
53 |
54 | With the above configuration, we can write our CSS using the [font shorthand property](https://developer.mozilla.org/en-US/docs/Web/CSS/font):
55 |
56 | ```css
57 | .foo {
58 | font: bold 1rem/1.2 times;
59 | }
60 |
61 | .bar {
62 | font: light condensed italic 1rem/1.2 roboto;
63 | }
64 | ```
65 |
66 | This would transpile into the following:
67 |
68 | ```css
69 | .foo {
70 | font: 700 1rem/1.2 'Times New Roman', Times, serif;
71 | }
72 |
73 | .bar {
74 | font: 300 condensed italic 1rem/1.2 Roboto, Arial, sans-serif;
75 | }
76 | ```
77 |
78 | Notice the weight was changed from `bold` to `700` and from `light` to `300`. This came from the configuration's declaration value aliases, which were defined as `"weight": ["bold", 700]` and `"weight": ["light", 300]`, respectively. You can do this with any of the prop groups, but since `style: italic`, `stretch: condensed` and `variant: small-caps` are already understood by the browser, it only made sense to use an alias for the weight in this case. You could have just as well congired the weight as `"weight": 300`, but that's not as human-readable as `light`, which the browser doesn't understand.
79 |
80 | Also, notice that fallback fonts were added to the `font-family`. This allows you to keep your syntax easy to read/write and let the plugin do the dirty work with configuration.
81 |
82 | You don't have to use the font shorthand property. You can also write-out each declaration individually or you can use the [`postcss-nested-props`](https://github.com/jedmao/postcss-nested-props) plugin to enable a nested syntax. Just make sure you unwrap the nested with that plugin before you run this one.
83 |
84 | ## Linting
85 |
86 | This plugin also handles linting so you can sleep sound knowing that nobody is using fonts or combinations of font declarations that are not supported or otherwise go against the design of the site. The following rules would all throw the same error, "pack not found":
87 |
88 | ```css
89 | .foo {
90 | font-family: "Futura PT";
91 | }
92 |
93 | .bar {
94 | font-family: roboto, sans-serif;
95 | }
96 |
97 | .baz {
98 | font: light 1rem/1.2 roboto;
99 | }
100 | ```
101 |
102 | Even though the `light` weight is found in your configuration, there is no font pack that uses `light` without also using `italic` and `condensed`. You have to use all three of them together to form a pack and to pass linting.
103 |
104 | As you can see, this plugin will stop unsupported font declarations dead in their tracks.
105 |
106 | ## Ignoring sections
107 |
108 | If you need to ignore a specific declaration, but don't want to ignore the entire stylesheet, you can do so by _preceding the declaration_ with a special comment:
109 |
110 | ```css
111 | .foo {
112 | /* postcss-font-pack: ignore-next */
113 | font: "Comic Sans", cursive;
114 | }
115 | ```
116 |
117 | This will cause the linter to ignore **_only_** the very **_next_** selector.
118 |
119 | You can also ignore ranges:
120 |
121 | ```css
122 | /* postcss-font-pack: start-ignore */
123 | .foo {
124 | font: "Comic Sans", cursive;
125 | font-size: 38px;
126 | }
127 | /* postcss-font-pack: end-ignore */
128 | ```
129 |
130 | ## Installation
131 |
132 | ```
133 | $ npm install postcss-font-pack
134 | ```
135 |
136 | ## Usage
137 |
138 | ### JavaScript
139 |
140 | ```js
141 | postcss([
142 | require('postcss-font-pack')({
143 | packs: require('./font-packs.json')
144 | })
145 | ]);
146 | ```
147 |
148 | ### TypeScript
149 |
150 | ```ts
151 | import * as postcssFontPack from 'postcss-font-pack';
152 |
153 | postcss([
154 | postcssFontPack({
155 | packs: require('./font-packs.json')
156 | })
157 | ]);
158 | ```
159 |
160 | ## Options
161 |
162 | ### `ignoreDeclarations`
163 |
164 | Type: `{ [prop: string]: string; }`
165 | Required: `false`
166 | Default: `undefined`
167 |
168 | A collection of declarations that you would like to ignore. These could be CSS hacks or something else that you really don't want throwing validation errors. Example below:
169 |
170 | ```js
171 | {
172 | ignoreDeclarations: [
173 | { font: '0/0 serif' }
174 | ]
175 | }
176 | ```
177 |
178 | ### `requireSize`
179 |
180 | Type: `boolean`
181 | Required: `false`
182 | Default: `false`
183 |
184 | When `true`, an error will be thrown if you have a rule with one or more font declarations, but without a font size.
185 |
186 | ```css
187 | .foo {
188 | font-family: roboto;
189 | /* missing required font-size */
190 | }
191 | ```
192 |
193 | Regardless of this option, if you have a rule with only a `font-size` specified you will get an error:
194 |
195 | ```css
196 | .foo {
197 | font-size: 1rem;
198 | /* font-size missing required family */
199 | }
200 | ```
201 |
202 | ### `packs`
203 |
204 | Type: `Object`
205 | Required: `true`
206 |
207 | An object literal where the keys are slugified fonts and the values are font packs. Each font pack consists of a required `family` and an optional collection of property groups, named as `propGroups`.
208 |
209 | #### `pack.family`
210 |
211 | Type: `string[]`
212 | Required: `true`
213 |
214 | If your font slug is `times`, this is where you would define the extended font name along with any fallbacks.
215 |
216 | _Note: If your font name requires quotes, you must add them yourself._
217 |
218 | #### `pack.propGroups`
219 |
220 | Type: `PropGroup[]`
221 | Required: `false`
222 |
223 | Define the property combinations that can be used together to reference a font.
224 |
225 | ##### `pack.propGroups[n]`
226 |
227 | Type: `PropGroup`
228 |
229 | Each prop group may contain 0 or more of the following keys:
230 | - [`weight`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style)
231 | - [`style`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style)
232 | - [`variant`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant)
233 | - [`stretch`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch)
234 |
235 | Each value can be a `string` or a `string[]` with two values. The first value is a slugified value that you can type in your CSS to reference the associated key. The second value is what the first value will be transpiled into, so make sure they are CSS-valid. The `weight` values can additionally be numbers.
236 |
237 | If an empty object is provided, this indicates that you want to support this font family with default browser values for weight, style, variant and stretch.
238 |
239 | _Note: If you don't include an empty object you will be unable to reference a family without also referencing additional properties._
240 |
241 | ## Testing
242 |
243 | Run the following command:
244 |
245 | ```
246 | $ npm test
247 | ```
248 |
249 | This will build scripts, run tests and generate a code coverage report. Anything less than 100% coverage will throw an error.
250 |
251 | ### Watching
252 |
253 | For much faster development cycles, run the following commands in 2 separate processes:
254 |
255 | ```
256 | $ npm run build:watch
257 | ```
258 |
259 | Compiles TypeScript source into the `./dist` folder and watches for changes.
260 |
261 | ```
262 | $ npm run watch
263 | ```
264 |
265 | Runs the tests in the `./dist` folder and watches for changes.
266 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "postcss-font-pack",
3 | "version": "1.2.0",
4 | "description": "PostCSS plugin to simplify font declarations by validating only configured font packs are used and adding fallbacks.",
5 | "main": "dist/plugin.js",
6 | "types": "dist/plugin.d.ts",
7 | "scripts": {
8 | "clean": "rimraf coverage dist *.log",
9 | "codecov": "codecov -f coverage/lcov.info",
10 | "build": "tsc",
11 | "build:watch": "tsc --watch",
12 | "prepublish": "npm test",
13 | "pretest": "npm run tslint && npm run clean && npm run build",
14 | "test": "nyc ava",
15 | "test:watch": "ava --watch",
16 | "tslint": "tslint --project tsconfig.json",
17 | "watch": "npm run test:watch"
18 | },
19 | "ava": {
20 | "files": [
21 | "dist/**/*.spec.js"
22 | ],
23 | "source": [
24 | "dist/**/*.js"
25 | ]
26 | },
27 | "nyc": {
28 | "lines": 100,
29 | "statements": 100,
30 | "functions": 100,
31 | "branches": 100,
32 | "include": [
33 | "dist/**/*.js"
34 | ],
35 | "exclude": [
36 | "dist/**/*.spec.js"
37 | ],
38 | "reporter": [
39 | "lcov",
40 | "text"
41 | ],
42 | "cache": true,
43 | "all": true,
44 | "check-coverage": true
45 | },
46 | "repository": {
47 | "type": "git",
48 | "url": "git+https://github.com/jedmao/postcss-font-pack.git"
49 | },
50 | "keywords": [
51 | "postcss",
52 | "postcss-plugin",
53 | "font",
54 | "pack",
55 | "fallbacks"
56 | ],
57 | "author": "Jed Mao ",
58 | "license": "MIT",
59 | "bugs": {
60 | "url": "https://github.com/jedmao/postcss-font-pack/issues"
61 | },
62 | "homepage": "https://github.com/jedmao/postcss-font-pack#readme",
63 | "dependencies": {
64 | "lodash": "^4.17.4",
65 | "postcss": "^6.0.14"
66 | },
67 | "devDependencies": {
68 | "@types/node": "^8.0.47",
69 | "ava": "^0.23.0",
70 | "nyc": "^11.3.0",
71 | "rimraf": "^2.6.2",
72 | "tslint": "^5.8.0",
73 | "typescript": "^2.6.1"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/plugin.spec.ts:
--------------------------------------------------------------------------------
1 | import test, { TestContext } from 'ava';
2 | import * as postcss from 'postcss';
3 |
4 | import * as plugin from './plugin';
5 |
6 | test('throws if configuration options are not provided', macro,
7 | '',
8 | /missing required configuration/
9 | );
10 |
11 | test('throws if packs option is not provided', macro,
12 | '',
13 | /missing required option: packs/,
14 | {}
15 | );
16 |
17 | test('throws if packs option has no keys', macro,
18 | '',
19 | /packs option has no keys/,
20 | { packs: {} }
21 | );
22 |
23 | test('throws if a pack family is not specified', macro,
24 | '',
25 | /missing required pack.family/,
26 | { packs: { a: { propGroups: [] } } }
27 | );
28 |
29 | test('throws if a pack family is empty', macro,
30 | '',
31 | /pack\.family is empty/,
32 | { packs: { a: { family: [] } } }
33 | );
34 |
35 | test('throws if prop value is null', macro,
36 | '',
37 | /prop value expects string, number or array/,
38 | {
39 | packs: {
40 | roboto: {
41 | family: ['Roboto'],
42 | propGroups: [
43 | {
44 | weight: null
45 | }
46 | ]
47 | }
48 | }
49 | }
50 | );
51 |
52 | test('throws if font declaration is missing a size', macro,
53 | 'body{font:roboto}',
54 | /font property requires size and family/,
55 | {
56 | packs: {
57 | roboto: {
58 | family: ['Roboto']
59 | }
60 | }
61 | }
62 | );
63 |
64 | test('throws if font declaration is missing a family', macro,
65 | 'body{font:0}',
66 | /font property requires size and family/, {
67 | packs: {
68 | roboto: {
69 | family: ['Roboto']
70 | }
71 | }
72 | }
73 | );
74 |
75 | test('throws if no pack is found for font-family property', macro,
76 | 'body{font-family:foo}',
77 | /pack not found/,
78 | {
79 | packs: {
80 | roboto: {
81 | family: ['Roboto']
82 | }
83 | }
84 | }
85 | );
86 |
87 | test('throws if more than one pack is found', macro,
88 | 'body{font:bold 0 roboto}',
89 | /more than one pack found/,
90 | {
91 | packs: {
92 | roboto: {
93 | family: ['Roboto'],
94 | propGroups: [
95 | {
96 | weight: ['bold', 700]
97 | },
98 | {
99 | weight: ['bold', 600]
100 | }
101 | ]
102 | }
103 | }
104 | }
105 | );
106 |
107 | test('throws if fallbacks are provided', macro,
108 | 'body{font:0 roboto, Arial, sans-serif}',
109 | /pack not found/,
110 | {
111 | packs: {
112 | roboto: {
113 | family: ['Roboto', 'Arial', 'sans-serif']
114 | }
115 | }
116 | }
117 | );
118 |
119 | test('ignores a font declaration', macro,
120 | 'body{font:0/0 serif}',
121 | 'body{font:0/0 serif}',
122 | {
123 | ignoreDeclarations: [
124 | { font: '0/0 serif' }
125 | ],
126 | packs: {
127 | roboto: {
128 | family: ['Roboto', 'Arial', 'sans-serif']
129 | }
130 | }
131 | }
132 | );
133 |
134 | test('resolves a font-family declaration', macro,
135 | 'body{font-family:roboto}',
136 | 'body{font-family:Roboto, Arial, sans-serif}',
137 | {
138 | packs: {
139 | roboto: {
140 | family: ['Roboto', 'Arial', 'sans-serif']
141 | }
142 | }
143 | }
144 | );
145 |
146 | test('resolves a font-weight declaration', macro,
147 | 'body{font-family:roboto;font-weight:300}',
148 | 'body{font-family:Roboto, Arial, sans-serif;font-weight:300}',
149 | {
150 | packs: {
151 | roboto: {
152 | family: ['Roboto', 'Arial', 'sans-serif'],
153 | propGroups: [
154 | {
155 | weight: 300
156 | }
157 | ]
158 | }
159 | }
160 | }
161 | );
162 |
163 | test('resolves a font-weight declaration with an alias', macro,
164 | 'body{font-family:roboto;font-weight:light}',
165 | 'body{font-family:Roboto, Arial, sans-serif;font-weight:300}',
166 | {
167 | packs: {
168 | roboto: {
169 | family: ['Roboto', 'Arial', 'sans-serif'],
170 | propGroups: [
171 | {
172 | weight: ['light', 300]
173 | }
174 | ]
175 | }
176 | }
177 | }
178 | );
179 |
180 | test('resolves a font-style declaration', macro,
181 | 'body{font-family:roboto;font-style:italic}',
182 | 'body{font-family:Roboto, Arial, sans-serif;font-style:italic}',
183 | {
184 | packs: {
185 | roboto: {
186 | family: ['Roboto', 'Arial', 'sans-serif'],
187 | propGroups: [
188 | {
189 | style: 'italic'
190 | }
191 | ]
192 | }
193 | }
194 | }
195 | );
196 |
197 | test('resolves a font-variant declaration', macro,
198 | 'body{font-family:roboto;font-variant:small-caps}',
199 | 'body{font-family:Roboto, Arial, sans-serif;font-variant:small-caps}',
200 | {
201 | packs: {
202 | roboto: {
203 | family: ['Roboto', 'Arial', 'sans-serif'],
204 | propGroups: [
205 | {
206 | variant: 'small-caps'
207 | }
208 | ]
209 | }
210 | }
211 | }
212 | );
213 |
214 | test('resolves a font-stretch declaration', macro,
215 | 'body{font-family:roboto;font-stretch:expanded}',
216 | 'body{font-family:Roboto, Arial, sans-serif;font-stretch:expanded}',
217 | {
218 | packs: {
219 | roboto: {
220 | family: ['Roboto', 'Arial', 'sans-serif'],
221 | propGroups: [
222 | {
223 | stretch: 'expanded'
224 | }
225 | ]
226 | }
227 | }
228 | }
229 | );
230 |
231 | test('resolves a font declaration (shorthand syntax)', macro,
232 | 'body{font:light italic small-caps expanded 1rem/1.2 roboto}',
233 | 'body{font:300 italic small-caps expanded 1rem/1.2 Roboto, Arial, sans-serif}',
234 | {
235 | packs: {
236 | roboto: {
237 | family: ['Roboto', 'Arial', 'sans-serif'],
238 | propGroups: [
239 | {
240 | weight: ['light', 300],
241 | style: 'italic',
242 | variant: 'small-caps',
243 | stretch: 'expanded'
244 | }
245 | ]
246 | }
247 | }
248 | }
249 | );
250 |
251 | test('resolves an empty pack', macro,
252 | 'body{font:1rem/1.2 roboto}',
253 | 'body{font:1rem/1.2 Roboto, Arial, sans-serif}',
254 | {
255 | packs: {
256 | roboto: {
257 | family: ['Roboto', 'Arial', 'sans-serif'],
258 | propGroups: [
259 | {}
260 | ]
261 | }
262 | }
263 | }
264 | );
265 |
266 | test('throws if a font pack is not found', macro,
267 | 'body{font:oblique 1rem/1.2 roboto}',
268 | /pack not found/,
269 | {
270 | packs: {
271 | roboto: {
272 | family: ['Roboto', 'Arial', 'sans-serif'],
273 | propGroups: [
274 | {
275 | style: 'italic'
276 | }
277 | ]
278 | }
279 | }
280 | }
281 | );
282 |
283 | test('throws if a font pack is only partially matched', macro,
284 | 'body{font:italic 1rem/1.2 roboto}',
285 | /pack not found/,
286 | {
287 | packs: {
288 | roboto: {
289 | family: ['Roboto', 'Arial', 'sans-serif'],
290 | propGroups: [
291 | {
292 | style: 'italic',
293 | stretch: 'expanded'
294 | }
295 | ]
296 | }
297 | }
298 | }
299 | );
300 |
301 | test('throws if only a font-size is provided', macro,
302 | 'body{font-size:0}',
303 | /font-size missing required family/,
304 | {
305 | packs: {
306 | roboto: {
307 | family: ['Roboto', 'Arial', 'sans-serif']
308 | }
309 | }
310 | }
311 | );
312 |
313 | test('remains silent for rules without font declarations', macro,
314 | 'body{color:red}',
315 | 'body{color:red}',
316 | {
317 | packs: {
318 | roboto: {
319 | family: ['Roboto', 'Arial', 'sans-serif']
320 | }
321 | }
322 | }
323 | );
324 |
325 | // Plugin Options
326 | // requireSize: true
327 | test('throws if no font-size is specified', macro,
328 | 'body{font-family:roboto}',
329 | /missing required font-size/,
330 | {
331 | requireSize: true,
332 | packs: {
333 | roboto: {
334 | family: ['Roboto', 'Arial', 'sans-serif']
335 | }
336 | }
337 | }
338 | );
339 |
340 | test('remains silent when both size and family are provided', t => {
341 | const options: plugin.Options = {
342 | requireSize: true,
343 | packs: {
344 | roboto: {
345 | family: ['Roboto', 'Arial', 'sans-serif']
346 | }
347 | }
348 | };
349 | macro(t,
350 | 'body{font-family:roboto;font-size:0}',
351 | 'body{font-family:Roboto, Arial, sans-serif;font-size:0}',
352 | options
353 | );
354 |
355 | macro(t,
356 | 'body{font:1rem roboto}',
357 | 'body{font:1rem Roboto, Arial, sans-serif}',
358 | options
359 | );
360 | });
361 |
362 | // Directives
363 | const options: plugin.Options = {
364 | requireSize: true,
365 | packs: {
366 | roboto: {
367 | family: ['Roboto', 'Arial', 'sans-serif']
368 | }
369 | }
370 | };
371 |
372 | test('throws an error if an unknown foo-bar directive is defined', macro,
373 | '/* postcss-font-pack: foo-bar */',
374 | /Unsupported directive: foo-bar/,
375 | options
376 | );
377 |
378 | test('throws an error if start-ignore is defined twice', macro,
379 | [
380 | '/* postcss-font-pack: start-ignore */',
381 | '/* postcss-font-pack: start-ignore */'
382 | ].join(''),
383 | /start-ignore already defined/,
384 | options
385 | );
386 |
387 | test('throws an error if ignore-next is defined after a start-ignore', macro,
388 | [
389 | '/* postcss-font-pack: start-ignore */',
390 | '/* postcss-font-pack: ignore-next */'
391 | ].join(''),
392 | /Unnecessary ignore-next after start-ignore/,
393 | options
394 | );
395 |
396 | test('throws an error if end-ignore is defined before a start-ignore', macro,
397 | '/* postcss-font-pack: end-ignore */',
398 | /start-ignore not defined/,
399 | options
400 | );
401 |
402 | test('ignores a /* postcss-foo: bar */ comment', t => {
403 | const css = '/* postcss-foo: bar */';
404 | macro(t, css, css, options);
405 | });
406 |
407 | test('ignores multiple ranges spanning across multiple lines', t => {
408 | const css = [
409 | '/* postcss-font-pack: start-ignore */',
410 | 'body{font:foo}',
411 | '/* postcss-font-pack: end-ignore */',
412 | '/* postcss-font-pack: start-ignore */',
413 | 'body{font:bar}',
414 | '/* postcss-font-pack: end-ignore */'
415 | ].join('\n');
416 | macro(t, css, css, options);
417 | });
418 |
419 | const fontProps = [
420 | 'font',
421 | 'font-family',
422 | 'font-weight',
423 | 'font-style',
424 | 'font-variant',
425 | 'font-stretch',
426 | 'font-size'
427 | ];
428 |
429 | test('ignores declarations within start-ignore and end-ignore range', t => {
430 | for (const fontProp of fontProps) {
431 | const css = sandwich(fontProp);
432 | macro(t, css, css, options);
433 | }
434 |
435 | function sandwich(propName: string) {
436 | return [
437 | '/* postcss-font-pack: start-ignore */',
438 | `body{${propName}:foo}`,
439 | '/* postcss-font-pack: end-ignore */'
440 | ].join('');
441 | }
442 | });
443 |
444 | test('ignores the next font declaration after ignore-next', t => {
445 | for (const fontProp of fontProps) {
446 | const css = sandwich(fontProp);
447 | macro(t, css, css, options);
448 | const css2 = sandwich2(fontProp);
449 | macro(t, css2, css2, options);
450 | }
451 |
452 | function sandwich(propName: string) {
453 | return [
454 | '/* postcss-font-pack: ignore-next */',
455 | `body{${propName}:foo}`
456 | ].join('');
457 | }
458 |
459 | function sandwich2(propName: string) {
460 | return [
461 | 'body {',
462 | ' /* postcss-font-pack: ignore-next */',
463 | ` ${propName}: foo`,
464 | '}'
465 | ].join('');
466 | }
467 | });
468 |
469 | test('does not ignore the 2nd font declaration after ignore-next', macro,
470 | [
471 | '/* postcss-font-pack: ignore-next */',
472 | 'body{font:1rem roboto}',
473 | 'body{font:1rem roboto}'
474 | ].join(''),
475 | [
476 | '/* postcss-font-pack: ignore-next */',
477 | 'body{font:1rem roboto}',
478 | 'body{font:1rem Roboto, Arial, sans-serif}'
479 | ].join(''),
480 | options
481 | );
482 |
483 | function macro(
484 | t: TestContext,
485 | input: string,
486 | expectedOutput?: string | RegExp,
487 | options?: plugin.Options
488 | ) {
489 | if (expectedOutput instanceof RegExp) {
490 | t.throws(transpile, expectedOutput);
491 | return;
492 | }
493 | t.is(
494 | transpile(),
495 | stripTabs(expectedOutput)
496 | );
497 | function transpile() {
498 | const processor = postcss([plugin(options)]);
499 | return processor.process(stripTabs(input)).css;
500 | }
501 | function stripTabs(input: string) {
502 | return input.replace(/\t/g, '');
503 | }
504 | }
505 |
--------------------------------------------------------------------------------
/src/plugin.ts:
--------------------------------------------------------------------------------
1 | import * as postcss from 'postcss';
2 | const _ = require('lodash');
3 |
4 | const plugin = 'postcss-font-pack';
5 | const errorContext = { plugin };
6 | const errorPrefix = `[${plugin}]`;
7 | const sizeLineHeightPattern = /^\S+(?:\/\S+)?$/;
8 | const directivePattern = new RegExp(`^${plugin}: ([a-z-]+)$`);
9 |
10 | const PostCssFontPack = postcss.plugin('postcss-font-pack', options => {
11 |
12 | return root => {
13 | if (!options) {
14 | throw new Error(`${errorPrefix} missing required configuration`);
15 | }
16 |
17 | const packs = options.packs;
18 | if (!packs) {
19 | throw new Error(`${errorPrefix} missing required option: packs`);
20 | }
21 |
22 | if (!Object.keys(packs).length) {
23 | throw new Error(`${errorPrefix} packs option has no keys`);
24 | }
25 |
26 | const lookup = buildLookupTable(packs);
27 | const zonesToIgnore = findZonesToIgnore(root);
28 | const ignores = options.ignoreDeclarations;
29 |
30 | function isIgnored(decl: postcss.Declaration) {
31 | return _.some(options.ignoreDeclarations, { [decl.prop]: decl.value });
32 | }
33 |
34 | function isWithinIgnoreRange(decl: postcss.Declaration) {
35 | if (
36 | zonesToIgnore.nexts.length &&
37 | isPositionAfterOther(decl.source.start, zonesToIgnore.nexts[0])
38 | ) {
39 | zonesToIgnore.nexts.shift();
40 | return true;
41 | }
42 |
43 | for (const range of zonesToIgnore.ranges) {
44 | if (
45 | isPositionAfterOther(decl.source.start, range.start) &&
46 | isPositionAfterOther(range.end, decl.source.end)
47 | ) {
48 | return true;
49 | }
50 | }
51 |
52 | return false;
53 |
54 | function isPositionAfterOther(
55 | position: PostCssFontPack.Position,
56 | other: PostCssFontPack.Position
57 | ) {
58 | if (position.line < other.line) {
59 | return false;
60 | }
61 | if (position.line > other.line) {
62 | return true;
63 | }
64 | return position.column >= other.column;
65 | }
66 | }
67 |
68 | root.walkRules(rule => {
69 | const props: any = {};
70 | let filteredPacks: PostCssFontPack.Hash[] = [];
71 | let fontDeclarationCount = 0;
72 | let isSizeProvided = false;
73 |
74 | function resolveDeclaration(decl: postcss.Declaration) {
75 |
76 | if (isIgnored(decl) || isWithinIgnoreRange(decl)) {
77 | return;
78 | }
79 |
80 | function validatePackFound() {
81 | if (!filteredPacks || !filteredPacks.length) {
82 | throw decl.error('pack not found', errorContext);
83 | }
84 | }
85 |
86 | if (decl.prop === 'font') {
87 | const values = postcss.list.space(decl.value);
88 | fontDeclarationCount += values.length;
89 | const family = values.pop();
90 | const sizeLineHeight = values.pop();
91 | props.font = { family, sizeLineHeight, values };
92 | if (
93 | _.isUndefined(family) ||
94 | _.isUndefined(sizeLineHeight) ||
95 | !sizeLineHeightPattern.test(sizeLineHeight)
96 | ) {
97 | throw decl.error(
98 | 'font property requires size and family',
99 | errorContext
100 | );
101 | }
102 | isSizeProvided = true;
103 | filteredPacks = lookup[family];
104 |
105 | values.forEach(val => {
106 | filteredPacks = _.filter(filteredPacks, (o: any) => {
107 | const prop = o[`reverse:${val}`];
108 | if (_.isUndefined(prop)) {
109 | return false;
110 | }
111 | props.font[prop] = val;
112 | return true;
113 | });
114 | });
115 | delete props.font.values;
116 | validatePackFound();
117 | } else {
118 | fontDeclarationCount++;
119 | const prop = decl.prop.substr(5);
120 | if (prop === 'family') {
121 | filteredPacks = lookup[decl.value];
122 | } else {
123 | filteredPacks = _.filter(filteredPacks, (o: any) => {
124 | return o.hasOwnProperty(`${prop}:${decl.value}`);
125 | });
126 | }
127 | validatePackFound();
128 | props[prop] = decl.value;
129 | }
130 | }
131 |
132 | rule.walkDecls(/^font(-family)?$/, resolveDeclaration);
133 | rule.walkDecls(/^font-(weight|style|variant|stretch)$/, resolveDeclaration);
134 | rule.walkDecls('font-size', decl => {
135 | if (isWithinIgnoreRange(decl)) {
136 | return;
137 | }
138 | isSizeProvided = true;
139 | if (++fontDeclarationCount === 1) {
140 | throw new Error(`${errorPrefix} font-size missing required family`);
141 | }
142 | });
143 |
144 | if (fontDeclarationCount === 0) {
145 | return;
146 | }
147 |
148 | if (options.requireSize && !isSizeProvided) {
149 | throw new Error(`${errorPrefix} missing required font-size`);
150 | }
151 |
152 | filteredPacks = _.reject(filteredPacks, (p2: any) => {
153 | let isMatch = true;
154 | _.forEach(Object.keys(p2), (prop: any) => {
155 | if (_.startsWith(prop, 'reverse:')) {
156 | return true;
157 | }
158 | const [packProp, packValue] = prop.split(':');
159 | let propValue = props[packProp];
160 | if (_.isUndefined(propValue) && props.font) {
161 | propValue = props.font[packProp];
162 | }
163 | if (packValue !== propValue) {
164 | isMatch = false;
165 | return false;
166 | }
167 | return true;
168 | });
169 | return !isMatch;
170 | });
171 |
172 | if (filteredPacks.length > 1) {
173 | throw new Error(`${errorPrefix} more than one pack found`);
174 | }
175 |
176 | if (filteredPacks.length === 0) {
177 | throw new Error(`${errorPrefix} pack not found`);
178 | }
179 |
180 | // passes validation
181 | const pack = filteredPacks[0];
182 | const font = props.font;
183 | if (font) {
184 | rule.walkDecls('font', decl => {
185 | const sizeFamily = [
186 | font.sizeLineHeight,
187 | pack[`family:${font.family}`]
188 | ];
189 | delete font.sizeLineHeight;
190 | delete font.family;
191 | decl.value = _.union(
192 | Object.keys(font).map(prop => {
193 | return pack[`${prop}:${font[prop]}`];
194 | }),
195 | sizeFamily
196 | ).join(' ');
197 | });
198 | delete props.font;
199 | }
200 | Object.keys(props).forEach(prop => {
201 | rule.walkDecls(`font-${prop}`, decl => {
202 | decl.value = pack[`${prop}:${decl.value}`];
203 | });
204 | });
205 | });
206 | };
207 | });
208 |
209 | namespace PostCssFontPack {
210 | /**
211 | * Plugin options.
212 | */
213 | export interface Options {
214 | /**
215 | * Declarations to ignore, like CSS hacks (e.g., [{font: '0/0 serif'}]).
216 | */
217 | ignoreDeclarations?: { [prop: string]: string; };
218 | /**
219 | * When true, an error will be thrown if you have a rule with one or more
220 | * font declarations, but without a font size.
221 | */
222 | requireSize?: boolean;
223 | /**
224 | * Supported font packs.
225 | */
226 | packs: Packs;
227 | }
228 | export interface Packs {
229 | [slug: string]: Pack;
230 | }
231 | export interface Pack {
232 | family: string[];
233 | propGroups?: PropGroup[];
234 | }
235 | /**
236 | * A collection of supported properties for the associated font family.
237 | */
238 | export interface PropGroup {
239 | [index: string]: any;
240 | weight?: string|number|(string|number)[];
241 | style?: string|string[];
242 | variant?: string|string[];
243 | stretch?: string|string[];
244 | }
245 | export interface Range {
246 | start: Position;
247 | end: Position;
248 | }
249 | export interface Position {
250 | line: number;
251 | column: number;
252 | }
253 | export interface Lookup {
254 | [slug: string]: Hash[];
255 | }
256 | export interface Hash {
257 | [key: string]: T;
258 | }
259 | }
260 |
261 | function buildLookupTable(packs: PostCssFontPack.Packs) {
262 | const lookup: PostCssFontPack.Lookup = {};
263 | Object.keys(packs).forEach(slug => {
264 | const pack = packs[slug];
265 | if (!pack.family) {
266 | throw new Error(`${errorPrefix} missing required pack.family`);
267 | }
268 | if (!pack.family.length) {
269 | throw new Error(`${errorPrefix} pack.family is empty`);
270 | }
271 | const family: PostCssFontPack.Hash = {
272 | [`family:${slug}`]: pack.family.join(', ')
273 | };
274 | if (!pack.propGroups || !pack.propGroups.length) {
275 | lookup[slug] = [family];
276 | return;
277 | }
278 | lookup[slug] = pack.propGroups.map(prop => {
279 | const props: { [key: string]: string; } = {};
280 | Object.keys(prop).forEach(p => {
281 | const v = prop[p];
282 | switch (typeof v) {
283 | case 'string':
284 | case 'number':
285 | props[`${p}:${v}`] = v;
286 | props[`reverse:${v}`] = p;
287 | break;
288 | default:
289 | if (!Array.isArray(v)) {
290 | throw new TypeError(`${errorPrefix} prop value expects string, number or array`);
291 | }
292 | props[`${p}:${v[0]}`] = v[1];
293 | props[`reverse:${v[0]}`] = p;
294 | }
295 | });
296 | return _.assign({}, family, props);
297 | });
298 | });
299 | return lookup;
300 | }
301 |
302 | function findZonesToIgnore(root: postcss.Root) {
303 | let start: PostCssFontPack.Position = null;
304 | const ranges: PostCssFontPack.Range[] = [];
305 | const nexts: PostCssFontPack.Position[] = [];
306 | root.walkComments(comment => {
307 | const m = comment.text.match(directivePattern);
308 | const directive = m && m[1];
309 | if (!directive) {
310 | return;
311 | }
312 | switch (directive) {
313 | case 'start-ignore':
314 | if (start !== null) {
315 | throw comment.error(
316 | 'start-ignore already defined',
317 | errorContext
318 | );
319 | }
320 | start = comment.source.end;
321 | break;
322 | case 'end-ignore':
323 | if (start === null) {
324 | throw comment.error(
325 | 'start-ignore not defined',
326 | errorContext
327 | );
328 | }
329 | ranges.push({ start, end: comment.source.start });
330 | start = null;
331 | break;
332 | case 'ignore-next':
333 | if (start !== null) {
334 | throw comment.error(
335 | 'Unnecessary ignore-next after start-ignore',
336 | errorContext
337 | );
338 | }
339 | nexts.push(comment.source.end);
340 | break;
341 | default:
342 | throw comment.error(
343 | `Unsupported directive: ${directive}`,
344 | errorContext
345 | );
346 | }
347 | });
348 | return { ranges, nexts };
349 | }
350 |
351 | export = PostCssFontPack;
352 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "noImplicitAny": true,
5 | "target": "es6",
6 | "declaration": true,
7 | "newLine": "LF",
8 | "outDir": "dist",
9 | "rootDir": "src",
10 | "sourceMap": true,
11 | "lib": [
12 | "es2015"
13 | ]
14 | },
15 | "include": [
16 | "src/**/*.ts"
17 | ],
18 | "exclude": [
19 | "node_modules"
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "class-name": true,
4 | "curly": true,
5 | "eofline": true,
6 | "forin": true,
7 | "label-position": true,
8 | "max-line-length": [true, 120],
9 | "no-arg": true,
10 | "no-bitwise": true,
11 | "no-console": [true,
12 | "debug",
13 | "info",
14 | "time",
15 | "timeEnd",
16 | "trace"
17 | ],
18 | "no-construct": true,
19 | "no-debugger": true,
20 | "no-duplicate-variable": true,
21 | "no-empty": true,
22 | "no-eval": true,
23 | "no-string-literal": true,
24 | "no-trailing-whitespace": true,
25 | "one-line": [true,
26 | "check-open-brace",
27 | "check-catch",
28 | "check-else",
29 | "check-whitespace"
30 | ],
31 | "quotemark": [false],
32 | "radix": true,
33 | "semicolon": [true,
34 | "always"
35 | ],
36 | "triple-equals": [true, "allow-null-check"],
37 | "variable-name": false,
38 | "whitespace": [true,
39 | "check-branch",
40 | "check-decl",
41 | "check-operator",
42 | "check-type"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------