Repository: majodev/google-webfonts-helper
Branch: master
Commit: 6043c3c26908
Files: 56
Total size: 149.0 KB
Directory structure:
gitextract_h4jp3g9b/
├── .bowerrc
├── .devcontainer.json
├── .dockerignore
├── .eslintrc.cjs
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── build-only.yml
│ ├── build-publish-deploy.yml
│ └── docker.env
├── .gitignore
├── .prettierrc
├── .vscode/
│ ├── extensions.json
│ └── tasks.json
├── .yo-rc.json
├── Dockerfile
├── Gruntfile.js
├── LICENSE.txt
├── README.md
├── bower.json
├── client/
│ ├── app/
│ │ ├── app.js
│ │ ├── app.less
│ │ ├── cssCode/
│ │ │ ├── cssCode.directive.js
│ │ │ ├── cssCode.directive.spec.js
│ │ │ ├── cssCode.html
│ │ │ └── cssCode.less
│ │ ├── fonts/
│ │ │ ├── fonts.controller.js
│ │ │ ├── fonts.controller.spec.js
│ │ │ ├── fonts.html
│ │ │ ├── fonts.js
│ │ │ ├── fonts.less
│ │ │ └── fontsItem.html
│ │ └── highlightjs/
│ │ ├── highlightjs.directive.js
│ │ └── highlightjs.directive.spec.js
│ ├── index.html
│ └── robots.txt
├── docker-compose.yml
├── docker-helper.sh
├── package.json
├── server/
│ ├── api/
│ │ ├── fonts.controller.ts
│ │ ├── fonts.spec.ts
│ │ ├── healthy.controller.ts
│ │ └── healthy.spec.ts
│ ├── app.spec.ts
│ ├── app.ts
│ ├── config.ts
│ ├── logic/
│ │ ├── core.ts
│ │ ├── fetchCSS.ts
│ │ ├── fetchFontSubsetArchive.ts
│ │ ├── fetchFontURLs.ts
│ │ ├── fetchGoogleFonts.ts
│ │ └── store.ts
│ ├── routes.ts
│ └── utils/
│ ├── asyncRetry.spec.ts
│ ├── asyncRetry.ts
│ └── synchronized.ts
├── test/
│ └── googlefonts.json
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .bowerrc
================================================
{
"directory": "client/bower_components"
}
================================================
FILE: .devcontainer.json
================================================
// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.106.0/containers/docker-existing-docker-compose
// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
{
"name": "app",
// Update the 'dockerComposeFile' list if you have more compose files or use different names.
// The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
"dockerComposeFile": [
"./docker-compose.yml"
],
// The 'service' property is the name of the service for the container that VS Code should
// use. Update this value and .devcontainer/docker-compose.yml to the real service name.
"service": "service",
"remoteUser": "node",
// The optional 'workspaceFolder' property is the path VS Code should open by default when
// connected. This is typically a file mount in .devcontainer/docker-compose.yml
"workspaceFolder": "/app",
"shutdownAction": "none",
// Set *default* container specific settings.json values on container create.
"customizations": {
"vscode": {
"settings": {
"typescript.tsdk": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnType": false,
"editor.formatOnPaste": false
}
},
"extensions": [
"eamodio.gitlens",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yzhang.markdown-all-in-one"
]
}
}
// Uncomment the next line if you want start specific services in your Docker Compose config.
// "runServices": [],
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
// "shutdownAction": "none",
// Uncomment the next line to run commands after the container is created - for example installing git.
// "postCreateCommand": "apt-get update && apt-get install -y git",
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
// "remoteUser": "vscode"
}
================================================
FILE: .dockerignore
================================================
.tmp
dist
.env
client/bower_components
node_modules
server/logic/cachedFonts
.tscache
tscommand-
================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
ignorePatterns: [".eslintrc.cjs", "Gruntfile.js", "dist/**/*.js", "client/**/*.js"],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
root: true,
parserOptions: {
project: "./tsconfig.json"
}
};
================================================
FILE: .github/FUNDING.yml
================================================
github: majodev
================================================
FILE: .github/workflows/build-only.yml
================================================
name: Build gwfh
on:
push:
branches:
- "**"
- "!master"
- "!dev"
env:
DOCKER_ENV_FILE: ".github/workflows/docker.env"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-only:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build only
uses: docker/build-push-action@v6
with:
context: .
push: false
# load: true # add to local docker daemon so we can use it in the next step
tags: |
${{ steps.meta.outputs.tags }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
# - name: trivy scan
# uses: aquasecurity/trivy-action@master
# with:
# image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'
# format: 'template'
# template: '@/contrib/sarif.tpl'
# output: 'trivy-results.sarif'
# severity: 'CRITICAL,HIGH'
# ignore-unfixed: true
================================================
FILE: .github/workflows/build-publish-deploy.yml
================================================
# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images
name: Create, publish and deploy gwfh
on:
push:
tags: "**"
branches:
- "master"
- "dev"
env:
DOCKER_ENV_FILE: ".github/workflows/docker.env"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-publish-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.tags }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
# - name: Connect to tailscale network
# uses: tailscale/github-action@v2
# with:
# oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
# oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
# tags: tag:ci
# - name: Set image on dev cluster
# if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }}
# uses: actions-hub/kubectl@master
# env:
# KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_DEV }}
# NAMESPACE: gwfh-dev
# with:
# args: -n ${{ env.NAMESPACE }} set image deployment/app app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
# - name: Set image on prod cluster
# if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
# uses: actions-hub/kubectl@master
# env:
# KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_PROD }}
# NAMESPACE: gwfh-prod
# with:
# args: -n ${{ env.NAMESPACE }} set image deployment/app app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
================================================
FILE: .github/workflows/docker.env
================================================
CI=true
GITHUB_ACTIONS=true
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules*
public
.tmp
.idea
client/bower_components
dist
/server/config/local.env.js
/server/logic/cachedFonts
.env
npm-debug.log
.tscache
tscommand-
/tmp
================================================
FILE: .prettierrc
================================================
{
"semi": true,
"singleQuote": false,
"printWidth": 140
}
================================================
FILE: .vscode/extensions.json
================================================
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
"ms-azuretools.vscode-docker",
"ms-vscode-remote.remote-containers"
]
}
================================================
FILE: .vscode/tasks.json
================================================
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": [
"$tsc-watch"
],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
================================================
FILE: .yo-rc.json
================================================
{
"generator-angular-fullstack": {
"insertRoutes": true,
"registerRoutesFile": "server/routes.js",
"routesNeedle": "// Insert routes below",
"routesBase": "/api/",
"pluralizeRoutes": true,
"insertSockets": true,
"registerSocketsFile": "server/config/socketio.js",
"socketsNeedle": "// Insert sockets below",
"filters": {
"js": true,
"html": true,
"less": true,
"uirouter": true,
"bootstrap": true,
"uibootstrap": true
}
},
"generator-ng-component": {
"routeDirectory": "client/app/",
"directiveDirectory": "client/app/",
"filterDirectory": "client/app/",
"serviceDirectory": "client/app/",
"basePath": "client",
"moduleName": "",
"filters": [
"uirouter"
],
"extensions": [
"js",
"html",
"less"
],
"directiveSimpleTemplates": "",
"directiveComplexTemplates": "",
"filterTemplates": "",
"serviceTemplates": "",
"factoryTemplates": "",
"controllerTemplates": "",
"decoratorTemplates": "",
"providerTemplates": "",
"routeTemplates": ""
}
}
================================================
FILE: Dockerfile
================================================
### -----------------------
# --- Stage: development
# --- Purpose: Local dev environment (no application deps)
### -----------------------
FROM node:22.20.0-trixie AS development
# Replace shell with bash so we can source files
RUN rm /bin/sh && ln -s /bin/bash /bin/sh
# Set debconf to run non-interactively
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
# Install base dependencies
RUN apt-get update && apt-get install -y -q --no-install-recommends \
apt-transport-https \
build-essential \
ca-certificates \
curl \
git \
jpegoptim \
libssl-dev \
lsof \
optipng \
tini \
wget \
&& rm -rf /var/lib/apt/lists/*
# global npm installs
RUN npm install -g grunt-cli@1.2.0 \
&& npm cache clean --force
WORKDIR /app
### -----------------------
# --- Stage: builder
# --- Purpose: Installs application deps and builds the service
### -----------------------
FROM development AS builder
# install server and bundler deps
COPY package.json /app/package.json
COPY yarn.lock /app/yarn.lock
RUN yarn --pure-lockfile
# install clientside deps (bower is a managed application local dev dep)
COPY bower.json /app/bower.json
COPY .bowerrc /app/.bowerrc
RUN ./node_modules/.bin/bower install
# copy in all workspace files
COPY . /app/
# build dist
RUN grunt build
# prepare production node_modules (this cleans up dev deps)
# https://github.com/vercel/next.js/pull/23056
# https://github.com/yarnpkg/yarn/issues/6373
RUN yarn install --production --ignore-scripts --prefer-offline
### -----------------------
# --- Stage: production
# --- Purpose: Final step from a new slim image. this should be a minimal image only housing dist (production service)
### -----------------------
FROM node:22.20.0-trixie AS production
# https://github.com/nodejs/docker-node/blob/7de353256a35856c788b37c1826331dbba5f0785/docs/BestPractices.md
# Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker.
# You can also include Tini directly in your Dockerfile, ensuring your process is always started with an init wrapper.
RUN apt-get update && apt-get install -y -q --no-install-recommends \
ca-certificates \
lsof \
tini \
&& rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /app
# copy prebuilt production node_modules
COPY --chown=node:node --from=builder /app/node_modules /app/node_modules
# copy prebuilt dist
COPY --chown=node:node --from=builder /app/dist /app/dist
ENV NODE_ENV=production
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node","dist/server/app.js"]
================================================
FILE: Gruntfile.js
================================================
// Generated on 2014-12-21 using generator-angular-fullstack 2.0.13
'use strict';
module.exports = function (grunt) {
var localConfig = {};
// Load grunt tasks automatically, when needed
require('jit-grunt')(grunt, {
express: 'grunt-express-server',
useminPrepare: 'grunt-usemin',
ngtemplates: 'grunt-angular-templates',
injector: 'grunt-asset-injector',
});
// Time how long tasks take. Can help when optimizing build times
require('time-grunt')(grunt);
// Define the configuration for all the tasks
grunt.initConfig({
// Project settings
pkg: grunt.file.readJSON('package.json'),
yeoman: {
// configurable paths
client: require('./bower.json').appPath || 'client',
dist: 'dist'
},
express: {
options: {
port: process.env.PORT || 9000,
opts: ['node_modules/.bin/ts-node'],
},
dev: {
options: {
script: 'server/app.ts',
}
},
prod: {
options: {
script: 'dist/server/app.js'
}
}
},
watch: {
injectJS: {
files: [
'<%= yeoman.client %>/{app,components}/**/*.js',
'!<%= yeoman.client %>/{app,components}/**/*.spec.js',
'!<%= yeoman.client %>/{app,components}/**/*.mock.js',
'!<%= yeoman.client %>/app/app.js'],
tasks: ['injector:scripts']
},
injectCss: {
files: [
'<%= yeoman.client %>/{app,components}/**/*.css'
],
tasks: ['injector:css']
},
mochaTest: {
files: ['server/**/*.spec.ts', 'server/**/*.spec.js'],
tasks: ['env:test', 'mochaTest']
},
injectLess: {
files: [
'<%= yeoman.client %>/{app,components}/**/*.less'],
tasks: ['injector:less']
},
less: {
files: [
'<%= yeoman.client %>/{app,components}/**/*.less'],
tasks: ['less', 'autoprefixer']
},
gruntfile: {
files: ['Gruntfile.js']
},
livereload: {
files: [
'{.tmp,<%= yeoman.client %>}/{app,components}/**/*.css',
'{.tmp,<%= yeoman.client %>}/{app,components}/**/*.html',
'{.tmp,<%= yeoman.client %>}/{app,components}/**/*.js',
'!{.tmp,<%= yeoman.client %>}{app,components}/**/*.spec.js',
'!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.mock.js',
'<%= yeoman.client %>/assets/images/{,*//*}*.{png,jpg,jpeg,gif,webp,svg}'
],
options: {
livereload: true
}
},
express: {
files: [
'server/**/*.{ts,js,json}'
],
tasks: ['express:dev', 'wait'],
options: {
livereload: true,
nospawn: true //Without this option specified express won't be reloaded
}
}
},
// Empties folders to start fresh
clean: {
dist: {
files: [{
dot: true,
src: [
'.tmp',
'<%= yeoman.dist %>/*',
'!<%= yeoman.dist %>/.git*',
'!<%= yeoman.dist %>/.openshift',
'!<%= yeoman.dist %>/Procfile'
]
}]
},
server: '.tmp',
cachedFonts: 'server/logic/cachedFonts/*.*'
},
// Add vendor prefixed styles
autoprefixer: {
options: {
browsers: ['last 1 version']
},
dist: {
files: [{
expand: true,
cwd: '.tmp/',
src: '{,*/}*.css',
dest: '.tmp/'
}]
}
},
// Use nodemon to run server in debug mode with an initial breakpoint
nodemon: {
debug: {
script: 'server/app.ts',
options: {
env: {
PORT: process.env.PORT || 9000
},
callback: function (nodemon) {
nodemon.on('log', function (event) {
console.log(event.colour);
});
}
}
}
},
// Automatically inject Bower components into the app
wiredep: {
target: {
src: '<%= yeoman.client %>/index.html',
ignorePath: '<%= yeoman.client %>/',
exclude: [/bootstrap-sass-official/, '/json3/', '/es5-shim/', /bootstrap.css/, /font-awesome.css/]
}
},
// Renames files for browser caching purposes
rev: {
dist: {
files: {
src: [
'<%= yeoman.dist %>/public/{,*/}*.js',
'<%= yeoman.dist %>/public/{,*/}*.css',
'<%= yeoman.dist %>/public/assets/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',
'<%= yeoman.dist %>/public/assets/fonts/*'
]
}
}
},
// Reads HTML for usemin blocks to enable smart builds that automatically
// concat, minify and revision files. Creates configurations in memory so
// additional tasks can operate on them
useminPrepare: {
html: ['<%= yeoman.client %>/index.html'],
options: {
dest: '<%= yeoman.dist %>/public'
}
},
// Performs rewrites based on rev and the useminPrepare configuration
usemin: {
html: ['<%= yeoman.dist %>/public/{,*/}*.html'],
css: ['<%= yeoman.dist %>/public/{,*/}*.css'],
js: ['<%= yeoman.dist %>/public/{,*/}*.js'],
options: {
assetsDirs: [
'<%= yeoman.dist %>/public',
'<%= yeoman.dist %>/public/assets/images'
],
// This is so we update image references in our ng-templates
patterns: {
js: [
[/(assets\/images\/.*?\.(?:gif|jpeg|jpg|png|webp|svg))/gm, 'Update the JS to reference our revved images']
]
}
}
},
// The following *-min tasks produce minified files in the dist folder
imagemin: {
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.client %>/assets/images',
src: '{,*/}*.{png,jpg,jpeg,gif}',
dest: '<%= yeoman.dist %>/public/assets/images'
}]
}
},
svgmin: {
dist: {
files: [{
expand: true,
cwd: '<%= yeoman.client %>/assets/images',
src: '{,*/}*.svg',
dest: '<%= yeoman.dist %>/public/assets/images'
}]
}
},
// Allow the use of non-minsafe AngularJS files. Automatically makes it
// minsafe compatible so Uglify does not destroy the ng references
ngAnnotate: {
dist: {
files: [{
expand: true,
cwd: '.tmp/concat',
src: '*/**.js',
dest: '.tmp/concat'
}]
}
},
// Package all the html partials into a single javascript payload
ngtemplates: {
options: {
// This should be the name of your apps angular module
module: 'googleWebfontsHelperApp',
htmlmin: {
collapseBooleanAttributes: true,
collapseWhitespace: true,
removeAttributeQuotes: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true
},
usemin: 'app/app.js'
},
main: {
cwd: '<%= yeoman.client %>',
src: ['{app,components}/**/*.html'],
dest: '.tmp/templates.js'
},
tmp: {
cwd: '.tmp',
src: ['{app,components}/**/*.html'],
dest: '.tmp/tmp-templates.js'
}
},
// Copies remaining files to places other tasks can use
copy: {
dist: {
files: [{
expand: true,
dot: true,
cwd: '<%= yeoman.client %>',
dest: '<%= yeoman.dist %>/public',
src: [
'*.{ico,png,txt}',
'bower_components/font-awesome/fonts/*',
'bower_components/bootstrap/fonts/*',
'assets/images/{,*/}*.{webp}',
'assets/fonts/**/*',
'index.html'
]
}, {
expand: true,
cwd: '.tmp/images',
dest: '<%= yeoman.dist %>/public/assets/images',
src: ['generated/*']
},
// {
// expand: true,
// dest: '<%= yeoman.dist %>',
// src: [
// 'server/**/*',
// '!server/**/*.spec.js',
// ]
// }
]
},
styles: {
expand: true,
cwd: '<%= yeoman.client %>',
dest: '.tmp/',
src: ['{app,components}/**/*.css']
}
},
ts: {
default: {
tsconfig: './tsconfig.json'
}
},
// Run some tasks in parallel to speed up the build process
concurrent: {
server: [
'less',
],
test: [
'less',
],
debug: {
tasks: [
'nodemon',
// 'node-inspector'
],
options: {
logConcurrentOutput: true
}
},
dist: [
'less',
'imagemin',
'svgmin'
]
},
mochaTest: {
options: {
reporter: 'spec',
require: 'ts-node/register'
},
src: ['server/**/*.spec.ts', 'server/**/*.spec.js']
},
env: {
test: {
NODE_ENV: 'test'
},
prod: {
NODE_ENV: 'production'
},
all: localConfig
},
// Compiles Less to CSS
less: {
options: {
paths: [
'<%= yeoman.client %>/bower_components',
'<%= yeoman.client %>/app',
'<%= yeoman.client %>/components'
]
},
server: {
files: {
'.tmp/app/app.css': '<%= yeoman.client %>/app/app.less'
}
},
},
injector: {
options: {
},
// Inject application script files into index.html (doesn't include bower)
scripts: {
options: {
transform: function (filePath) {
filePath = filePath.replace('/client/', '');
filePath = filePath.replace('/.tmp/', '');
return '<script src="' + filePath + '"></script>';
},
starttag: '<!-- injector:js -->',
endtag: '<!-- endinjector -->'
},
files: {
'<%= yeoman.client %>/index.html': [
['{.tmp,<%= yeoman.client %>}/{app,components}/**/*.js',
'!{.tmp,<%= yeoman.client %>}/app/app.js',
'!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.spec.js',
'!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.mock.js']
]
}
},
// Inject component less into app.less
less: {
options: {
transform: function (filePath) {
filePath = filePath.replace('/client/app/', '');
filePath = filePath.replace('/client/components/', '');
return '@import \'' + filePath + '\';';
},
starttag: '// injector',
endtag: '// endinjector'
},
files: {
'<%= yeoman.client %>/app/app.less': [
'<%= yeoman.client %>/{app,components}/**/*.less',
'!<%= yeoman.client %>/app/app.less'
]
}
},
// Inject component css into index.html
css: {
options: {
transform: function (filePath) {
filePath = filePath.replace('/client/', '');
filePath = filePath.replace('/.tmp/', '');
return '<link rel="stylesheet" href="' + filePath + '">';
},
starttag: '<!-- injector:css -->',
endtag: '<!-- endinjector -->'
},
files: {
'<%= yeoman.client %>/index.html': [
'<%= yeoman.client %>/{app,components}/**/*.css'
]
}
}
},
});
// Used for delaying livereload until after server has restarted
grunt.registerTask('wait', function () {
grunt.log.ok('Waiting for server reload...');
var done = this.async();
setTimeout(function () {
grunt.log.writeln('Done waiting!');
done();
}, 1500);
});
grunt.registerTask('express-keepalive', 'Keep grunt running', function () {
this.async();
});
grunt.registerTask('serve', function (target) {
if (target === 'dist') {
return grunt.task.run(['build', 'env:all', 'env:prod', 'express:prod', 'wait', 'express-keepalive']);
}
if (target === 'debug') {
return grunt.task.run([
'clean:server',
'clean:cachedFonts',
'env:all',
'injector:less',
'concurrent:server',
'injector',
'wiredep',
'autoprefixer',
'concurrent:debug'
]);
}
grunt.task.run([
'clean:server',
'clean:cachedFonts',
'env:all',
'injector:less',
'concurrent:server',
'injector',
'wiredep',
'autoprefixer',
'express:dev',
'wait',
'watch'
]);
});
grunt.registerTask('server', function () {
grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.');
grunt.task.run(['serve']);
});
grunt.registerTask('test', function (target) {
if (target === 'server') {
return grunt.task.run([
'env:all',
'env:test',
'mochaTest'
]);
}
else if (target === 'client') {
return grunt.task.run([
'clean:server',
'clean:cachedFonts',
'env:all',
'injector:less',
'concurrent:test',
'injector',
'autoprefixer'
]);
}
else grunt.task.run([
'test:server',
'test:client'
]);
});
grunt.registerTask('build', [
'clean:dist',
'injector:less',
'concurrent:dist',
'injector',
'wiredep',
'useminPrepare',
'autoprefixer',
'ngtemplates',
'concat',
'ngAnnotate',
'copy:dist',
'ts',
'cssmin',
'uglify',
'rev',
'usemin'
]);
grunt.registerTask('default', [
'test',
'build'
]);
};
================================================
FILE: LICENSE.txt
================================================
The MIT License (MIT)
Copyright (c) 2016 Mario Ranftl | majodev and "The Google Webfonts Helper" Project Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# google-webfonts-helper [](https://gwfh.mranftl.com) [](https://gwfh.mranftl.com) [](https://github.com/sponsors/majodev)
> A Hassle-Free Way to Self-Host Google Fonts
✅ **[https://gwfh.mranftl.com](https://gwfh.mranftl.com)**
## Current Sponsors
> *Help me keep this service alive by [sponsoring me](https://github.com/sponsors/majodev). Thank you. ❤️*
[<img src="https://sponsors.mranftl.com/avatar/0" width="35">](https://sponsors.mranftl.com/profile/0)
[<img src="https://sponsors.mranftl.com/avatar/1" width="35">](https://sponsors.mranftl.com/profile/1)
[<img src="https://sponsors.mranftl.com/avatar/2" width="35">](https://sponsors.mranftl.com/profile/2)
[<img src="https://sponsors.mranftl.com/avatar/3" width="35">](https://sponsors.mranftl.com/profile/3)
[<img src="https://sponsors.mranftl.com/avatar/4" width="35">](https://sponsors.mranftl.com/profile/4)
[<img src="https://sponsors.mranftl.com/avatar/5" width="35">](https://sponsors.mranftl.com/profile/5)
[<img src="https://sponsors.mranftl.com/avatar/6" width="35">](https://sponsors.mranftl.com/profile/6)
[<img src="https://sponsors.mranftl.com/avatar/7" width="35">](https://sponsors.mranftl.com/profile/7)
[<img src="https://sponsors.mranftl.com/avatar/8" width="35">](https://sponsors.mranftl.com/profile/8)
[<img src="https://sponsors.mranftl.com/avatar/9" width="35">](https://sponsors.mranftl.com/profile/9)
[<img src="https://sponsors.mranftl.com/avatar/10" width="35">](https://sponsors.mranftl.com/profile/10)
[<img src="https://sponsors.mranftl.com/avatar/11" width="35">](https://sponsors.mranftl.com/profile/11)
[<img src="https://sponsors.mranftl.com/avatar/12" width="35">](https://sponsors.mranftl.com/profile/12)
[<img src="https://sponsors.mranftl.com/avatar/13" width="35">](https://sponsors.mranftl.com/profile/13)
[<img src="https://sponsors.mranftl.com/avatar/14" width="35">](https://sponsors.mranftl.com/profile/14)
[<img src="https://sponsors.mranftl.com/avatar/15" width="35">](https://sponsors.mranftl.com/profile/15)
[<img src="https://sponsors.mranftl.com/avatar/16" width="35">](https://sponsors.mranftl.com/profile/16)
[<img src="https://sponsors.mranftl.com/avatar/17" width="35">](https://sponsors.mranftl.com/profile/17)
[<img src="https://sponsors.mranftl.com/avatar/18" width="35">](https://sponsors.mranftl.com/profile/18)
[<img src="https://sponsors.mranftl.com/avatar/19" width="35">](https://sponsors.mranftl.com/profile/19)
[<img src="https://sponsors.mranftl.com/avatar/20" width="35">](https://sponsors.mranftl.com/profile/20)
[<img src="https://sponsors.mranftl.com/avatar/21" width="35">](https://sponsors.mranftl.com/profile/21)
[<img src="https://sponsors.mranftl.com/avatar/22" width="35">](https://sponsors.mranftl.com/profile/22)
[<img src="https://sponsors.mranftl.com/avatar/23" width="35">](https://sponsors.mranftl.com/profile/23)
[<img src="https://sponsors.mranftl.com/avatar/24" width="35">](https://sponsors.mranftl.com/profile/24)
[<img src="https://sponsors.mranftl.com/avatar/25" width="35">](https://sponsors.mranftl.com/profile/25)
[<img src="https://sponsors.mranftl.com/avatar/26" width="35">](https://sponsors.mranftl.com/profile/26)
[<img src="https://sponsors.mranftl.com/avatar/27" width="35">](https://sponsors.mranftl.com/profile/27)
[<img src="https://sponsors.mranftl.com/avatar/28" width="35">](https://sponsors.mranftl.com/profile/28)
[<img src="https://sponsors.mranftl.com/avatar/29" width="35">](https://sponsors.mranftl.com/profile/29)
[<img src="https://sponsors.mranftl.com/avatar/30" width="35">](https://sponsors.mranftl.com/profile/30)
[<img src="https://sponsors.mranftl.com/avatar/31" width="35">](https://sponsors.mranftl.com/profile/31)
[<img src="https://sponsors.mranftl.com/avatar/32" width="35">](https://sponsors.mranftl.com/profile/32)
[<img src="https://sponsors.mranftl.com/avatar/33" width="35">](https://sponsors.mranftl.com/profile/33)
[<img src="https://sponsors.mranftl.com/avatar/34" width="35">](https://sponsors.mranftl.com/profile/34)
[<img src="https://sponsors.mranftl.com/avatar/35" width="35">](https://sponsors.mranftl.com/profile/35)
[<img src="https://sponsors.mranftl.com/avatar/36" width="35">](https://sponsors.mranftl.com/profile/36)
[<img src="https://sponsors.mranftl.com/avatar/37" width="35">](https://sponsors.mranftl.com/profile/37)
[<img src="https://sponsors.mranftl.com/avatar/38" width="35">](https://sponsors.mranftl.com/profile/38)
[<img src="https://sponsors.mranftl.com/avatar/39" width="35">](https://sponsors.mranftl.com/profile/39)
[<img src="https://sponsors.mranftl.com/avatar/40" width="35">](https://sponsors.mranftl.com/profile/40)
[<img src="https://sponsors.mranftl.com/avatar/41" width="35">](https://sponsors.mranftl.com/profile/41)
[<img src="https://sponsors.mranftl.com/avatar/42" width="35">](https://sponsors.mranftl.com/profile/42)
[<img src="https://sponsors.mranftl.com/avatar/43" width="35">](https://sponsors.mranftl.com/profile/43)
[<img src="https://sponsors.mranftl.com/avatar/44" width="35">](https://sponsors.mranftl.com/profile/44)
[<img src="https://sponsors.mranftl.com/avatar/45" width="35">](https://sponsors.mranftl.com/profile/45)
[<img src="https://sponsors.mranftl.com/avatar/46" width="35">](https://sponsors.mranftl.com/profile/46)
[<img src="https://sponsors.mranftl.com/avatar/47" width="35">](https://sponsors.mranftl.com/profile/47)
[<img src="https://sponsors.mranftl.com/avatar/48" width="35">](https://sponsors.mranftl.com/profile/48)
[<img src="https://sponsors.mranftl.com/avatar/49" width="35">](https://sponsors.mranftl.com/profile/49)
[<img src="https://sponsors.mranftl.com/avatar/50" width="35">](https://sponsors.mranftl.com/profile/50)
[<img src="https://sponsors.mranftl.com/avatar/51" width="35">](https://sponsors.mranftl.com/profile/51)
[<img src="https://sponsors.mranftl.com/avatar/52" width="35">](https://sponsors.mranftl.com/profile/52)
[<img src="https://sponsors.mranftl.com/avatar/53" width="35">](https://sponsors.mranftl.com/profile/53)
[<img src="https://sponsors.mranftl.com/avatar/54" width="35">](https://sponsors.mranftl.com/profile/54)
[<img src="https://sponsors.mranftl.com/avatar/55" width="35">](https://sponsors.mranftl.com/profile/55)
[<img src="https://sponsors.mranftl.com/avatar/56" width="35">](https://sponsors.mranftl.com/profile/56)
[<img src="https://sponsors.mranftl.com/avatar/57" width="35">](https://sponsors.mranftl.com/profile/57)
[<img src="https://sponsors.mranftl.com/avatar/58" width="35">](https://sponsors.mranftl.com/profile/58)
[<img src="https://sponsors.mranftl.com/avatar/59" width="35">](https://sponsors.mranftl.com/profile/59)
[<img src="https://sponsors.mranftl.com/avatar/60" width="35">](https://sponsors.mranftl.com/profile/60)
[<img src="https://sponsors.mranftl.com/avatar/61" width="35">](https://sponsors.mranftl.com/profile/61)
[<img src="https://sponsors.mranftl.com/avatar/62" width="35">](https://sponsors.mranftl.com/profile/62)
[<img src="https://sponsors.mranftl.com/avatar/63" width="35">](https://sponsors.mranftl.com/profile/63)
[<img src="https://sponsors.mranftl.com/avatar/64" width="35">](https://sponsors.mranftl.com/profile/64)
[<img src="https://sponsors.mranftl.com/avatar/65" width="35">](https://sponsors.mranftl.com/profile/65)
[<img src="https://sponsors.mranftl.com/avatar/66" width="35">](https://sponsors.mranftl.com/profile/66)
[<img src="https://sponsors.mranftl.com/avatar/67" width="35">](https://sponsors.mranftl.com/profile/67)
[<img src="https://sponsors.mranftl.com/avatar/68" width="35">](https://sponsors.mranftl.com/profile/68)
## ToC
- [google-webfonts-helper ](#google-webfonts-helper---)
- [Current Sponsors](#current-sponsors)
- [ToC](#toc)
- [Give it a try: https://gwfh.mranftl.com](#give-it-a-try-httpsgwfhmranftlcom)
- [Running gwfh on your own server](#running-gwfh-on-your-own-server)
- [Development](#development)
- [Quickstart](#quickstart)
- [Production build](#production-build)
- [JSON API](#json-api)
- [GET `/api/fonts`](#get-apifonts)
- [GET `/api/fonts/[id]?subsets=latin,latin-ext`](#get-apifontsidsubsetslatinlatin-ext)
- [GET `/api/fonts/[id]?download=zip&subsets=latin&formats=woff,woff2&variants=regular`](#get-apifontsiddownloadzipsubsetslatinformatswoffwoff2variantsregular)
- [History](#history)
- [License](#license)
## Give it a try: [https://gwfh.mranftl.com](https://gwfh.mranftl.com)
This service might be handy if you want to host a specific [Google font](https://fonts.google.com/) on your **own** server:
* font style and charset customization
* CSS snippets
* `.eot`, `.woff`, `.woff2`, `.svg`, `.ttf` font file formats download (zipped).
[](https://gwfh.mranftl.com)
## Running gwfh on your own server
I provide prebuilt Docker images via [GitHub Packages](https://github.com/majodev/google-webfonts-helper/pkgs/container/google-webfonts-helper). You can use them as follows:
```bash
# See https://developers.google.com/fonts/docs/developer_api for creating your own API-Key.
docker run -e GOOGLE_FONTS_API_KEY=<YOUR-API-KEY> -p 8080:8080 ghcr.io/majodev/google-webfonts-helper:<TAG>
# Express server listening on 8080, in production mode
```
## Development
### Quickstart
Do this to setup a development environment:
```bash
# Ensure to set the GOOGLE_FONTS_API_KEY env var inside your own gitignored .env file
# See https://developers.google.com/fonts/docs/developer_api for creating your own API-Key.
echo "GOOGLE_FONTS_API_KEY=<YOUR-API-KEY>" > .env
# Start up the development docker container (multistage Dockerfile, stage 1 only)
./docker-helper.sh --up
# [+] Running 1/0
# ⠿ Container gwfh-service-1 Running
# node@3b506a285f7f:/app$
# within this development container:
node$ yarn --pure-lockfile
node$ ./node_modules/.bin/bower install
# start development server
node$ grunt serve
# [...]
# Express server listening on 9000, in development mode
# The application is now available at http://127.0.0.1:9000 (watching for code changes)
# start production server (same command as within the final docker multistage build)
node$ grunt build
node$ NODE_ENV=production node dist/server/app.js
# Express server listening on 8080, in production mode
```
### Production build
If you want to build and run your own **production** container locally:
```bash
# Build the production docker container (final stage)
docker build . -t <your-image-tag>
# Run it (if you have previously started the development container, halt it!)
./docker-helper.sh --halt
docker run -e GOOGLE_FONTS_API_KEY=<YOUR-API-KEY> -p 8080:8080 <your-image-tag>
# 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('<css-code></css-code>');
element = $compile(element)(scope);
scope.$apply();
expect(element.text()).toBe('this is the cssCode directive');
}));
});
================================================
FILE: client/app/cssCode/cssCode.html
================================================
<pre ng-if="type.modernSupport"><code data-hljs="css" highlightjs>/* {{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+ */
}
</code></pre>
<pre ng-if="type.legacySupport"><code data-hljs="css" highlightjs>/* {{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+ */
}
</code></pre>
<pre ng-if="type.historicSupport"><code data-hljs="css" highlightjs>/* {{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 */
}
</code></pre>
<style ng-if="type.styleTag" type="text/css">
@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('{{variant.eot}}'); /* IE9 Compat Modes */
src: url('{{variant.eot}}?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('{{variant.woff2}}') format('woff2'), /* Super Modern Browsers */
url('{{variant.woff}}') format('woff'), /* Modern Browsers */
url('{{variant.ttf}}') format('truetype'), /* Safari, Android, iOS */
url('{{variant.svg}}') format('svg'); /* Legacy iOS */
}
</style>
================================================
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
================================================
<div class="fonts-top-container">
<!-- main header -->
<div class="top-overlay">
<div class="row">
<div class="col-lg-2 col-md-3 col-sm-3 col-xs-4">
<div class="input-group">
<!-- search box -->
<div id="searchwrap" class="btn-group">
<input id="searchinput" class="form-control" ng-model="query" type="search"
placeholder="{{fonts.length}} fonts {{predicate.name}}" autofocus>
<span id="searchclear" class="glyphicon glyphicon-remove-circle" ng-class="{'show': query.length > 0}"
ng-click="query = ''"></span>
</div>
<!-- Filter / Order options -->
<div class="input-group-btn" dropdown is-open="status.isopen">
<button id="orderButton" type="button" class="btn btn-default" dropdown-toggle ng-disabled="disabled">
<i class="fa fa-filter"></i>
</button>
<ul class="dropdown-menu" role="menu">
<li role="presentation" class="dropdown-header">Order by</li>
<li ng-click="scrollListTop()" ng-model="predicate"
btn-radio="{name: 'by family', filter: 'family', bindArg: 'category'}"><a href="#">Family</a></li>
<li ng-click="scrollListTop()" ng-model="predicate"
btn-radio="{name: 'by category', filter: ['category','family'], bindArg: 'category'}"><a
href="#">Category</a></li>
<li ng-click="scrollListTop()" ng-model="predicate"
btn-radio="{name: 'by popularity', filter: 'popularity', pre: 'Rank '}"><a href="#">Popularity</a></li>
<li ng-click="scrollListTop()" ng-model="predicate"
btn-radio="{name: 'by last modified', filter: ['-lastModified','family'], bindArg: 'lastModified'}"><a
href="#">Last modified</a></li>
<li ng-click="scrollListTop()" ng-model="predicate"
btn-radio="{name: 'by # styles', filter: ['-variants.length','family'], bindArg: 'variants', len: true, post: ' styles'}">
<a href="#">Number of styles</a>
</li>
<li ng-click="scrollListTop()" ng-model="predicate"
btn-radio="{name: 'by # charsets', filter: ['-subsets.length','family'], bindArg: 'subsets', len: true, post: ' charsets'}">
<a href="#">Number of charsets</a>
</li>
<li class="divider"></li>
<li ng-click="reverse=!reverse; scrollListTop()"><a href="#">{{reverse === true ? "↑ ascending" : "↓
descending"}}</a></li>
</ul>
</div>
</div>
</div>
<div class="col-lg-10 col-md-9 col-sm-9 col-xs-8">
<!-- App name -->
<h4 class="page-header"><a href="/" ng-click="selectedItemID=''">google-webfonts-helper</a><br /><small>Get eot,
ttf, svg, woff and woff2 + CSS</small></h4>
<div class="nav-push-right">
<a class="btn btn-default actSponsorButton actSponsorButton{{ sponsor.type }}"
href="https://github.com/{{ sponsor.login }}" ng-repeat="sponsor in sponsors" role="button" target="_blank"
data-toggle="tooltip" data-placement="bottom" title="{{ sponsor.login }}">
<span>
<img class="sponsor-img" src="{{ sponsor.url }}" alt="{{ sponsor.login }}" />
<span class="sponsor-img-overlay" />
</span>
</a>
<a href="https://github.com/sponsors/majodev" target="_blank" role="button" type="button"
class="btn btn-default actNavButton" data-toggle="tooltip" data-placement="bottom"
title="Join {{ sponsors.length }} lovely sponsors!">
<span>
<i class="fa fa-heart-o sponsorheart"></i> <strong>Sponsor</strong>
</span>
</a>
<a href="https://github.com/majodev/google-webfonts-helper" target="_blank" role="button" type="button"
class="btn btn-default actNavButton">
<span>
<i class="fa fa-star-o"></i> <strong>Star</strong>
</span>
</a>
</div>
</div>
</div>
</div>
<div class="box">
<!-- wrapper for scrollbar divs -->
<div class="row-fluid">
<div class="col-lg-2 col-md-3 col-sm-3 col-xs-4 scrollerLeft column"
cg-busy="{promise: fontsPromise, message: 'Listing fonts...'}">
<div class="list-group">
<a href="/fonts/{{font.id}}?subsets={{font.defSubset}}"
class="list-group-item {{selectedItemID === font.id ? 'active' : ''}}"
ng-repeat="font in fonts | filter:query | orderBy:predicate.filter:reverse">
<h5 class="list-group-item-heading">{{font.family}} <small>{{predicate.pre}}{{predicate.bindArg ?
(predicate.len ? font[predicate.bindArg].length : font[predicate.bindArg]) :
font[predicate.filter]}}{{predicate.post}}</small></h5>
</a>
</div>
</div>
<div class="col-lg-10 col-md-9 col-sm-9 col-xs-8 scrollerRight column">
<div ui-view>
<!-- fontItem details template gets appended here -->
<div>
<!-- Placeholder no item selected -->
<header class="jumbotron masthead">
<div class="inner">
<h1>google-webfonts-helper</h1>
<h2>A Hassle-Free Way to Self-Host Google Fonts</h2>
<h6>by <a href="http://mranftl.com" target="_blank">Mario Ranftl</a></h6>
<hr />
<h3><small><i class="fa fa-arrow-circle-o-left pulse"></i> Select a font to
continue...</small></h3>
</div>
</header>
<p class="download-info">
<a href="https://github.com/majodev/google-webfonts-helper" class="btn btn-default btn-large"
target="_blank">View project on GitHub</a>
<a href="http://mranftl.com/2014/12/23/self-hosting-google-web-fonts/" class="btn btn-default btn-large"
target="_blank">Read the author's note</a>
<a href="https://github.com/sponsors/majodev" class="btn btn-default btn-large" target="_blank">Sponsor
this project</a>
</p>
<h3>Useful resources</h3>
<ul class="list-unstyled">
<li><a href="https://fonts.google.com/attribution" class="btn btn-link btn-large" target="_blank">Google
Fonts Open Source Font Attribution</a></li>
<li><a href="http://css-tricks.com/snippets/css/using-font-face/" class="btn btn-link btn-large"
target="_blank">Using @font-face</a></li>
<li><a href="http://caniuse.com/#feat=woff" class="btn btn-link btn-large" target="_blank">Can I use woff?
(compatibility information)</a></li>
<li><a href="https://gist.github.com/sergejmueller/cf6b4f2133bcb3e2f64a" class="btn btn-link btn-large"
target="_blank">WOFF 2.0 – Learn more about the next generation Web Font Format</a></li>
<li><a href="https://gist.github.com/lindsayevans/794800" class="btn btn-link btn-large"
target="_blank">Sample MIME server config (Apache, Nginx, IIS)</a></li>
<li><a href="https://github.com/gabiseabra/google-fonts-webpack-plugin" class="btn btn-link btn-large"
target="_blank">Google Fonts Webpack Plugin</a></li>
<li><a href="https://www.news47ell.com/how-to/host-google-fonts-locally-wordpress/"
class="btn btn-link btn-large" target="_blank">How to Host Google Fonts Locally in WordPress</a></li>
</ul>
</div>
<div class="apiError" ng-class="{'show': error === true}">
<h2>API Error ({{errorStatus}})</h2>
<pre>REQUEST CONFIG: {{errorConfig}}
REQUEST HEADERS: {{errorHeaders}}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
================================================
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
================================================
<!-- Inject all styles to use them directly -->
<div ng-repeat="variant in fontItem.variants" css-code variant="variant" font-item="fontItem" folder-prefix="folderPrefix"
type="{styleTag:true}"></div>
<div cg-busy="{promise: loadingPromise, message: loadingMessage}">
<div ng-hide="busy">
<div class="row">
<div class="col-sm-12">
<h1 style="font-family:{{fontItem.variants[0].fontFamily}},'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:400; font-style:{{fontItem.variants[0].fontStyle}};">{{fontItem.family}}<br/><small>{{fontItem.category}}</small></h1>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<h5>
<ng-pluralize count="fontItem.variants.length" when="{'0': 'no style',
'one': '{} style',
'other': '{} styles'}">
</ng-pluralize> <small><span ng-repeat="variant in fontItem.variants">{{variant.id}}{{$last ? "" : ", "}}</span></small></h5>
<h5>
<ng-pluralize count="fontItem.subsets.length" when="{'0': 'no charsets',
'one': '{} charset',
'other': '{} charsets'}">
</ng-pluralize> <small><span ng-repeat="subset in fontItem.subsets">{{subset}}{{$last ? "" : ", "}}</span></small></h5>
</div>
<div class="col-sm-6">
<h5>Rank {{fontItem.popularity}} <small>in popularity of {{fonts.length}} fonts in total</small></h5>
<h5>Last modified {{fontItem.lastModified}} <small>({{fontItem.version}})</small></h5>
</div>
</div>
<!-- character set customization -->
<div>
<hr>
<h4>1. Select charsets: <small>(default is <code>{{fontItem.defSubset}}</code>)</small></h4>
<div class="subsetGroup">
<label class="checkbox-inline" ng-repeat="(key,value) in fontItem.subsetMap">
<input type="checkbox" ng-model="fontItem.subsetMap[key]" name="key" ng-click="subsetSelect(); requiresReload=true;" data-ng-disabled="checkSubsetMinimalSelection(key)">{{key}}
</label>
</div>
</div>
</div>
<div class="fontVariant" ng-hide="busy" cg-busy="{promise: subsetsChkbTimeoutP, message: customizationReloadMessage}">
<!-- FONT VARIANT BEGINS -->
<!-- styles set customization -->
<div>
<hr>
<h4>2. Select styles: <small>(default is <code>{{fontItem.defVariant}}</code>)</small></h4>
<div id="fontStyleControl" class="form-inline folderPrefixBar">
<div class="row">
<div class="col-xs-5">
<div id="previewFontSizeInput" class="input-group">
<span class="input-group-addon">Preview size</span>
<input class="form-control input-sm" type="number" ng-model="fontSize" placeholder="16" ng-init="fontSize=16" aria-describedby="px-addon">
<span class="input-group-addon" id="px-addon">px</span>
</div>
</div>
<div class="col-xs-5">
<div class="btn-group" role="group">
<button class="btn btn-default btn-sm" ng-click="fontSize = fontSize - 1">-</button>
<button class="btn btn-default btn-sm" ng-click="fontSize = fontSize + 1">+</button>
</div>
<span class="text-muted"> Sample text editable.</span>
</div>
</div>
</div>
<div id="fontStylePreviewChooser">
<div ng-repeat="variant in fontItem.variants">
<dl class="dl-horizontal variantList">
<dt>
<label class="checkbox-inline">
<input type="checkbox" ng-model="variantsMap[variant.id]" name="variant.id" data-ng-disabled="checkVariantMinimalSelection(variant.id)" ng-click="variantSelect();"><span class="">{{variant.id}}</span>
</label>
</dt>
<dd>
<p style="font-family:{{variant.fontFamily}}; font-weight:{{variant.fontWeight}}; font-style:{{variant.fontStyle}}; font-size:{{fontSize > 0 ? fontSize : 16}}px;"
contenteditable>The quick brown fox jumps over the lazy dog.</p>
</dd>
</dl>
</div>
</div>
</div>
<!-- css snippets -->
<div class="fontItemCSSWrap">
<h4>3. Copy CSS: <small>(default is <code>Modern Browsers</code>)</small></h4>
<tabset type="pills">
<tab heading="Modern Browsers" select="modernSupportActive();">
<p class="small">Choose <code>Modern Browsers</code> if supporting old browsers is not relevant. Formats in this snippet: <code>[{{fontFormats}}]</code></p>
<div ng-click="selectText($event)" class="cssCodeStyle">
<div ng-repeat="variant in fontItem.variants | filter:variantFilter" css-code variant="variant" font-item="fontItem" folder-prefix="folderPrefix"
type="{modernSupport:true}"></div>
</div>
<div class="form-inline folderPrefixBar small">Customize folder prefix (optional):
<div class="form-group">
<input class="form-control input-sm" type="text" ng-model="folderPrefix" placeholder="no folder prefix" ng-init="folderPrefix='../fonts/'"
value="../fonts/">
</div>
</div>
<p class="small">Click on code to select all statements, then copy/paste it into your own CSS file.</p>
</tab>
<tab heading="Legacy Support" select="legacySupportActive();">
<p class="small">Choose <code>Legacy Support</code> if old browsers still need to be supported. Formats in this snippet: <code>[{{fontFormats}}]</code></p>
<div ng-click="selectText($event)" class="cssCodeStyle">
<div ng-repeat="variant in fontItem.variants | filter:variantFilter" css-code variant="variant" font-item="fontItem" folder-prefix="folderPrefix"
type="{legacySupport:true}"></div>
</div>
<div class="form-inline folderPrefixBar small">Customize folder prefix (optional):
<div class="form-group">
<input class="form-control input-sm" type="text" ng-model="folderPrefix" placeholder="no folder prefix" ng-init="folderPrefix='../fonts/'"
value="../fonts/">
</div>
</div>
<p class="small">Click on code to select all statements, then copy/paste it into your own CSS file.</p>
</tab>
<tab heading="Historic Support" select="historicSupportActive();">
<p class="small">Choose <code>Historic Support</code> if very old browsers still need to be supported. Formats in this snippet: <code>[{{fontFormats}}]</code></p>
<div ng-click="selectText($event)" class="cssCodeStyle">
<div ng-repeat="variant in fontItem.variants | filter:variantFilter" css-code variant="variant" font-item="fontItem" folder-prefix="folderPrefix"
type="{historicSupport:true}"></div>
</div>
<div class="form-inline folderPrefixBar small">Customize folder prefix (optional):
<div class="form-group">
<input class="form-control input-sm" type="text" ng-model="folderPrefix" placeholder="no folder prefix" ng-init="folderPrefix='../fonts/'"
value="../fonts/">
</div>
</div>
<p class="small">Click on code to select all statements, then copy/paste it into your own CSS file.</p>
</tab>
<!-- <tab heading="Plain links" class="pull-right">
<p class="small"><code>Plain links</code> provides a list of all font file urls from Google, ordered by their style. These are the same files that get packed into the archive in the next step (but they do not have meaningful filenames).</p>
<div ng-repeat="variant in fontItem.variants | filter:variantFilter">
<h5>{{fontItem.id}}-{{variant.id}} <small>{{fontItem.storeID}}</small></h5>
<ul>
<li>eot: <a href="{{variant.eot}}">{{variant.eot}}</a></li>
<li>woff2: <a href="{{variant.woff2}}">{{variant.woff2}}</a></li>
<li>woff: <a href="{{variant.woff}}">{{variant.woff}}</a></li>
<li>ttf: <a href="{{variant.ttf}}">{{variant.ttf}}</a></li>
<li>svg: <a href="{{variant.svg}}">{{variant.svg}}</a></li>
</ul>
</div>
</tab> -->
</tabset>
</div>
<!-- download button -->
<div>
<hr>
<h4>4. Download files:</h4>
<p>Your generated archive for <strong>{{fontItem.family}}</strong> with charsets <strong><code>[{{downloadSubSetID}}]</code></strong> and styles <strong><code>[{{variantDownloadQueryString}}]</code></strong> includes the formats <strong><code>[{{fontFormats}}]</code></strong>.</p>
<a href="/api/fonts/{{fontItem.id}}?download=zip&subsets={{downloadSubSetID}}&variants={{variantDownloadQueryString}}&formats={{fontFormats}}"
target="_blank" role="button" class="btn btn-primary download-button"><i class="fa fa-download fa-lg"></i><br/>{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}.zip</a>
<p><small>Fonts are copyright of their respective authors.<br><b>See <a target="_blank" href="https://fonts.google.com/attribution">Google Fonts Open Source Font Attribution</a> to find out the specific license that this font uses.</b></small></p>
<!-- <div class="row">
<div class="col-sm-10 col-sm-offset-1">
</div>
</div>
<div class="row">
<div class="col-sm-10 col-sm-offset-1">
</div>
</div> -->
</div>
<!-- final instructions -->
<div>
<hr>
<h4>You are now ready to self-host {{fontItem.family}}.<br/>
<small>If this service has actually been helpful for you, please <a href="https://github.com/majodev/google-webfonts-helper/" target="_blank">star it on GitHub</a>. If you've encountered serious problems, file an issue <a href="https://github.com/majodev/google-webfonts-helper/issues">here</a>.<br />
❤️ <a href="https://github.com/sponsors/majodev" target="_blank">You can help to keep this project alive by sponsoring me.</a> Thank you ❤️.</small></h4>
<hr>
</div>
</div>
<!-- FONT VARIANT ENDS -->
</div>
<div class="apiError" ng-class="{'show': error === true}">
<h2>API Error ({{errorStatus}})</h2>
<pre>REQUEST CONFIG: {{errorConfig}}
REQUEST HEADERS: {{errorHeaders}}</pre>
</div>
================================================
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('<highlightjs></highlightjs>');
element = $compile(element)(scope);
expect(element.text()).toBe('this is the highlightjs directive');
}));
});
================================================
FILE: client/index.html
================================================
<!doctype html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<base href="/">
<meta name="google-site-verification" content="al_wEWN9QTMdH0LIzJpanPUiK1lrThiJVuQYDoyflJg" />
<title>google webfonts helper</title>
<meta name="description" content="A Hassle-Free Way to Self-Host Google Fonts. Get eot, ttf, svg, woff and woff2 files + CSS snippets!">
<meta name="author" content="Mario Ranftl">
<meta name="keywords" content="font, host, self-host, font-face, serve, zip, archive, css, woff, woff2, eot, ttf, svg, web fonts, google web fonts, google fonts, instructions, modern browsers, best support, font service">
<meta name="robots" content="all">
<meta name="copyright" content="Copyright 2016 Mario Ranftl (majodev | MIT License)">
<meta name="viewport" content="width=device-width">
<!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
<!-- build:css(client) app/vendor.css -->
<!-- bower:css -->
<link rel="stylesheet" href="bower_components/angular-busy/dist/angular-busy.css" />
<link rel="stylesheet" href="bower_components/highlightjs/styles/default.css" />
<!-- endbower -->
<!-- endbuild -->
<!-- build:css({.tmp,client}) app/app.css -->
<link rel="stylesheet" href="app/app.css">
<!-- injector:css -->
<!-- endinjector -->
<!-- endbuild -->
</head>
<body ng-app="googleWebfontsHelperApp">
<!--[if lt IE 7]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<!-- Add your site or application content here -->
<div ui-view=""></div>
<!-- <a ui-sref="fonts">FONTS state</a> -->
<!--[if lt IE 9]>
<script src="bower_components/es5-shim/es5-shim.js"></script>
<script src="bower_components/json3/lib/json3.min.js"></script>
<![endif]-->
<!-- build:js({client,node_modules}) app/vendor.js -->
<!-- bower:js -->
<script src="bower_components/jquery/dist/jquery.js"></script>
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/angular-animate/angular-animate.js"></script>
<script src="bower_components/angular-bootstrap/ui-bootstrap-tpls.js"></script>
<script src="bower_components/angular-busy/dist/angular-busy.js"></script>
<script src="bower_components/angular-cookies/angular-cookies.js"></script>
<script src="bower_components/angular-resource/angular-resource.js"></script>
<script src="bower_components/angular-sanitize/angular-sanitize.js"></script>
<script src="bower_components/angular-ui-router/release/angular-ui-router.js"></script>
<script src="bower_components/bootstrap/dist/js/bootstrap.js"></script>
<script src="bower_components/highlightjs/highlight.pack.js"></script>
<script src="bower_components/lodash/dist/lodash.compat.js"></script>
<!-- endbower -->
<!-- endbuild -->
<!-- build:js({.tmp,client}) app/app.js -->
<script src="app/app.js"></script>
<!-- injector:js -->
<script src="app/cssCode/cssCode.directive.js"></script>
<script src="app/fonts/fonts.controller.js"></script>
<script src="app/fonts/fonts.js"></script>
<script src="app/highlightjs/highlightjs.directive.js"></script>
<!-- endinjector -->
<!-- endbuild -->
</body>
</html>
================================================
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<IAPIListFont[]>, 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<IAPIFont | string | NodeJS.WritableStream>, 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<JSZip> {
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(<Buffer>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(<Buffer>res1.body);
// 8 files in archive1
should(_.keys(archive1.files).length).eql(8);
const archive2 = await JSZip.loadAsync(<Buffer>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(<Buffer>res1.body);
// 60 files in archive1
should(_.keys(archive1.files).length).eql(60);
const archive2 = await JSZip.loadAsync(<Buffer>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(<Buffer>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(<Buffer>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(<Buffer>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(<Buffer>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(<Buffer>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(<Buffer>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<string>, 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: <IUserAgents>{
// 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<IVariantItem[] | null> {
return _loadVariantItems(`loadVariantItems__${fontBundle.storeID}`, fontBundle);
}
const _loadVariantItems = synchronizedBy(async function (fontBundle: IFontBundle): Promise<IVariantItem[] | null> {
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<IFontSubsetArchive> {
return _loadFontSubsetArchive(`loadFontSubsetArchive__${fontBundle.storeID}`, fontBundle, variants);
}
const _loadFontSubsetArchive = synchronizedBy(async function (
fontBundle: IFontBundle,
variants: IVariantItem[]
): Promise<IFontSubsetArchive> {
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<IResource[]> {
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<string>(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") && (<css.Declaration>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<IFontSubsetArchive> {
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<Readable> {
return asyncRetry<Readable>(
async () => {
const res = await axios.get<Readable>(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: <keyof IUserAgents>key,
userAgent: <string>config.USER_AGENTS[<keyof IUserAgents>key],
};
});
export async function fetchFontURLs(fontFamily: string, fontVariants: string[], fontSubsets: string[]): Promise<IVariantItem[] | null> {
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<IFontItem[]> {
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<IGoogleFontsRes>(`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<string, IFontItem>();
const urlMap = new Map<string, IVariantItem[]>();
const archiveMap = new Map<string, IFontSubsetArchive>();
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<T>(fn: () => Promise<T>, options: { retries: number }, errors: any[] = []): Promise<T> {
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<T>(target: () => Promise<T>): (cacheKey: string) => Promise<T>;
export function synchronizedBy<A1, T>(target: (arg1: A1) => Promise<T>): (cacheKey: string, arg1: A1) => Promise<T>;
export function synchronizedBy<A1, A2, T>(target: (arg1: A1, arg2: A2) => Promise<T>): (cacheKey: string, arg1: A1, arg2: A2) => Promise<T>;
export function synchronizedBy<A, T>(target: (...args: A[]) => Promise<T>): (cacheKey: string, ...args: A[]) => Promise<T> {
const mutexMap = new Map<string, Promise<T>>();
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<T>(function (this: Promise<T>, 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"
]
}
gitextract_h4jp3g9b/ ├── .bowerrc ├── .devcontainer.json ├── .dockerignore ├── .eslintrc.cjs ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── build-only.yml │ ├── build-publish-deploy.yml │ └── docker.env ├── .gitignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ └── tasks.json ├── .yo-rc.json ├── Dockerfile ├── Gruntfile.js ├── LICENSE.txt ├── README.md ├── bower.json ├── client/ │ ├── app/ │ │ ├── app.js │ │ ├── app.less │ │ ├── cssCode/ │ │ │ ├── cssCode.directive.js │ │ │ ├── cssCode.directive.spec.js │ │ │ ├── cssCode.html │ │ │ └── cssCode.less │ │ ├── fonts/ │ │ │ ├── fonts.controller.js │ │ │ ├── fonts.controller.spec.js │ │ │ ├── fonts.html │ │ │ ├── fonts.js │ │ │ ├── fonts.less │ │ │ └── fontsItem.html │ │ └── highlightjs/ │ │ ├── highlightjs.directive.js │ │ └── highlightjs.directive.spec.js │ ├── index.html │ └── robots.txt ├── docker-compose.yml ├── docker-helper.sh ├── package.json ├── server/ │ ├── api/ │ │ ├── fonts.controller.ts │ │ ├── fonts.spec.ts │ │ ├── healthy.controller.ts │ │ └── healthy.spec.ts │ ├── app.spec.ts │ ├── app.ts │ ├── config.ts │ ├── logic/ │ │ ├── core.ts │ │ ├── fetchCSS.ts │ │ ├── fetchFontSubsetArchive.ts │ │ ├── fetchFontURLs.ts │ │ ├── fetchGoogleFonts.ts │ │ └── store.ts │ ├── routes.ts │ └── utils/ │ ├── asyncRetry.spec.ts │ ├── asyncRetry.ts │ └── synchronized.ts ├── test/ │ └── googlefonts.json └── tsconfig.json
SYMBOL INDEX (53 symbols across 14 files)
FILE: client/app/fonts/fonts.controller.js
function apiError (line 3) | function apiError($scope, status, headers, config) {
function setCustomizationReloadMessage (line 204) | function setCustomizationReloadMessage(time) {
FILE: server/api/fonts.controller.ts
type IAPIListFont (line 12) | interface IAPIListFont {
function getApiFonts (line 24) | async function getApiFonts(req: Request, res: Response<IAPIListFont[]>, ...
type IAPIFont (line 51) | interface IAPIFont {
function getApiFontsById (line 77) | async function getApiFontsById(req: Request, res: Response<IAPIFont | st...
function loadZipArchive (line 183) | function loadZipArchive(zipPath: string): PromiseLike<JSZip> {
FILE: server/api/healthy.controller.ts
function getHealthy (line 5) | async function getHealthy(req: Request, res: Response<string>, next: Nex...
FILE: server/app.ts
function ready (line 17) | function ready() {
FILE: server/config.ts
constant GOOGLE_FONTS_API_KEY (line 6) | const GOOGLE_FONTS_API_KEY = process.env.GOOGLE_FONTS_API_KEY;
type IUserAgents (line 14) | interface IUserAgents {
FILE: server/logic/core.ts
function loadFontItems (line 16) | function loadFontItems(): IFontItem[] {
function loadFontBundle (line 20) | function loadFontBundle(fontID: string, subsets: string[] | null): IFont...
function loadVariantItems (line 24) | async function loadVariantItems(fontBundle: IFontBundle): Promise<IVaria...
function loadFontSubsetArchive (line 48) | async function loadFontSubsetArchive(fontBundle: IFontBundle, variants: ...
type ISubsetMap (line 72) | interface ISubsetMap {
function loadSubsetMap (line 76) | function loadSubsetMap(fontBundle: IFontBundle): ISubsetMap {
FILE: server/logic/fetchCSS.ts
constant RETRIES (line 7) | const RETRIES = 2;
constant REQUEST_TIMEOUT_MS (line 8) | const REQUEST_TIMEOUT_MS = 6000;
type IResource (line 10) | interface IResource {
function fetchCSS (line 18) | async function fetchCSS(family: string, cssSubsetString: string, type: k...
function parseRemoteCSS (line 45) | function parseRemoteCSS(remoteCSS: string, type: string): IResource[] {
function getCSSRuleDeclarationPropertyValue (line 102) | function getCSSRuleDeclarationPropertyValue(rule: css.Rule, property: st...
FILE: server/logic/fetchFontSubsetArchive.ts
constant RETRIES (line 13) | const RETRIES = 2;
constant REQUEST_TIMEOUT_MS (line 14) | const REQUEST_TIMEOUT_MS = 6000;
type IFontSubsetArchive (line 16) | interface IFontSubsetArchive {
type IFontFile (line 21) | interface IFontFile {
function fetchFontSubsetArchive (line 27) | async function fetchFontSubsetArchive(
function fetchFontSubsetArchiveStream (line 100) | async function fetchFontSubsetArchiveStream(url: string): Promise<Readab...
FILE: server/logic/fetchFontURLs.ts
type IVariantURL (line 6) | interface IVariantURL {
type IVariantItem (line 11) | interface IVariantItem {
constant TARGETS (line 19) | const TARGETS = _.map(_.keys(config.USER_AGENTS), (key) => {
function fetchFontURLs (line 26) | async function fetchFontURLs(fontFamily: string, fontVariants: string[],...
FILE: server/logic/fetchGoogleFonts.ts
constant RETRIES (line 9) | const RETRIES = 2;
constant REQUEST_TIMEOUT_MS (line 10) | const REQUEST_TIMEOUT_MS = 10000;
type IFontItem (line 12) | interface IFontItem {
type IGoogleFontsRes (line 25) | interface IGoogleFontsRes {
type IGoogleFontsResItem (line 30) | interface IGoogleFontsResItem {
function fetchGoogleFonts (line 44) | async function fetchGoogleFonts(): Promise<IFontItem[]> {
function transform (line 72) | function transform(resData: IGoogleFontsRes): IFontItem[] {
FILE: server/logic/store.ts
type IFontBundle (line 13) | interface IFontBundle {
function initStore (line 23) | async function initStore() {
function reinitStore (line 31) | async function reinitStore() {
function getStoredFontItems (line 43) | function getStoredFontItems(): IFontItem[] {
function getFontBundle (line 47) | function getFontBundle(fontID: string, wantedSubsets: string[] | null): ...
function getStoredVariantItems (line 73) | function getStoredVariantItems({ storeID }: IFontBundle): IVariantItem[]...
function getStoredFontSubsetArchive (line 81) | function getStoredFontSubsetArchive({ storeID }: IFontBundle): IFontSubs...
function storeVariantItems (line 89) | function storeVariantItems({ storeID }: IFontBundle, variants: IVariantI...
function storeFontSubsetArchive (line 103) | function storeFontSubsetArchive({ storeID }: IFontBundle, subsetFontArch...
function getStats (line 117) | function getStats() {
FILE: server/routes.ts
function setupRoutes (line 5) | function setupRoutes(app: express.Express) {
FILE: server/utils/asyncRetry.ts
function asyncRetry (line 5) | async function asyncRetry<T>(fn: () => Promise<T>, options: { retries: n...
FILE: server/utils/synchronized.ts
function synchronizedBy (line 8) | function synchronizedBy<A, T>(target: (...args: A[]) => Promise<T>): (ca...
Condensed preview — 56 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (163K chars).
[
{
"path": ".bowerrc",
"chars": 47,
"preview": "{\n \"directory\": \"client/bower_components\"\n}\n"
},
{
"path": ".devcontainer.json",
"chars": 2367,
"preview": "// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:\n// https://github.co"
},
{
"path": ".dockerignore",
"chars": 97,
"preview": ".tmp\ndist\n.env\nclient/bower_components\nnode_modules\nserver/logic/cachedFonts\n.tscache\ntscommand-\n"
},
{
"path": ".eslintrc.cjs",
"chars": 344,
"preview": "module.exports = {\n ignorePatterns: [\".eslintrc.cjs\", \"Gruntfile.js\", \"dist/**/*.js\", \"client/**/*.js\"],\n extends:"
},
{
"path": ".github/FUNDING.yml",
"chars": 15,
"preview": "github: majodev"
},
{
"path": ".github/workflows/build-only.yml",
"chars": 1784,
"preview": "name: Build gwfh\n\non:\n push:\n branches: \n - \"**\"\n - \"!master\"\n - \"!dev\"\n\nenv:\n DOCKER_ENV_FILE: \".gi"
},
{
"path": ".github/workflows/build-publish-deploy.yml",
"chars": 2562,
"preview": "# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images\nname: Create, publish and deploy gwfh\n"
},
{
"path": ".github/workflows/docker.env",
"chars": 27,
"preview": "CI=true\nGITHUB_ACTIONS=true"
},
{
"path": ".gitignore",
"chars": 168,
"preview": ".DS_Store\nnode_modules*\npublic\n.tmp\n.idea\nclient/bower_components\ndist\n/server/config/local.env.js\n/server/logic/cachedF"
},
{
"path": ".prettierrc",
"chars": 69,
"preview": "{\n \"semi\": true,\n \"singleQuote\": false,\n \"printWidth\": 140\n}"
},
{
"path": ".vscode/extensions.json",
"chars": 292,
"preview": "{\n\t// See http://go.microsoft.com/fwlink/?LinkId=827846\n\t// for the documentation about the extensions.json format\n\t\"rec"
},
{
"path": ".vscode/tasks.json",
"chars": 470,
"preview": "{\n // See https://go.microsoft.com/fwlink/?LinkId=733558 \n // for the documentation about the tasks.json format\n "
},
{
"path": ".yo-rc.json",
"chars": 1119,
"preview": "{\n \"generator-angular-fullstack\": {\n \"insertRoutes\": true,\n \"registerRoutesFile\": \"server/routes.js\",\n \"routes"
},
{
"path": "Dockerfile",
"chars": 2620,
"preview": "### -----------------------\n# --- Stage: development\n# --- Purpose: Local dev environment (no application deps)\n### ----"
},
{
"path": "Gruntfile.js",
"chars": 13857,
"preview": "// Generated on 2014-12-21 using generator-angular-fullstack 2.0.13\n'use strict';\n\nmodule.exports = function (grunt) {\n "
},
{
"path": "LICENSE.txt",
"chars": 1142,
"preview": "The MIT License (MIT)\nCopyright (c) 2016 Mario Ranftl | majodev and \"The Google Webfonts Helper\" Project Contributors\n\nP"
},
{
"path": "README.md",
"chars": 15364,
"preview": "# google-webfonts-helper [\n .directive('cssCode', [function() {\n return {\n templa"
},
{
"path": "client/app/cssCode/cssCode.directive.spec.js",
"chars": 573,
"preview": "'use strict';\n\ndescribe('Directive: cssCode', function () {\n\n // load the directive's module and view\n beforeEach(modu"
},
{
"path": "client/app/cssCode/cssCode.html",
"chars": 3567,
"preview": "<pre ng-if=\"type.modernSupport\"><code data-hljs=\"css\" highlightjs>/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID"
},
{
"path": "client/app/cssCode/cssCode.less",
"chars": 138,
"preview": "pre {\n background-color: transparent;\n border: 0;\n border-radius: 0;\n padding-bottom: 0px;\n padding-top: 0px;\n mar"
},
{
"path": "client/app/fonts/fonts.controller.js",
"chars": 7891,
"preview": "'use strict';\n\nfunction apiError($scope, status, headers, config) {\n // called asynchronously if an error occurs\n // o"
},
{
"path": "client/app/fonts/fonts.controller.spec.js",
"chars": 457,
"preview": "'use strict';\n\ndescribe('Controller: FontsCtrl', function () {\n\n // load the controller's module\n beforeEach(module('g"
},
{
"path": "client/app/fonts/fonts.html",
"chars": 8067,
"preview": "<div class=\"fonts-top-container\">\n <!-- main header -->\n <div class=\"top-overlay\">\n\n <div class=\"row\">\n <div c"
},
{
"path": "client/app/fonts/fonts.js",
"chars": 405,
"preview": "'use strict';\n\nangular.module('googleWebfontsHelperApp')\n .config(function ($stateProvider) {\n $stateProvider\n "
},
{
"path": "client/app/fonts/fonts.less",
"chars": 5139,
"preview": "// Variables\n// -----------------------------------------------------------------------------\n\n@header-height: 55px;\n\n\n/"
},
{
"path": "client/app/fonts/fontsItem.html",
"chars": 10554,
"preview": "<!-- Inject all styles to use them directly -->\n<div ng-repeat=\"variant in fontItem.variants\" css-code variant=\"variant\""
},
{
"path": "client/app/highlightjs/highlightjs.directive.js",
"chars": 1132,
"preview": "'use strict';\n\n// via http://stackoverflow.com/questions/25581560/dynamic-syntax-highlighting-with-angularjs-and-highlig"
},
{
"path": "client/app/highlightjs/highlightjs.directive.spec.js",
"chars": 512,
"preview": "'use strict';\n\ndescribe('Directive: highlightjs', function () {\n\n // load the directive's module\n beforeEach(module('g"
},
{
"path": "client/index.html",
"chars": 3814,
"preview": "<!doctype html>\n<!--[if lt IE 7]> <html class=\"no-js lt-ie9 lt-ie8 lt-ie7\"> <![endif]-->\n<!--[if IE 7]> <ht"
},
{
"path": "client/robots.txt",
"chars": 31,
"preview": "# robotstxt.org\n\nUser-agent: *\n"
},
{
"path": "docker-compose.yml",
"chars": 970,
"preview": "services:\n service:\n build:\n context: .\n target: development\n ports:\n - \"9000:9000\" # development\n"
},
{
"path": "docker-helper.sh",
"chars": 640,
"preview": "#!/bin/bash\n\nif [ \"$1\" = \"--up\" ]; then\n docker compose up --no-start\n docker compose start # ensure we are starte"
},
{
"path": "package.json",
"chars": 2986,
"preview": "{\n \"name\": \"google-webfonts-helper\",\n \"version\": \"1.1.0\",\n \"homepage\": \"https://gwfh.mranftl.com\",\n \"main\": \"server/"
},
{
"path": "server/api/fonts.controller.ts",
"chars": 5831,
"preview": "import { NextFunction, Request, Response } from \"express\";\nimport * as fs from \"fs\";\nimport * as JSZip from \"jszip\";\nimp"
},
{
"path": "server/api/fonts.spec.ts",
"chars": 22958,
"preview": "import { fromBuffer as fileTypeFromBuffer } from \"file-type\";\nimport * as JSZip from \"jszip\";\nimport * as _ from \"lodash"
},
{
"path": "server/api/healthy.controller.ts",
"chars": 507,
"preview": "import { NextFunction, Request, Response } from \"express\";\nimport { getStats } from \"../logic/store\";\n\n// /-/healthy\nexp"
},
{
"path": "server/api/healthy.spec.ts",
"chars": 294,
"preview": "import * as request from \"supertest\";\nimport { app } from \"../app\";\n\ndescribe(\"GET /-/healthy\", () => {\n it(\"should res"
},
{
"path": "server/app.spec.ts",
"chars": 511,
"preview": "import * as request from \"supertest\";\nimport { app } from \"./app\";\n\ndescribe(\"GET /api/not_defined\", () => {\n it(\"shoul"
},
{
"path": "server/app.ts",
"chars": 2102,
"preview": "/* eslint-disable @typescript-eslint/no-var-requires */\nrequire(\"source-map-support\").install();\n\nimport * as express fr"
},
{
"path": "server/config.ts",
"chars": 2630,
"preview": "import * as _ from \"lodash\";\nimport * as path from \"path\";\n\nconst env = process.env.NODE_ENV || \"development\";\n\nconst GO"
},
{
"path": "server/logic/core.ts",
"chars": 2718,
"preview": "import * as _ from \"lodash\";\nimport { synchronizedBy } from \"../utils/synchronized\";\nimport { fetchFontSubsetArchive, IF"
},
{
"path": "server/logic/fetchCSS.ts",
"chars": 3133,
"preview": "import * as css from \"css\";\nimport * as _ from \"lodash\";\nimport { IUserAgents } from \"../config\";\nimport { asyncRetry } "
},
{
"path": "server/logic/fetchFontSubsetArchive.ts",
"chars": 3754,
"preview": "import * as Bluebird from \"bluebird\";\nimport * as fs from \"fs\";\nimport * as JSZip from \"jszip\";\nimport * as _ from \"loda"
},
{
"path": "server/logic/fetchFontURLs.ts",
"chars": 2913,
"preview": "import * as Bluebird from \"bluebird\";\nimport * as _ from \"lodash\";\nimport { config, IUserAgents } from \"../config\";\nimpo"
},
{
"path": "server/logic/fetchGoogleFonts.ts",
"chars": 2361,
"preview": "import * as fs from \"fs/promises\";\nimport * as _ from \"lodash\";\nimport * as path from \"path\";\nimport * as speakingurl fr"
},
{
"path": "server/logic/store.ts",
"chars": 3646,
"preview": "import { mkdir } from \"fs/promises\";\nimport * as _ from \"lodash\";\nimport { config } from \"../config\";\nimport { IFontSubs"
},
{
"path": "server/routes.ts",
"chars": 932,
"preview": "import * as express from \"express\";\nimport { getApiFonts, getApiFontsById } from \"./api/fonts.controller\";\nimport { getH"
},
{
"path": "server/utils/asyncRetry.spec.ts",
"chars": 1822,
"preview": "import * as Bluebird from \"bluebird\";\nimport * as should from \"should\";\nimport { asyncRetry } from \"./asyncRetry\";\n\ndesc"
},
{
"path": "server/utils/asyncRetry.ts",
"chars": 1008,
"preview": "import * as Bluebird from \"bluebird\";\nimport * as _ from \"lodash\";\n\n// eslint-disable-next-line @typescript-eslint/no-ex"
},
{
"path": "server/utils/synchronized.ts",
"chars": 1862,
"preview": "import * as _ from \"lodash\";\nimport { config } from \"../config\";\n\n// cached promise by key for in-flight request handlin"
},
{
"path": "test/googlefonts.json",
"chars": 2271,
"preview": "{\n \"kind\": \"webfonts#webfontList\",\n \"items\": [\n {\n \"family\": \"Arvo\",\n \"variants\": [\n \"regular\",\n "
},
{
"path": "tsconfig.json",
"chars": 311,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ESNEXT\",\n \"module\": \"commonjs\",\n \"sourceMap\": true,\n "
}
]
About this extraction
This page contains the full source code of the majodev/google-webfonts-helper GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 56 files (149.0 KB), approximately 42.2k tokens, and a symbol index with 53 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.