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 ''; }, starttag: '', endtag: '' }, 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 ''; }, starttag: '', endtag: '' }, 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 [![Uptime Robot status](https://img.shields.io/uptimerobot/status/m793130668-adecafe120852713ed46d6c6)](https://gwfh.mranftl.com) [![Uptime Robot ratio (30 days)](https://img.shields.io/uptimerobot/ratio/m793130668-adecafe120852713ed46d6c6)](https://gwfh.mranftl.com) [![GitHub Sponsors](https://img.shields.io/github/sponsors/majodev)](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. ❤️* [](https://sponsors.mranftl.com/profile/0) [](https://sponsors.mranftl.com/profile/1) [](https://sponsors.mranftl.com/profile/2) [](https://sponsors.mranftl.com/profile/3) [](https://sponsors.mranftl.com/profile/4) [](https://sponsors.mranftl.com/profile/5) [](https://sponsors.mranftl.com/profile/6) [](https://sponsors.mranftl.com/profile/7) [](https://sponsors.mranftl.com/profile/8) [](https://sponsors.mranftl.com/profile/9) [](https://sponsors.mranftl.com/profile/10) [](https://sponsors.mranftl.com/profile/11) [](https://sponsors.mranftl.com/profile/12) [](https://sponsors.mranftl.com/profile/13) [](https://sponsors.mranftl.com/profile/14) [](https://sponsors.mranftl.com/profile/15) [](https://sponsors.mranftl.com/profile/16) [](https://sponsors.mranftl.com/profile/17) [](https://sponsors.mranftl.com/profile/18) [](https://sponsors.mranftl.com/profile/19) [](https://sponsors.mranftl.com/profile/20) [](https://sponsors.mranftl.com/profile/21) [](https://sponsors.mranftl.com/profile/22) [](https://sponsors.mranftl.com/profile/23) [](https://sponsors.mranftl.com/profile/24) [](https://sponsors.mranftl.com/profile/25) [](https://sponsors.mranftl.com/profile/26) [](https://sponsors.mranftl.com/profile/27) [](https://sponsors.mranftl.com/profile/28) [](https://sponsors.mranftl.com/profile/29) [](https://sponsors.mranftl.com/profile/30) [](https://sponsors.mranftl.com/profile/31) [](https://sponsors.mranftl.com/profile/32) [](https://sponsors.mranftl.com/profile/33) [](https://sponsors.mranftl.com/profile/34) [](https://sponsors.mranftl.com/profile/35) [](https://sponsors.mranftl.com/profile/36) [](https://sponsors.mranftl.com/profile/37) [](https://sponsors.mranftl.com/profile/38) [](https://sponsors.mranftl.com/profile/39) [](https://sponsors.mranftl.com/profile/40) [](https://sponsors.mranftl.com/profile/41) [](https://sponsors.mranftl.com/profile/42) [](https://sponsors.mranftl.com/profile/43) [](https://sponsors.mranftl.com/profile/44) [](https://sponsors.mranftl.com/profile/45) [](https://sponsors.mranftl.com/profile/46) [](https://sponsors.mranftl.com/profile/47) [](https://sponsors.mranftl.com/profile/48) [](https://sponsors.mranftl.com/profile/49) [](https://sponsors.mranftl.com/profile/50) [](https://sponsors.mranftl.com/profile/51) [](https://sponsors.mranftl.com/profile/52) [](https://sponsors.mranftl.com/profile/53) [](https://sponsors.mranftl.com/profile/54) [](https://sponsors.mranftl.com/profile/55) [](https://sponsors.mranftl.com/profile/56) [](https://sponsors.mranftl.com/profile/57) [](https://sponsors.mranftl.com/profile/58) [](https://sponsors.mranftl.com/profile/59) [](https://sponsors.mranftl.com/profile/60) [](https://sponsors.mranftl.com/profile/61) [](https://sponsors.mranftl.com/profile/62) [](https://sponsors.mranftl.com/profile/63) [](https://sponsors.mranftl.com/profile/64) [](https://sponsors.mranftl.com/profile/65) [](https://sponsors.mranftl.com/profile/66) [](https://sponsors.mranftl.com/profile/67) [](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). [![pic running](https://mranftl.com/static/apps/google-webfonts-helper/full_view.png)](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= -p 8080:8080 ghcr.io/majodev/google-webfonts-helper: # 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=" > .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 # Run it (if you have previously started the development container, halt it!) ./docker-helper.sh --halt docker run -e GOOGLE_FONTS_API_KEY= -p 8080:8080 # Express server listening on 8080, in production mode ``` To mitigate security issues especially with the projects' deprecated dependencies, the final image is based on a minimal container image. It runs rootless and has no development dependencies. ## JSON API The API is public, feel free to use it directly (rate-limits may apply). ### GET `/api/fonts` Returns a list of all fonts, sorted by popularity. E.g. `curl https://gwfh.mranftl.com/api/fonts`: ```json [{ "id": "open-sans", "family": "Open Sans", "variants": ["300", "300italic", "regular", "italic", "600", "600italic", "700", "700italic", "800", "800italic"], "subsets": ["devanagari", "greek", "latin", "cyrillic-ext", "cyrillic", "greek-ext", "vietnamese", "latin-ext"], "category": "sans-serif", "version": "v10", "lastModified": "2014-10-17", "popularity": 1, "defSubset": "latin", "defVariant": "regular" } [...] ] ``` ### GET `/api/fonts/[id]?subsets=latin,latin-ext` Returns a font with urls to the actual font files google's servers. `subsets` is optional (will serve the `defSubset` if unspecified). E.g. `curl "https://gwfh.mranftl.com/api/fonts/modern-antiqua?subsets=latin,latin-ext"` (the double quotes are important as query parameters may else be stripped!): ```json { "id": "modern-antiqua", "family": "Modern Antiqua", "variants": [{ "id": "regular", "eot": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkhzThM-TJeMvVB0dIsYy4U7E.eot", "fontFamily": "'Modern Antiqua'", "fontStyle": "normal", "fontWeight": "400", "woff": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkh1bbnkJREviNM815YSrb1io.woff", "local": ["Modern Antiqua Regular", "ModernAntiqua-Regular"], "ttf": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkhxr_S_FdaWWVbb1LgBbjq4o.ttf", "svg": "https://fonts.gstatic.com/l/font?kit=8qX_tr6Xzy4t9fvZDXPkh0sAoW0rAsWAgyWthbXBUKs#ModernAntiqua", "woff2": "https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkh08GHjg64nS_BBLu6wRo0k8.woff2" }], "subsets": ["latin", "latin-ext"], "category": "display", "version": "v6", "lastModified": "2014-08-28", "popularity": 522, "defSubset": "latin", "defVariant": "regular", "subsetMap": { "latin": true, "latin-ext": true }, "storeID": "latin-ext_latin" } ``` ### GET `/api/fonts/[id]?download=zip&subsets=latin&formats=woff,woff2&variants=regular` Download a zipped archive with all `.eot`, `.woff`, `.woff2`, `.svg`, `.ttf` files of a specified font. The query parameters `formats` and `variants` are optional (includes everything if no filtering is applied). is E.g. `curl -o fontfiles.zip "https://gwfh.mranftl.com/api/fonts/lato?download=zip&subsets=latin,latin-ext&variants=regular,700&formats=woff"` (the double quotes are important as query parameters may else be stripped!) ## History > 2025: * Switch to `node:22` for the final image. * Adds support for linux/arm64 architecture (patches [imagemin/optipng-bin](https://github.com/imagemin/optipng-bin/pull/128)) > 2024: * Switch to `node:20` for the final image. > 2023: * Project upgraded to be compatible with Node.js v18+. * Automated prebuilt Docker images via [GitHub Actions](https://github.com/majodev/google-webfonts-helper/actions). * `/server` was fully refactored/modernized (async/await) and now compiles with TypeScript. * Switch to `node:18` for the final image. * `/client` can still be considered very legacy Angular code. > 2022: This service was mostly on life-support, most of its code and dependencies can be considered deprecated. The current docker image wrapping `node@v0.10.44` runs rootless and is hopefully enough to keep the bandits out. API attack surface should be minimal anyways. > 2014: This service was originally a prototype I've created to get familiar with Angular and Express. All magic by [generator-angular-fullstack](https://github.com/DaftMonk/generator-angular-fullstack). See [my note here](http://mranftl.com/2014/12/23/self-hosting-google-web-fonts/). Idea originally by Clemens Lang who created an [awesome bash script](https://neverpanic.de/blog/2014/03/19/downloading-google-web-fonts-for-local-hosting/) to download Google fonts in all formats. ## License (c) Mario Ranftl [MIT License](http://majodev.mit-license.org/) [Google Fonts Open Source Font Attribution](https://fonts.google.com/attribution) ================================================ FILE: bower.json ================================================ { "name": "google-webfonts-helper", "version": "1.1.0", "dependencies": { "angular-animate": "1.3.8", "angular-bootstrap": "0.11.2", "angular-busy": "4.1.2", "angular-cookies": "1.3.8", "angular-resource": "1.3.8", "angular-sanitize": "1.3.8", "angular-ui-router": "0.2.18", "angular": "1.3.8", "bootstrap": "3.1.1", "es5-shim": "3.0.2", "font-awesome": "4.2.0", "highlightjs": "8.4.0", "jquery": "1.11.3", "json3": "3.3.2", "lodash": "2.4.2" }, "devDependencies": { "angular-mocks": "1.3.8", "angular-scenario": "1.3.8" } } ================================================ FILE: client/app/app.js ================================================ 'use strict'; angular.module('googleWebfontsHelperApp', [ 'ngCookies', 'ngResource', 'ngSanitize', 'ui.router', 'ui.bootstrap', 'cgBusy' ]) .config(function($stateProvider, $urlRouterProvider, $locationProvider) { $urlRouterProvider .otherwise('/fonts'); // default urls is /fonts $locationProvider.html5Mode(true); }); ================================================ FILE: client/app/app.less ================================================ @import 'bootstrap/less/bootstrap.less'; @import 'bootstrap/less/theme.less'; @import 'font-awesome/less/font-awesome.less'; @import (inline) 'angular-busy/dist/angular-busy.css'; @icon-font-path: '/bower_components/bootstrap/fonts/'; @fa-font-path: '/bower_components/font-awesome/fonts'; /** * App-wide Styles */ .browsehappy { margin: 0.2em 0; background: #ccc; color: #000; padding: 0.2em 0; } // injector @import 'cssCode/cssCode.less'; @import 'fonts/fonts.less'; // endinjector ================================================ FILE: client/app/cssCode/cssCode.directive.js ================================================ 'use strict'; angular.module('googleWebfontsHelperApp') .directive('cssCode', [function() { return { templateUrl: 'app/cssCode/cssCode.html', restrict: 'EA', scope: { type: '=', variant: '=', fontItem: '=', folderPrefix: '=' }, link: function(scope, element) { } }; }]); ================================================ FILE: client/app/cssCode/cssCode.directive.spec.js ================================================ 'use strict'; describe('Directive: cssCode', function () { // load the directive's module and view beforeEach(module('googleWebfontsHelperApp')); beforeEach(module('app/cssCode/cssCode.html')); var element, scope; beforeEach(inject(function ($rootScope) { scope = $rootScope.$new(); })); it('should make hidden element visible', inject(function ($compile) { element = angular.element(''); element = $compile(element)(scope); scope.$apply(); expect(element.text()).toBe('this is the cssCode directive'); })); }); ================================================ FILE: client/app/cssCode/cssCode.html ================================================
/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */
@font-face {
  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  font-family: {{variant.fontFamily}};
  font-style: {{variant.fontStyle}};
  font-weight: {{variant.fontWeight}};
  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */
@font-face {
  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  font-family: {{variant.fontFamily}};
  font-style: {{variant.fontStyle}};
  font-weight: {{variant.fontWeight}};
  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */
@font-face {
  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  font-family: {{variant.fontFamily}};
  font-style: {{variant.fontStyle}};
  font-weight: {{variant.fontWeight}};
  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.eot'); /* IE9 Compat Modes */
  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff') format('woff'), /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */
       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.ttf') format('truetype'), /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.svg#{{variant.svg.substring(variant.svg.indexOf('#')+1);}}') format('svg'); /* Legacy iOS */
}
================================================ FILE: client/app/cssCode/cssCode.less ================================================ pre { background-color: transparent; border: 0; border-radius: 0; padding-bottom: 0px; padding-top: 0px; margin-bottom: 0px; } ================================================ FILE: client/app/fonts/fonts.controller.js ================================================ 'use strict'; function apiError($scope, status, headers, config) { // called asynchronously if an error occurs // or server returns response with an error status. $scope.error = true; $scope.errorStatus = status; $scope.errorHeaders = JSON.stringify(headers, null, 2); $scope.errorConfig = JSON.stringify(config, null, 2); } var previousFontItem = false; // holds reference to previous font item, for partial refreshs, will be nulled if fontID changes var subsetsChkbTimeoutP = null; // timeout - promise for cgBusy 3000ms until request for customization is made var subsetsChkbReload = null; // interval - promise for cgBusy loading text rewrite (waiting till customization) 1000ms var variantsMap = {}; // map holds currently checked variants of a fontItem angular.module('googleWebfontsHelperApp') .controller('FontsCtrl', function($scope, $http) { $scope.fonts = []; $scope.sponsors = []; $scope.busy = true; $scope.selectedItemID = ''; $scope.predicate = { name: 'by family', filter: 'family', bindArg: 'category' }; // default ordering predicate $scope.reverse = false; $scope.fontsPromise = $http.get('/api/fonts') .success(function(fonts) { $scope.fonts = fonts; $scope.busy = false; }) .error(function(data, status, headers, config) { apiError($scope, status, headers, config); }); $scope.sponsorsPromise = $http.get('https://sponsors.mranftl.com/json') .success(function (data) { $scope.sponsors = data.sponsors; setTimeout(function () { $('[data-toggle="tooltip"]').tooltip(); }, 0); }) // err is not handled, because it is not critical $scope.scrollListTop = function() { $('.scrollerLeft').scrollTop(0); }; }) .controller('FontsItemCtrl', function($scope, $stateParams, $http, $state, $timeout, $interval) { var subSetString = $stateParams.subsets || ''; if (subsetsChkbTimeoutP) { $timeout.cancel(subsetsChkbTimeoutP); $interval.cancel(subsetsChkbReload); } $scope.fontID = $stateParams.id; $scope.$parent.selectedItemID = $scope.fontID; if (previousFontItem && previousFontItem.id === $stateParams.id) { // former item is a candiate for instant population until load is complete. $scope.fontItem = previousFontItem; $scope.loadingMessage = 'Customizing ' + $stateParams.id + '...'; // reuse current variantMap $scope.variantsMap = variantsMap; } else { // clear it previousFontItem = false; $scope.loadingMessage = 'Loading ' + $stateParams.id + '...'; } $scope.error = false; $scope.fontFormats = 'woff2'; $scope.downloadSubSetID = ''; $scope.subSetsSelected = 0; $scope.loadingPromise = $http.get('/api/fonts/' + $stateParams.id + '?subsets=' + subSetString) .success(function(fontItem) { $scope.fontItem = fontItem; $scope.downloadSubSetID = fontItem.storeID.replace(/_/g, ','); $.each($scope.fontItem.subsetMap, function(item) { if ($scope.fontItem.subsetMap[item] === true) { $scope.subSetsSelected += 1; } }); if (!previousFontItem) { // first load of fontItem - reload variants Map and set the default font style variantsMap = {}; $.each(fontItem.variants, function(index, variantItem) { // console.log(variantItem); variantsMap[variantItem.id] = variantItem.id === fontItem.defVariant; }); // console.log(variantsMap); $scope.variantsMap = variantsMap; $scope.variantDownloadQueryString = $scope.fontItem.defVariant; } else { // trigger variant select so variant query string matches again $scope.variantSelect(); } $scope.busy = false; }) .error(function(data, status, headers, config) { apiError($scope, status, headers, config); }); if (previousFontItem === false) { $scope.busy = true; } $scope.checkSubsetMinimalSelection = function(key) { if ($scope.subSetsSelected === 1 && $scope.fontItem.subsetMap[key] === true) { return true; } else { return false; } }; $scope.variantSelect = function() { var variantDownloadQueryString = ''; $.each(variantsMap, function(checkKey) { if (variantsMap[checkKey] === true) { variantDownloadQueryString += checkKey + ','; } }); if (variantDownloadQueryString.length === 0) { // you will only get the defaultvariant! variantDownloadQueryString = $scope.fontItem.defVariant; } else { // remove last comma from string variantDownloadQueryString = variantDownloadQueryString.substring(0, variantDownloadQueryString.length - 1); } $scope.variantDownloadQueryString = variantDownloadQueryString; }; $scope.subsetSelect = function() { if (subsetsChkbTimeoutP) { $timeout.cancel(subsetsChkbTimeoutP); $interval.cancel(subsetsChkbReload); } subsetsChkbTimeoutP = $timeout(function() { var queryParams = ''; var lenChecked = 0; var map = $scope.fontItem.subsetMap; var defaultSet = $scope.fontItem.defSubset; $.each(map, function(item) { if (map[item] === true) { queryParams += item + ','; lenChecked += 1; } }); $scope.subSetsSelected = lenChecked; if (lenChecked === 0) { // you will get the defaultset map[defaultSet] = true; queryParams = defaultSet; } else { // remove last comma from string queryParams = queryParams.substring(0, queryParams.length - 1); } previousFontItem = $scope.fontItem; // wait until doing the request (overrides previous promise!)... subsetsChkbTimeoutP = $timeout(function() { $state.go('fonts.item', { id: $scope.fontID, subsets: queryParams }); }, 3000); var timeUntil = 3; function setCustomizationReloadMessage(time) { $scope.customizationReloadMessage = 'Customization will be requested in ' + time + ' sec...'; } setCustomizationReloadMessage(timeUntil); subsetsChkbReload = $interval(function() { timeUntil -= 1; setCustomizationReloadMessage(timeUntil); }, 1000, 3); // make available for cgBusy $scope.subsetsChkbTimeoutP = subsetsChkbTimeoutP; }); // make available for cgBusy $scope.subsetsChkbTimeoutP = subsetsChkbTimeoutP; }; // selected variants filter $scope.variantFilter = function(variant) { if ($scope.variantsMap[variant.id] === false) { return; } return variant; }; $scope.checkVariantMinimalSelection = function(key) { var countSelected = 0; $.each(variantsMap, function(checkKey) { if (variantsMap[checkKey] === true) { countSelected += 1; } }); if (countSelected === 1 && variantsMap[key] === true) { return true; } else { return false; } }; $scope.selectText = function(evt) { var element = evt.currentTarget; // console.log(element); var doc = document, text = element, range, selection; if (doc.body.createTextRange) { range = document.body.createTextRange(); range.moveToElementText(text); range.select(); } else if (window.getSelection) { selection = window.getSelection(); range = document.createRange(); range.selectNodeContents(text); selection.removeAllRanges(); selection.addRange(range); } }; $scope.modernSupportActive = function() { $scope.fontFormats = 'woff2'; }; $scope.legacySupportActive = function() { $scope.fontFormats = 'woff2,ttf'; }; $scope.historicSupportActive = function() { $scope.fontFormats = 'woff2,woff,ttf,svg,eot'; }; }); ================================================ FILE: client/app/fonts/fonts.controller.spec.js ================================================ 'use strict'; describe('Controller: FontsCtrl', function () { // load the controller's module beforeEach(module('googleWebfontsHelperApp')); var FontsCtrl, scope; // Initialize the controller and a mock scope beforeEach(inject(function ($controller, $rootScope) { scope = $rootScope.$new(); FontsCtrl = $controller('FontsCtrl', { $scope: scope }); })); it('should ...', function () { expect(1).toEqual(1); }); }); ================================================ FILE: client/app/fonts/fonts.html ================================================ ================================================ FILE: client/app/fonts/fonts.js ================================================ 'use strict'; angular.module('googleWebfontsHelperApp') .config(function ($stateProvider) { $stateProvider .state('fonts', { url: '/fonts', templateUrl: 'app/fonts/fonts.html', controller: 'FontsCtrl' }) .state('fonts.item', { url: '/:id?subsets', templateUrl: 'app/fonts/fontsItem.html', controller: 'FontsItemCtrl' }); }); ================================================ FILE: client/app/fonts/fonts.less ================================================ // Variables // ----------------------------------------------------------------------------- @header-height: 55px; // Fixes // ----------------------------------------------------------------------------- /* ui bootstrap click to pointers */ .nav, .pagination, .carousel, .panel-title a { cursor: pointer; } // Page wide // ----------------------------------------------------------------------------- html, body { height: 100%; } .fonts-top-container, .top-overlay, .box { min-width: 900px; } .fonts-top-container, .row-fluid { height: 100%; } .fonts-top-container:before, .fonts-top-container:after, .column:before, .column:after { content: ""; display: table; } .fonts-top-container:after, .column:after { clear: both; } // Header // ----------------------------------------------------------------------------- .top-overlay { height: @header-height; padding: 10px 15px 25px 15px; border-bottom: #eee solid 1px; background: #fff; } .page-header { margin: 0; border-bottom: none; float: left; } .nav-push-right { float: right; } .actNavButton { i { font-size: 16px; } } .actSponsorButton { padding: 0; margin-left: 4px; position: relative; overflow: hidden; opacity: 0; animation: show 600ms 100ms cubic-bezier(0.38, 0.97, 0.56, 0.76) forwards; } @keyframes show { 100% { opacity: 1; transform: none; } } .actSponsorButtonUser { border-radius: 50%; } .actSponsorButtonOrganization { border-radius: 8px; } .sponsorheart { color: #DB61A2; } .sponsor-img { height: 34px; width: 34px; } .sponsor-img-overlay { height: 34px; width: 34px; position: absolute; top: 0; left: 0; background-color: #DB61A2; opacity: 0.05; } .sponsor-img-overlay:hover { opacity: 0.4; } .ordering { float: left; margin-bottom: 5px; } // search #searchwrap { width: 100%; } #searchinput { width: 100%; font-size: 11px; } #searchclear { position: absolute; right: 5px; top: 0; bottom: 0; height: 14px; margin: auto; font-size: 14px; cursor: pointer; color: #ccc; display: none; } #searchclear.show { display: initial; z-index: 1000; } #orderButton { border-bottom-right-radius: 4px; border-top-right-radius: 4px; } // Masthead first page // ----------------------------------------------------------------------------- .masthead { margin-left: -15px; margin-right: -15px; background-color: transparent; background: linear-gradient(fade(#fff, 0%), fade(#fff, 100%)); position: relative; } .masthead:after { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: url('/assets/images/swirl.png') repeat; opacity: 1; z-index: -2; } .pulse { -webkit-animation: pulse 1s infinite; -moz-animation: pulse 1s infinite; -o-animation: pulse 1s infinite; animation: pulse 1s infinite; } @-webkit-keyframes pulse { 0% { -webkit-transform: scale(1); } 50% { -webkit-transform: scale(1.3); } 100% { -webkit-transform: scale(1); } } @-moz-keyframes pulse { 0% { -moz-transform: scale(1); } 50% { -moz-transform: scale(1.3); } 100% { -moz-transform: scale(1); } } @-o-keyframes pulse { 0% { -o-transform: scale(1); } 50% { -o-transform: scale(1.3); } 100% { -o-transform: scale(1); } } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } } // Scroll colums // ----------------------------------------------------------------------------- .box { position: absolute; bottom: 0; left: 0; right: 0; top: @header-height; } .column { height: 100%; overflow: auto; *zoom: 1; } .scrollerLeft { background: #eee; } .scrollerRight {} // Font content // ----------------------------------------------------------------------------- // #previewFontSizeInput { // width: 50px; // } .dl-horizontal.variantList { margin-bottom: 0px; dt { width: auto; } dd { margin-left: 120px; } } .list-group { padding-top: 10px; padding-bottom: 10px; } .list-group-item.active h5 small { color: #efefef; } .list-group-item-heading { margin-top: 0px; margin-bottom: 0px; } .download-button { white-space: normal; // margin-top: 30px; } .head-right-block { margin-top: 54px; } .folderPrefixBar { margin-bottom: 12px; margin-top: 12px; } .nav-tabs { margin-bottom: 5px; } .fontItemCSSWrap { margin-top: 20px; padding-top: 20px; border: 0; border-top: 1px solid #eeeeee; } #fontPreviewToggle { margin-top: 12px; } .cssCodeStyle { background: #eee; padding-top: 8px; padding-bottom: 8px; } ul.nav.nav-pills { padding-bottom: 10px; margin-bottom: 5px; } // Erros // ----------------------------------------------------------------------------- .apiError { display: none; } .apiError.show { display: initial; } // General // ----------------------------------------------------------------------------- pre { font-size: 80%; } code { font-size: 75%; } textarea { resize: none; } .mini { font-size: 70%; } ================================================ FILE: client/app/fonts/fontsItem.html ================================================

{{fontItem.family}}
{{fontItem.category}}

 {{variant.id}}{{$last ? "" : ", "}}
 {{subset}}{{$last ? "" : ", "}}
Rank {{fontItem.popularity}} in popularity of {{fonts.length}} fonts in total
Last modified {{fontItem.lastModified}} ({{fontItem.version}})

1. Select charsets: (default is {{fontItem.defSubset}})


2. Select styles: (default is {{fontItem.defVariant}})

Preview size px
 Sample text editable.

The quick brown fox jumps over the lazy dog.

3. Copy CSS: (default is Modern Browsers)

Choose Modern Browsers if supporting old browsers is not relevant. Formats in this snippet: [{{fontFormats}}]

Customize folder prefix (optional): 

Click on code to select all statements, then copy/paste it into your own CSS file.

Choose Legacy Support if old browsers still need to be supported. Formats in this snippet: [{{fontFormats}}]

Customize folder prefix (optional): 

Click on code to select all statements, then copy/paste it into your own CSS file.

Choose Historic Support if very old browsers still need to be supported. Formats in this snippet: [{{fontFormats}}]

Customize folder prefix (optional): 

Click on code to select all statements, then copy/paste it into your own CSS file.


4. Download files:

Your generated archive for {{fontItem.family}} with charsets [{{downloadSubSetID}}] and styles [{{variantDownloadQueryString}}] includes the formats [{{fontFormats}}].


{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}.zip

Fonts are copyright of their respective authors.
See Google Fonts Open Source Font Attribution to find out the specific license that this font uses.


You are now ready to self-host {{fontItem.family}}.
If this service has actually been helpful for you, please star it on GitHub. If you've encountered serious problems, file an issue here.
❤️ You can help to keep this project alive by sponsoring me. Thank you ❤️.


API Error ({{errorStatus}})

REQUEST CONFIG: {{errorConfig}}
REQUEST HEADERS: {{errorHeaders}}
================================================ FILE: client/app/highlightjs/highlightjs.directive.js ================================================ 'use strict'; // via http://stackoverflow.com/questions/25581560/dynamic-syntax-highlighting-with-angularjs-and-highlight-js angular.module('googleWebfontsHelperApp') .directive('highlightjs', ['$interpolate', '$timeout', function($interpolate, $timeout) { return { restrict: 'EA', scope: true, // must inherit parent scope all expressions are allowed inside content! compile: function(tElem, tAttrs) { var interpolateFn = $interpolate(tElem.html(), true); tElem.html(''); // disable automatic intepolation bindings return function(scope, elem, attrs) { scope.$watch(interpolateFn, function(value) { $timeout(function() { var highlighter = elem.attr('data-hljs'); // use data-hljs to define the highligher to use if (typeof highlighter !== 'undefined') { elem.html(hljs.highlight(highlighter, value).value); } else { elem.html(hljs.highlightAuto(value).value); } }, 0); }); } }, link: function(scope, element) {} }; }]); ================================================ FILE: client/app/highlightjs/highlightjs.directive.spec.js ================================================ 'use strict'; describe('Directive: highlightjs', function () { // load the directive's module beforeEach(module('googleWebfontsHelperApp')); var element, scope; beforeEach(inject(function ($rootScope) { scope = $rootScope.$new(); })); it('should make hidden element visible', inject(function ($compile) { element = angular.element(''); element = $compile(element)(scope); expect(element.text()).toBe('this is the highlightjs directive'); })); }); ================================================ FILE: client/index.html ================================================ google webfonts helper
================================================ FILE: client/robots.txt ================================================ # robotstxt.org User-agent: * ================================================ FILE: docker-compose.yml ================================================ services: service: build: context: . target: development ports: - "9000:9000" # development - "8080:8080" # production - "35729:35729" # livereload - "5858:5858" # debugger - "9229:9229" # profiler working_dir: &PROJECT_ROOT_DIR /app # linux permissions: we must explicitly run as the node user user: node volumes: # mount working directory # https://docs.docker.com/docker-for-mac/osxfs-caching/#delegated # the container’s view is authoritative (permit delays before updates on the container appear in the host) - .:/app:delegated environment: # Set your key in the .gitignored .env file. GOOGLE_FONTS_API_KEY: ${GOOGLE_FONTS_API_KEY} # Overrides default command so things don't shut down after the process ends. command: - /bin/sh - -c - | git config --global --add safe.directory /app while sleep 1000; do :; done ================================================ FILE: docker-helper.sh ================================================ #!/bin/bash if [ "$1" = "--up" ]; then docker compose up --no-start docker compose start # ensure we are started, handle also allowed to be consumed by vscode docker compose exec service bash fi if [ "$1" = "--halt" ]; then docker compose stop fi if [ "$1" = "--rebuild" ]; then docker compose up -d --force-recreate --no-deps --build service fi if [ "$1" = "--destroy" ]; then docker compose down --rmi local -v --remove-orphans fi [ -n "$1" -a \( "$1" = "--up" -o "$1" = "--halt" -o "$1" = "--rebuild" -o "$1" = "--destroy" \) ] \ || { echo "usage: $0 --up | --halt | --rebuild | --destroy" >&2; exit 1; } ================================================ FILE: package.json ================================================ { "name": "google-webfonts-helper", "version": "1.1.0", "homepage": "https://gwfh.mranftl.com", "main": "server/app.ts", "author": "majodev", "license": "MIT", "keywords": [ "google fonts", "web fonts", "download", "woff", "svg", "ttf", "woff2", "eot", "css", "snippet", "hosting" ], "repository": { "type": "git", "url": "https://github.com/majodev/google-webfonts-helper.git" }, "dependencies": { "axios": "1.12.2", "bluebird": "3.7.2", "compression": "1.8.1", "css": "3.0.0", "express": "4.21.2", "jszip": "3.10.1", "lodash": "4.17.21", "morgan": "1.10.1", "source-map-support": "0.5.21", "speakingurl": "14.0.1" }, "devDependencies": { "@types/async": "^3.2.16", "@types/bluebird": "^3.5.38", "@types/css": "^0.0.33", "@types/express": "^4.17.17", "@types/lodash": "^4.14.191", "@types/mocha": "^10.0.1", "@types/node": "18", "@types/speakingurl": "^13.0.3", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/parser": "^5.52.0", "bower": "^1.3.8", "connect-livereload": "~0.4.0", "errorhandler": "~1.0.0", "eslint": "^8.34.0", "file-type": "16.5.4", "grunt": "~0.4.4", "grunt-angular-templates": "^0.5.4", "grunt-asset-injector": "^0.1.0", "grunt-autoprefixer": "~0.7.2", "grunt-concurrent": "~0.5.0", "grunt-contrib-clean": "~0.5.0", "grunt-contrib-concat": "~0.4.0", "grunt-contrib-copy": "~0.5.0", "grunt-contrib-cssmin": "~0.9.0", "grunt-contrib-htmlmin": "~0.2.0", "grunt-contrib-imagemin": "4.0.0", "grunt-contrib-less": "^0.11.0", "grunt-contrib-uglify": "~0.4.0", "grunt-contrib-watch": "~0.6.1", "grunt-dom-munger": "^3.4.0", "grunt-env": "~0.4.1", "grunt-express-server": "~0.4.17", "grunt-mocha-test": "0.13.3", "grunt-newer": "~0.7.0", "grunt-ng-annotate": "^0.2.3", "grunt-nodemon": "0.4.2", "grunt-rev": "~0.1.0", "grunt-svgmin": "~0.4.0", "grunt-ts": "^6.0.0-beta.22", "grunt-usemin": "~2.1.1", "grunt-wiredep": "~1.8.0", "jit-grunt": "^0.5.0", "mocha": "^10.2.0", "prettier": "^2.8.4", "prettier-eslint": "^15.0.1", "prettier-plugin-organize-imports": "^3.2.2", "punycode": "^1.4.1", "should": "13.2.3", "supertest": "6.3.3", "time-grunt": "~0.3.1", "ts-node": "^10.9.1", "typescript": "^4.9.5" }, "scripts": { "start": "ts-node server/app.ts", "lint": "eslint --ext .ts .", "build": "grunt build", "test": "grunt test", "dev": "grunt serve", "tsc": "tsc --noEmit --skipLibCheck" }, "private": true, "resolutions": { "imagemin-optipng": "git+https://github.com/PruvoNet/imagemin-optipng.git#68dc79939c380fb12a83f7ec7cc5943f9aa41149", "optipng-bin": "git+https://github.com/PruvoNet/optipng-bin.git#ffb7e8f17710428596def1bb832d8e8e3fe382af" } } ================================================ FILE: server/api/fonts.controller.ts ================================================ import { NextFunction, Request, Response } from "express"; import * as fs from "fs"; import * as JSZip from "jszip"; import * as _ from "lodash"; import * as path from "path"; import { IUserAgents } from "../config"; import { loadFontBundle, loadFontItems, loadFontSubsetArchive, loadSubsetMap, loadVariantItems } from "../logic/core"; import { IFontSubsetArchive } from "../logic/fetchFontSubsetArchive"; // Get list of fonts // /api/fonts interface IAPIListFont { id: string; family: string; variants: string[]; subsets: string[]; category: string; version: string; lastModified: string; // e.g. 2022-09-22 popularity: number; defSubset: string; defVariant: string; } export async function getApiFonts(req: Request, res: Response, next: NextFunction) { try { const fonts = loadFontItems(); const apiListFonts: IAPIListFont[] = _.map(fonts, (font) => { return { id: font.id, family: font.family, variants: font.variants, subsets: font.subsets, category: font.category, version: font.version, lastModified: font.lastModified, popularity: font.popularity, defSubset: font.defSubset, defVariant: font.defVariant, }; }); return res.json(apiListFonts); } catch (e) { next(e); } } // Get specific fonts (fixed charsets) including links // /api/fonts/:id interface IAPIFont { id: string; family: string; subsets: string[]; category: string; version: string; lastModified: string; // e.g. 2022-09-22 popularity: number; defSubset: string; defVariant: string; subsetMap: { [subset: string]: boolean; }; storeID: string; variants: { id: string; fontFamily: string | null; fontStyle: string | null; fontWeight: string | null; eot?: string; woff?: string; woff2?: string; svg?: string; ttf?: string; }[]; } export async function getApiFontsById(req: Request, res: Response, next: NextFunction) { try { // get the subset string if it was supplied... // e.g. "subset=latin,latin-ext," will be transformed into ["latin","latin-ext"] (non whitespace arrays) const subsets = _.isString(req.query.subsets) ? _.without(req.query.subsets.split(/[,]+/), "") : null; const fontBundle = await loadFontBundle(req.params.id, subsets); if (_.isNil(fontBundle)) { return res.status(404).send("Not found"); } const subsetMap = loadSubsetMap(fontBundle); const variantItems = await loadVariantItems(fontBundle); if (_.isNil(variantItems)) { return res.status(404).send("Not found"); } // default case: json serialize... if (req.query.download !== "zip") { const { font } = fontBundle; const apiFont: IAPIFont = { id: font.id, family: font.family, subsets: font.subsets, category: font.category, version: font.version, lastModified: font.lastModified, popularity: font.popularity, defSubset: font.defSubset, defVariant: font.defVariant, subsetMap: subsetMap, // be compatible with legacy storeIDs, without binding on our new convention. storeID: fontBundle.subsets.join("_"), variants: _.map(variantItems, (variant) => { return { id: variant.id, fontFamily: variant.fontFamily, fontStyle: variant.fontStyle, fontWeight: variant.fontWeight, ..._.reduce( variant.urls, (sum, vurl) => { sum[vurl.format] = vurl.url; return sum; }, {} as IUserAgents ), }; }), }; return res.json(apiFont); } // otherwise: download as zip const variants = _.isString(req.query.variants) ? _.without(req.query.variants.split(/[,]+/), "") : null; const formats = _.isString(req.query.formats) ? _.without(req.query.formats.split(/[,]+/), "") : null; let subsetFontArchive: IFontSubsetArchive; try { subsetFontArchive = await loadFontSubsetArchive(fontBundle, variantItems); } catch (e) { console.error("getApiFontsById.loadFontSubsetArchive received error -> 404", e); return res.status(404).send("Not found"); } const filteredFiles = _.filter(subsetFontArchive.files, (file) => { return (_.isNil(variants) || _.includes(variants, file.variant)) && (_.isNil(formats) || _.includes(formats, file.format)); }); if (filteredFiles.length === 0) { return res.status(404).send("Not found"); } // we build a new .zip from the existing cached .zip, filtered by the requested variants and formats. const archive = await loadZipArchive(subsetFontArchive.zipPath); // remove all files that are not in the filtered list. _.each(subsetFontArchive.files, function (file) { if (!_.includes(filteredFiles, file)) { archive.remove(file.path); } }); // tell the browser that this is a zip file. res.writeHead(200, { "Content-Type": "application/zip", "Content-disposition": `attachment; filename=${path.basename(subsetFontArchive.zipPath)}`, }); return archive .generateNodeStream({ // streamFiles: true, compression: "DEFLATE", }) .pipe(res); } catch (e) { next(e); } } // exported for testing function loadZipArchive(zipPath: string): PromiseLike { return new JSZip.external.Promise(function (resolve, reject) { fs.readFile(zipPath, function (err, data) { if (err) { reject(err); } else { resolve(data); } }); }).then(function (data: unknown) { return JSZip.loadAsync(data); }); } ================================================ FILE: server/api/fonts.spec.ts ================================================ import { fromBuffer as fileTypeFromBuffer } from "file-type"; import * as JSZip from "jszip"; import * as _ from "lodash"; import * as should from "should"; import * as request from "supertest"; import { app } from "../app"; import { getStats, reinitStore } from "../logic/store"; describe("GET /api/fonts", () => { afterEach(() => { return reinitStore(); }); it("should respond with JSON array with all fonts", async () => { const res = await request(app).get("/api/fonts").timeout(10000).expect(200).expect("Content-Type", /json/); should(res.body).be.instanceof(Array); }); }); describe("GET /api/fonts/:id", () => { afterEach(() => { return reinitStore(); }); it("should respond with font files for arvo", async function () { const res = await request(app).get("/api/fonts/arvo").timeout(10000).expect(200).expect("Content-Type", /json/); should(res.body).be.instanceof(Object); should(res.body).have.property("id", "arvo"); should(res.body).have.property("family", "Arvo"); should(res.body).have.property("subsets", ["latin"]); should(res.body).have.property("category", "serif"); should(res.body).have.property("version", "v20"); should(res.body).have.property("lastModified", "2022-09-22"); should(res.body).have.property("popularity", 1); should(res.body).have.property("defSubset", "latin"); should(res.body).have.property("defVariant", "regular"); should(res.body).have.property("subsetMap", { latin: true }); should(res.body).have.property("storeID", "latin"); should(res.body.variants).be.instanceof(Array); should(res.body.variants).be.lengthOf(4); if (res.body.variants.length === 4) { should(res.body.variants[0]).have.property("id", "regular"); should(res.body.variants[0]).have.property("fontFamily", "'Arvo'"); should(res.body.variants[0]).have.property("fontStyle", "normal"); should(res.body.variants[0]).have.property("fontWeight", "400"); should(res.body.variants[1]).have.property("id", "italic"); should(res.body.variants[1]).have.property("fontFamily", "'Arvo'"); should(res.body.variants[1]).have.property("fontStyle", "italic"); should(res.body.variants[1]).have.property("fontWeight", "400"); should(res.body.variants[2]).have.property("id", "700"); should(res.body.variants[2]).have.property("fontFamily", "'Arvo'"); should(res.body.variants[2]).have.property("fontStyle", "normal"); should(res.body.variants[2]).have.property("fontWeight", "700"); should(res.body.variants[3]).have.property("id", "700italic"); should(res.body.variants[3]).have.property("fontFamily", "'Arvo'"); should(res.body.variants[3]).have.property("fontStyle", "italic"); should(res.body.variants[3]).have.property("fontWeight", "700"); _.each(res.body.variants, (variant) => { should(variant).have.property("woff").String(); should(variant).have.property("woff2").String(); should(variant).have.property("svg").String(); should(variant).have.property("eot").String(); should(variant).have.property("ttf").String(); should(_.get(variant, "woff", {}).length).greaterThan(1); should(_.get(variant, "woff2", {}).length).greaterThan(1); should(_.get(variant, "svg", {}).length).greaterThan(1); should(_.get(variant, "eot", {}).length).greaterThan(1); should(_.get(variant, "ttf", {}).length).greaterThan(1); }); } should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(0); }).timeout(10000); it("should respond with font files for istok-web multi charsets filtered", async () => { const res = await request(app) .get("/api/fonts/istok-web?subsets=cyrillic,cyrillic-ext,latin") .timeout(10000) .expect(200) .expect("Content-Type", /json/); should(res.body).be.instanceof(Object); should(res.body).have.property("id", "istok-web"); should(res.body).have.property("family", "Istok Web"); should(res.body).have.property("subsets", ["cyrillic", "cyrillic-ext", "latin", "latin-ext"]); should(res.body).have.property("category", "sans-serif"); should(res.body).have.property("version", "v20"); should(res.body).have.property("lastModified", "2022-09-22"); should(res.body).have.property("popularity", 2); should(res.body).have.property("defSubset", "latin"); should(res.body).have.property("defVariant", "regular"); should(res.body).have.property("subsetMap", { cyrillic: true, "cyrillic-ext": true, latin: true, "latin-ext": false, }); should(res.body).have.property("storeID", "cyrillic_cyrillic-ext_latin"); should(res.body.variants).be.instanceof(Array); should(res.body.variants).be.lengthOf(4); if (res.body.variants.length === 4) { should(res.body.variants[0]).have.property("id", "regular"); should(res.body.variants[0]).have.property("fontFamily", "'Istok Web'"); should(res.body.variants[0]).have.property("fontStyle", "normal"); should(res.body.variants[0]).have.property("fontWeight", "400"); should(res.body.variants[1]).have.property("id", "italic"); should(res.body.variants[1]).have.property("fontFamily", "'Istok Web'"); should(res.body.variants[1]).have.property("fontStyle", "italic"); should(res.body.variants[1]).have.property("fontWeight", "400"); should(res.body.variants[2]).have.property("id", "700"); should(res.body.variants[2]).have.property("fontFamily", "'Istok Web'"); should(res.body.variants[2]).have.property("fontStyle", "normal"); should(res.body.variants[2]).have.property("fontWeight", "700"); should(res.body.variants[3]).have.property("id", "700italic"); should(res.body.variants[3]).have.property("fontFamily", "'Istok Web'"); should(res.body.variants[3]).have.property("fontStyle", "italic"); should(res.body.variants[3]).have.property("fontWeight", "700"); _.each(res.body.variants, (variant) => { should(variant).have.property("woff").String(); should(variant).have.property("woff2").String(); should(variant).have.property("svg").String(); should(variant).have.property("eot").String(); should(variant).have.property("ttf").String(); should(_.get(variant, "woff", {}).length).greaterThan(1); should(_.get(variant, "woff2", {}).length).greaterThan(1); should(_.get(variant, "svg", {}).length).greaterThan(1); should(_.get(variant, "eot", {}).length).greaterThan(1); should(_.get(variant, "ttf", {}).length).greaterThan(1); }); } should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(0); }).timeout(10000); it("should respond with 200 for known font istok-web empty subsets", async function () { this.timeout(10000); const res = await request(app).get("/api/fonts/istok-web?subsets=").timeout(10000).expect(200).expect("Content-Type", /json/); should(res.body).be.instanceof(Object); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(0); }).timeout(10000); it("should respond with 404 for unknown font", async () => { await request(app) .get("/api/fonts/unknown-font") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); should(getStats().urlMap).eql(0); should(getStats().archiveMap).eql(0); }).timeout(10000); it("should respond with 404 for unknown font and subset", async () => { await request(app) .get("/api/fonts/unknown-font?subsets=latin") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); should(getStats().urlMap).eql(0); should(getStats().archiveMap).eql(0); }).timeout(10000); it("should respond with 404 for known font istok-web and unknown subset", async () => { await request(app) .get("/api/fonts/istok-web?subsets=unknownsubset") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); }).timeout(10000); }); describe("GET /api/fonts/:id?download=zip", () => { afterEach(() => { return reinitStore(); }); it("should (concurrently) download istok-web", async function () { this.timeout(10000); let triggered = 0; await Promise.all([ request(app) .get("/api/fonts/istok-web?download=zip&subsets=latin&formats=woff,woff2") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip") .then(() => { triggered += 1; }), request(app) .get("/api/fonts/istok-web?download=zip&subsets=latin&formats=woff,woff2") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip") .then(() => { triggered += 1; }), ]); should(triggered).eql(2); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); }).timeout(10000); it("should (concurrently) download istok-web (subsets and formats mix)", async function () { this.timeout(10000); let triggered = 0; const [res1, res2] = await Promise.all([ request(app) .get("/api/fonts/istok-web?download=zip&subsets=cyrillic-ext,latin,latin-ext&formats=woff,woff2") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip") .then((res) => { triggered += 1; return res; }), request(app) .get("/api/fonts/istok-web?download=zip&subsets=latin-ext,latin,cyrillic-ext&formats=woff,woff2,eot,ttf,svg") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip") .then((res) => { triggered += 1; return res; }), ]); should(triggered).eql(2); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive1 = await JSZip.loadAsync(res1.body); // 8 files in archive1 should(_.keys(archive1.files).length).eql(8); const archive2 = await JSZip.loadAsync(res2.body); // 60 files in archive2 should(_.keys(archive2.files).length).eql(20); }).timeout(10000); it("should (concurrently) download playfair-display (different but unknown subsets resolve to the same key)", async function () { let triggered = 0; this.timeout(30000); const [res1, res2] = await Promise.all([ request(app) .get( "/api/fonts/playfair-display?download=zip&subsets=devanagari,vietnamese,cyrillic-ext,latin,greek-ext,greek,cyrillic,latin-ext,hebrew,korean,oriya" ) .responseType("blob") .timeout(30000) .expect(200) .expect("Content-Type", "application/zip") .then((res) => { triggered += 1; return res; }), request(app) .get("/api/fonts/playfair-display?download=zip&subsets=cyrillic,latin,latin-ext,vietnamese") .responseType("blob") .timeout(30000) .expect(200) .expect("Content-Type", "application/zip") .then((res) => { triggered += 1; return res; }), ]); should(triggered).eql(2); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive1 = await JSZip.loadAsync(res1.body); // 60 files in archive1 should(_.keys(archive1.files).length).eql(60); const archive2 = await JSZip.loadAsync(res2.body); // 60 files in archive2 should(_.keys(archive2.files).length).eql(60); }).timeout(10000); it("should respond with 200 for download attempt of known font istok-web with unspecified subset", async function () { this.timeout(10000); const res = await request(app) .get("/api/fonts/istok-web?download=zip&formats=woff,woff2") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip"); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive = await JSZip.loadAsync(res.body); // 4 default variants, 2 formats -> 8 files in archive should(_.keys(archive.files).length).eql(8); const files = _.map(_.sortBy(_.keys(archive.files)), (key) => { const file = archive.files[key]; return file; }); should(files[0].name).eql("istok-web-v20-latin-700.woff"); should((await fileTypeFromBuffer(await files[0].async("nodebuffer")))?.mime).eql("font/woff"); should(files[1].name).eql("istok-web-v20-latin-700.woff2"); should((await fileTypeFromBuffer(await files[1].async("nodebuffer")))?.mime).eql("font/woff2"); should(files[2].name).eql("istok-web-v20-latin-700italic.woff"); should((await fileTypeFromBuffer(await files[2].async("nodebuffer")))?.mime).eql("font/woff"); should(files[3].name).eql("istok-web-v20-latin-700italic.woff2"); should((await fileTypeFromBuffer(await files[3].async("nodebuffer")))?.mime).eql("font/woff2"); should(files[4].name).eql("istok-web-v20-latin-italic.woff"); should((await fileTypeFromBuffer(await files[4].async("nodebuffer")))?.mime).eql("font/woff"); should(files[5].name).eql("istok-web-v20-latin-italic.woff2"); should((await fileTypeFromBuffer(await files[5].async("nodebuffer")))?.mime).eql("font/woff2"); should(files[6].name).eql("istok-web-v20-latin-regular.woff"); should((await fileTypeFromBuffer(await files[6].async("nodebuffer")))?.mime).eql("font/woff"); should(files[7].name).eql("istok-web-v20-latin-regular.woff2"); should((await fileTypeFromBuffer(await files[7].async("nodebuffer")))?.mime).eql("font/woff2"); }).timeout(10000); it("should respond with 200 for download attempt of known font istok-web with unspecified formats", async () => { const res = await request(app) .get("/api/fonts/istok-web?download=zip&subsets=latin") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip"); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive = await JSZip.loadAsync(res.body); // 4 default variants, 5 formats -> 20 files in archive should(_.keys(archive.files).length).eql(20); const files = _.map(_.sortBy(_.keys(archive.files)), (key) => { const file = archive.files[key]; return file; }); // _.each(files, (file) => console.log(file.name)); should(files[0].name).eql("istok-web-v20-latin-700.eot"); should((await fileTypeFromBuffer(await files[0].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject"); should(files[1].name).eql("istok-web-v20-latin-700.svg"); should((await fileTypeFromBuffer(await files[1].async("nodebuffer")))?.mime).eql("application/xml"); should(files[2].name).eql("istok-web-v20-latin-700.ttf"); should((await fileTypeFromBuffer(await files[2].async("nodebuffer")))?.mime).eql("font/ttf"); should(files[3].name).eql("istok-web-v20-latin-700.woff"); should((await fileTypeFromBuffer(await files[3].async("nodebuffer")))?.mime).eql("font/woff"); should(files[4].name).eql("istok-web-v20-latin-700.woff2"); should((await fileTypeFromBuffer(await files[4].async("nodebuffer")))?.mime).eql("font/woff2"); should(files[5].name).eql("istok-web-v20-latin-700italic.eot"); should((await fileTypeFromBuffer(await files[5].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject"); should(files[6].name).eql("istok-web-v20-latin-700italic.svg"); should((await fileTypeFromBuffer(await files[6].async("nodebuffer")))?.mime).eql("application/xml"); should(files[7].name).eql("istok-web-v20-latin-700italic.ttf"); should((await fileTypeFromBuffer(await files[7].async("nodebuffer")))?.mime).eql("font/ttf"); should(files[8].name).eql("istok-web-v20-latin-700italic.woff"); should((await fileTypeFromBuffer(await files[8].async("nodebuffer")))?.mime).eql("font/woff"); should(files[9].name).eql("istok-web-v20-latin-700italic.woff2"); should((await fileTypeFromBuffer(await files[9].async("nodebuffer")))?.mime).eql("font/woff2"); should(files[10].name).eql("istok-web-v20-latin-italic.eot"); should((await fileTypeFromBuffer(await files[10].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject"); should(files[11].name).eql("istok-web-v20-latin-italic.svg"); should((await fileTypeFromBuffer(await files[11].async("nodebuffer")))?.mime).eql("application/xml"); should(files[12].name).eql("istok-web-v20-latin-italic.ttf"); should((await fileTypeFromBuffer(await files[12].async("nodebuffer")))?.mime).eql("font/ttf"); should(files[13].name).eql("istok-web-v20-latin-italic.woff"); should((await fileTypeFromBuffer(await files[13].async("nodebuffer")))?.mime).eql("font/woff"); should(files[14].name).eql("istok-web-v20-latin-italic.woff2"); should((await fileTypeFromBuffer(await files[14].async("nodebuffer")))?.mime).eql("font/woff2"); should(files[15].name).eql("istok-web-v20-latin-regular.eot"); should((await fileTypeFromBuffer(await files[15].async("nodebuffer")))?.mime).eql("application/vnd.ms-fontobject"); should(files[16].name).eql("istok-web-v20-latin-regular.svg"); should((await fileTypeFromBuffer(await files[16].async("nodebuffer")))?.mime).eql("application/xml"); should(files[17].name).eql("istok-web-v20-latin-regular.ttf"); should((await fileTypeFromBuffer(await files[17].async("nodebuffer")))?.mime).eql("font/ttf"); should(files[18].name).eql("istok-web-v20-latin-regular.woff"); should((await fileTypeFromBuffer(await files[18].async("nodebuffer")))?.mime).eql("font/woff"); should(files[19].name).eql("istok-web-v20-latin-regular.woff2"); should((await fileTypeFromBuffer(await files[19].async("nodebuffer")))?.mime).eql("font/woff2"); }).timeout(10000); it("should respond with 200 for download attempt of known font istok-web and empty subsets", async () => { const res = await request(app) .get("/api/fonts/istok-web?download=zip&subsets=") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip"); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive = await JSZip.loadAsync(res.body); // defaults to latin with 4 default variants, 5 formats -> 20 files in archive should(_.keys(archive.files).length).eql(20); _.each(_.sortBy(_.keys(archive.files)), (key) => { should(key.indexOf("istok-web-v20-latin-")).eql(0); }); }).timeout(10000); it("should respond with 200 for download attempt of known font istok-web and a single unknown format sneaked in", async () => { const res = await request(app) .get("/api/fonts/istok-web?download=zip&formats=woff,woff2,rolf") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip"); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive = await JSZip.loadAsync(res.body); // defaults to latin with 4 default variants, 2 formats -> 8 files in archive should(_.keys(archive.files).length).eql(8); _.each(_.sortBy(_.keys(archive.files)), (key) => { should(key.indexOf("istok-web-v20-latin-")).eql(0); }); }).timeout(10000); it("should respond with 200 for download attempt of known font istok-web with variants", async () => { const res = await request(app) .get("/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=regular") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip"); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive = await JSZip.loadAsync(res.body); // defaults to latin with 1 variant, 2 formats -> 2 files in archive should(_.keys(archive.files).length).eql(2); _.each(_.sortBy(_.keys(archive.files)), (key) => { should(_.endsWith(key, ".woff") || _.endsWith(key, ".woff2")).eql(true); should(key.indexOf("regular") === -1).eql(false); }); }).timeout(10000); it("should respond with 200 for download attempt of known font istok-web with one known, one unknown variant", async () => { const res = await request(app) .get("/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=regular,unknownvar") .responseType("blob") .timeout(10000) .expect(200) .expect("Content-Type", "application/zip"); should(getStats().urlMap).eql(1); should(getStats().archiveMap).eql(1); const archive = await JSZip.loadAsync(res.body); // defaults to latin with 1 variant, 2 formats -> 2 files in archive should(_.keys(archive.files).length).eql(2); _.each(_.sortBy(_.keys(archive.files)), (key) => { should(_.endsWith(key, ".woff") || _.endsWith(key, ".woff2")).eql(true); should(key.indexOf("regular") === -1).eql(false); }); }).timeout(10000); it("should respond with 404 for download attempt of known font istok-web with empty variants", async () => { await request(app) .get("/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); }).timeout(10000); // https://gwfh.mranftl.com/api/fonts/siemreap?download=zip&subsets=latin,latin-ext&formats=eot,woff,woff2,svg,ttf it("should respond with 404 for download attempt of unknown font and unknown subset", async () => { await request(app) .get("/api/fonts/unknown-font?download=zip&subsets=latin&formats=woff,woff2") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); }).timeout(10000); it("should respond with 404 for download attempt of known font istok-web and unknown subset", async () => { await request(app) .get("/api/fonts/istok-web?download=zip&subsets=unknown&formats=woff,woff2") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); }).timeout(10000); it("should respond with 404 for download attempt of known font istok-web and unknown format", async () => { await request(app) .get("/api/fonts/istok-web?download=zip&formats=rolf") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); }).timeout(10000); it("should respond with 404 for download attempt of known font istok-web and empty formats", async () => { await request(app) .get("/api/fonts/istok-web?download=zip&formats=") .timeout(10000) .expect(404) .expect("Content-Type", /text\/html/); }).timeout(10000); }); ================================================ FILE: server/api/healthy.controller.ts ================================================ import { NextFunction, Request, Response } from "express"; import { getStats } from "../logic/store"; // /-/healthy export async function getHealthy(req: Request, res: Response, next: NextFunction) { try { const { fontMap, urlMap, archiveMap, files, urls } = getStats(); res.type("text/plain"); return res.send(`${fontMap} fonts available. ${urlMap} unique subsets loaded (${urls} URLs), ${archiveMap} subset archives fetched (${files} files).`); } catch (e) { next(e); } } ================================================ FILE: server/api/healthy.spec.ts ================================================ import * as request from "supertest"; import { app } from "../app"; describe("GET /-/healthy", () => { it("should respond with 200", async () => { await request(app) .get("/-/healthy") .timeout(4000) .expect(200) .expect("Content-Type", /text\/plain/); }); }); ================================================ FILE: server/app.spec.ts ================================================ import * as request from "supertest"; import { app } from "./app"; describe("GET /api/not_defined", () => { it("should respond with 404", async () => { await request(app) .get("/api/not_defined") .timeout(4000) .expect(404) .expect("Content-Type", /text\/html/); }); }); describe("GET /", () => { it("should respond with 200", async () => { await request(app) .get("/") .timeout(4000) .expect(200) .expect("Content-Type", /text\/html/); }); }); ================================================ FILE: server/app.ts ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ require("source-map-support").install(); import * as express from "express"; import * as http from "http"; import * as JSZip from "jszip"; import * as path from "path"; import { config } from "./config"; import { initStore } from "./logic/store"; import { setupRoutes } from "./routes"; // use native promises JSZip.external.Promise = Promise; export const app = express(); export function ready() { return init; } const init = (async () => { const server = http.createServer(app); server.timeout = config.TIMEOUT_MS; const env = app.get("env"); // http://expressjs.com/en/api.html app.set("x-powered-by", false); if (config.ENABLE_MIDDLEWARE_COMPRESSION) { app.use(require("compression")()); } if (env === "production") { app.use(express.static(path.join(config.ROOT, "public"))); app.set("appPath", config.ROOT + "/public"); if (config.ENABLE_MIDDLEWARE_ACCESS_LOG) { app.use(require("morgan")(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length]')); } } else { app.use(require("connect-livereload")()); app.use(express.static(path.join(config.ROOT, ".tmp"))); app.use(express.static(path.join(config.ROOT, "client"))); app.set("appPath", config.ROOT + "/client"); app.use(require("morgan")("dev")); app.use(require("errorhandler")()); // Error handler - has to be last } setupRoutes(app); await initStore(); // Start server server.listen(config.PORT, config.IP, function () { console.log( "Express server listening on %d, in %s mode (timeout=%dms, compress=%s, accesslog=%s)", config.PORT, app.get("env"), server.timeout, config.ENABLE_MIDDLEWARE_COMPRESSION, config.ENABLE_MIDDLEWARE_ACCESS_LOG ); }); process.once("SIGINT", function () { console.log("SIGINT received, closing server..."); server.close(); }); process.once("SIGTERM", function () { console.log("SIGTERM received, closing server..."); server.close(); }); })(); ================================================ FILE: server/config.ts ================================================ import * as _ from "lodash"; import * as path from "path"; const env = process.env.NODE_ENV || "development"; const GOOGLE_FONTS_API_KEY = process.env.GOOGLE_FONTS_API_KEY; if (!_.isString(GOOGLE_FONTS_API_KEY) || _.isEmpty(GOOGLE_FONTS_API_KEY)) { console.error('Error: ENV var "GOOGLE_FONTS_API_KEY" must be set!'); console.error("See https://developers.google.com/fonts/docs/developer_api"); process.exit(1); } export interface IUserAgents { eot: string; woff: string; woff2: string; svg: string; ttf: string; } export const config = { ENV: env, // Root path of server ROOT: path.normalize(__dirname + "/.."), // Server port PORT: process.env.PORT ? _.parseInt(process.env.PORT) : env === "production" ? 8080 : 9000, IP: process.env.IP || undefined, // Server port TIMEOUT_MS: process.env.TIMEOUT_MS ? _.parseInt(process.env.TIMEOUT_MS) : 60 * 1000, // 60 seconds // Middlewares ENABLE_MIDDLEWARE_ACCESS_LOG: process.env.ENABLE_MIDDLEWARE_ACCESS_LOG === "true" ? true : false, // default false ENABLE_MIDDLEWARE_COMPRESSION: process.env.ENABLE_MIDDLEWARE_COMPRESSION === "false" ? false : true, // default true GOOGLE_FONTS_API_KEY, GOOGLE_FONTS_USE_TEST_JSON: process.env.GOOGLE_FONTS_USE_TEST_JSON === "true" ? true : env === "test" ? true : false, // enabled in test, else default false CACHE_DIR: process.env.CACHE_DIR || `${path.normalize(__dirname + "/logic")}/cachedFonts`, USER_AGENTS: { // see http://www.dvdprojekt.de/category.php?name=Safari for a list of sample user handlers // test generation through running grunt mochaTest:src eot: process.env.USER_AGENT_EOT || "Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0)", woff: process.env.USER_AGENT_WOFF || "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0", // must serve complete woff2 file for one variant (no unicode range support yet!) // see http://www.useragentstring.com/pages/Firefox/ // see http://caniuse.com/#search=woff2 // see http://caniuse.com/#feat=font-unicode-range // see https://developers.googleblog.com/2015/02/smaller-fonts-with-woff-20-and-unicode.html woff2: process.env.USER_AGENT_WOFF2 || "Mozilla/5.0 (Windows NT 6.3; rv:39.0) Gecko/20100101 Firefox/39.0", svg: process.env.USER_AGENT_SVG || "Mozilla/4.0 (iPad; CPU OS 4_0_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/4.1 Mobile/9A405 Safari/7534.48.3", ttf: process.env.USER_AGENT_TTF || "Mozilla/5.0 (Unknown; Linux x86_64) AppleWebKit/538.1 (KHTML, like Gecko) Safari/538.1 Daum/4.1", }, }; ================================================ FILE: server/logic/core.ts ================================================ import * as _ from "lodash"; import { synchronizedBy } from "../utils/synchronized"; import { fetchFontSubsetArchive, IFontSubsetArchive } from "./fetchFontSubsetArchive"; import { fetchFontURLs, IVariantItem } from "./fetchFontURLs"; import { IFontItem } from "./fetchGoogleFonts"; import { getFontBundle, getStoredFontItems, getStoredFontSubsetArchive, getStoredVariantItems, IFontBundle, storeFontSubsetArchive, storeVariantItems, } from "./store"; export function loadFontItems(): IFontItem[] { return getStoredFontItems(); } export function loadFontBundle(fontID: string, subsets: string[] | null): IFontBundle | null { return getFontBundle(fontID, subsets); } export async function loadVariantItems(fontBundle: IFontBundle): Promise { return _loadVariantItems(`loadVariantItems__${fontBundle.storeID}`, fontBundle); } const _loadVariantItems = synchronizedBy(async function (fontBundle: IFontBundle): Promise { const storedVariantItems = getStoredVariantItems(fontBundle); if (!_.isNil(storedVariantItems)) { return storedVariantItems; } const { storeID, font, subsets } = fontBundle; const variantItems = await fetchFontURLs(font.family, font.variants, subsets); if (variantItems === null) { console.error(`loadVariantItems resolved null for storeID=${storeID}`); return null; } // SIDE-EFFECT! storeVariantItems(fontBundle, variantItems); return variantItems; }); export async function loadFontSubsetArchive(fontBundle: IFontBundle, variants: IVariantItem[]): Promise { return _loadFontSubsetArchive(`loadFontSubsetArchive__${fontBundle.storeID}`, fontBundle, variants); } const _loadFontSubsetArchive = synchronizedBy(async function ( fontBundle: IFontBundle, variants: IVariantItem[] ): Promise { const storedFontSubsetArchive = getStoredFontSubsetArchive(fontBundle); if (!_.isNil(storedFontSubsetArchive)) { return storedFontSubsetArchive; } const fontSubsetArchive = await fetchFontSubsetArchive(fontBundle.font.id, fontBundle.font.version, fontBundle.subsets, variants); if (fontSubsetArchive.files.length === 0) { throw new Error(`No files received for '${fontBundle.storeID}' font subset archive!`); } // SIDE-EFFECT! storeFontSubsetArchive(fontBundle, fontSubsetArchive); return fontSubsetArchive; }); export interface ISubsetMap { [subset: string]: boolean; } export function loadSubsetMap(fontBundle: IFontBundle): ISubsetMap { return _.reduce( fontBundle.font.subsets, (sum, subset) => { sum[subset] = _.includes(fontBundle.subsets, subset); return sum; }, {} as ISubsetMap ); } ================================================ FILE: server/logic/fetchCSS.ts ================================================ import * as css from "css"; import * as _ from "lodash"; import { IUserAgents } from "../config"; import { asyncRetry } from "../utils/asyncRetry"; import axios from "axios"; const RETRIES = 2; const REQUEST_TIMEOUT_MS = 6000; interface IResource { src: string | null; fontFamily: string | null; fontStyle: string | null; fontWeight: string | null; url: string; } export async function fetchCSS(family: string, cssSubsetString: string, type: keyof IUserAgents, userAgent: string): Promise { const reqPath = `/css?family=${encodeURIComponent(family)}&subset=${cssSubsetString}`; const hostname = "fonts.googleapis.com"; const url = `http://${hostname}${reqPath}`; const txt = await asyncRetry( async () => { const res = await axios.get(url, { timeout: REQUEST_TIMEOUT_MS, responseType: "text", maxRedirects: 0, // https://github.com/axios/axios/issues/2610 headers: { Accept: "text/css,*/*;q=0.1", "User-Agent": userAgent, } }); return res.data; }, { retries: RETRIES } ); return parseRemoteCSS(txt, type); } function parseRemoteCSS(remoteCSS: string, type: string): IResource[] { const parsedCSS = css.parse(remoteCSS); if (_.isNil(parsedCSS.stylesheet)) { throw new Error(`parseRemoteCSS: no stylesheets in parsed css for ${type}: ${remoteCSS}`); } const resources: IResource[] = []; _.each(parsedCSS.stylesheet.rules, (rule) => { // only font-face rules are relevant... if (rule.type !== "font-face") { return; } try { const src = getCSSRuleDeclarationPropertyValue(rule, "src"); if (_.isNil(src)) { console.warn(`parseRemoteCSS: no src in parsed css for ${type}: ${remoteCSS}`); return; } let matched = src.match("http:\\/\\/[^\\)]+") if (_.isNil(matched) || matched.length === 0) { // might be https in the future matched = src.match("https:\\/\\/[^\\)]+"); if (_.isNil(matched) || matched.length === 0) { console.warn(`parseRemoteCSS: no matched url in parsed css for ${type}: ${remoteCSS}`); return; } } const url = matched[0]; // console.log(url); const resource: IResource = { src: getCSSRuleDeclarationPropertyValue(rule, "src"), fontFamily: getCSSRuleDeclarationPropertyValue(rule, "font-family"), fontStyle: getCSSRuleDeclarationPropertyValue(rule, "font-style"), fontWeight: getCSSRuleDeclarationPropertyValue(rule, "font-weight"), url }; // push the current rule (= resource) to the resources array resources.push(resource); } catch (e) { console.error("cannot load resource of type", type, remoteCSS, e); } }); return resources; } function getCSSRuleDeclarationPropertyValue(rule: css.Rule, property: string): string | null { return _.get( _.find(rule.declarations, (declaration) => { return _.has(declaration, "property") && (declaration).property === property; }), "value", null ); } ================================================ FILE: server/logic/fetchFontSubsetArchive.ts ================================================ import * as Bluebird from "bluebird"; import * as fs from "fs"; import * as JSZip from "jszip"; import * as _ from "lodash"; import * as path from "path"; import { finished } from "stream/promises"; import { config } from "../config"; import { asyncRetry } from "../utils/asyncRetry"; import { IVariantItem } from "./fetchFontURLs"; import { Readable } from "stream"; import axios from "axios"; const RETRIES = 2; const REQUEST_TIMEOUT_MS = 6000; export interface IFontSubsetArchive { zipPath: string; // absolute path to the zip file files: IFontFile[]; } export interface IFontFile { variant: string; format: string; path: string; // relative path within the zip file } export async function fetchFontSubsetArchive( fontID: string, fontVersion: string, subsets: string[], variants: IVariantItem[] ): Promise { const subsetFontArchive: IFontSubsetArchive = { zipPath: path.join(config.CACHE_DIR, `/${fontID}-${fontVersion}-${subsets.join("_")}.zip`), files: [], }; const archive = new JSZip(); const streams: (Readable | fs.WriteStream)[] = _.compact( _.flatten( await Bluebird.map(variants, (variant) => { return Bluebird.map(variant.urls, async (variantUrl) => { const filename = `${fontID}-${fontVersion}-${subsets.join("_")}-${variant.id}.${variantUrl.format}`; // download the file for type (filename now known) let readable: Readable; try { // console.log("fetchFontSubsetArchive...", variantUrl.format, filename, variantUrl.url); readable = await fetchFontSubsetArchiveStream(variantUrl.url); archive.file(filename, readable); } catch (e) { // if a specific format does not work, silently discard it. console.error("fetchFontSubsetArchive discarding", variantUrl.format, filename, variantUrl.url, e); return null; } subsetFontArchive.files.push({ variant: variant.id, // variants and format are used to filter them out later! format: variantUrl.format, path: filename, }); return readable; }); }) ) ); const target = fs.createWriteStream(subsetFontArchive.zipPath); streams.push(target); console.info(`fetchFontSubsetArchive create archive... file=${subsetFontArchive.zipPath}`); try { await finished(archive.generateNodeStream({ compression: "DEFLATE", }).pipe(target)); console.info(`fetchFontSubsetArchive create archive done! file=${subsetFontArchive.zipPath}`); } catch (e) { console.error("fetchFontSubsetArchive archive.generateNodeStream pipe failed", e); // ensure all fs streams into the archive and the actual zip file are destroyed _.each(streams, (stream, index) => { try { console.warn(`fetchFontSubsetArchive archive.generateNodeStream destroy stream ${index}/${streams.length}...`) stream.destroy(); } catch (err) { console.error("fetchFontSubsetArchive archive.generateNodeStream pipe failed, stream.destroy failed (catched)", fontID, subsets, err); } }); console.error("fetchFontSubsetArchive archive.generateNodeStream pipe failed, streams destroyed. Rethrowing err...", fontID, subsets, e); throw e; } return subsetFontArchive; } async function fetchFontSubsetArchiveStream(url: string): Promise { return asyncRetry( async () => { const res = await axios.get(url, { timeout: REQUEST_TIMEOUT_MS, responseType: "stream", maxRedirects: 0 // https://github.com/axios/axios/issues/2610 }); return res.data; }, { retries: RETRIES } ); } ================================================ FILE: server/logic/fetchFontURLs.ts ================================================ import * as Bluebird from "bluebird"; import * as _ from "lodash"; import { config, IUserAgents } from "../config"; import { fetchCSS } from "./fetchCSS"; export interface IVariantURL { format: keyof IUserAgents; url: string; } export interface IVariantItem { id: string; fontFamily: null | string; fontStyle: null | string; fontWeight: null | string; urls: IVariantURL[]; } const TARGETS = _.map(_.keys(config.USER_AGENTS), (key) => { return { format: key, userAgent: config.USER_AGENTS[key], }; }); export async function fetchFontURLs(fontFamily: string, fontVariants: string[], fontSubsets: string[]): Promise { let variants: IVariantItem[] = []; const cssSubsetString = fontSubsets.join(","); // make the variant string google API compatible... await Bluebird.map(fontVariants, async (variant) => { const cssFontFamily = `${fontFamily}:${variant}`; const variantItem: IVariantItem = { id: variant, fontFamily: null, fontStyle: null, fontWeight: null, urls: [], }; await Bluebird.map(TARGETS, async (target) => { const resources = await fetchCSS(cssFontFamily, cssSubsetString, target.format, target.userAgent); if (resources.length === 0) { console.warn( `fetchFontURLs: no css ressources encountered for fontFamily='${cssFontFamily}' subset='${cssSubsetString}' format=${target.format}`, resources ); return; } if (resources.length > 1) { console.warn( `fetchFontURLs: multiple css ressources encountered for fontFamily='${cssFontFamily}' subset='${cssSubsetString}' format=${target.format}`, resources ); } _.each(resources, (resource) => { // save the format (woff, eot, svg, ttf, usw...) variantItem.urls.push({ format: target.format, // rewrite url to use https instead on http! url: resource.url.split("http://").join("https://"), // resource.url.replace(/^http:\/\//i, 'https://'); }); // if not defined, also save procedded font-family, fontstyle, font-weight, unicode-range if (_.isNil(variantItem.fontFamily) && !_.isNil(resource.fontFamily)) { variantItem.fontFamily = resource.fontFamily; } if (_.isNil(variantItem.fontStyle) && !_.isNil(resource.fontStyle)) { variantItem.fontStyle = resource.fontStyle; } if (_.isNil(variantItem.fontWeight) && !_.isNil(resource.fontWeight)) { variantItem.fontWeight = resource.fontWeight; } }); }); variants.push(variantItem); }); variants = _.sortBy(variants, function ({ fontWeight, fontStyle }) { const styleOrder = fontStyle === "normal" ? 0 : 1; return `${fontWeight}-${styleOrder}`; }); return variants; } ================================================ FILE: server/logic/fetchGoogleFonts.ts ================================================ import * as fs from "fs/promises"; import * as _ from "lodash"; import * as path from "path"; import * as speakingurl from "speakingurl"; import { config } from "../config"; import { asyncRetry } from "../utils/asyncRetry"; import axios from "axios"; const RETRIES = 2; const REQUEST_TIMEOUT_MS = 10000; export interface IFontItem { id: string; family: string; subsets: string[]; category: string; version: string; lastModified: string; popularity: number; defSubset: string; defVariant: string; variants: string[]; } interface IGoogleFontsRes { kind: string; items: IGoogleFontsResItem[]; } interface IGoogleFontsResItem { family: string; variants: string[]; subsets: string[]; version: string; lastModified: string; files: { [key: string]: string; }; category: string; kind: "webfonts#webfont"; } // build up fonts cache via google API... export async function fetchGoogleFonts(): Promise { if (config.GOOGLE_FONTS_USE_TEST_JSON) { const localPath = path.join(config.ROOT, "test/googlefonts.json"); if (config.ENV !== "test") { console.warn(`fetchGoogleFonts is using local "${localPath}"`); } const testJson = await fs.readFile(localPath); return transform(JSON.parse(testJson.toString())); } return asyncRetry( async () => { const res = await axios.get(`https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=${config.GOOGLE_FONTS_API_KEY}`, { timeout: REQUEST_TIMEOUT_MS, responseType: "json", maxRedirects: 0 // https://github.com/axios/axios/issues/2610 }); return transform(res.data); }, { retries: RETRIES } ); } function transform(resData: IGoogleFontsRes): IFontItem[] { return _.map(resData.items, (item, index) => { return { id: speakingurl(item.family), family: item.family, variants: item.variants, subsets: item.subsets, category: item.category, version: item.version, lastModified: item.lastModified, popularity: index + 1, // property order by popularity -> index // use latin per default, else first found font defSubset: _.includes(item.subsets, "latin") ? "latin" : item.subsets[0], defVariant: _.includes(item.variants, "regular") ? "regular" : item.variants[0], }; }); } ================================================ FILE: server/logic/store.ts ================================================ import { mkdir } from "fs/promises"; import * as _ from "lodash"; import { config } from "../config"; import { IFontSubsetArchive } from "./fetchFontSubsetArchive"; import { IVariantItem } from "./fetchFontURLs"; import { fetchGoogleFonts, IFontItem } from "./fetchGoogleFonts"; // FontBundle holds: // * the found stored font from google, // * the requested (and found) subsets and // * the unique storeID to access Maps in the store. // It should be used as the sole way to interact with the store and must be build via store.getFontBundle export interface IFontBundle { storeID: string; subsets: string[]; font: IFontItem; } const fontMap = new Map(); const urlMap = new Map(); const archiveMap = new Map(); export async function initStore() { await mkdir(config.CACHE_DIR, { recursive: true }); _.each(await fetchGoogleFonts(), (font: IFontItem) => { fontMap.set(font.id, font); }); } export async function reinitStore() { if (config.ENV !== "test") { console.warn("reinitStore was called, building fresh stores..."); } fontMap.clear(); urlMap.clear(); archiveMap.clear(); return initStore(); } export function getStoredFontItems(): IFontItem[] { return Array.from(fontMap.values()); } export function getFontBundle(fontID: string, wantedSubsets: string[] | null): IFontBundle | null { const font = fontMap.get(fontID); if (_.isNil(font)) { return null; } const match = !_.isArray(wantedSubsets) || wantedSubsets.length === 0 ? [font.defSubset] // supply filter with the default subset as defined in googleFontsAPI fetcher (latin or if no found other) : _.intersection(font.subsets, wantedSubsets); const subsets = _.sortBy(_.uniq(match)); if (subsets.length === 0) { return null; } return { // not this must be a stable key fully identifying the font, its version and wantedSubsets storeID: `${font.id}@${font.version}__${subsets.join("_")}`, subsets, font, }; } export function getStoredVariantItems({ storeID }: IFontBundle): IVariantItem[] | null { const variants = urlMap.get(storeID); if (_.isNil(variants)) { return null; } return variants; } export function getStoredFontSubsetArchive({ storeID }: IFontBundle): IFontSubsetArchive | null { const subsetFontArchive = archiveMap.get(storeID); if (_.isNil(subsetFontArchive)) { return null; } return subsetFontArchive; } export function storeVariantItems({ storeID }: IFontBundle, variants: IVariantItem[]) { const existings = urlMap.get(storeID); if (!_.isNil(existings)) { console.warn("storeVariantItems: duplicate save of storeID: ", storeID); if (config.ENV === "test") { throw new Error("storeVariantItems duplicate write"); } return; } urlMap.set(storeID, variants); } export function storeFontSubsetArchive({ storeID }: IFontBundle, subsetFontArchive: IFontSubsetArchive) { const existings = archiveMap.get(storeID); if (!_.isNil(existings)) { console.warn("storeFontSubsetArchive: duplicate save of storeID: ", storeID); if (config.ENV === "test") { throw new Error("storeFontSubsetArchive duplicate write"); } return; } archiveMap.set(storeID, subsetFontArchive); } export function getStats() { return { fontMap: fontMap.size, urlMap: urlMap.size, archiveMap: archiveMap.size, urls: _.sumBy(Array.from(urlMap.values()), function (f) { return f.length; }), files: _.sumBy(Array.from(archiveMap.values()), function (archive) { return archive.files.length; }), }; } ================================================ FILE: server/routes.ts ================================================ import * as express from "express"; import { getApiFonts, getApiFontsById } from "./api/fonts.controller"; import { getHealthy } from "./api/healthy.controller"; export function setupRoutes(app: express.Express) { app.use("/fonts", express.static(app.get("appPath") + "/index.html")); app.use("/fonts/", express.static(app.get("appPath") + "/index.html")); app.use("/fonts/:id", express.static(app.get("appPath") + "/index.html")); app.route("/api/fonts").get(getApiFonts); app.route("/api/fonts/:id").get(getApiFontsById); app.route("/-/healthy").get(getHealthy); // All undefined asset or api routes should return a 404 app.route("/:url(-|api|auth|components|app|bower_components|assets)/*").get(function (req, res) { res.status(404).send("Not found"); }); // All other routes should redirect to the index.html app.route("/*").get(function (req, res) { res.redirect(req.baseUrl + "/"); }); } ================================================ FILE: server/utils/asyncRetry.spec.ts ================================================ import * as Bluebird from "bluebird"; import * as should from "should"; import { asyncRetry } from "./asyncRetry"; describe("utils/asyncRetry", function () { it("retry works as expected when last succeeds", async () => { const RETRIES = 2; let cnt = 0; await asyncRetry( async () => { await Bluebird.delay(1); cnt += 1; if (cnt <= RETRIES) { throw new Error("not yet"); } }, { retries: RETRIES } ); should(cnt).eql(RETRIES + 1); }); it("retry works as expected when all fail with same error", async () => { const RETRIES = 2; let cnt = 0; let err: AggregateError | null = null; try { await asyncRetry( async () => { await Bluebird.delay(1); cnt += 1; throw new Error("step err"); }, { retries: RETRIES } ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { err = e; } // console.log(err); should(cnt).eql(RETRIES + 1); should(err).instanceOf(AggregateError); should(err?.errors.length).eql(1); // unique errors returned by msg }); it("retry works as expected when all fail with different errors", async () => { const RETRIES = 2; let cnt = 0; let err: AggregateError | null = null; try { await asyncRetry( async () => { await Bluebird.delay(1); cnt += 1; throw new Error("step" + cnt); }, { retries: RETRIES } ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { err = e; } // console.log(err); should(cnt).eql(RETRIES + 1); should(err).instanceOf(AggregateError); should(err?.errors.length).eql(RETRIES + 1); }); }); ================================================ FILE: server/utils/asyncRetry.ts ================================================ import * as Bluebird from "bluebird"; import * as _ from "lodash"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function asyncRetry(fn: () => Promise, options: { retries: number }, errors: any[] = []): Promise { let t: T; try { t = await fn(); } catch (e) { if (errors.length >= options.retries) { throw new AggregateError( _.unionBy([...errors, e], "message"), `asyncRetry: maximal retries exceeded. retries=${options.retries} errors=${errors.length}` ); } // 2 ** 0 * 500 = 500ms // 2 ** 1 * 500 = 1000ms => 1500ms // 2 ** 2 * 500 = 2000ms => 3500ms const bailoutMS = 2 ** errors.length * 500; // console.error(`asyncRetry: try ${errors.length + 1} failed, retries=${options.retries}. Delaying next try ${bailoutMS}ms`); await Bluebird.delay(bailoutMS); // console.warn(`asyncRetry: retrying after ${bailoutMS}ms`); return asyncRetry(fn, options, [...errors, e]); } return t; } ================================================ FILE: server/utils/synchronized.ts ================================================ import * as _ from "lodash"; import { config } from "../config"; // cached promise by key for in-flight request handling export function synchronizedBy(target: () => Promise): (cacheKey: string) => Promise; export function synchronizedBy(target: (arg1: A1) => Promise): (cacheKey: string, arg1: A1) => Promise; export function synchronizedBy(target: (arg1: A1, arg2: A2) => Promise): (cacheKey: string, arg1: A1, arg2: A2) => Promise; export function synchronizedBy(target: (...args: A[]) => Promise): (cacheKey: string, ...args: A[]) => Promise { const mutexMap = new Map>(); return async function (cacheKey: string, ...params: A[]) { let mutexPromise = mutexMap.get(cacheKey); if (_.isNil(mutexPromise)) { // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-empty-function let resolveMutexPromise: Function = () => {}; // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-empty-function let rejectMutexPromise: Function = () => {}; mutexPromise = new Promise(function (this: Promise, resolve, reject) { resolveMutexPromise = resolve.bind(this); rejectMutexPromise = reject.bind(this); }); mutexMap.set(cacheKey, mutexPromise); try { const ret = await target(...params); resolveMutexPromise(ret); } catch (err) { rejectMutexPromise(err); } } else { if (config.ENV === "test") { console.log("synchronizedBy cache hit:", cacheKey); } } try { const value = await mutexPromise; // rm from cache again mutexMap.delete(cacheKey); return value; } catch (error) { // rm from cache again mutexMap.delete(cacheKey); throw error; } }; } ================================================ FILE: test/googlefonts.json ================================================ { "kind": "webfonts#webfontList", "items": [ { "family": "Arvo", "variants": [ "regular", "italic", "700", "700italic" ], "subsets": [ "latin" ], "version": "v20", "lastModified": "2022-09-22", "files": { "700": "http://localhost/font.ttf", "regular": "http://localhost/font.ttf", "italic": "http://localhost/font.ttf", "700italic": "http://localhost/font.ttf" }, "category": "serif", "kind": "webfonts#webfont" }, { "family": "Istok Web", "variants": [ "regular", "italic", "700", "700italic" ], "subsets": [ "cyrillic", "cyrillic-ext", "latin", "latin-ext" ], "version": "v20", "lastModified": "2022-09-22", "files": { "700": "http://localhost/font.ttf", "regular": "http://localhost/font.ttf", "italic": "http://localhost/font.ttf", "700italic": "http://localhost/font.ttf" }, "category": "sans-serif", "kind": "webfonts#webfont" }, { "family": "Playfair Display", "variants": [ "regular", "500", "600", "700", "800", "900", "italic", "500italic", "600italic", "700italic", "800italic", "900italic" ], "subsets": [ "cyrillic", "latin", "latin-ext", "vietnamese" ], "version": "v30", "lastModified": "2022-09-22", "files": { "500": "http://localhost/font.ttf", "600": "http://localhost/font.ttf", "700": "http://localhost/font.ttf", "800": "http://localhost/font.ttf", "900": "http://localhost/font.ttf", "regular": "http://localhost/font.ttf", "italic": "http://localhost/font.ttf", "500italic": "http://localhost/font.ttf", "600italic": "http://localhost/font.ttf", "700italic": "http://localhost/font.ttf", "800italic": "http://localhost/font.ttf", "900italic": "http://localhost/font.ttf" }, "category": "serif", "kind": "webfonts#webfont" } ] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ESNEXT", "module": "commonjs", "sourceMap": true, "outDir": "./dist/server", "rootDir": "./server", "strict": true }, "include": [ "server/**/*.ts", "server/**/*.js", "server/**/*.json" ] }