# Express server listening on 8080, in production mode
```
To mitigate security issues especially with the projects' deprecated dependencies, the final image is based on a minimal container image. It runs rootless and has no development dependencies.
## JSON API
The API is public, feel free to use it directly (rate-limits may apply).
### GET `/api/fonts`
Returns a list of all fonts, sorted by popularity. E.g. `curl https://gwfh.mranftl.com/api/fonts`:
```json
[{
"id": "open-sans",
"family": "Open Sans",
"variants": ["300", "300italic", "regular", "italic", "600", "600italic", "700", "700italic", "800", "800italic"],
"subsets": ["devanagari", "greek", "latin", "cyrillic-ext", "cyrillic", "greek-ext", "vietnamese", "latin-ext"],
"category": "sans-serif",
"version": "v10",
"lastModified": "2014-10-17",
"popularity": 1,
"defSubset": "latin",
"defVariant": "regular"
} [...]
]
```
### GET `/api/fonts/[id]?subsets=latin,latin-ext`
Returns a font with urls to the actual font files google's servers. `subsets` is optional (will serve the `defSubset` if unspecified). E.g. `curl "https://gwfh.mranftl.com/api/fonts/modern-antiqua?subsets=latin,latin-ext"` (the double quotes are important as query parameters may else be stripped!):
```json
{
"id": "modern-antiqua",
"family": "Modern Antiqua",
"variants": [{
"id": "regular",
"eot": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkhzThM-TJeMvVB0dIsYy4U7E.eot",
"fontFamily": "'Modern Antiqua'",
"fontStyle": "normal",
"fontWeight": "400",
"woff": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkh1bbnkJREviNM815YSrb1io.woff",
"local": ["Modern Antiqua Regular", "ModernAntiqua-Regular"],
"ttf": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkhxr_S_FdaWWVbb1LgBbjq4o.ttf",
"svg": "https://fonts.gstatic.com/l/font?kit=8qX_tr6Xzy4t9fvZDXPkh0sAoW0rAsWAgyWthbXBUKs#ModernAntiqua",
"woff2": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkh08GHjg64nS_BBLu6wRo0k8.woff2"
}],
"subsets": ["latin", "latin-ext"],
"category": "display",
"version": "v6",
"lastModified": "2014-08-28",
"popularity": 522,
"defSubset": "latin",
"defVariant": "regular",
"subsetMap": {
"latin": true,
"latin-ext": true
},
"storeID": "latin-ext_latin"
}
```
### GET `/api/fonts/[id]?download=zip&subsets=latin&formats=woff,woff2&variants=regular`
Download a zipped archive with all `.eot`, `.woff`, `.woff2`, `.svg`, `.ttf` files of a specified font. The query parameters `formats` and `variants` are optional (includes everything if no filtering is applied). is E.g. `curl -o fontfiles.zip "https://gwfh.mranftl.com/api/fonts/lato?download=zip&subsets=latin,latin-ext&variants=regular,700&formats=woff"` (the double quotes are important as query parameters may else be stripped!)
## History
> 2025:
* Switch to `node:22` for the final image.
* Adds support for linux/arm64 architecture (patches [imagemin/optipng-bin](https://github.com/imagemin/optipng-bin/pull/128))
> 2024:
* Switch to `node:20` for the final image.
> 2023:
* Project upgraded to be compatible with Node.js v18+.
* Automated prebuilt Docker images via [GitHub Actions](https://github.com/majodev/google-webfonts-helper/actions).
* `/server` was fully refactored/modernized (async/await) and now compiles with TypeScript.
* Switch to `node:18` for the final image.
* `/client` can still be considered very legacy Angular code.
> 2022:
This service was mostly on life-support, most of its code and dependencies can be considered deprecated. The current docker image wrapping `node@v0.10.44` runs rootless and is hopefully enough to keep the bandits out. API attack surface should be minimal anyways.
> 2014:
This service was originally a prototype I've created to get familiar with Angular and Express. All magic by [generator-angular-fullstack](https://github.com/DaftMonk/generator-angular-fullstack). See [my note here](http://mranftl.com/2014/12/23/self-hosting-google-web-fonts/).
Idea originally by Clemens Lang who created an [awesome bash script](https://neverpanic.de/blog/2014/03/19/downloading-google-web-fonts-for-local-hosting/) to download Google fonts in all formats.
## License
(c) Mario Ranftl
[MIT License](http://majodev.mit-license.org/)
[Google Fonts Open Source Font Attribution](https://fonts.google.com/attribution)
================================================
FILE: bower.json
================================================
{
"name": "google-webfonts-helper",
"version": "1.1.0",
"dependencies": {
"angular-animate": "1.3.8",
"angular-bootstrap": "0.11.2",
"angular-busy": "4.1.2",
"angular-cookies": "1.3.8",
"angular-resource": "1.3.8",
"angular-sanitize": "1.3.8",
"angular-ui-router": "0.2.18",
"angular": "1.3.8",
"bootstrap": "3.1.1",
"es5-shim": "3.0.2",
"font-awesome": "4.2.0",
"highlightjs": "8.4.0",
"jquery": "1.11.3",
"json3": "3.3.2",
"lodash": "2.4.2"
},
"devDependencies": {
"angular-mocks": "1.3.8",
"angular-scenario": "1.3.8"
}
}
================================================
FILE: client/app/app.js
================================================
'use strict';
angular.module('googleWebfontsHelperApp', [
'ngCookies',
'ngResource',
'ngSanitize',
'ui.router',
'ui.bootstrap',
'cgBusy'
])
.config(function($stateProvider, $urlRouterProvider, $locationProvider) {
$urlRouterProvider
.otherwise('/fonts'); // default urls is /fonts
$locationProvider.html5Mode(true);
});
================================================
FILE: client/app/app.less
================================================
@import 'bootstrap/less/bootstrap.less';
@import 'bootstrap/less/theme.less';
@import 'font-awesome/less/font-awesome.less';
@import (inline) 'angular-busy/dist/angular-busy.css';
@icon-font-path: '/bower_components/bootstrap/fonts/';
@fa-font-path: '/bower_components/font-awesome/fonts';
/**
* App-wide Styles
*/
.browsehappy {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
// injector
@import 'cssCode/cssCode.less';
@import 'fonts/fonts.less';
// endinjector
================================================
FILE: client/app/cssCode/cssCode.directive.js
================================================
'use strict';
angular.module('googleWebfontsHelperApp')
.directive('cssCode', [function() {
return {
templateUrl: 'app/cssCode/cssCode.html',
restrict: 'EA',
scope: {
type: '=',
variant: '=',
fontItem: '=',
folderPrefix: '='
},
link: function(scope, element) {
}
};
}]);
================================================
FILE: client/app/cssCode/cssCode.directive.spec.js
================================================
'use strict';
describe('Directive: cssCode', function () {
// load the directive's module and view
beforeEach(module('googleWebfontsHelperApp'));
beforeEach(module('app/cssCode/cssCode.html'));
var element, scope;
beforeEach(inject(function ($rootScope) {
scope = $rootScope.$new();
}));
it('should make hidden element visible', inject(function ($compile) {
element = angular.element(' ');
element = $compile(element)(scope);
scope.$apply();
expect(element.text()).toBe('this is the cssCode directive');
}));
});
================================================
FILE: client/app/cssCode/cssCode.html
================================================
/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: {{variant.fontFamily}};
font-style: {{variant.fontStyle}};
font-weight: {{variant.fontWeight}};
src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: {{variant.fontFamily}};
font-style: {{variant.fontStyle}};
font-weight: {{variant.fontWeight}};
src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: {{variant.fontFamily}};
font-style: {{variant.fontStyle}};
font-weight: {{variant.fontWeight}};
src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.eot'); /* IE9 Compat Modes */
src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff') format('woff'), /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */
url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.ttf') format('truetype'), /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.svg#{{variant.svg.substring(variant.svg.indexOf('#')+1);}}') format('svg'); /* Legacy iOS */
}
================================================
FILE: client/app/cssCode/cssCode.less
================================================
pre {
background-color: transparent;
border: 0;
border-radius: 0;
padding-bottom: 0px;
padding-top: 0px;
margin-bottom: 0px;
}
================================================
FILE: client/app/fonts/fonts.controller.js
================================================
'use strict';
function apiError($scope, status, headers, config) {
// called asynchronously if an error occurs
// or server returns response with an error status.
$scope.error = true;
$scope.errorStatus = status;
$scope.errorHeaders = JSON.stringify(headers, null, 2);
$scope.errorConfig = JSON.stringify(config, null, 2);
}
var previousFontItem = false; // holds reference to previous font item, for partial refreshs, will be nulled if fontID changes
var subsetsChkbTimeoutP = null; // timeout - promise for cgBusy 3000ms until request for customization is made
var subsetsChkbReload = null; // interval - promise for cgBusy loading text rewrite (waiting till customization) 1000ms
var variantsMap = {}; // map holds currently checked variants of a fontItem
angular.module('googleWebfontsHelperApp')
.controller('FontsCtrl', function($scope, $http) {
$scope.fonts = [];
$scope.sponsors = [];
$scope.busy = true;
$scope.selectedItemID = '';
$scope.predicate = {
name: 'by family',
filter: 'family',
bindArg: 'category'
}; // default ordering predicate
$scope.reverse = false;
$scope.fontsPromise = $http.get('/api/fonts')
.success(function(fonts) {
$scope.fonts = fonts;
$scope.busy = false;
})
.error(function(data, status, headers, config) {
apiError($scope, status, headers, config);
});
$scope.sponsorsPromise = $http.get('https://sponsors.mranftl.com/json')
.success(function (data) {
$scope.sponsors = data.sponsors;
setTimeout(function () {
$('[data-toggle="tooltip"]').tooltip();
}, 0);
}) // err is not handled, because it is not critical
$scope.scrollListTop = function() {
$('.scrollerLeft').scrollTop(0);
};
})
.controller('FontsItemCtrl', function($scope, $stateParams, $http, $state, $timeout, $interval) {
var subSetString = $stateParams.subsets || '';
if (subsetsChkbTimeoutP) {
$timeout.cancel(subsetsChkbTimeoutP);
$interval.cancel(subsetsChkbReload);
}
$scope.fontID = $stateParams.id;
$scope.$parent.selectedItemID = $scope.fontID;
if (previousFontItem && previousFontItem.id === $stateParams.id) {
// former item is a candiate for instant population until load is complete.
$scope.fontItem = previousFontItem;
$scope.loadingMessage = 'Customizing ' + $stateParams.id + '...';
// reuse current variantMap
$scope.variantsMap = variantsMap;
} else {
// clear it
previousFontItem = false;
$scope.loadingMessage = 'Loading ' + $stateParams.id + '...';
}
$scope.error = false;
$scope.fontFormats = 'woff2';
$scope.downloadSubSetID = '';
$scope.subSetsSelected = 0;
$scope.loadingPromise = $http.get('/api/fonts/' + $stateParams.id + '?subsets=' + subSetString)
.success(function(fontItem) {
$scope.fontItem = fontItem;
$scope.downloadSubSetID = fontItem.storeID.replace(/_/g, ',');
$.each($scope.fontItem.subsetMap, function(item) {
if ($scope.fontItem.subsetMap[item] === true) {
$scope.subSetsSelected += 1;
}
});
if (!previousFontItem) {
// first load of fontItem - reload variants Map and set the default font style
variantsMap = {};
$.each(fontItem.variants, function(index, variantItem) {
// console.log(variantItem);
variantsMap[variantItem.id] = variantItem.id === fontItem.defVariant;
});
// console.log(variantsMap);
$scope.variantsMap = variantsMap;
$scope.variantDownloadQueryString = $scope.fontItem.defVariant;
} else {
// trigger variant select so variant query string matches again
$scope.variantSelect();
}
$scope.busy = false;
})
.error(function(data, status, headers, config) {
apiError($scope, status, headers, config);
});
if (previousFontItem === false) {
$scope.busy = true;
}
$scope.checkSubsetMinimalSelection = function(key) {
if ($scope.subSetsSelected === 1 && $scope.fontItem.subsetMap[key] === true) {
return true;
} else {
return false;
}
};
$scope.variantSelect = function() {
var variantDownloadQueryString = '';
$.each(variantsMap, function(checkKey) {
if (variantsMap[checkKey] === true) {
variantDownloadQueryString += checkKey + ',';
}
});
if (variantDownloadQueryString.length === 0) {
// you will only get the defaultvariant!
variantDownloadQueryString = $scope.fontItem.defVariant;
} else {
// remove last comma from string
variantDownloadQueryString = variantDownloadQueryString.substring(0, variantDownloadQueryString.length - 1);
}
$scope.variantDownloadQueryString = variantDownloadQueryString;
};
$scope.subsetSelect = function() {
if (subsetsChkbTimeoutP) {
$timeout.cancel(subsetsChkbTimeoutP);
$interval.cancel(subsetsChkbReload);
}
subsetsChkbTimeoutP = $timeout(function() {
var queryParams = '';
var lenChecked = 0;
var map = $scope.fontItem.subsetMap;
var defaultSet = $scope.fontItem.defSubset;
$.each(map, function(item) {
if (map[item] === true) {
queryParams += item + ',';
lenChecked += 1;
}
});
$scope.subSetsSelected = lenChecked;
if (lenChecked === 0) {
// you will get the defaultset
map[defaultSet] = true;
queryParams = defaultSet;
} else {
// remove last comma from string
queryParams = queryParams.substring(0, queryParams.length - 1);
}
previousFontItem = $scope.fontItem;
// wait until doing the request (overrides previous promise!)...
subsetsChkbTimeoutP = $timeout(function() {
$state.go('fonts.item', {
id: $scope.fontID,
subsets: queryParams
});
}, 3000);
var timeUntil = 3;
function setCustomizationReloadMessage(time) {
$scope.customizationReloadMessage = 'Customization will be requested in ' + time + ' sec...';
}
setCustomizationReloadMessage(timeUntil);
subsetsChkbReload = $interval(function() {
timeUntil -= 1;
setCustomizationReloadMessage(timeUntil);
}, 1000, 3);
// make available for cgBusy
$scope.subsetsChkbTimeoutP = subsetsChkbTimeoutP;
});
// make available for cgBusy
$scope.subsetsChkbTimeoutP = subsetsChkbTimeoutP;
};
// selected variants filter
$scope.variantFilter = function(variant) {
if ($scope.variantsMap[variant.id] === false) {
return;
}
return variant;
};
$scope.checkVariantMinimalSelection = function(key) {
var countSelected = 0;
$.each(variantsMap, function(checkKey) {
if (variantsMap[checkKey] === true) {
countSelected += 1;
}
});
if (countSelected === 1 && variantsMap[key] === true) {
return true;
} else {
return false;
}
};
$scope.selectText = function(evt) {
var element = evt.currentTarget;
// console.log(element);
var doc = document,
text = element,
range, selection;
if (doc.body.createTextRange) {
range = document.body.createTextRange();
range.moveToElementText(text);
range.select();
} else if (window.getSelection) {
selection = window.getSelection();
range = document.createRange();
range.selectNodeContents(text);
selection.removeAllRanges();
selection.addRange(range);
}
};
$scope.modernSupportActive = function() {
$scope.fontFormats = 'woff2';
};
$scope.legacySupportActive = function() {
$scope.fontFormats = 'woff2,ttf';
};
$scope.historicSupportActive = function() {
$scope.fontFormats = 'woff2,woff,ttf,svg,eot';
};
});
================================================
FILE: client/app/fonts/fonts.controller.spec.js
================================================
'use strict';
describe('Controller: FontsCtrl', function () {
// load the controller's module
beforeEach(module('googleWebfontsHelperApp'));
var FontsCtrl, scope;
// Initialize the controller and a mock scope
beforeEach(inject(function ($controller, $rootScope) {
scope = $rootScope.$new();
FontsCtrl = $controller('FontsCtrl', {
$scope: scope
});
}));
it('should ...', function () {
expect(1).toEqual(1);
});
});
================================================
FILE: client/app/fonts/fonts.html
================================================
================================================
FILE: client/app/fonts/fonts.js
================================================
'use strict';
angular.module('googleWebfontsHelperApp')
.config(function ($stateProvider) {
$stateProvider
.state('fonts', {
url: '/fonts',
templateUrl: 'app/fonts/fonts.html',
controller: 'FontsCtrl'
})
.state('fonts.item', {
url: '/:id?subsets',
templateUrl: 'app/fonts/fontsItem.html',
controller: 'FontsItemCtrl'
});
});
================================================
FILE: client/app/fonts/fonts.less
================================================
// Variables
// -----------------------------------------------------------------------------
@header-height: 55px;
// Fixes
// -----------------------------------------------------------------------------
/* ui bootstrap click to pointers */
.nav,
.pagination,
.carousel,
.panel-title a {
cursor: pointer;
}
// Page wide
// -----------------------------------------------------------------------------
html,
body {
height: 100%;
}
.fonts-top-container,
.top-overlay,
.box {
min-width: 900px;
}
.fonts-top-container,
.row-fluid {
height: 100%;
}
.fonts-top-container:before,
.fonts-top-container:after,
.column:before,
.column:after {
content: "";
display: table;
}
.fonts-top-container:after,
.column:after {
clear: both;
}
// Header
// -----------------------------------------------------------------------------
.top-overlay {
height: @header-height;
padding: 10px 15px 25px 15px;
border-bottom: #eee solid 1px;
background: #fff;
}
.page-header {
margin: 0;
border-bottom: none;
float: left;
}
.nav-push-right {
float: right;
}
.actNavButton {
i {
font-size: 16px;
}
}
.actSponsorButton {
padding: 0;
margin-left: 4px;
position: relative;
overflow: hidden;
opacity: 0;
animation: show 600ms 100ms cubic-bezier(0.38, 0.97, 0.56, 0.76) forwards;
}
@keyframes show {
100% {
opacity: 1;
transform: none;
}
}
.actSponsorButtonUser {
border-radius: 50%;
}
.actSponsorButtonOrganization {
border-radius: 8px;
}
.sponsorheart {
color: #DB61A2;
}
.sponsor-img {
height: 34px;
width: 34px;
}
.sponsor-img-overlay {
height: 34px;
width: 34px;
position: absolute;
top: 0;
left: 0;
background-color: #DB61A2;
opacity: 0.05;
}
.sponsor-img-overlay:hover {
opacity: 0.4;
}
.ordering {
float: left;
margin-bottom: 5px;
}
// search
#searchwrap {
width: 100%;
}
#searchinput {
width: 100%;
font-size: 11px;
}
#searchclear {
position: absolute;
right: 5px;
top: 0;
bottom: 0;
height: 14px;
margin: auto;
font-size: 14px;
cursor: pointer;
color: #ccc;
display: none;
}
#searchclear.show {
display: initial;
z-index: 1000;
}
#orderButton {
border-bottom-right-radius: 4px;
border-top-right-radius: 4px;
}
// Masthead first page
// -----------------------------------------------------------------------------
.masthead {
margin-left: -15px;
margin-right: -15px;
background-color: transparent;
background: linear-gradient(fade(#fff, 0%), fade(#fff, 100%));
position: relative;
}
.masthead:after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: url('/assets/images/swirl.png') repeat;
opacity: 1;
z-index: -2;
}
.pulse {
-webkit-animation: pulse 1s infinite;
-moz-animation: pulse 1s infinite;
-o-animation: pulse 1s infinite;
animation: pulse 1s infinite;
}
@-webkit-keyframes pulse {
0% {
-webkit-transform: scale(1);
}
50% {
-webkit-transform: scale(1.3);
}
100% {
-webkit-transform: scale(1);
}
}
@-moz-keyframes pulse {
0% {
-moz-transform: scale(1);
}
50% {
-moz-transform: scale(1.3);
}
100% {
-moz-transform: scale(1);
}
}
@-o-keyframes pulse {
0% {
-o-transform: scale(1);
}
50% {
-o-transform: scale(1.3);
}
100% {
-o-transform: scale(1);
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
// Scroll colums
// -----------------------------------------------------------------------------
.box {
position: absolute;
bottom: 0;
left: 0;
right: 0;
top: @header-height;
}
.column {
height: 100%;
overflow: auto;
*zoom: 1;
}
.scrollerLeft {
background: #eee;
}
.scrollerRight {}
// Font content
// -----------------------------------------------------------------------------
// #previewFontSizeInput {
// width: 50px;
// }
.dl-horizontal.variantList {
margin-bottom: 0px;
dt {
width: auto;
}
dd {
margin-left: 120px;
}
}
.list-group {
padding-top: 10px;
padding-bottom: 10px;
}
.list-group-item.active h5 small {
color: #efefef;
}
.list-group-item-heading {
margin-top: 0px;
margin-bottom: 0px;
}
.download-button {
white-space: normal;
// margin-top: 30px;
}
.head-right-block {
margin-top: 54px;
}
.folderPrefixBar {
margin-bottom: 12px;
margin-top: 12px;
}
.nav-tabs {
margin-bottom: 5px;
}
.fontItemCSSWrap {
margin-top: 20px;
padding-top: 20px;
border: 0;
border-top: 1px solid #eeeeee;
}
#fontPreviewToggle {
margin-top: 12px;
}
.cssCodeStyle {
background: #eee;
padding-top: 8px;
padding-bottom: 8px;
}
ul.nav.nav-pills {
padding-bottom: 10px;
margin-bottom: 5px;
}
// Erros
// -----------------------------------------------------------------------------
.apiError {
display: none;
}
.apiError.show {
display: initial;
}
// General
// -----------------------------------------------------------------------------
pre {
font-size: 80%;
}
code {
font-size: 75%;
}
textarea {
resize: none;
}
.mini {
font-size: 70%;
}
================================================
FILE: client/app/fonts/fontsItem.html
================================================
{{fontItem.family}}{{fontItem.category}}
{{variant.id}}{{$last ? "" : ", "}}
{{subset}}{{$last ? "" : ", "}}
Rank {{fontItem.popularity}} in popularity of {{fonts.length}} fonts in total
Last modified {{fontItem.lastModified}} ({{fontItem.version}})
2. Select styles: (default is {{fontItem.defVariant}})
3. Copy CSS: (default is Modern Browsers)
Choose Modern Browsers if supporting old browsers is not relevant. Formats in this snippet: [{{fontFormats}}]
Click on code to select all statements, then copy/paste it into your own CSS file.
Choose Legacy Support if old browsers still need to be supported. Formats in this snippet: [{{fontFormats}}]
Click on code to select all statements, then copy/paste it into your own CSS file.
Choose Historic Support if very old browsers still need to be supported. Formats in this snippet: [{{fontFormats}}]
Click on code to select all statements, then copy/paste it into your own CSS file.
API Error ({{errorStatus}})
REQUEST CONFIG: {{errorConfig}}
REQUEST HEADERS: {{errorHeaders}}
================================================
FILE: client/app/highlightjs/highlightjs.directive.js
================================================
'use strict';
// via http://stackoverflow.com/questions/25581560/dynamic-syntax-highlighting-with-angularjs-and-highlight-js
angular.module('googleWebfontsHelperApp')
.directive('highlightjs', ['$interpolate', '$timeout', function($interpolate, $timeout) {
return {
restrict: 'EA',
scope: true, // must inherit parent scope all expressions are allowed inside content!
compile: function(tElem, tAttrs) {
var interpolateFn = $interpolate(tElem.html(), true);
tElem.html(''); // disable automatic intepolation bindings
return function(scope, elem, attrs) {
scope.$watch(interpolateFn, function(value) {
$timeout(function() {
var highlighter = elem.attr('data-hljs'); // use data-hljs to define the highligher to use
if (typeof highlighter !== 'undefined') {
elem.html(hljs.highlight(highlighter, value).value);
} else {
elem.html(hljs.highlightAuto(value).value);
}
}, 0);
});
}
},
link: function(scope, element) {}
};
}]);
================================================
FILE: client/app/highlightjs/highlightjs.directive.spec.js
================================================
'use strict';
describe('Directive: highlightjs', function () {
// load the directive's module
beforeEach(module('googleWebfontsHelperApp'));
var element,
scope;
beforeEach(inject(function ($rootScope) {
scope = $rootScope.$new();
}));
it('should make hidden element visible', inject(function ($compile) {
element = angular.element(' ');
element = $compile(element)(scope);
expect(element.text()).toBe('this is the highlightjs directive');
}));
});
================================================
FILE: client/index.html
================================================
google webfonts helper
================================================
FILE: client/robots.txt
================================================
# robotstxt.org
User-agent: *
================================================
FILE: docker-compose.yml
================================================
services:
service:
build:
context: .
target: development
ports:
- "9000:9000" # development
- "8080:8080" # production
- "35729:35729" # livereload
- "5858:5858" # debugger
- "9229:9229" # profiler
working_dir: &PROJECT_ROOT_DIR /app
# linux permissions: we must explicitly run as the node user
user: node
volumes:
# mount working directory
# https://docs.docker.com/docker-for-mac/osxfs-caching/#delegated
# the container’s view is authoritative (permit delays before updates on the container appear in the host)
- .:/app:delegated
environment:
# Set your key in the .gitignored .env file.
GOOGLE_FONTS_API_KEY: ${GOOGLE_FONTS_API_KEY}
# Overrides default command so things don't shut down after the process ends.
command:
- /bin/sh
- -c
- |
git config --global --add safe.directory /app
while sleep 1000; do :; done
================================================
FILE: docker-helper.sh
================================================
#!/bin/bash
if [ "$1" = "--up" ]; then
docker compose up --no-start
docker compose start # ensure we are started, handle also allowed to be consumed by vscode
docker compose exec service bash
fi
if [ "$1" = "--halt" ]; then
docker compose stop
fi
if [ "$1" = "--rebuild" ]; then
docker compose up -d --force-recreate --no-deps --build service
fi
if [ "$1" = "--destroy" ]; then
docker compose down --rmi local -v --remove-orphans
fi
[ -n "$1" -a \( "$1" = "--up" -o "$1" = "--halt" -o "$1" = "--rebuild" -o "$1" = "--destroy" \) ] \
|| { echo "usage: $0 --up | --halt | --rebuild | --destroy" >&2; exit 1; }
================================================
FILE: package.json
================================================
{
"name": "google-webfonts-helper",
"version": "1.1.0",
"homepage": "https://gwfh.mranftl.com",
"main": "server/app.ts",
"author": "majodev",
"license": "MIT",
"keywords": [
"google fonts",
"web fonts",
"download",
"woff",
"svg",
"ttf",
"woff2",
"eot",
"css",
"snippet",
"hosting"
],
"repository": {
"type": "git",
"url": "https://github.com/majodev/google-webfonts-helper.git"
},
"dependencies": {
"axios": "1.12.2",
"bluebird": "3.7.2",
"compression": "1.8.1",
"css": "3.0.0",
"express": "4.21.2",
"jszip": "3.10.1",
"lodash": "4.17.21",
"morgan": "1.10.1",
"source-map-support": "0.5.21",
"speakingurl": "14.0.1"
},
"devDependencies": {
"@types/async": "^3.2.16",
"@types/bluebird": "^3.5.38",
"@types/css": "^0.0.33",
"@types/express": "^4.17.17",
"@types/lodash": "^4.14.191",
"@types/mocha": "^10.0.1",
"@types/node": "18",
"@types/speakingurl": "^13.0.3",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"bower": "^1.3.8",
"connect-livereload": "~0.4.0",
"errorhandler": "~1.0.0",
"eslint": "^8.34.0",
"file-type": "16.5.4",
"grunt": "~0.4.4",
"grunt-angular-templates": "^0.5.4",
"grunt-asset-injector": "^0.1.0",
"grunt-autoprefixer": "~0.7.2",
"grunt-concurrent": "~0.5.0",
"grunt-contrib-clean": "~0.5.0",
"grunt-contrib-concat": "~0.4.0",
"grunt-contrib-copy": "~0.5.0",
"grunt-contrib-cssmin": "~0.9.0",
"grunt-contrib-htmlmin": "~0.2.0",
"grunt-contrib-imagemin": "4.0.0",
"grunt-contrib-less": "^0.11.0",
"grunt-contrib-uglify": "~0.4.0",
"grunt-contrib-watch": "~0.6.1",
"grunt-dom-munger": "^3.4.0",
"grunt-env": "~0.4.1",
"grunt-express-server": "~0.4.17",
"grunt-mocha-test": "0.13.3",
"grunt-newer": "~0.7.0",
"grunt-ng-annotate": "^0.2.3",
"grunt-nodemon": "0.4.2",
"grunt-rev": "~0.1.0",
"grunt-svgmin": "~0.4.0",
"grunt-ts": "^6.0.0-beta.22",
"grunt-usemin": "~2.1.1",
"grunt-wiredep": "~1.8.0",
"jit-grunt": "^0.5.0",
"mocha": "^10.2.0",
"prettier": "^2.8.4",
"prettier-eslint": "^15.0.1",
"prettier-plugin-organize-imports": "^3.2.2",
"punycode": "^1.4.1",
"should": "13.2.3",
"supertest": "6.3.3",
"time-grunt": "~0.3.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
},
"scripts": {
"start": "ts-node server/app.ts",
"lint": "eslint --ext .ts .",
"build": "grunt build",
"test": "grunt test",
"dev": "grunt serve",
"tsc": "tsc --noEmit --skipLibCheck"
},
"private": true,
"resolutions": {
"imagemin-optipng": "git+https://github.com/PruvoNet/imagemin-optipng.git#68dc79939c380fb12a83f7ec7cc5943f9aa41149",
"optipng-bin": "git+https://github.com/PruvoNet/optipng-bin.git#ffb7e8f17710428596def1bb832d8e8e3fe382af"
}
}
================================================
FILE: server/api/fonts.controller.ts
================================================
import { NextFunction, Request, Response } from "express";
import * as fs from "fs";
import * as JSZip from "jszip";
import * as _ from "lodash";
import * as path from "path";
import { IUserAgents } from "../config";
import { loadFontBundle, loadFontItems, loadFontSubsetArchive, loadSubsetMap, loadVariantItems } from "../logic/core";
import { IFontSubsetArchive } from "../logic/fetchFontSubsetArchive";
// Get list of fonts
// /api/fonts
interface IAPIListFont {
id: string;
family: string;
variants: string[];
subsets: string[];
category: string;
version: string;
lastModified: string; // e.g. 2022-09-22
popularity: number;
defSubset: string;
defVariant: string;
}
export async function getApiFonts(req: Request, res: Response, next: NextFunction) {
try {
const fonts = loadFontItems();
const apiListFonts: IAPIListFont[] = _.map(fonts, (font) => {
return {
id: font.id,
family: font.family,
variants: font.variants,
subsets: font.subsets,
category: font.category,
version: font.version,
lastModified: font.lastModified,
popularity: font.popularity,
defSubset: font.defSubset,
defVariant: font.defVariant,
};
});
return res.json(apiListFonts);
} catch (e) {
next(e);
}
}
// Get specific fonts (fixed charsets) including links
// /api/fonts/:id
interface IAPIFont {
id: string;
family: string;
subsets: string[];
category: string;
version: string;
lastModified: string; // e.g. 2022-09-22
popularity: number;
defSubset: string;
defVariant: string;
subsetMap: {
[subset: string]: boolean;
};
storeID: string;
variants: {
id: string;
fontFamily: string | null;
fontStyle: string | null;
fontWeight: string | null;
eot?: string;
woff?: string;
woff2?: string;
svg?: string;
ttf?: string;
}[];
}
export async function getApiFontsById(req: Request, res: Response, next: NextFunction) {
try {
// get the subset string if it was supplied...
// e.g. "subset=latin,latin-ext," will be transformed into ["latin","latin-ext"] (non whitespace arrays)
const subsets = _.isString(req.query.subsets) ? _.without(req.query.subsets.split(/[,]+/), "") : null;
const fontBundle = await loadFontBundle(req.params.id, subsets);
if (_.isNil(fontBundle)) {
return res.status(404).send("Not found");
}
const subsetMap = loadSubsetMap(fontBundle);
const variantItems = await loadVariantItems(fontBundle);
if (_.isNil(variantItems)) {
return res.status(404).send("Not found");
}
// default case: json serialize...
if (req.query.download !== "zip") {
const { font } = fontBundle;
const apiFont: IAPIFont = {
id: font.id,
family: font.family,
subsets: font.subsets,
category: font.category,
version: font.version,
lastModified: font.lastModified,
popularity: font.popularity,
defSubset: font.defSubset,
defVariant: font.defVariant,
subsetMap: subsetMap,
// be compatible with legacy storeIDs, without binding on our new convention.
storeID: fontBundle.subsets.join("_"),
variants: _.map(variantItems, (variant) => {
return {
id: variant.id,
fontFamily: variant.fontFamily,
fontStyle: variant.fontStyle,
fontWeight: variant.fontWeight,
..._.reduce(
variant.urls,
(sum, vurl) => {
sum[vurl.format] = vurl.url;
return sum;
},
{} as IUserAgents
),
};
}),
};
return res.json(apiFont);
}
// otherwise: download as zip
const variants = _.isString(req.query.variants) ? _.without(req.query.variants.split(/[,]+/), "") : null;
const formats = _.isString(req.query.formats) ? _.without(req.query.formats.split(/[,]+/), "") : null;
let subsetFontArchive: IFontSubsetArchive;
try {
subsetFontArchive = await loadFontSubsetArchive(fontBundle, variantItems);
} catch (e) {
console.error("getApiFontsById.loadFontSubsetArchive received error -> 404", e);
return res.status(404).send("Not found");
}
const filteredFiles = _.filter(subsetFontArchive.files, (file) => {
return (_.isNil(variants) || _.includes(variants, file.variant)) && (_.isNil(formats) || _.includes(formats, file.format));
});
if (filteredFiles.length === 0) {
return res.status(404).send("Not found");
}
// we build a new .zip from the existing cached .zip, filtered by the requested variants and formats.
const archive = await loadZipArchive(subsetFontArchive.zipPath);
// remove all files that are not in the filtered list.
_.each(subsetFontArchive.files, function (file) {
if (!_.includes(filteredFiles, file)) {
archive.remove(file.path);
}
});
// tell the browser that this is a zip file.
res.writeHead(200, {
"Content-Type": "application/zip",
"Content-disposition": `attachment; filename=${path.basename(subsetFontArchive.zipPath)}`,
});
return archive
.generateNodeStream({
// streamFiles: true,
compression: "DEFLATE",
})
.pipe(res);
} catch (e) {
next(e);
}
}
// exported for testing
function loadZipArchive(zipPath: string): PromiseLike {
return new JSZip.external.Promise(function (resolve, reject) {
fs.readFile(zipPath, function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
}).then(function (data: unknown) {
return JSZip.loadAsync(data);
});
}
================================================
FILE: server/api/fonts.spec.ts
================================================
import { fromBuffer as fileTypeFromBuffer } from "file-type";
import * as JSZip from "jszip";
import * as _ from "lodash";
import * as should from "should";
import * as request from "supertest";
import { app } from "../app";
import { getStats, reinitStore } from "../logic/store";
describe("GET /api/fonts", () => {
afterEach(() => {
return reinitStore();
});
it("should respond with JSON array with all fonts", async () => {
const res = await request(app).get("/api/fonts").timeout(10000).expect(200).expect("Content-Type", /json/);
should(res.body).be.instanceof(Array);
});
});
describe("GET /api/fonts/:id", () => {
afterEach(() => {
return reinitStore();
});
it("should respond with font files for arvo", async function () {
const res = await request(app).get("/api/fonts/arvo").timeout(10000).expect(200).expect("Content-Type", /json/);
should(res.body).be.instanceof(Object);
should(res.body).have.property("id", "arvo");
should(res.body).have.property("family", "Arvo");
should(res.body).have.property("subsets", ["latin"]);
should(res.body).have.property("category", "serif");
should(res.body).have.property("version", "v20");
should(res.body).have.property("lastModified", "2022-09-22");
should(res.body).have.property("popularity", 1);
should(res.body).have.property("defSubset", "latin");
should(res.body).have.property("defVariant", "regular");
should(res.body).have.property("subsetMap", { latin: true });
should(res.body).have.property("storeID", "latin");
should(res.body.variants).be.instanceof(Array);
should(res.body.variants).be.lengthOf(4);
if (res.body.variants.length === 4) {
should(res.body.variants[0]).have.property("id", "regular");
should(res.body.variants[0]).have.property("fontFamily", "'Arvo'");
should(res.body.variants[0]).have.property("fontStyle", "normal");
should(res.body.variants[0]).have.property("fontWeight", "400");
should(res.body.variants[1]).have.property("id", "italic");
should(res.body.variants[1]).have.property("fontFamily", "'Arvo'");
should(res.body.variants[1]).have.property("fontStyle", "italic");
should(res.body.variants[1]).have.property("fontWeight", "400");
should(res.body.variants[2]).have.property("id", "700");
should(res.body.variants[2]).have.property("fontFamily", "'Arvo'");
should(res.body.variants[2]).have.property("fontStyle", "normal");
should(res.body.variants[2]).have.property("fontWeight", "700");
should(res.body.variants[3]).have.property("id", "700italic");
should(res.body.variants[3]).have.property("fontFamily", "'Arvo'");
should(res.body.variants[3]).have.property("fontStyle", "italic");
should(res.body.variants[3]).have.property("fontWeight", "700");
_.each(res.body.variants, (variant) => {
should(variant).have.property("woff").String();
should(variant).have.property("woff2").String();
should(variant).have.property("svg").String();
should(variant).have.property("eot").String();
should(variant).have.property("ttf").String();
should(_.get(variant, "woff", {}).length).greaterThan(1);
should(_.get(variant, "woff2", {}).length).greaterThan(1);
should(_.get(variant, "svg", {}).length).greaterThan(1);
should(_.get(variant, "eot", {}).length).greaterThan(1);
should(_.get(variant, "ttf", {}).length).greaterThan(1);
});
}
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(0);
}).timeout(10000);
it("should respond with font files for istok-web multi charsets filtered", async () => {
const res = await request(app)
.get("/api/fonts/istok-web?subsets=cyrillic,cyrillic-ext,latin")
.timeout(10000)
.expect(200)
.expect("Content-Type", /json/);
should(res.body).be.instanceof(Object);
should(res.body).have.property("id", "istok-web");
should(res.body).have.property("family", "Istok Web");
should(res.body).have.property("subsets", ["cyrillic", "cyrillic-ext", "latin", "latin-ext"]);
should(res.body).have.property("category", "sans-serif");
should(res.body).have.property("version", "v20");
should(res.body).have.property("lastModified", "2022-09-22");
should(res.body).have.property("popularity", 2);
should(res.body).have.property("defSubset", "latin");
should(res.body).have.property("defVariant", "regular");
should(res.body).have.property("subsetMap", {
cyrillic: true,
"cyrillic-ext": true,
latin: true,
"latin-ext": false,
});
should(res.body).have.property("storeID", "cyrillic_cyrillic-ext_latin");
should(res.body.variants).be.instanceof(Array);
should(res.body.variants).be.lengthOf(4);
if (res.body.variants.length === 4) {
should(res.body.variants[0]).have.property("id", "regular");
should(res.body.variants[0]).have.property("fontFamily", "'Istok Web'");
should(res.body.variants[0]).have.property("fontStyle", "normal");
should(res.body.variants[0]).have.property("fontWeight", "400");
should(res.body.variants[1]).have.property("id", "italic");
should(res.body.variants[1]).have.property("fontFamily", "'Istok Web'");
should(res.body.variants[1]).have.property("fontStyle", "italic");
should(res.body.variants[1]).have.property("fontWeight", "400");
should(res.body.variants[2]).have.property("id", "700");
should(res.body.variants[2]).have.property("fontFamily", "'Istok Web'");
should(res.body.variants[2]).have.property("fontStyle", "normal");
should(res.body.variants[2]).have.property("fontWeight", "700");
should(res.body.variants[3]).have.property("id", "700italic");
should(res.body.variants[3]).have.property("fontFamily", "'Istok Web'");
should(res.body.variants[3]).have.property("fontStyle", "italic");
should(res.body.variants[3]).have.property("fontWeight", "700");
_.each(res.body.variants, (variant) => {
should(variant).have.property("woff").String();
should(variant).have.property("woff2").String();
should(variant).have.property("svg").String();
should(variant).have.property("eot").String();
should(variant).have.property("ttf").String();
should(_.get(variant, "woff", {}).length).greaterThan(1);
should(_.get(variant, "woff2", {}).length).greaterThan(1);
should(_.get(variant, "svg", {}).length).greaterThan(1);
should(_.get(variant, "eot", {}).length).greaterThan(1);
should(_.get(variant, "ttf", {}).length).greaterThan(1);
});
}
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(0);
}).timeout(10000);
it("should respond with 200 for known font istok-web empty subsets", async function () {
this.timeout(10000);
const res = await request(app).get("/api/fonts/istok-web?subsets=").timeout(10000).expect(200).expect("Content-Type", /json/);
should(res.body).be.instanceof(Object);
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(0);
}).timeout(10000);
it("should respond with 404 for unknown font", async () => {
await request(app)
.get("/api/fonts/unknown-font")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
should(getStats().urlMap).eql(0);
should(getStats().archiveMap).eql(0);
}).timeout(10000);
it("should respond with 404 for unknown font and subset", async () => {
await request(app)
.get("/api/fonts/unknown-font?subsets=latin")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
should(getStats().urlMap).eql(0);
should(getStats().archiveMap).eql(0);
}).timeout(10000);
it("should respond with 404 for known font istok-web and unknown subset", async () => {
await request(app)
.get("/api/fonts/istok-web?subsets=unknownsubset")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
}).timeout(10000);
});
describe("GET /api/fonts/:id?download=zip", () => {
afterEach(() => {
return reinitStore();
});
it("should (concurrently) download istok-web", async function () {
this.timeout(10000);
let triggered = 0;
await Promise.all([
request(app)
.get("/api/fonts/istok-web?download=zip&subsets=latin&formats=woff,woff2")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip")
.then(() => {
triggered += 1;
}),
request(app)
.get("/api/fonts/istok-web?download=zip&subsets=latin&formats=woff,woff2")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip")
.then(() => {
triggered += 1;
}),
]);
should(triggered).eql(2);
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
}).timeout(10000);
it("should (concurrently) download istok-web (subsets and formats mix)", async function () {
this.timeout(10000);
let triggered = 0;
const [res1, res2] = await Promise.all([
request(app)
.get("/api/fonts/istok-web?download=zip&subsets=cyrillic-ext,latin,latin-ext&formats=woff,woff2")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip")
.then((res) => {
triggered += 1;
return res;
}),
request(app)
.get("/api/fonts/istok-web?download=zip&subsets=latin-ext,latin,cyrillic-ext&formats=woff,woff2,eot,ttf,svg")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip")
.then((res) => {
triggered += 1;
return res;
}),
]);
should(triggered).eql(2);
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive1 = await JSZip.loadAsync(res1.body);
// 8 files in archive1
should(_.keys(archive1.files).length).eql(8);
const archive2 = await JSZip.loadAsync(res2.body);
// 60 files in archive2
should(_.keys(archive2.files).length).eql(20);
}).timeout(10000);
it("should (concurrently) download playfair-display (different but unknown subsets resolve to the same key)", async function () {
let triggered = 0;
this.timeout(30000);
const [res1, res2] = await Promise.all([
request(app)
.get(
"/api/fonts/playfair-display?download=zip&subsets=devanagari,vietnamese,cyrillic-ext,latin,greek-ext,greek,cyrillic,latin-ext,hebrew,korean,oriya"
)
.responseType("blob")
.timeout(30000)
.expect(200)
.expect("Content-Type", "application/zip")
.then((res) => {
triggered += 1;
return res;
}),
request(app)
.get("/api/fonts/playfair-display?download=zip&subsets=cyrillic,latin,latin-ext,vietnamese")
.responseType("blob")
.timeout(30000)
.expect(200)
.expect("Content-Type", "application/zip")
.then((res) => {
triggered += 1;
return res;
}),
]);
should(triggered).eql(2);
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive1 = await JSZip.loadAsync(res1.body);
// 60 files in archive1
should(_.keys(archive1.files).length).eql(60);
const archive2 = await JSZip.loadAsync(res2.body);
// 60 files in archive2
should(_.keys(archive2.files).length).eql(60);
}).timeout(10000);
it("should respond with 200 for download attempt of known font istok-web with unspecified subset", async function () {
this.timeout(10000);
const res = await request(app)
.get("/api/fonts/istok-web?download=zip&formats=woff,woff2")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip");
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive = await JSZip.loadAsync(res.body);
// 4 default variants, 2 formats -> 8 files in archive
should(_.keys(archive.files).length).eql(8);
const files = _.map(_.sortBy(_.keys(archive.files)), (key) => {
const file = archive.files[key];
return file;
});
should(files[0].name).eql("istok-web-v20-latin-700.woff");
should((await fileTypeFromBuffer(await files[0].async("nodebuffer")))?.mime).eql("font/woff");
should(files[1].name).eql("istok-web-v20-latin-700.woff2");
should((await fileTypeFromBuffer(await files[1].async("nodebuffer")))?.mime).eql("font/woff2");
should(files[2].name).eql("istok-web-v20-latin-700italic.woff");
should((await fileTypeFromBuffer(await files[2].async("nodebuffer")))?.mime).eql("font/woff");
should(files[3].name).eql("istok-web-v20-latin-700italic.woff2");
should((await fileTypeFromBuffer(await files[3].async("nodebuffer")))?.mime).eql("font/woff2");
should(files[4].name).eql("istok-web-v20-latin-italic.woff");
should((await fileTypeFromBuffer(await files[4].async("nodebuffer")))?.mime).eql("font/woff");
should(files[5].name).eql("istok-web-v20-latin-italic.woff2");
should((await fileTypeFromBuffer(await files[5].async("nodebuffer")))?.mime).eql("font/woff2");
should(files[6].name).eql("istok-web-v20-latin-regular.woff");
should((await fileTypeFromBuffer(await files[6].async("nodebuffer")))?.mime).eql("font/woff");
should(files[7].name).eql("istok-web-v20-latin-regular.woff2");
should((await fileTypeFromBuffer(await files[7].async("nodebuffer")))?.mime).eql("font/woff2");
}).timeout(10000);
it("should respond with 200 for download attempt of known font istok-web with unspecified formats", async () => {
const res = await request(app)
.get("/api/fonts/istok-web?download=zip&subsets=latin")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip");
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive = await JSZip.loadAsync(res.body);
// 4 default variants, 5 formats -> 20 files in archive
should(_.keys(archive.files).length).eql(20);
const files = _.map(_.sortBy(_.keys(archive.files)), (key) => {
const file = archive.files[key];
return file;
});
// _.each(files, (file) => console.log(file.name));
should(files[0].name).eql("istok-web-v20-latin-700.eot");
should((await fileTypeFromBuffer(await files[0].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject");
should(files[1].name).eql("istok-web-v20-latin-700.svg");
should((await fileTypeFromBuffer(await files[1].async("nodebuffer")))?.mime).eql("application/xml");
should(files[2].name).eql("istok-web-v20-latin-700.ttf");
should((await fileTypeFromBuffer(await files[2].async("nodebuffer")))?.mime).eql("font/ttf");
should(files[3].name).eql("istok-web-v20-latin-700.woff");
should((await fileTypeFromBuffer(await files[3].async("nodebuffer")))?.mime).eql("font/woff");
should(files[4].name).eql("istok-web-v20-latin-700.woff2");
should((await fileTypeFromBuffer(await files[4].async("nodebuffer")))?.mime).eql("font/woff2");
should(files[5].name).eql("istok-web-v20-latin-700italic.eot");
should((await fileTypeFromBuffer(await files[5].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject");
should(files[6].name).eql("istok-web-v20-latin-700italic.svg");
should((await fileTypeFromBuffer(await files[6].async("nodebuffer")))?.mime).eql("application/xml");
should(files[7].name).eql("istok-web-v20-latin-700italic.ttf");
should((await fileTypeFromBuffer(await files[7].async("nodebuffer")))?.mime).eql("font/ttf");
should(files[8].name).eql("istok-web-v20-latin-700italic.woff");
should((await fileTypeFromBuffer(await files[8].async("nodebuffer")))?.mime).eql("font/woff");
should(files[9].name).eql("istok-web-v20-latin-700italic.woff2");
should((await fileTypeFromBuffer(await files[9].async("nodebuffer")))?.mime).eql("font/woff2");
should(files[10].name).eql("istok-web-v20-latin-italic.eot");
should((await fileTypeFromBuffer(await files[10].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject");
should(files[11].name).eql("istok-web-v20-latin-italic.svg");
should((await fileTypeFromBuffer(await files[11].async("nodebuffer")))?.mime).eql("application/xml");
should(files[12].name).eql("istok-web-v20-latin-italic.ttf");
should((await fileTypeFromBuffer(await files[12].async("nodebuffer")))?.mime).eql("font/ttf");
should(files[13].name).eql("istok-web-v20-latin-italic.woff");
should((await fileTypeFromBuffer(await files[13].async("nodebuffer")))?.mime).eql("font/woff");
should(files[14].name).eql("istok-web-v20-latin-italic.woff2");
should((await fileTypeFromBuffer(await files[14].async("nodebuffer")))?.mime).eql("font/woff2");
should(files[15].name).eql("istok-web-v20-latin-regular.eot");
should((await fileTypeFromBuffer(await files[15].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject");
should(files[16].name).eql("istok-web-v20-latin-regular.svg");
should((await fileTypeFromBuffer(await files[16].async("nodebuffer")))?.mime).eql("application/xml");
should(files[17].name).eql("istok-web-v20-latin-regular.ttf");
should((await fileTypeFromBuffer(await files[17].async("nodebuffer")))?.mime).eql("font/ttf");
should(files[18].name).eql("istok-web-v20-latin-regular.woff");
should((await fileTypeFromBuffer(await files[18].async("nodebuffer")))?.mime).eql("font/woff");
should(files[19].name).eql("istok-web-v20-latin-regular.woff2");
should((await fileTypeFromBuffer(await files[19].async("nodebuffer")))?.mime).eql("font/woff2");
}).timeout(10000);
it("should respond with 200 for download attempt of known font istok-web and empty subsets", async () => {
const res = await request(app)
.get("/api/fonts/istok-web?download=zip&subsets=")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip");
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive = await JSZip.loadAsync(res.body);
// defaults to latin with 4 default variants, 5 formats -> 20 files in archive
should(_.keys(archive.files).length).eql(20);
_.each(_.sortBy(_.keys(archive.files)), (key) => {
should(key.indexOf("istok-web-v20-latin-")).eql(0);
});
}).timeout(10000);
it("should respond with 200 for download attempt of known font istok-web and a single unknown format sneaked in", async () => {
const res = await request(app)
.get("/api/fonts/istok-web?download=zip&formats=woff,woff2,rolf")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip");
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive = await JSZip.loadAsync(res.body);
// defaults to latin with 4 default variants, 2 formats -> 8 files in archive
should(_.keys(archive.files).length).eql(8);
_.each(_.sortBy(_.keys(archive.files)), (key) => {
should(key.indexOf("istok-web-v20-latin-")).eql(0);
});
}).timeout(10000);
it("should respond with 200 for download attempt of known font istok-web with variants", async () => {
const res = await request(app)
.get("/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=regular")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip");
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive = await JSZip.loadAsync(res.body);
// defaults to latin with 1 variant, 2 formats -> 2 files in archive
should(_.keys(archive.files).length).eql(2);
_.each(_.sortBy(_.keys(archive.files)), (key) => {
should(_.endsWith(key, ".woff") || _.endsWith(key, ".woff2")).eql(true);
should(key.indexOf("regular") === -1).eql(false);
});
}).timeout(10000);
it("should respond with 200 for download attempt of known font istok-web with one known, one unknown variant", async () => {
const res = await request(app)
.get("/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=regular,unknownvar")
.responseType("blob")
.timeout(10000)
.expect(200)
.expect("Content-Type", "application/zip");
should(getStats().urlMap).eql(1);
should(getStats().archiveMap).eql(1);
const archive = await JSZip.loadAsync(res.body);
// defaults to latin with 1 variant, 2 formats -> 2 files in archive
should(_.keys(archive.files).length).eql(2);
_.each(_.sortBy(_.keys(archive.files)), (key) => {
should(_.endsWith(key, ".woff") || _.endsWith(key, ".woff2")).eql(true);
should(key.indexOf("regular") === -1).eql(false);
});
}).timeout(10000);
it("should respond with 404 for download attempt of known font istok-web with empty variants", async () => {
await request(app)
.get("/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
}).timeout(10000);
// https://gwfh.mranftl.com/api/fonts/siemreap?download=zip&subsets=latin,latin-ext&formats=eot,woff,woff2,svg,ttf
it("should respond with 404 for download attempt of unknown font and unknown subset", async () => {
await request(app)
.get("/api/fonts/unknown-font?download=zip&subsets=latin&formats=woff,woff2")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
}).timeout(10000);
it("should respond with 404 for download attempt of known font istok-web and unknown subset", async () => {
await request(app)
.get("/api/fonts/istok-web?download=zip&subsets=unknown&formats=woff,woff2")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
}).timeout(10000);
it("should respond with 404 for download attempt of known font istok-web and unknown format", async () => {
await request(app)
.get("/api/fonts/istok-web?download=zip&formats=rolf")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
}).timeout(10000);
it("should respond with 404 for download attempt of known font istok-web and empty formats", async () => {
await request(app)
.get("/api/fonts/istok-web?download=zip&formats=")
.timeout(10000)
.expect(404)
.expect("Content-Type", /text\/html/);
}).timeout(10000);
});
================================================
FILE: server/api/healthy.controller.ts
================================================
import { NextFunction, Request, Response } from "express";
import { getStats } from "../logic/store";
// /-/healthy
export async function getHealthy(req: Request, res: Response, next: NextFunction) {
try {
const { fontMap, urlMap, archiveMap, files, urls } = getStats();
res.type("text/plain");
return res.send(`${fontMap} fonts available.
${urlMap} unique subsets loaded (${urls} URLs), ${archiveMap} subset archives fetched (${files} files).`);
} catch (e) {
next(e);
}
}
================================================
FILE: server/api/healthy.spec.ts
================================================
import * as request from "supertest";
import { app } from "../app";
describe("GET /-/healthy", () => {
it("should respond with 200", async () => {
await request(app)
.get("/-/healthy")
.timeout(4000)
.expect(200)
.expect("Content-Type", /text\/plain/);
});
});
================================================
FILE: server/app.spec.ts
================================================
import * as request from "supertest";
import { app } from "./app";
describe("GET /api/not_defined", () => {
it("should respond with 404", async () => {
await request(app)
.get("/api/not_defined")
.timeout(4000)
.expect(404)
.expect("Content-Type", /text\/html/);
});
});
describe("GET /", () => {
it("should respond with 200", async () => {
await request(app)
.get("/")
.timeout(4000)
.expect(200)
.expect("Content-Type", /text\/html/);
});
});
================================================
FILE: server/app.ts
================================================
/* eslint-disable @typescript-eslint/no-var-requires */
require("source-map-support").install();
import * as express from "express";
import * as http from "http";
import * as JSZip from "jszip";
import * as path from "path";
import { config } from "./config";
import { initStore } from "./logic/store";
import { setupRoutes } from "./routes";
// use native promises
JSZip.external.Promise = Promise;
export const app = express();
export function ready() {
return init;
}
const init = (async () => {
const server = http.createServer(app);
server.timeout = config.TIMEOUT_MS;
const env = app.get("env");
// http://expressjs.com/en/api.html
app.set("x-powered-by", false);
if (config.ENABLE_MIDDLEWARE_COMPRESSION) {
app.use(require("compression")());
}
if (env === "production") {
app.use(express.static(path.join(config.ROOT, "public")));
app.set("appPath", config.ROOT + "/public");
if (config.ENABLE_MIDDLEWARE_ACCESS_LOG) {
app.use(require("morgan")(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length]'));
}
} else {
app.use(require("connect-livereload")());
app.use(express.static(path.join(config.ROOT, ".tmp")));
app.use(express.static(path.join(config.ROOT, "client")));
app.set("appPath", config.ROOT + "/client");
app.use(require("morgan")("dev"));
app.use(require("errorhandler")()); // Error handler - has to be last
}
setupRoutes(app);
await initStore();
// Start server
server.listen(config.PORT, config.IP, function () {
console.log(
"Express server listening on %d, in %s mode (timeout=%dms, compress=%s, accesslog=%s)",
config.PORT,
app.get("env"),
server.timeout,
config.ENABLE_MIDDLEWARE_COMPRESSION,
config.ENABLE_MIDDLEWARE_ACCESS_LOG
);
});
process.once("SIGINT", function () {
console.log("SIGINT received, closing server...");
server.close();
});
process.once("SIGTERM", function () {
console.log("SIGTERM received, closing server...");
server.close();
});
})();
================================================
FILE: server/config.ts
================================================
import * as _ from "lodash";
import * as path from "path";
const env = process.env.NODE_ENV || "development";
const GOOGLE_FONTS_API_KEY = process.env.GOOGLE_FONTS_API_KEY;
if (!_.isString(GOOGLE_FONTS_API_KEY) || _.isEmpty(GOOGLE_FONTS_API_KEY)) {
console.error('Error: ENV var "GOOGLE_FONTS_API_KEY" must be set!');
console.error("See https://developers.google.com/fonts/docs/developer_api");
process.exit(1);
}
export interface IUserAgents {
eot: string;
woff: string;
woff2: string;
svg: string;
ttf: string;
}
export const config = {
ENV: env,
// Root path of server
ROOT: path.normalize(__dirname + "/.."),
// Server port
PORT: process.env.PORT ? _.parseInt(process.env.PORT) : env === "production" ? 8080 : 9000,
IP: process.env.IP || undefined,
// Server port
TIMEOUT_MS: process.env.TIMEOUT_MS ? _.parseInt(process.env.TIMEOUT_MS) : 60 * 1000, // 60 seconds
// Middlewares
ENABLE_MIDDLEWARE_ACCESS_LOG: process.env.ENABLE_MIDDLEWARE_ACCESS_LOG === "true" ? true : false, // default false
ENABLE_MIDDLEWARE_COMPRESSION: process.env.ENABLE_MIDDLEWARE_COMPRESSION === "false" ? false : true, // default true
GOOGLE_FONTS_API_KEY,
GOOGLE_FONTS_USE_TEST_JSON: process.env.GOOGLE_FONTS_USE_TEST_JSON === "true" ? true : env === "test" ? true : false, // enabled in test, else default false
CACHE_DIR: process.env.CACHE_DIR || `${path.normalize(__dirname + "/logic")}/cachedFonts`,
USER_AGENTS: {
// see http://www.dvdprojekt.de/category.php?name=Safari for a list of sample user handlers
// test generation through running grunt mochaTest:src
eot: process.env.USER_AGENT_EOT || "Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0)",
woff: process.env.USER_AGENT_WOFF || "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0",
// must serve complete woff2 file for one variant (no unicode range support yet!)
// see http://www.useragentstring.com/pages/Firefox/
// see http://caniuse.com/#search=woff2
// see http://caniuse.com/#feat=font-unicode-range
// see https://developers.googleblog.com/2015/02/smaller-fonts-with-woff-20-and-unicode.html
woff2: process.env.USER_AGENT_WOFF2 || "Mozilla/5.0 (Windows NT 6.3; rv:39.0) Gecko/20100101 Firefox/39.0",
svg:
process.env.USER_AGENT_SVG ||
"Mozilla/4.0 (iPad; CPU OS 4_0_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/4.1 Mobile/9A405 Safari/7534.48.3",
ttf: process.env.USER_AGENT_TTF || "Mozilla/5.0 (Unknown; Linux x86_64) AppleWebKit/538.1 (KHTML, like Gecko) Safari/538.1 Daum/4.1",
},
};
================================================
FILE: server/logic/core.ts
================================================
import * as _ from "lodash";
import { synchronizedBy } from "../utils/synchronized";
import { fetchFontSubsetArchive, IFontSubsetArchive } from "./fetchFontSubsetArchive";
import { fetchFontURLs, IVariantItem } from "./fetchFontURLs";
import { IFontItem } from "./fetchGoogleFonts";
import {
getFontBundle,
getStoredFontItems,
getStoredFontSubsetArchive,
getStoredVariantItems,
IFontBundle,
storeFontSubsetArchive,
storeVariantItems,
} from "./store";
export function loadFontItems(): IFontItem[] {
return getStoredFontItems();
}
export function loadFontBundle(fontID: string, subsets: string[] | null): IFontBundle | null {
return getFontBundle(fontID, subsets);
}
export async function loadVariantItems(fontBundle: IFontBundle): Promise {
return _loadVariantItems(`loadVariantItems__${fontBundle.storeID}`, fontBundle);
}
const _loadVariantItems = synchronizedBy(async function (fontBundle: IFontBundle): Promise {
const storedVariantItems = getStoredVariantItems(fontBundle);
if (!_.isNil(storedVariantItems)) {
return storedVariantItems;
}
const { storeID, font, subsets } = fontBundle;
const variantItems = await fetchFontURLs(font.family, font.variants, subsets);
if (variantItems === null) {
console.error(`loadVariantItems resolved null for storeID=${storeID}`);
return null;
}
// SIDE-EFFECT!
storeVariantItems(fontBundle, variantItems);
return variantItems;
});
export async function loadFontSubsetArchive(fontBundle: IFontBundle, variants: IVariantItem[]): Promise {
return _loadFontSubsetArchive(`loadFontSubsetArchive__${fontBundle.storeID}`, fontBundle, variants);
}
const _loadFontSubsetArchive = synchronizedBy(async function (
fontBundle: IFontBundle,
variants: IVariantItem[]
): Promise {
const storedFontSubsetArchive = getStoredFontSubsetArchive(fontBundle);
if (!_.isNil(storedFontSubsetArchive)) {
return storedFontSubsetArchive;
}
const fontSubsetArchive = await fetchFontSubsetArchive(fontBundle.font.id, fontBundle.font.version, fontBundle.subsets, variants);
if (fontSubsetArchive.files.length === 0) {
throw new Error(`No files received for '${fontBundle.storeID}' font subset archive!`);
}
// SIDE-EFFECT!
storeFontSubsetArchive(fontBundle, fontSubsetArchive);
return fontSubsetArchive;
});
export interface ISubsetMap {
[subset: string]: boolean;
}
export function loadSubsetMap(fontBundle: IFontBundle): ISubsetMap {
return _.reduce(
fontBundle.font.subsets,
(sum, subset) => {
sum[subset] = _.includes(fontBundle.subsets, subset);
return sum;
},
{} as ISubsetMap
);
}
================================================
FILE: server/logic/fetchCSS.ts
================================================
import * as css from "css";
import * as _ from "lodash";
import { IUserAgents } from "../config";
import { asyncRetry } from "../utils/asyncRetry";
import axios from "axios";
const RETRIES = 2;
const REQUEST_TIMEOUT_MS = 6000;
interface IResource {
src: string | null;
fontFamily: string | null;
fontStyle: string | null;
fontWeight: string | null;
url: string;
}
export async function fetchCSS(family: string, cssSubsetString: string, type: keyof IUserAgents, userAgent: string): Promise {
const reqPath = `/css?family=${encodeURIComponent(family)}&subset=${cssSubsetString}`;
const hostname = "fonts.googleapis.com";
const url = `http://${hostname}${reqPath}`;
const txt = await asyncRetry(
async () => {
const res = await axios.get(url, {
timeout: REQUEST_TIMEOUT_MS,
responseType: "text",
maxRedirects: 0, // https://github.com/axios/axios/issues/2610
headers: {
Accept: "text/css,*/*;q=0.1",
"User-Agent": userAgent,
}
});
return res.data;
},
{ retries: RETRIES }
);
return parseRemoteCSS(txt, type);
}
function parseRemoteCSS(remoteCSS: string, type: string): IResource[] {
const parsedCSS = css.parse(remoteCSS);
if (_.isNil(parsedCSS.stylesheet)) {
throw new Error(`parseRemoteCSS: no stylesheets in parsed css for ${type}: ${remoteCSS}`);
}
const resources: IResource[] = [];
_.each(parsedCSS.stylesheet.rules, (rule) => {
// only font-face rules are relevant...
if (rule.type !== "font-face") {
return;
}
try {
const src = getCSSRuleDeclarationPropertyValue(rule, "src");
if (_.isNil(src)) {
console.warn(`parseRemoteCSS: no src in parsed css for ${type}: ${remoteCSS}`);
return;
}
let matched = src.match("http:\\/\\/[^\\)]+")
if (_.isNil(matched) || matched.length === 0) {
// might be https in the future
matched = src.match("https:\\/\\/[^\\)]+");
if (_.isNil(matched) || matched.length === 0) {
console.warn(`parseRemoteCSS: no matched url in parsed css for ${type}: ${remoteCSS}`);
return;
}
}
const url = matched[0];
// console.log(url);
const resource: IResource = {
src: getCSSRuleDeclarationPropertyValue(rule, "src"),
fontFamily: getCSSRuleDeclarationPropertyValue(rule, "font-family"),
fontStyle: getCSSRuleDeclarationPropertyValue(rule, "font-style"),
fontWeight: getCSSRuleDeclarationPropertyValue(rule, "font-weight"),
url
};
// push the current rule (= resource) to the resources array
resources.push(resource);
} catch (e) {
console.error("cannot load resource of type", type, remoteCSS, e);
}
});
return resources;
}
function getCSSRuleDeclarationPropertyValue(rule: css.Rule, property: string): string | null {
return _.get(
_.find(rule.declarations, (declaration) => {
return _.has(declaration, "property") && (declaration).property === property;
}),
"value",
null
);
}
================================================
FILE: server/logic/fetchFontSubsetArchive.ts
================================================
import * as Bluebird from "bluebird";
import * as fs from "fs";
import * as JSZip from "jszip";
import * as _ from "lodash";
import * as path from "path";
import { finished } from "stream/promises";
import { config } from "../config";
import { asyncRetry } from "../utils/asyncRetry";
import { IVariantItem } from "./fetchFontURLs";
import { Readable } from "stream";
import axios from "axios";
const RETRIES = 2;
const REQUEST_TIMEOUT_MS = 6000;
export interface IFontSubsetArchive {
zipPath: string; // absolute path to the zip file
files: IFontFile[];
}
export interface IFontFile {
variant: string;
format: string;
path: string; // relative path within the zip file
}
export async function fetchFontSubsetArchive(
fontID: string,
fontVersion: string,
subsets: string[],
variants: IVariantItem[]
): Promise {
const subsetFontArchive: IFontSubsetArchive = {
zipPath: path.join(config.CACHE_DIR, `/${fontID}-${fontVersion}-${subsets.join("_")}.zip`),
files: [],
};
const archive = new JSZip();
const streams: (Readable | fs.WriteStream)[] = _.compact(
_.flatten(
await Bluebird.map(variants, (variant) => {
return Bluebird.map(variant.urls, async (variantUrl) => {
const filename = `${fontID}-${fontVersion}-${subsets.join("_")}-${variant.id}.${variantUrl.format}`;
// download the file for type (filename now known)
let readable: Readable;
try {
// console.log("fetchFontSubsetArchive...", variantUrl.format, filename, variantUrl.url);
readable = await fetchFontSubsetArchiveStream(variantUrl.url);
archive.file(filename, readable);
} catch (e) {
// if a specific format does not work, silently discard it.
console.error("fetchFontSubsetArchive discarding", variantUrl.format, filename, variantUrl.url, e);
return null;
}
subsetFontArchive.files.push({
variant: variant.id, // variants and format are used to filter them out later!
format: variantUrl.format,
path: filename,
});
return readable;
});
})
)
);
const target = fs.createWriteStream(subsetFontArchive.zipPath);
streams.push(target);
console.info(`fetchFontSubsetArchive create archive... file=${subsetFontArchive.zipPath}`);
try {
await finished(archive.generateNodeStream({
compression: "DEFLATE",
}).pipe(target));
console.info(`fetchFontSubsetArchive create archive done! file=${subsetFontArchive.zipPath}`);
} catch (e) {
console.error("fetchFontSubsetArchive archive.generateNodeStream pipe failed", e);
// ensure all fs streams into the archive and the actual zip file are destroyed
_.each(streams, (stream, index) => {
try {
console.warn(`fetchFontSubsetArchive archive.generateNodeStream destroy stream ${index}/${streams.length}...`)
stream.destroy();
} catch (err) {
console.error("fetchFontSubsetArchive archive.generateNodeStream pipe failed, stream.destroy failed (catched)", fontID, subsets, err);
}
});
console.error("fetchFontSubsetArchive archive.generateNodeStream pipe failed, streams destroyed. Rethrowing err...", fontID, subsets, e);
throw e;
}
return subsetFontArchive;
}
async function fetchFontSubsetArchiveStream(url: string): Promise {
return asyncRetry(
async () => {
const res = await axios.get(url, {
timeout: REQUEST_TIMEOUT_MS,
responseType: "stream",
maxRedirects: 0 // https://github.com/axios/axios/issues/2610
});
return res.data;
},
{ retries: RETRIES }
);
}
================================================
FILE: server/logic/fetchFontURLs.ts
================================================
import * as Bluebird from "bluebird";
import * as _ from "lodash";
import { config, IUserAgents } from "../config";
import { fetchCSS } from "./fetchCSS";
export interface IVariantURL {
format: keyof IUserAgents;
url: string;
}
export interface IVariantItem {
id: string;
fontFamily: null | string;
fontStyle: null | string;
fontWeight: null | string;
urls: IVariantURL[];
}
const TARGETS = _.map(_.keys(config.USER_AGENTS), (key) => {
return {
format: key,
userAgent: config.USER_AGENTS[key],
};
});
export async function fetchFontURLs(fontFamily: string, fontVariants: string[], fontSubsets: string[]): Promise {
let variants: IVariantItem[] = [];
const cssSubsetString = fontSubsets.join(","); // make the variant string google API compatible...
await Bluebird.map(fontVariants, async (variant) => {
const cssFontFamily = `${fontFamily}:${variant}`;
const variantItem: IVariantItem = {
id: variant,
fontFamily: null,
fontStyle: null,
fontWeight: null,
urls: [],
};
await Bluebird.map(TARGETS, async (target) => {
const resources = await fetchCSS(cssFontFamily, cssSubsetString, target.format, target.userAgent);
if (resources.length === 0) {
console.warn(
`fetchFontURLs: no css ressources encountered for fontFamily='${cssFontFamily}' subset='${cssSubsetString}' format=${target.format}`,
resources
);
return;
}
if (resources.length > 1) {
console.warn(
`fetchFontURLs: multiple css ressources encountered for fontFamily='${cssFontFamily}' subset='${cssSubsetString}' format=${target.format}`,
resources
);
}
_.each(resources, (resource) => {
// save the format (woff, eot, svg, ttf, usw...)
variantItem.urls.push({
format: target.format,
// rewrite url to use https instead on http!
url: resource.url.split("http://").join("https://"), // resource.url.replace(/^http:\/\//i, 'https://');
});
// if not defined, also save procedded font-family, fontstyle, font-weight, unicode-range
if (_.isNil(variantItem.fontFamily) && !_.isNil(resource.fontFamily)) {
variantItem.fontFamily = resource.fontFamily;
}
if (_.isNil(variantItem.fontStyle) && !_.isNil(resource.fontStyle)) {
variantItem.fontStyle = resource.fontStyle;
}
if (_.isNil(variantItem.fontWeight) && !_.isNil(resource.fontWeight)) {
variantItem.fontWeight = resource.fontWeight;
}
});
});
variants.push(variantItem);
});
variants = _.sortBy(variants, function ({ fontWeight, fontStyle }) {
const styleOrder = fontStyle === "normal" ? 0 : 1;
return `${fontWeight}-${styleOrder}`;
});
return variants;
}
================================================
FILE: server/logic/fetchGoogleFonts.ts
================================================
import * as fs from "fs/promises";
import * as _ from "lodash";
import * as path from "path";
import * as speakingurl from "speakingurl";
import { config } from "../config";
import { asyncRetry } from "../utils/asyncRetry";
import axios from "axios";
const RETRIES = 2;
const REQUEST_TIMEOUT_MS = 10000;
export interface IFontItem {
id: string;
family: string;
subsets: string[];
category: string;
version: string;
lastModified: string;
popularity: number;
defSubset: string;
defVariant: string;
variants: string[];
}
interface IGoogleFontsRes {
kind: string;
items: IGoogleFontsResItem[];
}
interface IGoogleFontsResItem {
family: string;
variants: string[];
subsets: string[];
version: string;
lastModified: string;
files: {
[key: string]: string;
};
category: string;
kind: "webfonts#webfont";
}
// build up fonts cache via google API...
export async function fetchGoogleFonts(): Promise {
if (config.GOOGLE_FONTS_USE_TEST_JSON) {
const localPath = path.join(config.ROOT, "test/googlefonts.json");
if (config.ENV !== "test") {
console.warn(`fetchGoogleFonts is using local "${localPath}"`);
}
const testJson = await fs.readFile(localPath);
return transform(JSON.parse(testJson.toString()));
}
return asyncRetry(
async () => {
const res = await axios.get(`https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=${config.GOOGLE_FONTS_API_KEY}`, {
timeout: REQUEST_TIMEOUT_MS,
responseType: "json",
maxRedirects: 0 // https://github.com/axios/axios/issues/2610
});
return transform(res.data);
},
{ retries: RETRIES }
);
}
function transform(resData: IGoogleFontsRes): IFontItem[] {
return _.map(resData.items, (item, index) => {
return {
id: speakingurl(item.family),
family: item.family,
variants: item.variants,
subsets: item.subsets,
category: item.category,
version: item.version,
lastModified: item.lastModified,
popularity: index + 1, // property order by popularity -> index
// use latin per default, else first found font
defSubset: _.includes(item.subsets, "latin") ? "latin" : item.subsets[0],
defVariant: _.includes(item.variants, "regular") ? "regular" : item.variants[0],
};
});
}
================================================
FILE: server/logic/store.ts
================================================
import { mkdir } from "fs/promises";
import * as _ from "lodash";
import { config } from "../config";
import { IFontSubsetArchive } from "./fetchFontSubsetArchive";
import { IVariantItem } from "./fetchFontURLs";
import { fetchGoogleFonts, IFontItem } from "./fetchGoogleFonts";
// FontBundle holds:
// * the found stored font from google,
// * the requested (and found) subsets and
// * the unique storeID to access Maps in the store.
// It should be used as the sole way to interact with the store and must be build via store.getFontBundle
export interface IFontBundle {
storeID: string;
subsets: string[];
font: IFontItem;
}
const fontMap = new Map();
const urlMap = new Map();
const archiveMap = new Map();
export async function initStore() {
await mkdir(config.CACHE_DIR, { recursive: true });
_.each(await fetchGoogleFonts(), (font: IFontItem) => {
fontMap.set(font.id, font);
});
}
export async function reinitStore() {
if (config.ENV !== "test") {
console.warn("reinitStore was called, building fresh stores...");
}
fontMap.clear();
urlMap.clear();
archiveMap.clear();
return initStore();
}
export function getStoredFontItems(): IFontItem[] {
return Array.from(fontMap.values());
}
export function getFontBundle(fontID: string, wantedSubsets: string[] | null): IFontBundle | null {
const font = fontMap.get(fontID);
if (_.isNil(font)) {
return null;
}
const match =
!_.isArray(wantedSubsets) || wantedSubsets.length === 0
? [font.defSubset] // supply filter with the default subset as defined in googleFontsAPI fetcher (latin or if no found other)
: _.intersection(font.subsets, wantedSubsets);
const subsets = _.sortBy(_.uniq(match));
if (subsets.length === 0) {
return null;
}
return {
// not this must be a stable key fully identifying the font, its version and wantedSubsets
storeID: `${font.id}@${font.version}__${subsets.join("_")}`,
subsets,
font,
};
}
export function getStoredVariantItems({ storeID }: IFontBundle): IVariantItem[] | null {
const variants = urlMap.get(storeID);
if (_.isNil(variants)) {
return null;
}
return variants;
}
export function getStoredFontSubsetArchive({ storeID }: IFontBundle): IFontSubsetArchive | null {
const subsetFontArchive = archiveMap.get(storeID);
if (_.isNil(subsetFontArchive)) {
return null;
}
return subsetFontArchive;
}
export function storeVariantItems({ storeID }: IFontBundle, variants: IVariantItem[]) {
const existings = urlMap.get(storeID);
if (!_.isNil(existings)) {
console.warn("storeVariantItems: duplicate save of storeID: ", storeID);
if (config.ENV === "test") {
throw new Error("storeVariantItems duplicate write");
}
return;
}
urlMap.set(storeID, variants);
}
export function storeFontSubsetArchive({ storeID }: IFontBundle, subsetFontArchive: IFontSubsetArchive) {
const existings = archiveMap.get(storeID);
if (!_.isNil(existings)) {
console.warn("storeFontSubsetArchive: duplicate save of storeID: ", storeID);
if (config.ENV === "test") {
throw new Error("storeFontSubsetArchive duplicate write");
}
return;
}
archiveMap.set(storeID, subsetFontArchive);
}
export function getStats() {
return {
fontMap: fontMap.size,
urlMap: urlMap.size,
archiveMap: archiveMap.size,
urls: _.sumBy(Array.from(urlMap.values()), function (f) {
return f.length;
}),
files: _.sumBy(Array.from(archiveMap.values()), function (archive) {
return archive.files.length;
}),
};
}
================================================
FILE: server/routes.ts
================================================
import * as express from "express";
import { getApiFonts, getApiFontsById } from "./api/fonts.controller";
import { getHealthy } from "./api/healthy.controller";
export function setupRoutes(app: express.Express) {
app.use("/fonts", express.static(app.get("appPath") + "/index.html"));
app.use("/fonts/", express.static(app.get("appPath") + "/index.html"));
app.use("/fonts/:id", express.static(app.get("appPath") + "/index.html"));
app.route("/api/fonts").get(getApiFonts);
app.route("/api/fonts/:id").get(getApiFontsById);
app.route("/-/healthy").get(getHealthy);
// All undefined asset or api routes should return a 404
app.route("/:url(-|api|auth|components|app|bower_components|assets)/*").get(function (req, res) {
res.status(404).send("Not found");
});
// All other routes should redirect to the index.html
app.route("/*").get(function (req, res) {
res.redirect(req.baseUrl + "/");
});
}
================================================
FILE: server/utils/asyncRetry.spec.ts
================================================
import * as Bluebird from "bluebird";
import * as should from "should";
import { asyncRetry } from "./asyncRetry";
describe("utils/asyncRetry", function () {
it("retry works as expected when last succeeds", async () => {
const RETRIES = 2;
let cnt = 0;
await asyncRetry(
async () => {
await Bluebird.delay(1);
cnt += 1;
if (cnt <= RETRIES) {
throw new Error("not yet");
}
},
{ retries: RETRIES }
);
should(cnt).eql(RETRIES + 1);
});
it("retry works as expected when all fail with same error", async () => {
const RETRIES = 2;
let cnt = 0;
let err: AggregateError | null = null;
try {
await asyncRetry(
async () => {
await Bluebird.delay(1);
cnt += 1;
throw new Error("step err");
},
{ retries: RETRIES }
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
err = e;
}
// console.log(err);
should(cnt).eql(RETRIES + 1);
should(err).instanceOf(AggregateError);
should(err?.errors.length).eql(1); // unique errors returned by msg
});
it("retry works as expected when all fail with different errors", async () => {
const RETRIES = 2;
let cnt = 0;
let err: AggregateError | null = null;
try {
await asyncRetry(
async () => {
await Bluebird.delay(1);
cnt += 1;
throw new Error("step" + cnt);
},
{ retries: RETRIES }
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
err = e;
}
// console.log(err);
should(cnt).eql(RETRIES + 1);
should(err).instanceOf(AggregateError);
should(err?.errors.length).eql(RETRIES + 1);
});
});
================================================
FILE: server/utils/asyncRetry.ts
================================================
import * as Bluebird from "bluebird";
import * as _ from "lodash";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function asyncRetry(fn: () => Promise, options: { retries: number }, errors: any[] = []): Promise {
let t: T;
try {
t = await fn();
} catch (e) {
if (errors.length >= options.retries) {
throw new AggregateError(
_.unionBy([...errors, e], "message"),
`asyncRetry: maximal retries exceeded. retries=${options.retries} errors=${errors.length}`
);
}
// 2 ** 0 * 500 = 500ms
// 2 ** 1 * 500 = 1000ms => 1500ms
// 2 ** 2 * 500 = 2000ms => 3500ms
const bailoutMS = 2 ** errors.length * 500;
// console.error(`asyncRetry: try ${errors.length + 1} failed, retries=${options.retries}. Delaying next try ${bailoutMS}ms`);
await Bluebird.delay(bailoutMS);
// console.warn(`asyncRetry: retrying after ${bailoutMS}ms`);
return asyncRetry(fn, options, [...errors, e]);
}
return t;
}
================================================
FILE: server/utils/synchronized.ts
================================================
import * as _ from "lodash";
import { config } from "../config";
// cached promise by key for in-flight request handling
export function synchronizedBy(target: () => Promise): (cacheKey: string) => Promise;
export function synchronizedBy(target: (arg1: A1) => Promise): (cacheKey: string, arg1: A1) => Promise;
export function synchronizedBy(target: (arg1: A1, arg2: A2) => Promise): (cacheKey: string, arg1: A1, arg2: A2) => Promise;
export function synchronizedBy(target: (...args: A[]) => Promise): (cacheKey: string, ...args: A[]) => Promise {
const mutexMap = new Map>();
return async function (cacheKey: string, ...params: A[]) {
let mutexPromise = mutexMap.get(cacheKey);
if (_.isNil(mutexPromise)) {
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-empty-function
let resolveMutexPromise: Function = () => {};
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-empty-function
let rejectMutexPromise: Function = () => {};
mutexPromise = new Promise(function (this: Promise, resolve, reject) {
resolveMutexPromise = resolve.bind(this);
rejectMutexPromise = reject.bind(this);
});
mutexMap.set(cacheKey, mutexPromise);
try {
const ret = await target(...params);
resolveMutexPromise(ret);
} catch (err) {
rejectMutexPromise(err);
}
} else {
if (config.ENV === "test") {
console.log("synchronizedBy cache hit:", cacheKey);
}
}
try {
const value = await mutexPromise;
// rm from cache again
mutexMap.delete(cacheKey);
return value;
} catch (error) {
// rm from cache again
mutexMap.delete(cacheKey);
throw error;
}
};
}
================================================
FILE: test/googlefonts.json
================================================
{
"kind": "webfonts#webfontList",
"items": [
{
"family": "Arvo",
"variants": [
"regular",
"italic",
"700",
"700italic"
],
"subsets": [
"latin"
],
"version": "v20",
"lastModified": "2022-09-22",
"files": {
"700": "http://localhost/font.ttf",
"regular": "http://localhost/font.ttf",
"italic": "http://localhost/font.ttf",
"700italic": "http://localhost/font.ttf"
},
"category": "serif",
"kind": "webfonts#webfont"
},
{
"family": "Istok Web",
"variants": [
"regular",
"italic",
"700",
"700italic"
],
"subsets": [
"cyrillic",
"cyrillic-ext",
"latin",
"latin-ext"
],
"version": "v20",
"lastModified": "2022-09-22",
"files": {
"700": "http://localhost/font.ttf",
"regular": "http://localhost/font.ttf",
"italic": "http://localhost/font.ttf",
"700italic": "http://localhost/font.ttf"
},
"category": "sans-serif",
"kind": "webfonts#webfont"
},
{
"family": "Playfair Display",
"variants": [
"regular",
"500",
"600",
"700",
"800",
"900",
"italic",
"500italic",
"600italic",
"700italic",
"800italic",
"900italic"
],
"subsets": [
"cyrillic",
"latin",
"latin-ext",
"vietnamese"
],
"version": "v30",
"lastModified": "2022-09-22",
"files": {
"500": "http://localhost/font.ttf",
"600": "http://localhost/font.ttf",
"700": "http://localhost/font.ttf",
"800": "http://localhost/font.ttf",
"900": "http://localhost/font.ttf",
"regular": "http://localhost/font.ttf",
"italic": "http://localhost/font.ttf",
"500italic": "http://localhost/font.ttf",
"600italic": "http://localhost/font.ttf",
"700italic": "http://localhost/font.ttf",
"800italic": "http://localhost/font.ttf",
"900italic": "http://localhost/font.ttf"
},
"category": "serif",
"kind": "webfonts#webfont"
}
]
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNEXT",
"module": "commonjs",
"sourceMap": true,
"outDir": "./dist/server",
"rootDir": "./server",
"strict": true
},
"include": [
"server/**/*.ts",
"server/**/*.js",
"server/**/*.json"
]
}