[
  {
    "path": ".bowerrc",
    "content": "{\n    \"directory\": \"client/bower_components\"\n}\n"
  },
  {
    "path": ".devcontainer.json",
    "content": "// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:\n// https://github.com/microsoft/vscode-dev-containers/tree/v0.106.0/containers/docker-existing-docker-compose\n// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.\n{\n    \"name\": \"app\",\n    // Update the 'dockerComposeFile' list if you have more compose files or use different names.\n    // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.\n    \"dockerComposeFile\": [\n        \"./docker-compose.yml\"\n    ],\n    // The 'service' property is the name of the service for the container that VS Code should\n    // use. Update this value and .devcontainer/docker-compose.yml to the real service name.\n    \"service\": \"service\",\n    \"remoteUser\": \"node\",\n    // The optional 'workspaceFolder' property is the path VS Code should open by default when\n    // connected. This is typically a file mount in .devcontainer/docker-compose.yml\n    \"workspaceFolder\": \"/app\",\n    \"shutdownAction\": \"none\",\n    // Set *default* container specific settings.json values on container create.\n    \"customizations\": {\n        \"vscode\": {\n            \"settings\": {\n                \"typescript.tsdk\": \"node_modules/typescript/lib\",\n                \"[typescript]\": {\n                    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n                    \"editor.formatOnSave\": true,\n                    \"editor.formatOnType\": false,\n                    \"editor.formatOnPaste\": false\n                }\n            },\n            \"extensions\": [\n                \"eamodio.gitlens\",\n                \"dbaeumer.vscode-eslint\",\n                \"esbenp.prettier-vscode\",\n                \"yzhang.markdown-all-in-one\"\n            ]\n        }\n    }\n    // Uncomment the next line if you want start specific services in your Docker Compose config.\n    // \"runServices\": [],\n    // Uncomment the next line if you want to keep your containers running after VS Code shuts down.\n    // \"shutdownAction\": \"none\",\n    // Uncomment the next line to run commands after the container is created - for example installing git.\n    // \"postCreateCommand\": \"apt-get update && apt-get install -y git\",\n    // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.\n    // \"remoteUser\": \"vscode\"\n}"
  },
  {
    "path": ".dockerignore",
    "content": ".tmp\ndist\n.env\nclient/bower_components\nnode_modules\nserver/logic/cachedFonts\n.tscache\ntscommand-\n"
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n    ignorePatterns: [\".eslintrc.cjs\", \"Gruntfile.js\", \"dist/**/*.js\", \"client/**/*.js\"],\n    extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],\n    parser: '@typescript-eslint/parser',\n    plugins: ['@typescript-eslint'],\n    root: true,\n    parserOptions: {\n        project: \"./tsconfig.json\"\n    }\n};"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: majodev"
  },
  {
    "path": ".github/workflows/build-only.yml",
    "content": "name: Build gwfh\n\non:\n  push:\n    branches: \n      - \"**\"\n      - \"!master\"\n      - \"!dev\"\n\nenv:\n  DOCKER_ENV_FILE: \".github/workflows/docker.env\"\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-only:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build only\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: false\n          # load: true # add to local docker daemon so we can use it in the next step\n          tags: |\n            ${{ steps.meta.outputs.tags }}\n            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64\n\n      # - name: trivy scan\n      #   uses: aquasecurity/trivy-action@master\n      #   with:\n      #     image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'\n      #     format: 'template'\n      #     template: '@/contrib/sarif.tpl'\n      #     output: 'trivy-results.sarif'\n      #     severity: 'CRITICAL,HIGH'\n      #     ignore-unfixed: true\n"
  },
  {
    "path": ".github/workflows/build-publish-deploy.yml",
    "content": "# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images\nname: Create, publish and deploy gwfh\n\non:\n  push:\n    tags: \"**\"\n    branches:\n      - \"master\"\n      - \"dev\"\n\nenv:\n  DOCKER_ENV_FILE: \".github/workflows/docker.env\"\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-publish-deploy:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n\n      - uses: actions/checkout@v4\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: true\n          tags: |\n            ${{ steps.meta.outputs.tags }}\n            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64\n\n      # - name: Connect to tailscale network\n      #   uses: tailscale/github-action@v2\n      #   with:\n      #     oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}\n      #     oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}\n      #     tags: tag:ci\n\n      # - name: Set image on dev cluster\n      #   if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }}\n      #   uses: actions-hub/kubectl@master\n      #   env:\n      #     KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_DEV }}\n      #     NAMESPACE: gwfh-dev\n      #   with:\n      #     args: -n ${{ env.NAMESPACE }} set image deployment/app app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}\n\n      # - name: Set image on prod cluster\n      #   if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}\n      #   uses: actions-hub/kubectl@master\n      #   env:\n      #     KUBE_CONFIG: ${{ secrets.KUBE_CONFIG_PROD }}\n      #     NAMESPACE: gwfh-prod\n      #   with:\n      #     args: -n ${{ env.NAMESPACE }} set image deployment/app app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
  },
  {
    "path": ".github/workflows/docker.env",
    "content": "CI=true\nGITHUB_ACTIONS=true"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules*\npublic\n.tmp\n.idea\nclient/bower_components\ndist\n/server/config/local.env.js\n/server/logic/cachedFonts\n.env\nnpm-debug.log\n.tscache\ntscommand-\n/tmp"
  },
  {
    "path": ".prettierrc",
    "content": "{\n    \"semi\": true,\n    \"singleQuote\": false,\n    \"printWidth\": 140\n}"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n\t// See http://go.microsoft.com/fwlink/?LinkId=827846\n\t// for the documentation about the extensions.json format\n\t\"recommendations\": [\n\t\t// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp\n\t\t\"ms-azuretools.vscode-docker\",\n\t\t\"ms-vscode-remote.remote-containers\"\n\t]\n}"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n    // See https://go.microsoft.com/fwlink/?LinkId=733558 \n    // for the documentation about the tasks.json format\n    \"version\": \"2.0.0\",\n    \"tasks\": [\n        {\n            \"type\": \"typescript\",\n            \"tsconfig\": \"tsconfig.json\",\n            \"option\": \"watch\",\n            \"problemMatcher\": [\n                \"$tsc-watch\"\n            ],\n            \"group\": {\n                \"kind\": \"build\",\n                \"isDefault\": true\n            }\n        }\n    ]\n}"
  },
  {
    "path": ".yo-rc.json",
    "content": "{\n  \"generator-angular-fullstack\": {\n    \"insertRoutes\": true,\n    \"registerRoutesFile\": \"server/routes.js\",\n    \"routesNeedle\": \"// Insert routes below\",\n    \"routesBase\": \"/api/\",\n    \"pluralizeRoutes\": true,\n    \"insertSockets\": true,\n    \"registerSocketsFile\": \"server/config/socketio.js\",\n    \"socketsNeedle\": \"// Insert sockets below\",\n    \"filters\": {\n      \"js\": true,\n      \"html\": true,\n      \"less\": true,\n      \"uirouter\": true,\n      \"bootstrap\": true,\n      \"uibootstrap\": true\n    }\n  },\n  \"generator-ng-component\": {\n    \"routeDirectory\": \"client/app/\",\n    \"directiveDirectory\": \"client/app/\",\n    \"filterDirectory\": \"client/app/\",\n    \"serviceDirectory\": \"client/app/\",\n    \"basePath\": \"client\",\n    \"moduleName\": \"\",\n    \"filters\": [\n      \"uirouter\"\n    ],\n    \"extensions\": [\n      \"js\",\n      \"html\",\n      \"less\"\n    ],\n    \"directiveSimpleTemplates\": \"\",\n    \"directiveComplexTemplates\": \"\",\n    \"filterTemplates\": \"\",\n    \"serviceTemplates\": \"\",\n    \"factoryTemplates\": \"\",\n    \"controllerTemplates\": \"\",\n    \"decoratorTemplates\": \"\",\n    \"providerTemplates\": \"\",\n    \"routeTemplates\": \"\"\n  }\n}"
  },
  {
    "path": "Dockerfile",
    "content": "### -----------------------\n# --- Stage: development\n# --- Purpose: Local dev environment (no application deps)\n### -----------------------\nFROM node:22.20.0-trixie AS development\n\n# Replace shell with bash so we can source files\nRUN rm /bin/sh && ln -s /bin/bash /bin/sh\n\n# Set debconf to run non-interactively\nRUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections\n\n# Install base dependencies \nRUN apt-get update && apt-get install -y -q --no-install-recommends \\\n    apt-transport-https \\\n    build-essential \\\n    ca-certificates \\\n    curl \\\n    git \\\n    jpegoptim \\\n    libssl-dev \\\n    lsof \\\n    optipng \\\n    tini \\\n    wget \\\n    && rm -rf /var/lib/apt/lists/*\n\n# global npm installs\nRUN npm install -g grunt-cli@1.2.0 \\\n    && npm cache clean --force\n\nWORKDIR /app\n\n### -----------------------\n# --- Stage: builder\n# --- Purpose: Installs application deps and builds the service\n### -----------------------\n\nFROM development AS builder\n\n# install server and bundler deps\nCOPY package.json /app/package.json\nCOPY yarn.lock /app/yarn.lock\nRUN yarn --pure-lockfile\n\n# install clientside deps (bower is a managed application local dev dep)\nCOPY bower.json /app/bower.json\nCOPY .bowerrc /app/.bowerrc\nRUN  ./node_modules/.bin/bower install\n\n# copy in all workspace files\nCOPY . /app/\n\n# build dist\nRUN grunt build\n\n# prepare production node_modules (this cleans up dev deps)\n# https://github.com/vercel/next.js/pull/23056\n# https://github.com/yarnpkg/yarn/issues/6373\nRUN yarn install --production --ignore-scripts --prefer-offline\n\n### -----------------------\n# --- Stage: production\n# --- Purpose: Final step from a new slim image. this should be a minimal image only housing dist (production service)\n### -----------------------\nFROM node:22.20.0-trixie AS production\n\n# https://github.com/nodejs/docker-node/blob/7de353256a35856c788b37c1826331dbba5f0785/docs/BestPractices.md\n# Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker. \n# You can also include Tini directly in your Dockerfile, ensuring your process is always started with an init wrapper.\nRUN apt-get update && apt-get install -y -q --no-install-recommends \\\n    ca-certificates \\\n    lsof \\\n    tini \\\n    && rm -rf /var/lib/apt/lists/*\n\nUSER node\nWORKDIR /app\n\n# copy prebuilt production node_modules\nCOPY --chown=node:node --from=builder /app/node_modules /app/node_modules\n\n# copy prebuilt dist\nCOPY --chown=node:node --from=builder /app/dist /app/dist\n\nENV NODE_ENV=production\n\nEXPOSE 8080\nENTRYPOINT [\"/usr/bin/tini\", \"--\"]\nCMD [\"node\",\"dist/server/app.js\"]"
  },
  {
    "path": "Gruntfile.js",
    "content": "// Generated on 2014-12-21 using generator-angular-fullstack 2.0.13\n'use strict';\n\nmodule.exports = function (grunt) {\n  var localConfig = {};\n\n  // Load grunt tasks automatically, when needed\n  require('jit-grunt')(grunt, {\n    express: 'grunt-express-server',\n    useminPrepare: 'grunt-usemin',\n    ngtemplates: 'grunt-angular-templates',\n    injector: 'grunt-asset-injector',\n  });\n\n  // Time how long tasks take. Can help when optimizing build times\n  require('time-grunt')(grunt);\n\n  // Define the configuration for all the tasks\n  grunt.initConfig({\n\n    // Project settings\n    pkg: grunt.file.readJSON('package.json'),\n    yeoman: {\n      // configurable paths\n      client: require('./bower.json').appPath || 'client',\n      dist: 'dist'\n    },\n    express: {\n      options: {\n        port: process.env.PORT || 9000,\n        opts: ['node_modules/.bin/ts-node'],\n      },\n      dev: {\n        options: {\n          script: 'server/app.ts',\n        }\n      },\n      prod: {\n        options: {\n          script: 'dist/server/app.js'\n        }\n      }\n    },\n    watch: {\n      injectJS: {\n        files: [\n          '<%= yeoman.client %>/{app,components}/**/*.js',\n          '!<%= yeoman.client %>/{app,components}/**/*.spec.js',\n          '!<%= yeoman.client %>/{app,components}/**/*.mock.js',\n          '!<%= yeoman.client %>/app/app.js'],\n        tasks: ['injector:scripts']\n      },\n      injectCss: {\n        files: [\n          '<%= yeoman.client %>/{app,components}/**/*.css'\n        ],\n        tasks: ['injector:css']\n      },\n      mochaTest: {\n        files: ['server/**/*.spec.ts', 'server/**/*.spec.js'],\n        tasks: ['env:test', 'mochaTest']\n      },\n      injectLess: {\n        files: [\n          '<%= yeoman.client %>/{app,components}/**/*.less'],\n        tasks: ['injector:less']\n      },\n      less: {\n        files: [\n          '<%= yeoman.client %>/{app,components}/**/*.less'],\n        tasks: ['less', 'autoprefixer']\n      },\n      gruntfile: {\n        files: ['Gruntfile.js']\n      },\n      livereload: {\n        files: [\n          '{.tmp,<%= yeoman.client %>}/{app,components}/**/*.css',\n          '{.tmp,<%= yeoman.client %>}/{app,components}/**/*.html',\n          '{.tmp,<%= yeoman.client %>}/{app,components}/**/*.js',\n          '!{.tmp,<%= yeoman.client %>}{app,components}/**/*.spec.js',\n          '!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.mock.js',\n          '<%= yeoman.client %>/assets/images/{,*//*}*.{png,jpg,jpeg,gif,webp,svg}'\n        ],\n        options: {\n          livereload: true\n        }\n      },\n      express: {\n        files: [\n          'server/**/*.{ts,js,json}'\n        ],\n        tasks: ['express:dev', 'wait'],\n        options: {\n          livereload: true,\n          nospawn: true //Without this option specified express won't be reloaded\n        }\n      }\n    },\n\n    // Empties folders to start fresh\n    clean: {\n      dist: {\n        files: [{\n          dot: true,\n          src: [\n            '.tmp',\n            '<%= yeoman.dist %>/*',\n            '!<%= yeoman.dist %>/.git*',\n            '!<%= yeoman.dist %>/.openshift',\n            '!<%= yeoman.dist %>/Procfile'\n          ]\n        }]\n      },\n      server: '.tmp',\n      cachedFonts: 'server/logic/cachedFonts/*.*'\n    },\n\n    // Add vendor prefixed styles\n    autoprefixer: {\n      options: {\n        browsers: ['last 1 version']\n      },\n      dist: {\n        files: [{\n          expand: true,\n          cwd: '.tmp/',\n          src: '{,*/}*.css',\n          dest: '.tmp/'\n        }]\n      }\n    },\n\n    // Use nodemon to run server in debug mode with an initial breakpoint\n    nodemon: {\n      debug: {\n        script: 'server/app.ts',\n        options: {\n          env: {\n            PORT: process.env.PORT || 9000\n          },\n          callback: function (nodemon) {\n            nodemon.on('log', function (event) {\n              console.log(event.colour);\n            });\n          }\n        }\n      }\n    },\n\n    // Automatically inject Bower components into the app\n    wiredep: {\n      target: {\n        src: '<%= yeoman.client %>/index.html',\n        ignorePath: '<%= yeoman.client %>/',\n        exclude: [/bootstrap-sass-official/, '/json3/', '/es5-shim/', /bootstrap.css/, /font-awesome.css/]\n      }\n    },\n\n    // Renames files for browser caching purposes\n    rev: {\n      dist: {\n        files: {\n          src: [\n            '<%= yeoman.dist %>/public/{,*/}*.js',\n            '<%= yeoman.dist %>/public/{,*/}*.css',\n            '<%= yeoman.dist %>/public/assets/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',\n            '<%= yeoman.dist %>/public/assets/fonts/*'\n          ]\n        }\n      }\n    },\n\n    // Reads HTML for usemin blocks to enable smart builds that automatically\n    // concat, minify and revision files. Creates configurations in memory so\n    // additional tasks can operate on them\n    useminPrepare: {\n      html: ['<%= yeoman.client %>/index.html'],\n      options: {\n        dest: '<%= yeoman.dist %>/public'\n      }\n    },\n\n    // Performs rewrites based on rev and the useminPrepare configuration\n    usemin: {\n      html: ['<%= yeoman.dist %>/public/{,*/}*.html'],\n      css: ['<%= yeoman.dist %>/public/{,*/}*.css'],\n      js: ['<%= yeoman.dist %>/public/{,*/}*.js'],\n      options: {\n        assetsDirs: [\n          '<%= yeoman.dist %>/public',\n          '<%= yeoman.dist %>/public/assets/images'\n        ],\n        // This is so we update image references in our ng-templates\n        patterns: {\n          js: [\n            [/(assets\\/images\\/.*?\\.(?:gif|jpeg|jpg|png|webp|svg))/gm, 'Update the JS to reference our revved images']\n          ]\n        }\n      }\n    },\n\n    // The following *-min tasks produce minified files in the dist folder\n    imagemin: {\n      dist: {\n        files: [{\n          expand: true,\n          cwd: '<%= yeoman.client %>/assets/images',\n          src: '{,*/}*.{png,jpg,jpeg,gif}',\n          dest: '<%= yeoman.dist %>/public/assets/images'\n        }]\n      }\n    },\n\n    svgmin: {\n      dist: {\n        files: [{\n          expand: true,\n          cwd: '<%= yeoman.client %>/assets/images',\n          src: '{,*/}*.svg',\n          dest: '<%= yeoman.dist %>/public/assets/images'\n        }]\n      }\n    },\n\n    // Allow the use of non-minsafe AngularJS files. Automatically makes it\n    // minsafe compatible so Uglify does not destroy the ng references\n    ngAnnotate: {\n      dist: {\n        files: [{\n          expand: true,\n          cwd: '.tmp/concat',\n          src: '*/**.js',\n          dest: '.tmp/concat'\n        }]\n      }\n    },\n\n    // Package all the html partials into a single javascript payload\n    ngtemplates: {\n      options: {\n        // This should be the name of your apps angular module\n        module: 'googleWebfontsHelperApp',\n        htmlmin: {\n          collapseBooleanAttributes: true,\n          collapseWhitespace: true,\n          removeAttributeQuotes: true,\n          removeEmptyAttributes: true,\n          removeRedundantAttributes: true,\n          removeScriptTypeAttributes: true,\n          removeStyleLinkTypeAttributes: true\n        },\n        usemin: 'app/app.js'\n      },\n      main: {\n        cwd: '<%= yeoman.client %>',\n        src: ['{app,components}/**/*.html'],\n        dest: '.tmp/templates.js'\n      },\n      tmp: {\n        cwd: '.tmp',\n        src: ['{app,components}/**/*.html'],\n        dest: '.tmp/tmp-templates.js'\n      }\n    },\n\n    // Copies remaining files to places other tasks can use\n    copy: {\n      dist: {\n        files: [{\n          expand: true,\n          dot: true,\n          cwd: '<%= yeoman.client %>',\n          dest: '<%= yeoman.dist %>/public',\n          src: [\n            '*.{ico,png,txt}',\n            'bower_components/font-awesome/fonts/*',\n            'bower_components/bootstrap/fonts/*',\n            'assets/images/{,*/}*.{webp}',\n            'assets/fonts/**/*',\n            'index.html'\n          ]\n        }, {\n          expand: true,\n          cwd: '.tmp/images',\n          dest: '<%= yeoman.dist %>/public/assets/images',\n          src: ['generated/*']\n        },\n          // {\n          //   expand: true,\n          //   dest: '<%= yeoman.dist %>',\n          //   src: [\n          //     'server/**/*',\n          //     '!server/**/*.spec.js',\n          //   ]\n          // }\n        ]\n      },\n      styles: {\n        expand: true,\n        cwd: '<%= yeoman.client %>',\n        dest: '.tmp/',\n        src: ['{app,components}/**/*.css']\n      }\n    },\n\n    ts: {\n      default: {\n        tsconfig: './tsconfig.json'\n      }\n    },\n\n    // Run some tasks in parallel to speed up the build process\n    concurrent: {\n      server: [\n        'less',\n      ],\n      test: [\n        'less',\n      ],\n      debug: {\n        tasks: [\n          'nodemon',\n          // 'node-inspector'\n        ],\n        options: {\n          logConcurrentOutput: true\n        }\n      },\n      dist: [\n        'less',\n        'imagemin',\n        'svgmin'\n      ]\n    },\n\n    mochaTest: {\n      options: {\n        reporter: 'spec',\n        require: 'ts-node/register'\n      },\n      src: ['server/**/*.spec.ts', 'server/**/*.spec.js']\n    },\n\n    env: {\n      test: {\n        NODE_ENV: 'test'\n      },\n      prod: {\n        NODE_ENV: 'production'\n      },\n      all: localConfig\n    },\n\n    // Compiles Less to CSS\n    less: {\n      options: {\n        paths: [\n          '<%= yeoman.client %>/bower_components',\n          '<%= yeoman.client %>/app',\n          '<%= yeoman.client %>/components'\n        ]\n      },\n      server: {\n        files: {\n          '.tmp/app/app.css': '<%= yeoman.client %>/app/app.less'\n        }\n      },\n    },\n\n    injector: {\n      options: {\n\n      },\n      // Inject application script files into index.html (doesn't include bower)\n      scripts: {\n        options: {\n          transform: function (filePath) {\n            filePath = filePath.replace('/client/', '');\n            filePath = filePath.replace('/.tmp/', '');\n            return '<script src=\"' + filePath + '\"></script>';\n          },\n          starttag: '<!-- injector:js -->',\n          endtag: '<!-- endinjector -->'\n        },\n        files: {\n          '<%= yeoman.client %>/index.html': [\n            ['{.tmp,<%= yeoman.client %>}/{app,components}/**/*.js',\n              '!{.tmp,<%= yeoman.client %>}/app/app.js',\n              '!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.spec.js',\n              '!{.tmp,<%= yeoman.client %>}/{app,components}/**/*.mock.js']\n          ]\n        }\n      },\n\n      // Inject component less into app.less\n      less: {\n        options: {\n          transform: function (filePath) {\n            filePath = filePath.replace('/client/app/', '');\n            filePath = filePath.replace('/client/components/', '');\n            return '@import \\'' + filePath + '\\';';\n          },\n          starttag: '// injector',\n          endtag: '// endinjector'\n        },\n        files: {\n          '<%= yeoman.client %>/app/app.less': [\n            '<%= yeoman.client %>/{app,components}/**/*.less',\n            '!<%= yeoman.client %>/app/app.less'\n          ]\n        }\n      },\n\n      // Inject component css into index.html\n      css: {\n        options: {\n          transform: function (filePath) {\n            filePath = filePath.replace('/client/', '');\n            filePath = filePath.replace('/.tmp/', '');\n            return '<link rel=\"stylesheet\" href=\"' + filePath + '\">';\n          },\n          starttag: '<!-- injector:css -->',\n          endtag: '<!-- endinjector -->'\n        },\n        files: {\n          '<%= yeoman.client %>/index.html': [\n            '<%= yeoman.client %>/{app,components}/**/*.css'\n          ]\n        }\n      }\n    },\n  });\n\n  // Used for delaying livereload until after server has restarted\n  grunt.registerTask('wait', function () {\n    grunt.log.ok('Waiting for server reload...');\n\n    var done = this.async();\n\n    setTimeout(function () {\n      grunt.log.writeln('Done waiting!');\n      done();\n    }, 1500);\n  });\n\n  grunt.registerTask('express-keepalive', 'Keep grunt running', function () {\n    this.async();\n  });\n\n  grunt.registerTask('serve', function (target) {\n    if (target === 'dist') {\n      return grunt.task.run(['build', 'env:all', 'env:prod', 'express:prod', 'wait', 'express-keepalive']);\n    }\n\n    if (target === 'debug') {\n      return grunt.task.run([\n        'clean:server',\n        'clean:cachedFonts',\n        'env:all',\n        'injector:less',\n        'concurrent:server',\n        'injector',\n        'wiredep',\n        'autoprefixer',\n        'concurrent:debug'\n      ]);\n    }\n\n    grunt.task.run([\n      'clean:server',\n      'clean:cachedFonts',\n      'env:all',\n      'injector:less',\n      'concurrent:server',\n      'injector',\n      'wiredep',\n      'autoprefixer',\n      'express:dev',\n      'wait',\n      'watch'\n    ]);\n  });\n\n  grunt.registerTask('server', function () {\n    grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.');\n    grunt.task.run(['serve']);\n  });\n\n  grunt.registerTask('test', function (target) {\n    if (target === 'server') {\n      return grunt.task.run([\n        'env:all',\n        'env:test',\n        'mochaTest'\n      ]);\n    }\n\n    else if (target === 'client') {\n      return grunt.task.run([\n        'clean:server',\n        'clean:cachedFonts',\n        'env:all',\n        'injector:less',\n        'concurrent:test',\n        'injector',\n        'autoprefixer'\n      ]);\n    }\n\n    else grunt.task.run([\n      'test:server',\n      'test:client'\n    ]);\n  });\n\n  grunt.registerTask('build', [\n    'clean:dist',\n    'injector:less',\n    'concurrent:dist',\n    'injector',\n    'wiredep',\n    'useminPrepare',\n    'autoprefixer',\n    'ngtemplates',\n    'concat',\n    'ngAnnotate',\n    'copy:dist',\n    'ts',\n    'cssmin',\n    'uglify',\n    'rev',\n    'usemin'\n  ]);\n\n  grunt.registerTask('default', [\n    'test',\n    'build'\n  ]);\n};\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "The MIT License (MIT)\nCopyright (c) 2016 Mario Ranftl | majodev and \"The Google Webfonts Helper\" Project Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the “Software”), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# 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)\n> A Hassle-Free Way to Self-Host Google Fonts\n\n✅ **[https://gwfh.mranftl.com](https://gwfh.mranftl.com)**\n\n## Current Sponsors\n\n> *Help me keep this service alive by [sponsoring me](https://github.com/sponsors/majodev). Thank you. ❤️*\n\n[<img src=\"https://sponsors.mranftl.com/avatar/0\" width=\"35\">](https://sponsors.mranftl.com/profile/0)\n[<img src=\"https://sponsors.mranftl.com/avatar/1\" width=\"35\">](https://sponsors.mranftl.com/profile/1)\n[<img src=\"https://sponsors.mranftl.com/avatar/2\" width=\"35\">](https://sponsors.mranftl.com/profile/2)\n[<img src=\"https://sponsors.mranftl.com/avatar/3\" width=\"35\">](https://sponsors.mranftl.com/profile/3)\n[<img src=\"https://sponsors.mranftl.com/avatar/4\" width=\"35\">](https://sponsors.mranftl.com/profile/4)\n[<img src=\"https://sponsors.mranftl.com/avatar/5\" width=\"35\">](https://sponsors.mranftl.com/profile/5)\n[<img src=\"https://sponsors.mranftl.com/avatar/6\" width=\"35\">](https://sponsors.mranftl.com/profile/6)\n[<img src=\"https://sponsors.mranftl.com/avatar/7\" width=\"35\">](https://sponsors.mranftl.com/profile/7)\n[<img src=\"https://sponsors.mranftl.com/avatar/8\" width=\"35\">](https://sponsors.mranftl.com/profile/8)\n[<img src=\"https://sponsors.mranftl.com/avatar/9\" width=\"35\">](https://sponsors.mranftl.com/profile/9)\n[<img src=\"https://sponsors.mranftl.com/avatar/10\" width=\"35\">](https://sponsors.mranftl.com/profile/10)\n[<img src=\"https://sponsors.mranftl.com/avatar/11\" width=\"35\">](https://sponsors.mranftl.com/profile/11)\n[<img src=\"https://sponsors.mranftl.com/avatar/12\" width=\"35\">](https://sponsors.mranftl.com/profile/12)\n[<img src=\"https://sponsors.mranftl.com/avatar/13\" width=\"35\">](https://sponsors.mranftl.com/profile/13)\n[<img src=\"https://sponsors.mranftl.com/avatar/14\" width=\"35\">](https://sponsors.mranftl.com/profile/14)\n[<img src=\"https://sponsors.mranftl.com/avatar/15\" width=\"35\">](https://sponsors.mranftl.com/profile/15)\n[<img src=\"https://sponsors.mranftl.com/avatar/16\" width=\"35\">](https://sponsors.mranftl.com/profile/16)\n[<img src=\"https://sponsors.mranftl.com/avatar/17\" width=\"35\">](https://sponsors.mranftl.com/profile/17)\n[<img src=\"https://sponsors.mranftl.com/avatar/18\" width=\"35\">](https://sponsors.mranftl.com/profile/18)\n[<img src=\"https://sponsors.mranftl.com/avatar/19\" width=\"35\">](https://sponsors.mranftl.com/profile/19)\n[<img src=\"https://sponsors.mranftl.com/avatar/20\" width=\"35\">](https://sponsors.mranftl.com/profile/20)\n[<img src=\"https://sponsors.mranftl.com/avatar/21\" width=\"35\">](https://sponsors.mranftl.com/profile/21)\n[<img src=\"https://sponsors.mranftl.com/avatar/22\" width=\"35\">](https://sponsors.mranftl.com/profile/22)\n[<img src=\"https://sponsors.mranftl.com/avatar/23\" width=\"35\">](https://sponsors.mranftl.com/profile/23)\n[<img src=\"https://sponsors.mranftl.com/avatar/24\" width=\"35\">](https://sponsors.mranftl.com/profile/24)\n[<img src=\"https://sponsors.mranftl.com/avatar/25\" width=\"35\">](https://sponsors.mranftl.com/profile/25)\n[<img src=\"https://sponsors.mranftl.com/avatar/26\" width=\"35\">](https://sponsors.mranftl.com/profile/26)\n[<img src=\"https://sponsors.mranftl.com/avatar/27\" width=\"35\">](https://sponsors.mranftl.com/profile/27)\n[<img src=\"https://sponsors.mranftl.com/avatar/28\" width=\"35\">](https://sponsors.mranftl.com/profile/28)\n[<img src=\"https://sponsors.mranftl.com/avatar/29\" width=\"35\">](https://sponsors.mranftl.com/profile/29)\n[<img src=\"https://sponsors.mranftl.com/avatar/30\" width=\"35\">](https://sponsors.mranftl.com/profile/30)\n[<img src=\"https://sponsors.mranftl.com/avatar/31\" width=\"35\">](https://sponsors.mranftl.com/profile/31)\n[<img src=\"https://sponsors.mranftl.com/avatar/32\" width=\"35\">](https://sponsors.mranftl.com/profile/32)\n[<img src=\"https://sponsors.mranftl.com/avatar/33\" width=\"35\">](https://sponsors.mranftl.com/profile/33)\n[<img src=\"https://sponsors.mranftl.com/avatar/34\" width=\"35\">](https://sponsors.mranftl.com/profile/34)\n[<img src=\"https://sponsors.mranftl.com/avatar/35\" width=\"35\">](https://sponsors.mranftl.com/profile/35)\n[<img src=\"https://sponsors.mranftl.com/avatar/36\" width=\"35\">](https://sponsors.mranftl.com/profile/36)\n[<img src=\"https://sponsors.mranftl.com/avatar/37\" width=\"35\">](https://sponsors.mranftl.com/profile/37)\n[<img src=\"https://sponsors.mranftl.com/avatar/38\" width=\"35\">](https://sponsors.mranftl.com/profile/38)\n[<img src=\"https://sponsors.mranftl.com/avatar/39\" width=\"35\">](https://sponsors.mranftl.com/profile/39)\n[<img src=\"https://sponsors.mranftl.com/avatar/40\" width=\"35\">](https://sponsors.mranftl.com/profile/40)\n[<img src=\"https://sponsors.mranftl.com/avatar/41\" width=\"35\">](https://sponsors.mranftl.com/profile/41)\n[<img src=\"https://sponsors.mranftl.com/avatar/42\" width=\"35\">](https://sponsors.mranftl.com/profile/42)\n[<img src=\"https://sponsors.mranftl.com/avatar/43\" width=\"35\">](https://sponsors.mranftl.com/profile/43)\n[<img src=\"https://sponsors.mranftl.com/avatar/44\" width=\"35\">](https://sponsors.mranftl.com/profile/44)\n[<img src=\"https://sponsors.mranftl.com/avatar/45\" width=\"35\">](https://sponsors.mranftl.com/profile/45)\n[<img src=\"https://sponsors.mranftl.com/avatar/46\" width=\"35\">](https://sponsors.mranftl.com/profile/46)\n[<img src=\"https://sponsors.mranftl.com/avatar/47\" width=\"35\">](https://sponsors.mranftl.com/profile/47)\n[<img src=\"https://sponsors.mranftl.com/avatar/48\" width=\"35\">](https://sponsors.mranftl.com/profile/48)\n[<img src=\"https://sponsors.mranftl.com/avatar/49\" width=\"35\">](https://sponsors.mranftl.com/profile/49)\n[<img src=\"https://sponsors.mranftl.com/avatar/50\" width=\"35\">](https://sponsors.mranftl.com/profile/50)\n[<img src=\"https://sponsors.mranftl.com/avatar/51\" width=\"35\">](https://sponsors.mranftl.com/profile/51)\n[<img src=\"https://sponsors.mranftl.com/avatar/52\" width=\"35\">](https://sponsors.mranftl.com/profile/52)\n[<img src=\"https://sponsors.mranftl.com/avatar/53\" width=\"35\">](https://sponsors.mranftl.com/profile/53)\n[<img src=\"https://sponsors.mranftl.com/avatar/54\" width=\"35\">](https://sponsors.mranftl.com/profile/54)\n[<img src=\"https://sponsors.mranftl.com/avatar/55\" width=\"35\">](https://sponsors.mranftl.com/profile/55)\n[<img src=\"https://sponsors.mranftl.com/avatar/56\" width=\"35\">](https://sponsors.mranftl.com/profile/56)\n[<img src=\"https://sponsors.mranftl.com/avatar/57\" width=\"35\">](https://sponsors.mranftl.com/profile/57)\n[<img src=\"https://sponsors.mranftl.com/avatar/58\" width=\"35\">](https://sponsors.mranftl.com/profile/58)\n[<img src=\"https://sponsors.mranftl.com/avatar/59\" width=\"35\">](https://sponsors.mranftl.com/profile/59)\n[<img src=\"https://sponsors.mranftl.com/avatar/60\" width=\"35\">](https://sponsors.mranftl.com/profile/60)\n[<img src=\"https://sponsors.mranftl.com/avatar/61\" width=\"35\">](https://sponsors.mranftl.com/profile/61)\n[<img src=\"https://sponsors.mranftl.com/avatar/62\" width=\"35\">](https://sponsors.mranftl.com/profile/62)\n[<img src=\"https://sponsors.mranftl.com/avatar/63\" width=\"35\">](https://sponsors.mranftl.com/profile/63)\n[<img src=\"https://sponsors.mranftl.com/avatar/64\" width=\"35\">](https://sponsors.mranftl.com/profile/64)\n[<img src=\"https://sponsors.mranftl.com/avatar/65\" width=\"35\">](https://sponsors.mranftl.com/profile/65)\n[<img src=\"https://sponsors.mranftl.com/avatar/66\" width=\"35\">](https://sponsors.mranftl.com/profile/66)\n[<img src=\"https://sponsors.mranftl.com/avatar/67\" width=\"35\">](https://sponsors.mranftl.com/profile/67)\n[<img src=\"https://sponsors.mranftl.com/avatar/68\" width=\"35\">](https://sponsors.mranftl.com/profile/68)\n\n## ToC\n\n- [google-webfonts-helper   ](#google-webfonts-helper---)\n  - [Current Sponsors](#current-sponsors)\n  - [ToC](#toc)\n  - [Give it a try: https://gwfh.mranftl.com](#give-it-a-try-httpsgwfhmranftlcom)\n  - [Running gwfh on your own server](#running-gwfh-on-your-own-server)\n  - [Development](#development)\n    - [Quickstart](#quickstart)\n    - [Production build](#production-build)\n  - [JSON API](#json-api)\n    - [GET `/api/fonts`](#get-apifonts)\n    - [GET `/api/fonts/[id]?subsets=latin,latin-ext`](#get-apifontsidsubsetslatinlatin-ext)\n    - [GET `/api/fonts/[id]?download=zip&subsets=latin&formats=woff,woff2&variants=regular`](#get-apifontsiddownloadzipsubsetslatinformatswoffwoff2variantsregular)\n  - [History](#history)\n  - [License](#license)\n\n\n## Give it a try: [https://gwfh.mranftl.com](https://gwfh.mranftl.com)\n\nThis service might be handy if you want to host a specific [Google font](https://fonts.google.com/) on your **own** server:\n* font style and charset customization\n* CSS snippets\n* `.eot`, `.woff`, `.woff2`, `.svg`, `.ttf` font file formats download (zipped).\n\n[![pic running](https://mranftl.com/static/apps/google-webfonts-helper/full_view.png)](https://gwfh.mranftl.com)\n\n## Running gwfh on your own server\n\nI 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:\n```bash\n# See https://developers.google.com/fonts/docs/developer_api for creating your own API-Key.\n\ndocker run -e GOOGLE_FONTS_API_KEY=<YOUR-API-KEY> -p 8080:8080 ghcr.io/majodev/google-webfonts-helper:<TAG>\n# Express server listening on 8080, in production mode\n```\n\n## Development\n\n### Quickstart\n\nDo this to setup a development environment:\n```bash\n# Ensure to set the GOOGLE_FONTS_API_KEY env var inside your own gitignored .env file\n# See https://developers.google.com/fonts/docs/developer_api for creating your own API-Key.\necho \"GOOGLE_FONTS_API_KEY=<YOUR-API-KEY>\" > .env\n\n# Start up the development docker container (multistage Dockerfile, stage 1 only)\n./docker-helper.sh --up\n# [+] Running 1/0\n#  ⠿ Container gwfh-service-1  Running\n# node@3b506a285f7f:/app$\n\n# within this development container:\nnode$ yarn --pure-lockfile\nnode$ ./node_modules/.bin/bower install\n\n# start development server\nnode$ grunt serve\n# [...]\n# Express server listening on 9000, in development mode\n\n# The application is now available at http://127.0.0.1:9000 (watching for code changes)\n\n# start production server (same command as within the final docker multistage build)\nnode$ grunt build\nnode$ NODE_ENV=production node dist/server/app.js\n# Express server listening on 8080, in production mode\n```\n\n### Production build\n\nIf you want to build and run your own **production** container locally:\n```bash\n# Build the production docker container (final stage)\ndocker build . -t <your-image-tag>\n\n# Run it (if you have previously started the development container, halt it!)\n./docker-helper.sh --halt\ndocker run -e GOOGLE_FONTS_API_KEY=<YOUR-API-KEY> -p 8080:8080 <your-image-tag>\n# Express server listening on 8080, in production mode\n```\n\nTo 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. \n\n## JSON API\nThe API is public, feel free to use it directly (rate-limits may apply).\n\n### GET `/api/fonts`\nReturns a list of all fonts, sorted by popularity. E.g. `curl https://gwfh.mranftl.com/api/fonts`:\n```json\n[{\n  \"id\": \"open-sans\",\n  \"family\": \"Open Sans\",\n  \"variants\": [\"300\", \"300italic\", \"regular\", \"italic\", \"600\", \"600italic\", \"700\", \"700italic\", \"800\", \"800italic\"],\n  \"subsets\": [\"devanagari\", \"greek\", \"latin\", \"cyrillic-ext\", \"cyrillic\", \"greek-ext\", \"vietnamese\", \"latin-ext\"],\n  \"category\": \"sans-serif\",\n  \"version\": \"v10\",\n  \"lastModified\": \"2014-10-17\",\n  \"popularity\": 1,\n  \"defSubset\": \"latin\",\n  \"defVariant\": \"regular\"\n} [...]\n]\n```\n\n### GET `/api/fonts/[id]?subsets=latin,latin-ext`\nReturns 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!):\n\n```json\n{\n  \"id\": \"modern-antiqua\",\n  \"family\": \"Modern Antiqua\",\n  \"variants\": [{\n    \"id\": \"regular\",\n    \"eot\": \"https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkhzThM-TJeMvVB0dIsYy4U7E.eot\",\n    \"fontFamily\": \"'Modern Antiqua'\",\n    \"fontStyle\": \"normal\",\n    \"fontWeight\": \"400\",\n    \"woff\": \"https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkh1bbnkJREviNM815YSrb1io.woff\",\n    \"local\": [\"Modern Antiqua Regular\", \"ModernAntiqua-Regular\"],\n    \"ttf\": \"https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkhxr_S_FdaWWVbb1LgBbjq4o.ttf\",\n    \"svg\": \"https://fonts.gstatic.com/l/font?kit=8qX_tr6Xzy4t9fvZDXPkh0sAoW0rAsWAgyWthbXBUKs#ModernAntiqua\",\n    \"woff2\": \"https://fonts.gstatic.com/s/modernantiqua/v6/8qX_tr6Xzy4t9fvZDXPkh08GHjg64nS_BBLu6wRo0k8.woff2\"\n  }],\n  \"subsets\": [\"latin\", \"latin-ext\"],\n  \"category\": \"display\",\n  \"version\": \"v6\",\n  \"lastModified\": \"2014-08-28\",\n  \"popularity\": 522,\n  \"defSubset\": \"latin\",\n  \"defVariant\": \"regular\",\n  \"subsetMap\": {\n    \"latin\": true,\n    \"latin-ext\": true\n  },\n  \"storeID\": \"latin-ext_latin\"\n}\n```\n\n### GET `/api/fonts/[id]?download=zip&subsets=latin&formats=woff,woff2&variants=regular`\n\nDownload 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!)\n\n## History\n\n> 2025:\n\n* Switch to `node:22` for the final image.\n* Adds support for linux/arm64 architecture (patches [imagemin/optipng-bin](https://github.com/imagemin/optipng-bin/pull/128))\n\n> 2024:\n\n* Switch to `node:20` for the final image.\n\n> 2023:\n\n* Project upgraded to be compatible with Node.js v18+.\n* Automated prebuilt Docker images via [GitHub Actions](https://github.com/majodev/google-webfonts-helper/actions).\n* `/server` was fully refactored/modernized (async/await) and now compiles with TypeScript.\n* Switch to `node:18` for the final image.\n* `/client` can still be considered very legacy Angular code.\n\n> 2022:\n\nThis 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.\n\n> 2014:\n\nThis 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/).\n\nIdea 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.\n\n## License\n(c) Mario Ranftl\n[MIT License](http://majodev.mit-license.org/)\n\n[Google Fonts Open Source Font Attribution](https://fonts.google.com/attribution)"
  },
  {
    "path": "bower.json",
    "content": "{\n  \"name\": \"google-webfonts-helper\",\n  \"version\": \"1.1.0\",\n  \"dependencies\": {\n    \"angular-animate\": \"1.3.8\",\n    \"angular-bootstrap\": \"0.11.2\",\n    \"angular-busy\": \"4.1.2\",\n    \"angular-cookies\": \"1.3.8\",\n    \"angular-resource\": \"1.3.8\",\n    \"angular-sanitize\": \"1.3.8\",\n    \"angular-ui-router\": \"0.2.18\",\n    \"angular\": \"1.3.8\",\n    \"bootstrap\": \"3.1.1\",\n    \"es5-shim\": \"3.0.2\",\n    \"font-awesome\": \"4.2.0\",\n    \"highlightjs\": \"8.4.0\",\n    \"jquery\": \"1.11.3\",\n    \"json3\": \"3.3.2\",\n    \"lodash\": \"2.4.2\"\n  },\n  \"devDependencies\": {\n    \"angular-mocks\": \"1.3.8\",\n    \"angular-scenario\": \"1.3.8\"\n  }\n}"
  },
  {
    "path": "client/app/app.js",
    "content": "'use strict';\n\nangular.module('googleWebfontsHelperApp', [\n    'ngCookies',\n    'ngResource',\n    'ngSanitize',\n    'ui.router',\n    'ui.bootstrap',\n    'cgBusy'\n  ])\n  .config(function($stateProvider, $urlRouterProvider, $locationProvider) {\n    \n    $urlRouterProvider\n      .otherwise('/fonts'); // default urls is /fonts\n\n    $locationProvider.html5Mode(true);\n\n  });"
  },
  {
    "path": "client/app/app.less",
    "content": "@import 'bootstrap/less/bootstrap.less';\n@import 'bootstrap/less/theme.less';\n@import 'font-awesome/less/font-awesome.less';\n@import (inline) 'angular-busy/dist/angular-busy.css';\n\n@icon-font-path: '/bower_components/bootstrap/fonts/';\n@fa-font-path: '/bower_components/font-awesome/fonts';\n\n/**\n * App-wide Styles\n */\n\n.browsehappy {\n    margin: 0.2em 0;\n    background: #ccc;\n    color: #000;\n    padding: 0.2em 0;\n}\n\n// injector\n@import 'cssCode/cssCode.less';\n@import 'fonts/fonts.less';\n// endinjector"
  },
  {
    "path": "client/app/cssCode/cssCode.directive.js",
    "content": "'use strict';\n\nangular.module('googleWebfontsHelperApp')\n  .directive('cssCode', [function() {\n    return {\n      templateUrl: 'app/cssCode/cssCode.html',\n      restrict: 'EA',\n      scope: {\n        type: '=',\n        variant: '=',\n        fontItem: '=',\n        folderPrefix: '='\n      },\n      link: function(scope, element) {\n      }\n    };\n  }]);"
  },
  {
    "path": "client/app/cssCode/cssCode.directive.spec.js",
    "content": "'use strict';\n\ndescribe('Directive: cssCode', function () {\n\n  // load the directive's module and view\n  beforeEach(module('googleWebfontsHelperApp'));\n  beforeEach(module('app/cssCode/cssCode.html'));\n\n  var element, scope;\n\n  beforeEach(inject(function ($rootScope) {\n    scope = $rootScope.$new();\n  }));\n\n  it('should make hidden element visible', inject(function ($compile) {\n    element = angular.element('<css-code></css-code>');\n    element = $compile(element)(scope);\n    scope.$apply();\n    expect(element.text()).toBe('this is the cssCode directive');\n  }));\n});"
  },
  {
    "path": "client/app/cssCode/cssCode.html",
    "content": "<pre ng-if=\"type.modernSupport\"><code data-hljs=\"css\" highlightjs>/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */\n@font-face {\n  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */\n  font-family: {{variant.fontFamily}};\n  font-style: {{variant.fontStyle}};\n  font-weight: {{variant.fontWeight}};\n  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */\n}\n</code></pre>\n<pre ng-if=\"type.legacySupport\"><code data-hljs=\"css\" highlightjs>/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */\n@font-face {\n  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */\n  font-family: {{variant.fontFamily}};\n  font-style: {{variant.fontStyle}};\n  font-weight: {{variant.fontWeight}};\n  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */\n       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+ */\n}\n</code></pre>\n<pre ng-if=\"type.historicSupport\"><code data-hljs=\"css\" highlightjs>/* {{fontItem.id}}-{{variant.id}} - {{fontItem.storeID}} */\n@font-face {\n  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */\n  font-family: {{variant.fontFamily}};\n  font-style: {{variant.fontStyle}};\n  font-weight: {{variant.fontWeight}};\n  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.eot'); /* IE9 Compat Modes */\n  src: url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */\n       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */\n       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+ */\n       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+ */\n       url('{{folderPrefix}}{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}-{{variant.id}}.svg#{{variant.svg.substring(variant.svg.indexOf('#')+1);}}') format('svg'); /* Legacy iOS */\n}\n</code></pre>\n<style ng-if=\"type.styleTag\" type=\"text/css\">\n@font-face {\n  font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */\n  font-family: {{variant.fontFamily}};\n  font-style: {{variant.fontStyle}};\n  font-weight: {{variant.fontWeight}};\n  src: url('{{variant.eot}}'); /* IE9 Compat Modes */\n  src: url('{{variant.eot}}?#iefix') format('embedded-opentype'), /* IE6-IE8 */\n       url('{{variant.woff2}}') format('woff2'), /* Super Modern Browsers */\n       url('{{variant.woff}}') format('woff'), /* Modern Browsers */\n       url('{{variant.ttf}}')  format('truetype'), /* Safari, Android, iOS */\n       url('{{variant.svg}}') format('svg'); /* Legacy iOS */\n}\n</style>\n"
  },
  {
    "path": "client/app/cssCode/cssCode.less",
    "content": "pre {\n  background-color: transparent;\n  border: 0;\n  border-radius: 0;\n  padding-bottom: 0px;\n  padding-top: 0px;\n  margin-bottom: 0px;\n}"
  },
  {
    "path": "client/app/fonts/fonts.controller.js",
    "content": "'use strict';\n\nfunction apiError($scope, status, headers, config) {\n  // called asynchronously if an error occurs\n  // or server returns response with an error status.\n  $scope.error = true;\n  $scope.errorStatus = status;\n  $scope.errorHeaders = JSON.stringify(headers, null, 2);\n  $scope.errorConfig = JSON.stringify(config, null, 2);\n}\n\nvar previousFontItem = false; // holds reference to previous font item, for partial refreshs, will be nulled if fontID changes\n\nvar subsetsChkbTimeoutP = null; // timeout - promise for cgBusy 3000ms until request for customization is made\nvar subsetsChkbReload = null; // interval - promise for cgBusy loading text rewrite (waiting till customization) 1000ms\n\nvar variantsMap = {}; // map holds currently checked variants of a fontItem\n\nangular.module('googleWebfontsHelperApp')\n  .controller('FontsCtrl', function($scope, $http) {\n\n    $scope.fonts = [];\n    $scope.sponsors = [];\n    $scope.busy = true;\n    $scope.selectedItemID = '';\n\n    $scope.predicate = {\n      name: 'by family',\n      filter: 'family',\n      bindArg: 'category'\n    }; // default ordering predicate\n\n    $scope.reverse = false;\n\n    $scope.fontsPromise = $http.get('/api/fonts')\n      .success(function(fonts) {\n        $scope.fonts = fonts;\n        $scope.busy = false;\n      })\n      .error(function(data, status, headers, config) {\n        apiError($scope, status, headers, config);\n      });\n    \n    $scope.sponsorsPromise = $http.get('https://sponsors.mranftl.com/json')\n      .success(function (data) {\n        $scope.sponsors = data.sponsors;\n\n        setTimeout(function () {\n          $('[data-toggle=\"tooltip\"]').tooltip();\n        }, 0);\n\n      }) // err is not handled, because it is not critical\n\n    $scope.scrollListTop = function() {\n      $('.scrollerLeft').scrollTop(0);\n    };\n\n  })\n\n.controller('FontsItemCtrl', function($scope, $stateParams, $http, $state, $timeout, $interval) {\n\n  var subSetString = $stateParams.subsets || '';\n\n  if (subsetsChkbTimeoutP) {\n    $timeout.cancel(subsetsChkbTimeoutP);\n    $interval.cancel(subsetsChkbReload);\n  }\n\n  $scope.fontID = $stateParams.id;\n  $scope.$parent.selectedItemID = $scope.fontID;\n\n  if (previousFontItem && previousFontItem.id === $stateParams.id) {\n    // former item is a candiate for instant population until load is complete.\n    $scope.fontItem = previousFontItem;\n    $scope.loadingMessage = 'Customizing ' + $stateParams.id + '...';\n\n    // reuse current variantMap\n    $scope.variantsMap = variantsMap;\n\n  } else {\n    // clear it\n    previousFontItem = false;\n    $scope.loadingMessage = 'Loading ' + $stateParams.id + '...';\n  }\n\n  $scope.error = false;\n  $scope.fontFormats = 'woff2';\n\n  $scope.downloadSubSetID = '';\n  $scope.subSetsSelected = 0;\n\n  $scope.loadingPromise = $http.get('/api/fonts/' + $stateParams.id + '?subsets=' + subSetString)\n    .success(function(fontItem) {\n      $scope.fontItem = fontItem;\n\n      $scope.downloadSubSetID = fontItem.storeID.replace(/_/g, ',');\n\n      $.each($scope.fontItem.subsetMap, function(item) {\n        if ($scope.fontItem.subsetMap[item] === true) {\n          $scope.subSetsSelected += 1;\n        }\n      });\n\n      if (!previousFontItem) {\n        // first load of fontItem - reload variants Map and set the default font style\n        variantsMap = {};\n        $.each(fontItem.variants, function(index, variantItem) {\n          // console.log(variantItem);\n          variantsMap[variantItem.id] = variantItem.id === fontItem.defVariant;\n        });\n\n        // console.log(variantsMap);\n\n        $scope.variantsMap = variantsMap;\n        $scope.variantDownloadQueryString = $scope.fontItem.defVariant;\n\n      } else {\n        // trigger variant select so variant query string matches again\n        $scope.variantSelect();\n      }\n\n      $scope.busy = false;\n    })\n    .error(function(data, status, headers, config) {\n      apiError($scope, status, headers, config);\n    });\n\n  if (previousFontItem === false) {\n    $scope.busy = true;\n  }\n\n  $scope.checkSubsetMinimalSelection = function(key) {\n    if ($scope.subSetsSelected === 1 && $scope.fontItem.subsetMap[key] === true) {\n      return true;\n    } else {\n      return false;\n    }\n  };\n\n  $scope.variantSelect = function() {\n    var variantDownloadQueryString = '';\n\n    $.each(variantsMap, function(checkKey) {\n      if (variantsMap[checkKey] === true) {\n        variantDownloadQueryString += checkKey + ',';\n      }\n    });\n\n    if (variantDownloadQueryString.length === 0) {\n      // you will only get the defaultvariant!\n      variantDownloadQueryString = $scope.fontItem.defVariant;\n    } else {\n      // remove last comma from string\n      variantDownloadQueryString = variantDownloadQueryString.substring(0, variantDownloadQueryString.length - 1);\n    }\n\n    $scope.variantDownloadQueryString = variantDownloadQueryString;\n\n  };\n\n  $scope.subsetSelect = function() {\n\n    if (subsetsChkbTimeoutP) {\n      $timeout.cancel(subsetsChkbTimeoutP);\n      $interval.cancel(subsetsChkbReload);\n    }\n\n    subsetsChkbTimeoutP = $timeout(function() {\n      var queryParams = '';\n      var lenChecked = 0;\n      var map = $scope.fontItem.subsetMap;\n      var defaultSet = $scope.fontItem.defSubset;\n\n      $.each(map, function(item) {\n        if (map[item] === true) {\n          queryParams += item + ',';\n          lenChecked += 1;\n        }\n      });\n\n      $scope.subSetsSelected = lenChecked;\n\n      if (lenChecked === 0) {\n        // you will get the defaultset\n        map[defaultSet] = true;\n        queryParams = defaultSet;\n      } else {\n        // remove last comma from string\n        queryParams = queryParams.substring(0, queryParams.length - 1);\n      }\n\n      previousFontItem = $scope.fontItem;\n\n      // wait until doing the request (overrides previous promise!)...\n      subsetsChkbTimeoutP = $timeout(function() {\n        $state.go('fonts.item', {\n          id: $scope.fontID,\n          subsets: queryParams\n        });\n      }, 3000);\n\n      var timeUntil = 3;\n\n      function setCustomizationReloadMessage(time) {\n        $scope.customizationReloadMessage = 'Customization will be requested in ' + time + ' sec...';\n      }\n\n      setCustomizationReloadMessage(timeUntil);\n\n      subsetsChkbReload = $interval(function() {\n        timeUntil -= 1;\n        setCustomizationReloadMessage(timeUntil);\n      }, 1000, 3);\n\n\n\n      // make available for cgBusy\n      $scope.subsetsChkbTimeoutP = subsetsChkbTimeoutP;\n\n    });\n\n    // make available for cgBusy\n    $scope.subsetsChkbTimeoutP = subsetsChkbTimeoutP;\n\n  };\n\n  // selected variants filter\n  $scope.variantFilter = function(variant) {\n    if ($scope.variantsMap[variant.id] === false) {\n      return;\n    }\n\n    return variant;\n  };\n\n  $scope.checkVariantMinimalSelection = function(key) {\n\n    var countSelected = 0;\n\n    $.each(variantsMap, function(checkKey) {\n      if (variantsMap[checkKey] === true) {\n        countSelected += 1;\n      }\n    });\n\n    if (countSelected === 1 && variantsMap[key] === true) {\n      return true;\n    } else {\n      return false;\n    }\n  };\n\n  $scope.selectText = function(evt) {\n\n    var element = evt.currentTarget;\n\n    // console.log(element);\n\n    var doc = document,\n      text = element,\n      range, selection;\n    if (doc.body.createTextRange) {\n      range = document.body.createTextRange();\n      range.moveToElementText(text);\n      range.select();\n    } else if (window.getSelection) {\n      selection = window.getSelection();\n      range = document.createRange();\n      range.selectNodeContents(text);\n      selection.removeAllRanges();\n      selection.addRange(range);\n    }\n  };\n  \n  $scope.modernSupportActive = function() {\n    $scope.fontFormats =  'woff2';\n  };\n  $scope.legacySupportActive = function() {\n    $scope.fontFormats =  'woff2,ttf';\n  };\n  $scope.historicSupportActive = function() {\n    $scope.fontFormats =  'woff2,woff,ttf,svg,eot';\n  };\n\n});"
  },
  {
    "path": "client/app/fonts/fonts.controller.spec.js",
    "content": "'use strict';\n\ndescribe('Controller: FontsCtrl', function () {\n\n  // load the controller's module\n  beforeEach(module('googleWebfontsHelperApp'));\n\n  var FontsCtrl, scope;\n\n  // Initialize the controller and a mock scope\n  beforeEach(inject(function ($controller, $rootScope) {\n    scope = $rootScope.$new();\n    FontsCtrl = $controller('FontsCtrl', {\n      $scope: scope\n    });\n  }));\n\n  it('should ...', function () {\n    expect(1).toEqual(1);\n  });\n});\n"
  },
  {
    "path": "client/app/fonts/fonts.html",
    "content": "<div class=\"fonts-top-container\">\n  <!-- main header -->\n  <div class=\"top-overlay\">\n\n    <div class=\"row\">\n      <div class=\"col-lg-2 col-md-3 col-sm-3 col-xs-4\">\n        <div class=\"input-group\">\n          <!-- search box -->\n          <div id=\"searchwrap\" class=\"btn-group\">\n            <input id=\"searchinput\" class=\"form-control\" ng-model=\"query\" type=\"search\"\n              placeholder=\"{{fonts.length}} fonts {{predicate.name}}\" autofocus>\n            <span id=\"searchclear\" class=\"glyphicon glyphicon-remove-circle\" ng-class=\"{'show': query.length > 0}\"\n              ng-click=\"query = ''\"></span>\n          </div>\n          <!-- Filter / Order options -->\n          <div class=\"input-group-btn\" dropdown is-open=\"status.isopen\">\n            <button id=\"orderButton\" type=\"button\" class=\"btn btn-default\" dropdown-toggle ng-disabled=\"disabled\">\n              <i class=\"fa fa-filter\"></i>\n            </button>\n            <ul class=\"dropdown-menu\" role=\"menu\">\n              <li role=\"presentation\" class=\"dropdown-header\">Order by</li>\n              <li ng-click=\"scrollListTop()\" ng-model=\"predicate\"\n                btn-radio=\"{name: 'by family', filter: 'family', bindArg: 'category'}\"><a href=\"#\">Family</a></li>\n              <li ng-click=\"scrollListTop()\" ng-model=\"predicate\"\n                btn-radio=\"{name: 'by category', filter: ['category','family'], bindArg: 'category'}\"><a\n                  href=\"#\">Category</a></li>\n              <li ng-click=\"scrollListTop()\" ng-model=\"predicate\"\n                btn-radio=\"{name: 'by popularity', filter: 'popularity', pre: 'Rank '}\"><a href=\"#\">Popularity</a></li>\n              <li ng-click=\"scrollListTop()\" ng-model=\"predicate\"\n                btn-radio=\"{name: 'by last modified', filter: ['-lastModified','family'], bindArg: 'lastModified'}\"><a\n                  href=\"#\">Last modified</a></li>\n              <li ng-click=\"scrollListTop()\" ng-model=\"predicate\"\n                btn-radio=\"{name: 'by # styles', filter: ['-variants.length','family'], bindArg: 'variants', len: true, post: ' styles'}\">\n                <a href=\"#\">Number of styles</a>\n              </li>\n              <li ng-click=\"scrollListTop()\" ng-model=\"predicate\"\n                btn-radio=\"{name: 'by # charsets', filter: ['-subsets.length','family'], bindArg: 'subsets', len: true, post: ' charsets'}\">\n                <a href=\"#\">Number of charsets</a>\n              </li>\n              <li class=\"divider\"></li>\n              <li ng-click=\"reverse=!reverse; scrollListTop()\"><a href=\"#\">{{reverse === true ? \"↑ ascending\" : \"↓\n                  descending\"}}</a></li>\n            </ul>\n          </div>\n        </div>\n\n      </div>\n\n      <div class=\"col-lg-10 col-md-9 col-sm-9 col-xs-8\">\n        <!-- App name -->\n        <h4 class=\"page-header\"><a href=\"/\" ng-click=\"selectedItemID=''\">google-webfonts-helper</a><br /><small>Get eot,\n            ttf, svg, woff and woff2 + CSS</small></h4>\n\n        <div class=\"nav-push-right\">\n          <a class=\"btn btn-default actSponsorButton actSponsorButton{{ sponsor.type }}\"\n            href=\"https://github.com/{{ sponsor.login }}\" ng-repeat=\"sponsor in sponsors\" role=\"button\" target=\"_blank\"\n            data-toggle=\"tooltip\" data-placement=\"bottom\" title=\"{{ sponsor.login }}\">\n            <span>\n              <img class=\"sponsor-img\" src=\"{{ sponsor.url }}\" alt=\"{{ sponsor.login }}\" />\n              <span class=\"sponsor-img-overlay\" />\n            </span>\n          </a>\n\n          <a href=\"https://github.com/sponsors/majodev\" target=\"_blank\" role=\"button\" type=\"button\"\n            class=\"btn btn-default actNavButton\" data-toggle=\"tooltip\" data-placement=\"bottom\"\n            title=\"Join {{ sponsors.length }} lovely sponsors!\">\n            <span>\n              <i class=\"fa fa-heart-o sponsorheart\"></i>&nbsp;<strong>Sponsor</strong>\n            </span>\n          </a>\n          <a href=\"https://github.com/majodev/google-webfonts-helper\" target=\"_blank\" role=\"button\" type=\"button\"\n            class=\"btn btn-default actNavButton\">\n            <span>\n              <i class=\"fa fa-star-o\"></i>&nbsp;<strong>Star</strong>\n            </span>\n          </a>\n        </div>\n\n      </div>\n    </div>\n\n  </div>\n\n\n  <div class=\"box\">\n    <!-- wrapper for scrollbar divs -->\n    <div class=\"row-fluid\">\n      <div class=\"col-lg-2 col-md-3 col-sm-3 col-xs-4 scrollerLeft column\"\n        cg-busy=\"{promise: fontsPromise, message: 'Listing fonts...'}\">\n        <div class=\"list-group\">\n          <a href=\"/fonts/{{font.id}}?subsets={{font.defSubset}}\"\n            class=\"list-group-item {{selectedItemID === font.id ? 'active' : ''}}\"\n            ng-repeat=\"font in fonts | filter:query | orderBy:predicate.filter:reverse\">\n            <h5 class=\"list-group-item-heading\">{{font.family}} <small>{{predicate.pre}}{{predicate.bindArg ?\n                (predicate.len ? font[predicate.bindArg].length : font[predicate.bindArg]) :\n                font[predicate.filter]}}{{predicate.post}}</small></h5>\n          </a>\n        </div>\n      </div>\n      <div class=\"col-lg-10 col-md-9 col-sm-9 col-xs-8 scrollerRight column\">\n        <div ui-view>\n          <!-- fontItem details template gets appended here -->\n          <div>\n            <!-- Placeholder no item selected -->\n            <header class=\"jumbotron masthead\">\n              <div class=\"inner\">\n                <h1>google-webfonts-helper</h1>\n                <h2>A Hassle-Free Way to Self-Host Google Fonts</h2>\n                <h6>by <a href=\"http://mranftl.com\" target=\"_blank\">Mario Ranftl</a></h6>\n                <hr />\n\n                <h3><small><i class=\"fa fa-arrow-circle-o-left pulse\"></i>&nbsp;&nbsp;Select a font to\n                    continue...</small></h3>\n\n              </div>\n\n            </header>\n\n            <p class=\"download-info\">\n              <a href=\"https://github.com/majodev/google-webfonts-helper\" class=\"btn btn-default btn-large\"\n                target=\"_blank\">View project on GitHub</a>\n              <a href=\"http://mranftl.com/2014/12/23/self-hosting-google-web-fonts/\" class=\"btn btn-default btn-large\"\n                target=\"_blank\">Read the author's note</a>\n              <a href=\"https://github.com/sponsors/majodev\" class=\"btn btn-default btn-large\" target=\"_blank\">Sponsor\n                this project</a>\n            </p>\n\n            <h3>Useful resources</h3>\n            <ul class=\"list-unstyled\">\n              <li><a href=\"https://fonts.google.com/attribution\" class=\"btn btn-link btn-large\" target=\"_blank\">Google\n                  Fonts Open Source Font Attribution</a></li>\n              <li><a href=\"http://css-tricks.com/snippets/css/using-font-face/\" class=\"btn btn-link btn-large\"\n                  target=\"_blank\">Using @font-face</a></li>\n              <li><a href=\"http://caniuse.com/#feat=woff\" class=\"btn btn-link btn-large\" target=\"_blank\">Can I use woff?\n                  (compatibility information)</a></li>\n              <li><a href=\"https://gist.github.com/sergejmueller/cf6b4f2133bcb3e2f64a\" class=\"btn btn-link btn-large\"\n                  target=\"_blank\">WOFF 2.0 – Learn more about the next generation Web Font Format</a></li>\n              <li><a href=\"https://gist.github.com/lindsayevans/794800\" class=\"btn btn-link btn-large\"\n                  target=\"_blank\">Sample MIME server config (Apache, Nginx, IIS)</a></li>\n              <li><a href=\"https://github.com/gabiseabra/google-fonts-webpack-plugin\" class=\"btn btn-link btn-large\"\n                  target=\"_blank\">Google Fonts Webpack Plugin</a></li>\n              <li><a href=\"https://www.news47ell.com/how-to/host-google-fonts-locally-wordpress/\"\n                  class=\"btn btn-link btn-large\" target=\"_blank\">How to Host Google Fonts Locally in WordPress</a></li>\n            </ul>\n\n          </div>\n          <div class=\"apiError\" ng-class=\"{'show': error === true}\">\n            <h2>API Error ({{errorStatus}})</h2>\n            <pre>REQUEST CONFIG: {{errorConfig}}\nREQUEST HEADERS: {{errorHeaders}}</pre>\n          </div>\n\n        </div>\n      </div>\n    </div>\n  </div>\n\n</div>"
  },
  {
    "path": "client/app/fonts/fonts.js",
    "content": "'use strict';\n\nangular.module('googleWebfontsHelperApp')\n  .config(function ($stateProvider) {\n    $stateProvider\n      .state('fonts', {\n        url: '/fonts',\n        templateUrl: 'app/fonts/fonts.html',\n        controller: 'FontsCtrl'\n      })\n      .state('fonts.item', {\n        url: '/:id?subsets',\n        templateUrl: 'app/fonts/fontsItem.html',\n        controller: 'FontsItemCtrl'\n      });\n  });"
  },
  {
    "path": "client/app/fonts/fonts.less",
    "content": "// Variables\n// -----------------------------------------------------------------------------\n\n@header-height: 55px;\n\n\n// Fixes\n// -----------------------------------------------------------------------------\n\n/* ui bootstrap click to pointers */\n.nav,\n.pagination,\n.carousel,\n.panel-title a {\n  cursor: pointer;\n}\n\n\n\n// Page wide\n// -----------------------------------------------------------------------------\nhtml,\nbody {\n  height: 100%;\n}\n\n.fonts-top-container,\n.top-overlay,\n.box {\n  min-width: 900px;\n}\n\n.fonts-top-container,\n.row-fluid {\n  height: 100%;\n}\n\n.fonts-top-container:before,\n.fonts-top-container:after,\n.column:before,\n.column:after {\n  content: \"\";\n  display: table;\n}\n\n.fonts-top-container:after,\n.column:after {\n  clear: both;\n}\n\n\n// Header\n// -----------------------------------------------------------------------------\n\n.top-overlay {\n  height: @header-height;\n  padding: 10px 15px 25px 15px;\n  border-bottom: #eee solid 1px;\n  background: #fff;\n}\n\n.page-header {\n  margin: 0;\n  border-bottom: none;\n  float: left;\n}\n\n.nav-push-right {\n  float: right;\n}\n\n.actNavButton {\n  i {\n    font-size: 16px;\n  }\n}\n\n.actSponsorButton {\n  padding: 0;\n  margin-left: 4px;\n  position: relative;\n  overflow: hidden;\n  opacity: 0;\n  animation: show 600ms 100ms cubic-bezier(0.38, 0.97, 0.56, 0.76) forwards;\n}\n\n@keyframes show {\n  100% {\n    opacity: 1;\n    transform: none;\n  }\n}\n\n.actSponsorButtonUser {\n  border-radius: 50%;\n}\n\n.actSponsorButtonOrganization {\n  border-radius: 8px;\n}\n\n.sponsorheart {\n  color: #DB61A2;\n}\n\n.sponsor-img {\n  height: 34px;\n  width: 34px;\n}\n\n.sponsor-img-overlay {\n  height: 34px;\n  width: 34px;\n  position: absolute;\n  top: 0;\n  left: 0;\n  background-color: #DB61A2;\n  opacity: 0.05;\n}\n\n.sponsor-img-overlay:hover {\n  opacity: 0.4;\n}\n\n.ordering {\n  float: left;\n  margin-bottom: 5px;\n}\n\n\n// search\n\n#searchwrap {\n  width: 100%;\n}\n\n#searchinput {\n  width: 100%;\n  font-size: 11px;\n}\n\n#searchclear {\n  position: absolute;\n  right: 5px;\n  top: 0;\n  bottom: 0;\n  height: 14px;\n  margin: auto;\n  font-size: 14px;\n  cursor: pointer;\n  color: #ccc;\n  display: none;\n}\n\n#searchclear.show {\n  display: initial;\n  z-index: 1000;\n}\n\n#orderButton {\n  border-bottom-right-radius: 4px;\n  border-top-right-radius: 4px;\n}\n\n// Masthead first page\n// -----------------------------------------------------------------------------\n\n.masthead {\n  margin-left: -15px;\n  margin-right: -15px;\n  background-color: transparent;\n  background: linear-gradient(fade(#fff, 0%), fade(#fff, 100%));\n  position: relative;\n}\n\n.masthead:after {\n  content: '';\n  position: absolute;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  background: url('/assets/images/swirl.png') repeat;\n  opacity: 1;\n  z-index: -2;\n}\n\n.pulse {\n  -webkit-animation: pulse 1s infinite;\n  -moz-animation: pulse 1s infinite;\n  -o-animation: pulse 1s infinite;\n  animation: pulse 1s infinite;\n}\n\n@-webkit-keyframes pulse {\n  0% {\n    -webkit-transform: scale(1);\n  }\n\n  50% {\n    -webkit-transform: scale(1.3);\n  }\n\n  100% {\n    -webkit-transform: scale(1);\n  }\n}\n\n@-moz-keyframes pulse {\n  0% {\n    -moz-transform: scale(1);\n  }\n\n  50% {\n    -moz-transform: scale(1.3);\n  }\n\n  100% {\n    -moz-transform: scale(1);\n  }\n}\n\n@-o-keyframes pulse {\n  0% {\n    -o-transform: scale(1);\n  }\n\n  50% {\n    -o-transform: scale(1.3);\n  }\n\n  100% {\n    -o-transform: scale(1);\n  }\n}\n\n@keyframes pulse {\n  0% {\n    transform: scale(1);\n  }\n\n  50% {\n    transform: scale(1.3);\n  }\n\n  100% {\n    transform: scale(1);\n  }\n}\n\n\n// Scroll colums\n// -----------------------------------------------------------------------------\n\n.box {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  top: @header-height;\n}\n\n.column {\n  height: 100%;\n  overflow: auto;\n  *zoom: 1;\n}\n\n.scrollerLeft {\n  background: #eee;\n}\n\n.scrollerRight {}\n\n// Font content\n// -----------------------------------------------------------------------------\n\n// #previewFontSizeInput {\n//   width: 50px;\n// }\n\n.dl-horizontal.variantList {\n  margin-bottom: 0px;\n\n  dt {\n    width: auto;\n  }\n\n  dd {\n    margin-left: 120px;\n  }\n}\n\n.list-group {\n  padding-top: 10px;\n  padding-bottom: 10px;\n}\n\n.list-group-item.active h5 small {\n  color: #efefef;\n}\n\n.list-group-item-heading {\n  margin-top: 0px;\n  margin-bottom: 0px;\n}\n\n.download-button {\n  white-space: normal;\n  //   margin-top: 30px;\n}\n\n.head-right-block {\n  margin-top: 54px;\n}\n\n.folderPrefixBar {\n  margin-bottom: 12px;\n  margin-top: 12px;\n}\n\n.nav-tabs {\n  margin-bottom: 5px;\n}\n\n.fontItemCSSWrap {\n  margin-top: 20px;\n  padding-top: 20px;\n  border: 0;\n  border-top: 1px solid #eeeeee;\n}\n\n#fontPreviewToggle {\n  margin-top: 12px;\n}\n\n.cssCodeStyle {\n  background: #eee;\n  padding-top: 8px;\n  padding-bottom: 8px;\n}\n\nul.nav.nav-pills {\n  padding-bottom: 10px;\n  margin-bottom: 5px;\n}\n\n// Erros\n// -----------------------------------------------------------------------------\n\n.apiError {\n  display: none;\n}\n\n.apiError.show {\n  display: initial;\n}\n\n// General\n// -----------------------------------------------------------------------------\n\npre {\n  font-size: 80%;\n}\n\ncode {\n  font-size: 75%;\n}\n\ntextarea {\n  resize: none;\n}\n\n.mini {\n  font-size: 70%;\n}"
  },
  {
    "path": "client/app/fonts/fontsItem.html",
    "content": "<!-- Inject all styles to use them directly -->\n<div ng-repeat=\"variant in fontItem.variants\" css-code variant=\"variant\" font-item=\"fontItem\" folder-prefix=\"folderPrefix\"\n  type=\"{styleTag:true}\"></div>\n\n<div cg-busy=\"{promise: loadingPromise, message: loadingMessage}\">\n  <div ng-hide=\"busy\">\n    <div class=\"row\">\n      <div class=\"col-sm-12\">\n        <h1 style=\"font-family:{{fontItem.variants[0].fontFamily}},'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:400; font-style:{{fontItem.variants[0].fontStyle}};\">{{fontItem.family}}<br/><small>{{fontItem.category}}</small></h1>\n      </div>\n    </div>\n    <div class=\"row\">\n\n      <div class=\"col-sm-6\">\n        <h5>\n          <ng-pluralize count=\"fontItem.variants.length\" when=\"{'0': 'no style',\n                           'one': '{} style',\n                           'other': '{} styles'}\">\n          </ng-pluralize>&nbsp;<small><span ng-repeat=\"variant in fontItem.variants\">{{variant.id}}{{$last ? \"\" : \", \"}}</span></small></h5>\n        <h5>\n          <ng-pluralize count=\"fontItem.subsets.length\" when=\"{'0': 'no charsets',\n                           'one': '{} charset',\n                           'other': '{} charsets'}\">\n          </ng-pluralize>&nbsp;<small><span ng-repeat=\"subset in fontItem.subsets\">{{subset}}{{$last ? \"\" : \", \"}}</span></small></h5>\n      </div>\n\n      <div class=\"col-sm-6\">\n        <h5>Rank {{fontItem.popularity}}&nbsp;<small>in popularity of {{fonts.length}} fonts in total</small></h5>\n        <h5>Last modified {{fontItem.lastModified}}&nbsp;<small>({{fontItem.version}})</small></h5>\n      </div>\n\n    </div>\n\n    <!-- character set customization -->\n    <div>\n      <hr>\n      <h4>1. Select charsets:&nbsp;<small>(default is <code>{{fontItem.defSubset}}</code>)</small></h4>\n      <div class=\"subsetGroup\">\n        <label class=\"checkbox-inline\" ng-repeat=\"(key,value) in fontItem.subsetMap\">\n          <input type=\"checkbox\" ng-model=\"fontItem.subsetMap[key]\" name=\"key\" ng-click=\"subsetSelect(); requiresReload=true;\" data-ng-disabled=\"checkSubsetMinimalSelection(key)\">{{key}}\n        </label>\n      </div>\n    </div>\n\n  </div>\n\n\n  <div class=\"fontVariant\" ng-hide=\"busy\" cg-busy=\"{promise: subsetsChkbTimeoutP, message: customizationReloadMessage}\">\n    <!-- FONT VARIANT BEGINS -->\n\n    <!-- styles set customization -->\n    <div>\n      <hr>\n\n      <h4>2. Select styles:&nbsp;<small>(default is <code>{{fontItem.defVariant}}</code>)</small></h4>\n\n      <div id=\"fontStyleControl\" class=\"form-inline folderPrefixBar\">\n        <div class=\"row\">\n          <div class=\"col-xs-5\">\n            <div id=\"previewFontSizeInput\" class=\"input-group\">\n              <span class=\"input-group-addon\">Preview size</span>\n              <input class=\"form-control input-sm\" type=\"number\" ng-model=\"fontSize\" placeholder=\"16\" ng-init=\"fontSize=16\" aria-describedby=\"px-addon\">\n              <span class=\"input-group-addon\" id=\"px-addon\">px</span>\n            </div>\n          </div>\n          <div class=\"col-xs-5\">\n            <div class=\"btn-group\" role=\"group\">\n              <button class=\"btn btn-default btn-sm\" ng-click=\"fontSize = fontSize - 1\">-</button>\n              <button class=\"btn btn-default btn-sm\" ng-click=\"fontSize = fontSize + 1\">+</button>\n            </div>\n            <span class=\"text-muted\">&nbsp;Sample text editable.</span>\n          </div>\n        </div>\n      </div>\n\n      <div id=\"fontStylePreviewChooser\">\n        <div ng-repeat=\"variant in fontItem.variants\">\n          <dl class=\"dl-horizontal variantList\">\n            <dt>\n              <label class=\"checkbox-inline\">\n                <input type=\"checkbox\" ng-model=\"variantsMap[variant.id]\" name=\"variant.id\" data-ng-disabled=\"checkVariantMinimalSelection(variant.id)\" ng-click=\"variantSelect();\"><span class=\"\">{{variant.id}}</span>\n              </label>\n            </dt>\n            <dd>\n              <p style=\"font-family:{{variant.fontFamily}}; font-weight:{{variant.fontWeight}}; font-style:{{variant.fontStyle}}; font-size:{{fontSize > 0 ? fontSize : 16}}px;\"\n                contenteditable>The quick brown fox jumps over the lazy dog.</p>\n            </dd>\n          </dl>\n        </div>\n      </div>\n\n    </div>\n\n    <!-- css snippets -->\n    <div class=\"fontItemCSSWrap\">\n\n      <h4>3. Copy CSS:&nbsp;<small>(default is <code>Modern Browsers</code>)</small></h4>\n\n      <tabset type=\"pills\">\n        <tab heading=\"Modern Browsers\" select=\"modernSupportActive();\">\n          <p class=\"small\">Choose <code>Modern Browsers</code> if supporting old browsers is not relevant. Formats in this snippet: <code>[{{fontFormats}}]</code></p>\n\n          <div ng-click=\"selectText($event)\" class=\"cssCodeStyle\">\n            <div ng-repeat=\"variant in fontItem.variants | filter:variantFilter\" css-code variant=\"variant\" font-item=\"fontItem\" folder-prefix=\"folderPrefix\"\n              type=\"{modernSupport:true}\"></div>\n          </div>\n\n          <div class=\"form-inline folderPrefixBar small\">Customize folder prefix (optional):&nbsp;\n            <div class=\"form-group\">\n              <input class=\"form-control input-sm\" type=\"text\" ng-model=\"folderPrefix\" placeholder=\"no folder prefix\" ng-init=\"folderPrefix='../fonts/'\"\n                value=\"../fonts/\">\n            </div>\n          </div>\n          <p class=\"small\">Click on code to select all statements, then copy/paste it into your own CSS file.</p>\n        </tab>\n        \n        <tab heading=\"Legacy Support\" select=\"legacySupportActive();\">\n          <p class=\"small\">Choose <code>Legacy Support</code> if old browsers still need to be supported. Formats in this snippet: <code>[{{fontFormats}}]</code></p>\n          \n          <div ng-click=\"selectText($event)\" class=\"cssCodeStyle\">\n            <div ng-repeat=\"variant in fontItem.variants | filter:variantFilter\" css-code variant=\"variant\" font-item=\"fontItem\" folder-prefix=\"folderPrefix\"\n                 type=\"{legacySupport:true}\"></div>\n          </div>\n\n          <div class=\"form-inline folderPrefixBar small\">Customize folder prefix (optional):&nbsp;\n            <div class=\"form-group\">\n              <input class=\"form-control input-sm\" type=\"text\" ng-model=\"folderPrefix\" placeholder=\"no folder prefix\" ng-init=\"folderPrefix='../fonts/'\"\n                     value=\"../fonts/\">\n            </div>\n          </div>\n          <p class=\"small\">Click on code to select all statements, then copy/paste it into your own CSS file.</p>\n        </tab>\n        \n        <tab heading=\"Historic Support\" select=\"historicSupportActive();\">\n          <p class=\"small\">Choose <code>Historic Support</code> if very old browsers still need to be supported. Formats in this snippet: <code>[{{fontFormats}}]</code></p>\n          \n          <div ng-click=\"selectText($event)\" class=\"cssCodeStyle\">\n            <div ng-repeat=\"variant in fontItem.variants | filter:variantFilter\" css-code variant=\"variant\" font-item=\"fontItem\" folder-prefix=\"folderPrefix\"\n                 type=\"{historicSupport:true}\"></div>\n          </div>\n          \n          <div class=\"form-inline folderPrefixBar small\">Customize folder prefix (optional):&nbsp;\n            <div class=\"form-group\">\n              <input class=\"form-control input-sm\" type=\"text\" ng-model=\"folderPrefix\" placeholder=\"no folder prefix\" ng-init=\"folderPrefix='../fonts/'\"\n                     value=\"../fonts/\">\n            </div>\n          </div>\n          <p class=\"small\">Click on code to select all statements, then copy/paste it into your own CSS file.</p>\n        </tab>\n\n        <!-- <tab heading=\"Plain links\" class=\"pull-right\">\n          <p class=\"small\"><code>Plain links</code> provides a list of all font file urls from Google, ordered by their style. These are the same files that get packed into the archive in the next step (but they do not have meaningful filenames).</p>\n          <div ng-repeat=\"variant in fontItem.variants | filter:variantFilter\">\n            <h5>{{fontItem.id}}-{{variant.id}} <small>{{fontItem.storeID}}</small></h5>\n            <ul>\n              <li>eot: <a href=\"{{variant.eot}}\">{{variant.eot}}</a></li>\n              <li>woff2: <a href=\"{{variant.woff2}}\">{{variant.woff2}}</a></li>\n              <li>woff: <a href=\"{{variant.woff}}\">{{variant.woff}}</a></li>\n              <li>ttf: <a href=\"{{variant.ttf}}\">{{variant.ttf}}</a></li>\n              <li>svg: <a href=\"{{variant.svg}}\">{{variant.svg}}</a></li>\n            </ul>\n          </div>\n        </tab> -->\n\n      </tabset>\n    </div>\n\n\n    <!-- download button -->\n    <div>\n      <hr>\n      <h4>4. Download files:</h4>\n      <p>Your generated archive for <strong>{{fontItem.family}}</strong> with charsets <strong><code>[{{downloadSubSetID}}]</code></strong>        and styles <strong><code>[{{variantDownloadQueryString}}]</code></strong> includes the formats <strong><code>[{{fontFormats}}]</code></strong>.</p>\n\n      <a href=\"/api/fonts/{{fontItem.id}}?download=zip&amp;subsets={{downloadSubSetID}}&amp;variants={{variantDownloadQueryString}}&formats={{fontFormats}}\"\n        target=\"_blank\" role=\"button\" class=\"btn btn-primary download-button\"><i class=\"fa fa-download fa-lg\"></i><br/>{{fontItem.id}}-{{fontItem.version}}-{{fontItem.storeID}}.zip</a>\n      <p><small>Fonts are copyright of their respective authors.<br><b>See <a target=\"_blank\" href=\"https://fonts.google.com/attribution\">Google Fonts Open Source Font Attribution</a> to find out the specific license that this font uses.</b></small></p>\n\n      <!-- <div class=\"row\">\n        <div class=\"col-sm-10 col-sm-offset-1\">\n          \n        </div>\n      </div>\n      <div class=\"row\">\n        <div class=\"col-sm-10 col-sm-offset-1\">\n          \n        </div>\n      </div> -->\n\n\n\n    </div>\n\n    <!-- final instructions -->\n    <div>\n      <hr>\n      <h4>You are now ready to self-host {{fontItem.family}}.<br/>\n        <small>If this service has actually been helpful for you, please <a href=\"https://github.com/majodev/google-webfonts-helper/\" target=\"_blank\">star it on GitHub</a>. If you've encountered serious problems, file an issue <a href=\"https://github.com/majodev/google-webfonts-helper/issues\">here</a>.<br />\n        ❤️ <a href=\"https://github.com/sponsors/majodev\" target=\"_blank\">You can help to keep this project alive by sponsoring me.</a> Thank you ❤️.</small></h4>\n      <hr>\n    </div>\n\n\n  </div>\n  <!-- FONT VARIANT ENDS -->\n\n\n\n</div>\n\n<div class=\"apiError\" ng-class=\"{'show': error === true}\">\n  <h2>API Error ({{errorStatus}})</h2>\n  <pre>REQUEST CONFIG: {{errorConfig}}\nREQUEST HEADERS: {{errorHeaders}}</pre>\n</div>"
  },
  {
    "path": "client/app/highlightjs/highlightjs.directive.js",
    "content": "'use strict';\n\n// via http://stackoverflow.com/questions/25581560/dynamic-syntax-highlighting-with-angularjs-and-highlight-js\n\nangular.module('googleWebfontsHelperApp')\n  .directive('highlightjs', ['$interpolate', '$timeout', function($interpolate, $timeout) {\n    return {\n      restrict: 'EA',\n      scope: true, // must inherit parent scope all expressions are allowed inside content!\n      compile: function(tElem, tAttrs) {\n        var interpolateFn = $interpolate(tElem.html(), true);\n        tElem.html(''); // disable automatic intepolation bindings\n\n        return function(scope, elem, attrs) {\n          scope.$watch(interpolateFn, function(value) {\n            $timeout(function() {\n              var highlighter = elem.attr('data-hljs'); // use data-hljs to define the highligher to use\n\n              if (typeof highlighter !== 'undefined') {\n                elem.html(hljs.highlight(highlighter, value).value);\n              } else {\n                elem.html(hljs.highlightAuto(value).value);\n              }\n\n            }, 0);\n\n          });\n        }\n      },\n      link: function(scope, element) {}\n    };\n  }]);"
  },
  {
    "path": "client/app/highlightjs/highlightjs.directive.spec.js",
    "content": "'use strict';\n\ndescribe('Directive: highlightjs', function () {\n\n  // load the directive's module\n  beforeEach(module('googleWebfontsHelperApp'));\n\n  var element,\n    scope;\n\n  beforeEach(inject(function ($rootScope) {\n    scope = $rootScope.$new();\n  }));\n\n  it('should make hidden element visible', inject(function ($compile) {\n    element = angular.element('<highlightjs></highlightjs>');\n    element = $compile(element)(scope);\n    expect(element.text()).toBe('this is the highlightjs directive');\n  }));\n});"
  },
  {
    "path": "client/index.html",
    "content": "<!doctype html>\n<!--[if lt IE 7]>      <html class=\"no-js lt-ie9 lt-ie8 lt-ie7\"> <![endif]-->\n<!--[if IE 7]>         <html class=\"no-js lt-ie9 lt-ie8\"> <![endif]-->\n<!--[if IE 8]>         <html class=\"no-js lt-ie9\"> <![endif]-->\n<!--[if gt IE 8]><!--> <html class=\"no-js\"> <!--<![endif]-->\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <base href=\"/\">\n    <meta name=\"google-site-verification\" content=\"al_wEWN9QTMdH0LIzJpanPUiK1lrThiJVuQYDoyflJg\" />\n    <title>google webfonts helper</title>\n    <meta name=\"description\" content=\"A Hassle-Free Way to Self-Host Google Fonts. Get eot, ttf, svg, woff and woff2 files + CSS snippets!\">\n    <meta name=\"author\" content=\"Mario Ranftl\">\n    <meta name=\"keywords\" content=\"font, host, self-host, font-face, serve, zip, archive, css, woff, woff2, eot, ttf, svg, web fonts, google web fonts, google fonts, instructions, modern browsers, best support, font service\">\n    <meta name=\"robots\" content=\"all\">\n    <meta name=\"copyright\" content=\"Copyright 2016 Mario Ranftl (majodev | MIT License)\">\n    <meta name=\"viewport\" content=\"width=device-width\">\n    <!-- Place favicon.ico and apple-touch-icon.png in the root directory -->\n    <!-- build:css(client) app/vendor.css -->\n      <!-- bower:css -->\n      <link rel=\"stylesheet\" href=\"bower_components/angular-busy/dist/angular-busy.css\" />\n      <link rel=\"stylesheet\" href=\"bower_components/highlightjs/styles/default.css\" />\n      <!-- endbower -->\n    <!-- endbuild -->\n    <!-- build:css({.tmp,client}) app/app.css -->\n    <link rel=\"stylesheet\" href=\"app/app.css\">\n      <!-- injector:css -->\n      <!-- endinjector -->\n    <!-- endbuild -->\n  </head>\n  <body ng-app=\"googleWebfontsHelperApp\">\n    <!--[if lt IE 7]>\n      <p class=\"browsehappy\">You are using an <strong>outdated</strong> browser. Please <a href=\"http://browsehappy.com/\">upgrade your browser</a> to improve your experience.</p>\n    <![endif]-->\n\n    <!-- Add your site or application content here -->\n    <div ui-view=\"\"></div>\n    <!-- <a ui-sref=\"fonts\">FONTS state</a> -->\n\n    <!--[if lt IE 9]>\n    <script src=\"bower_components/es5-shim/es5-shim.js\"></script>\n    <script src=\"bower_components/json3/lib/json3.min.js\"></script>\n    <![endif]-->\n    <!-- build:js({client,node_modules}) app/vendor.js -->\n      <!-- bower:js -->\n      <script src=\"bower_components/jquery/dist/jquery.js\"></script>\n      <script src=\"bower_components/angular/angular.js\"></script>\n      <script src=\"bower_components/angular-animate/angular-animate.js\"></script>\n      <script src=\"bower_components/angular-bootstrap/ui-bootstrap-tpls.js\"></script>\n      <script src=\"bower_components/angular-busy/dist/angular-busy.js\"></script>\n      <script src=\"bower_components/angular-cookies/angular-cookies.js\"></script>\n      <script src=\"bower_components/angular-resource/angular-resource.js\"></script>\n      <script src=\"bower_components/angular-sanitize/angular-sanitize.js\"></script>\n      <script src=\"bower_components/angular-ui-router/release/angular-ui-router.js\"></script>\n      <script src=\"bower_components/bootstrap/dist/js/bootstrap.js\"></script>\n      <script src=\"bower_components/highlightjs/highlight.pack.js\"></script>\n      <script src=\"bower_components/lodash/dist/lodash.compat.js\"></script>\n      <!-- endbower -->\n    <!-- endbuild -->\n\n        <!-- build:js({.tmp,client}) app/app.js -->\n        <script src=\"app/app.js\"></script>\n          <!-- injector:js -->\n          <script src=\"app/cssCode/cssCode.directive.js\"></script>\n          <script src=\"app/fonts/fonts.controller.js\"></script>\n          <script src=\"app/fonts/fonts.js\"></script>\n          <script src=\"app/highlightjs/highlightjs.directive.js\"></script>\n          <!-- endinjector -->\n        <!-- endbuild -->\n</body>\n</html>\n"
  },
  {
    "path": "client/robots.txt",
    "content": "# robotstxt.org\n\nUser-agent: *\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  service:\n    build:\n      context: .\n      target: development\n    ports:\n      - \"9000:9000\" # development\n      - \"8080:8080\" # production\n      - \"35729:35729\" # livereload\n      - \"5858:5858\" # debugger\n      - \"9229:9229\" # profiler\n    working_dir: &PROJECT_ROOT_DIR /app\n    # linux permissions: we must explicitly run as the node user\n    user: node\n    volumes:\n      # mount working directory\n      # https://docs.docker.com/docker-for-mac/osxfs-caching/#delegated\n      # the container’s view is authoritative (permit delays before updates on the container appear in the host)\n      - .:/app:delegated\n\n    environment:\n      # Set your key in the .gitignored .env file.\n      GOOGLE_FONTS_API_KEY: ${GOOGLE_FONTS_API_KEY}\n\n    # Overrides default command so things don't shut down after the process ends.\n    command:\n      - /bin/sh\n      - -c\n      - |\n        git config --global --add safe.directory /app\n        while sleep 1000; do :; done\n"
  },
  {
    "path": "docker-helper.sh",
    "content": "#!/bin/bash\n\nif [ \"$1\" = \"--up\" ]; then\n    docker compose up --no-start\n    docker compose start # ensure we are started, handle also allowed to be consumed by vscode\n    docker compose exec service bash\nfi\n\nif [ \"$1\" = \"--halt\" ]; then\n    docker compose stop\nfi\n\nif [ \"$1\" = \"--rebuild\" ]; then\n    docker compose up -d --force-recreate --no-deps --build service\nfi\n\nif [ \"$1\" = \"--destroy\" ]; then\n    docker compose down --rmi local -v --remove-orphans\nfi\n\n[ -n \"$1\" -a \\( \"$1\" = \"--up\" -o \"$1\" = \"--halt\" -o \"$1\" = \"--rebuild\" -o \"$1\" = \"--destroy\" \\) ] \\\n    || { echo \"usage: $0 --up | --halt | --rebuild | --destroy\" >&2; exit 1; }"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"google-webfonts-helper\",\n  \"version\": \"1.1.0\",\n  \"homepage\": \"https://gwfh.mranftl.com\",\n  \"main\": \"server/app.ts\",\n  \"author\": \"majodev\",\n  \"license\": \"MIT\",\n  \"keywords\": [\n    \"google fonts\",\n    \"web fonts\",\n    \"download\",\n    \"woff\",\n    \"svg\",\n    \"ttf\",\n    \"woff2\",\n    \"eot\",\n    \"css\",\n    \"snippet\",\n    \"hosting\"\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/majodev/google-webfonts-helper.git\"\n  },\n  \"dependencies\": {\n    \"axios\": \"1.12.2\",\n    \"bluebird\": \"3.7.2\",\n    \"compression\": \"1.8.1\",\n    \"css\": \"3.0.0\",\n    \"express\": \"4.21.2\",\n    \"jszip\": \"3.10.1\",\n    \"lodash\": \"4.17.21\",\n    \"morgan\": \"1.10.1\",\n    \"source-map-support\": \"0.5.21\",\n    \"speakingurl\": \"14.0.1\"\n  },\n  \"devDependencies\": {\n    \"@types/async\": \"^3.2.16\",\n    \"@types/bluebird\": \"^3.5.38\",\n    \"@types/css\": \"^0.0.33\",\n    \"@types/express\": \"^4.17.17\",\n    \"@types/lodash\": \"^4.14.191\",\n    \"@types/mocha\": \"^10.0.1\",\n    \"@types/node\": \"18\",\n    \"@types/speakingurl\": \"^13.0.3\",\n    \"@types/supertest\": \"^2.0.12\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.52.0\",\n    \"@typescript-eslint/parser\": \"^5.52.0\",\n    \"bower\": \"^1.3.8\",\n    \"connect-livereload\": \"~0.4.0\",\n    \"errorhandler\": \"~1.0.0\",\n    \"eslint\": \"^8.34.0\",\n    \"file-type\": \"16.5.4\",\n    \"grunt\": \"~0.4.4\",\n    \"grunt-angular-templates\": \"^0.5.4\",\n    \"grunt-asset-injector\": \"^0.1.0\",\n    \"grunt-autoprefixer\": \"~0.7.2\",\n    \"grunt-concurrent\": \"~0.5.0\",\n    \"grunt-contrib-clean\": \"~0.5.0\",\n    \"grunt-contrib-concat\": \"~0.4.0\",\n    \"grunt-contrib-copy\": \"~0.5.0\",\n    \"grunt-contrib-cssmin\": \"~0.9.0\",\n    \"grunt-contrib-htmlmin\": \"~0.2.0\",\n    \"grunt-contrib-imagemin\": \"4.0.0\",\n    \"grunt-contrib-less\": \"^0.11.0\",\n    \"grunt-contrib-uglify\": \"~0.4.0\",\n    \"grunt-contrib-watch\": \"~0.6.1\",\n    \"grunt-dom-munger\": \"^3.4.0\",\n    \"grunt-env\": \"~0.4.1\",\n    \"grunt-express-server\": \"~0.4.17\",\n    \"grunt-mocha-test\": \"0.13.3\",\n    \"grunt-newer\": \"~0.7.0\",\n    \"grunt-ng-annotate\": \"^0.2.3\",\n    \"grunt-nodemon\": \"0.4.2\",\n    \"grunt-rev\": \"~0.1.0\",\n    \"grunt-svgmin\": \"~0.4.0\",\n    \"grunt-ts\": \"^6.0.0-beta.22\",\n    \"grunt-usemin\": \"~2.1.1\",\n    \"grunt-wiredep\": \"~1.8.0\",\n    \"jit-grunt\": \"^0.5.0\",\n    \"mocha\": \"^10.2.0\",\n    \"prettier\": \"^2.8.4\",\n    \"prettier-eslint\": \"^15.0.1\",\n    \"prettier-plugin-organize-imports\": \"^3.2.2\",\n    \"punycode\": \"^1.4.1\",\n    \"should\": \"13.2.3\",\n    \"supertest\": \"6.3.3\",\n    \"time-grunt\": \"~0.3.1\",\n    \"ts-node\": \"^10.9.1\",\n    \"typescript\": \"^4.9.5\"\n  },\n  \"scripts\": {\n    \"start\": \"ts-node server/app.ts\",\n    \"lint\": \"eslint --ext .ts .\",\n    \"build\": \"grunt build\",\n    \"test\": \"grunt test\",\n    \"dev\": \"grunt serve\",\n    \"tsc\": \"tsc --noEmit --skipLibCheck\"\n  },\n  \"private\": true,\n  \"resolutions\": {\n    \"imagemin-optipng\": \"git+https://github.com/PruvoNet/imagemin-optipng.git#68dc79939c380fb12a83f7ec7cc5943f9aa41149\",\n    \"optipng-bin\": \"git+https://github.com/PruvoNet/optipng-bin.git#ffb7e8f17710428596def1bb832d8e8e3fe382af\"\n  }\n}\n"
  },
  {
    "path": "server/api/fonts.controller.ts",
    "content": "import { NextFunction, Request, Response } from \"express\";\nimport * as fs from \"fs\";\nimport * as JSZip from \"jszip\";\nimport * as _ from \"lodash\";\nimport * as path from \"path\";\nimport { IUserAgents } from \"../config\";\nimport { loadFontBundle, loadFontItems, loadFontSubsetArchive, loadSubsetMap, loadVariantItems } from \"../logic/core\";\nimport { IFontSubsetArchive } from \"../logic/fetchFontSubsetArchive\";\n\n// Get list of fonts\n// /api/fonts\ninterface IAPIListFont {\n  id: string;\n  family: string;\n  variants: string[];\n  subsets: string[];\n  category: string;\n  version: string;\n  lastModified: string; // e.g. 2022-09-22\n  popularity: number;\n  defSubset: string;\n  defVariant: string;\n}\nexport async function getApiFonts(req: Request, res: Response<IAPIListFont[]>, next: NextFunction) {\n  try {\n    const fonts = loadFontItems();\n\n    const apiListFonts: IAPIListFont[] = _.map(fonts, (font) => {\n      return {\n        id: font.id,\n        family: font.family,\n        variants: font.variants,\n        subsets: font.subsets,\n        category: font.category,\n        version: font.version,\n        lastModified: font.lastModified,\n        popularity: font.popularity,\n        defSubset: font.defSubset,\n        defVariant: font.defVariant,\n      };\n    });\n\n    return res.json(apiListFonts);\n  } catch (e) {\n    next(e);\n  }\n}\n\n// Get specific fonts (fixed charsets) including links\n// /api/fonts/:id\ninterface IAPIFont {\n  id: string;\n  family: string;\n  subsets: string[];\n  category: string;\n  version: string;\n  lastModified: string; // e.g. 2022-09-22\n  popularity: number;\n  defSubset: string;\n  defVariant: string;\n  subsetMap: {\n    [subset: string]: boolean;\n  };\n  storeID: string;\n  variants: {\n    id: string;\n    fontFamily: string | null;\n    fontStyle: string | null;\n    fontWeight: string | null;\n    eot?: string;\n    woff?: string;\n    woff2?: string;\n    svg?: string;\n    ttf?: string;\n  }[];\n}\nexport async function getApiFontsById(req: Request, res: Response<IAPIFont | string | NodeJS.WritableStream>, next: NextFunction) {\n  try {\n    // get the subset string if it was supplied...\n    // e.g. \"subset=latin,latin-ext,\" will be transformed into [\"latin\",\"latin-ext\"] (non whitespace arrays)\n    const subsets = _.isString(req.query.subsets) ? _.without(req.query.subsets.split(/[,]+/), \"\") : null;\n\n    const fontBundle = await loadFontBundle(req.params.id, subsets);\n\n    if (_.isNil(fontBundle)) {\n      return res.status(404).send(\"Not found\");\n    }\n\n    const subsetMap = loadSubsetMap(fontBundle);\n    const variantItems = await loadVariantItems(fontBundle);\n\n    if (_.isNil(variantItems)) {\n      return res.status(404).send(\"Not found\");\n    }\n\n    // default case: json serialize...\n    if (req.query.download !== \"zip\") {\n      const { font } = fontBundle;\n\n      const apiFont: IAPIFont = {\n        id: font.id,\n        family: font.family,\n        subsets: font.subsets,\n        category: font.category,\n        version: font.version,\n        lastModified: font.lastModified,\n        popularity: font.popularity,\n        defSubset: font.defSubset,\n        defVariant: font.defVariant,\n        subsetMap: subsetMap,\n        // be compatible with legacy storeIDs, without binding on our new convention.\n        storeID: fontBundle.subsets.join(\"_\"),\n        variants: _.map(variantItems, (variant) => {\n          return {\n            id: variant.id,\n            fontFamily: variant.fontFamily,\n            fontStyle: variant.fontStyle,\n            fontWeight: variant.fontWeight,\n            ..._.reduce(\n              variant.urls,\n              (sum, vurl) => {\n                sum[vurl.format] = vurl.url;\n                return sum;\n              },\n              {} as IUserAgents\n            ),\n          };\n        }),\n      };\n\n      return res.json(apiFont);\n    }\n\n    // otherwise: download as zip\n    const variants = _.isString(req.query.variants) ? _.without(req.query.variants.split(/[,]+/), \"\") : null;\n    const formats = _.isString(req.query.formats) ? _.without(req.query.formats.split(/[,]+/), \"\") : null;\n\n    let subsetFontArchive: IFontSubsetArchive;\n\n    try {\n      subsetFontArchive = await loadFontSubsetArchive(fontBundle, variantItems);\n    } catch (e) {\n      console.error(\"getApiFontsById.loadFontSubsetArchive received error -> 404\", e);\n      return res.status(404).send(\"Not found\");\n    }\n\n    const filteredFiles = _.filter(subsetFontArchive.files, (file) => {\n      return (_.isNil(variants) || _.includes(variants, file.variant)) && (_.isNil(formats) || _.includes(formats, file.format));\n    });\n\n    if (filteredFiles.length === 0) {\n      return res.status(404).send(\"Not found\");\n    }\n\n    // we build a new .zip from the existing cached .zip, filtered by the requested variants and formats.\n    const archive = await loadZipArchive(subsetFontArchive.zipPath);\n\n    // remove all files that are not in the filtered list.\n    _.each(subsetFontArchive.files, function (file) {\n      if (!_.includes(filteredFiles, file)) {\n        archive.remove(file.path);\n      }\n    });\n\n    // tell the browser that this is a zip file.\n    res.writeHead(200, {\n      \"Content-Type\": \"application/zip\",\n      \"Content-disposition\": `attachment; filename=${path.basename(subsetFontArchive.zipPath)}`,\n    });\n\n    return archive\n      .generateNodeStream({\n        // streamFiles: true,\n        compression: \"DEFLATE\",\n      })\n      .pipe(res);\n  } catch (e) {\n    next(e);\n  }\n}\n\n// exported for testing\nfunction loadZipArchive(zipPath: string): PromiseLike<JSZip> {\n  return new JSZip.external.Promise(function (resolve, reject) {\n    fs.readFile(zipPath, function (err, data) {\n      if (err) {\n        reject(err);\n      } else {\n        resolve(data);\n      }\n    });\n  }).then(function (data: unknown) {\n    return JSZip.loadAsync(<Buffer>data);\n  });\n}\n"
  },
  {
    "path": "server/api/fonts.spec.ts",
    "content": "import { fromBuffer as fileTypeFromBuffer } from \"file-type\";\nimport * as JSZip from \"jszip\";\nimport * as _ from \"lodash\";\nimport * as should from \"should\";\nimport * as request from \"supertest\";\nimport { app } from \"../app\";\nimport { getStats, reinitStore } from \"../logic/store\";\n\ndescribe(\"GET /api/fonts\", () => {\n  afterEach(() => {\n    return reinitStore();\n  });\n\n  it(\"should respond with JSON array with all fonts\", async () => {\n    const res = await request(app).get(\"/api/fonts\").timeout(10000).expect(200).expect(\"Content-Type\", /json/);\n    should(res.body).be.instanceof(Array);\n  });\n});\n\ndescribe(\"GET /api/fonts/:id\", () => {\n  afterEach(() => {\n    return reinitStore();\n  });\n\n  it(\"should respond with font files for arvo\", async function () {\n    const res = await request(app).get(\"/api/fonts/arvo\").timeout(10000).expect(200).expect(\"Content-Type\", /json/);\n    should(res.body).be.instanceof(Object);\n\n    should(res.body).have.property(\"id\", \"arvo\");\n    should(res.body).have.property(\"family\", \"Arvo\");\n    should(res.body).have.property(\"subsets\", [\"latin\"]);\n    should(res.body).have.property(\"category\", \"serif\");\n    should(res.body).have.property(\"version\", \"v20\");\n    should(res.body).have.property(\"lastModified\", \"2022-09-22\");\n    should(res.body).have.property(\"popularity\", 1);\n    should(res.body).have.property(\"defSubset\", \"latin\");\n    should(res.body).have.property(\"defVariant\", \"regular\");\n    should(res.body).have.property(\"subsetMap\", { latin: true });\n    should(res.body).have.property(\"storeID\", \"latin\");\n\n    should(res.body.variants).be.instanceof(Array);\n    should(res.body.variants).be.lengthOf(4);\n\n    if (res.body.variants.length === 4) {\n      should(res.body.variants[0]).have.property(\"id\", \"regular\");\n      should(res.body.variants[0]).have.property(\"fontFamily\", \"'Arvo'\");\n      should(res.body.variants[0]).have.property(\"fontStyle\", \"normal\");\n      should(res.body.variants[0]).have.property(\"fontWeight\", \"400\");\n\n      should(res.body.variants[1]).have.property(\"id\", \"italic\");\n      should(res.body.variants[1]).have.property(\"fontFamily\", \"'Arvo'\");\n      should(res.body.variants[1]).have.property(\"fontStyle\", \"italic\");\n      should(res.body.variants[1]).have.property(\"fontWeight\", \"400\");\n\n      should(res.body.variants[2]).have.property(\"id\", \"700\");\n      should(res.body.variants[2]).have.property(\"fontFamily\", \"'Arvo'\");\n      should(res.body.variants[2]).have.property(\"fontStyle\", \"normal\");\n      should(res.body.variants[2]).have.property(\"fontWeight\", \"700\");\n\n      should(res.body.variants[3]).have.property(\"id\", \"700italic\");\n      should(res.body.variants[3]).have.property(\"fontFamily\", \"'Arvo'\");\n      should(res.body.variants[3]).have.property(\"fontStyle\", \"italic\");\n      should(res.body.variants[3]).have.property(\"fontWeight\", \"700\");\n\n      _.each(res.body.variants, (variant) => {\n        should(variant).have.property(\"woff\").String();\n        should(variant).have.property(\"woff2\").String();\n        should(variant).have.property(\"svg\").String();\n        should(variant).have.property(\"eot\").String();\n        should(variant).have.property(\"ttf\").String();\n\n        should(_.get(variant, \"woff\", {}).length).greaterThan(1);\n        should(_.get(variant, \"woff2\", {}).length).greaterThan(1);\n        should(_.get(variant, \"svg\", {}).length).greaterThan(1);\n        should(_.get(variant, \"eot\", {}).length).greaterThan(1);\n        should(_.get(variant, \"ttf\", {}).length).greaterThan(1);\n      });\n    }\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(0);\n  }).timeout(10000);\n\n  it(\"should respond with font files for istok-web multi charsets filtered\", async () => {\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?subsets=cyrillic,cyrillic-ext,latin\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", /json/);\n    should(res.body).be.instanceof(Object);\n\n    should(res.body).have.property(\"id\", \"istok-web\");\n    should(res.body).have.property(\"family\", \"Istok Web\");\n    should(res.body).have.property(\"subsets\", [\"cyrillic\", \"cyrillic-ext\", \"latin\", \"latin-ext\"]);\n    should(res.body).have.property(\"category\", \"sans-serif\");\n    should(res.body).have.property(\"version\", \"v20\");\n    should(res.body).have.property(\"lastModified\", \"2022-09-22\");\n    should(res.body).have.property(\"popularity\", 2);\n    should(res.body).have.property(\"defSubset\", \"latin\");\n    should(res.body).have.property(\"defVariant\", \"regular\");\n    should(res.body).have.property(\"subsetMap\", {\n      cyrillic: true,\n      \"cyrillic-ext\": true,\n      latin: true,\n      \"latin-ext\": false,\n    });\n    should(res.body).have.property(\"storeID\", \"cyrillic_cyrillic-ext_latin\");\n\n    should(res.body.variants).be.instanceof(Array);\n    should(res.body.variants).be.lengthOf(4);\n\n    if (res.body.variants.length === 4) {\n      should(res.body.variants[0]).have.property(\"id\", \"regular\");\n      should(res.body.variants[0]).have.property(\"fontFamily\", \"'Istok Web'\");\n      should(res.body.variants[0]).have.property(\"fontStyle\", \"normal\");\n      should(res.body.variants[0]).have.property(\"fontWeight\", \"400\");\n\n      should(res.body.variants[1]).have.property(\"id\", \"italic\");\n      should(res.body.variants[1]).have.property(\"fontFamily\", \"'Istok Web'\");\n      should(res.body.variants[1]).have.property(\"fontStyle\", \"italic\");\n      should(res.body.variants[1]).have.property(\"fontWeight\", \"400\");\n\n      should(res.body.variants[2]).have.property(\"id\", \"700\");\n      should(res.body.variants[2]).have.property(\"fontFamily\", \"'Istok Web'\");\n      should(res.body.variants[2]).have.property(\"fontStyle\", \"normal\");\n      should(res.body.variants[2]).have.property(\"fontWeight\", \"700\");\n\n      should(res.body.variants[3]).have.property(\"id\", \"700italic\");\n      should(res.body.variants[3]).have.property(\"fontFamily\", \"'Istok Web'\");\n      should(res.body.variants[3]).have.property(\"fontStyle\", \"italic\");\n      should(res.body.variants[3]).have.property(\"fontWeight\", \"700\");\n\n      _.each(res.body.variants, (variant) => {\n        should(variant).have.property(\"woff\").String();\n        should(variant).have.property(\"woff2\").String();\n        should(variant).have.property(\"svg\").String();\n        should(variant).have.property(\"eot\").String();\n        should(variant).have.property(\"ttf\").String();\n\n        should(_.get(variant, \"woff\", {}).length).greaterThan(1);\n        should(_.get(variant, \"woff2\", {}).length).greaterThan(1);\n        should(_.get(variant, \"svg\", {}).length).greaterThan(1);\n        should(_.get(variant, \"eot\", {}).length).greaterThan(1);\n        should(_.get(variant, \"ttf\", {}).length).greaterThan(1);\n      });\n    }\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(0);\n  }).timeout(10000);\n\n  it(\"should respond with 200 for known font istok-web empty subsets\", async function () {\n    this.timeout(10000);\n    const res = await request(app).get(\"/api/fonts/istok-web?subsets=\").timeout(10000).expect(200).expect(\"Content-Type\", /json/);\n    should(res.body).be.instanceof(Object);\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(0);\n  }).timeout(10000);\n\n  it(\"should respond with 404 for unknown font\", async () => {\n    await request(app)\n      .get(\"/api/fonts/unknown-font\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n\n    should(getStats().urlMap).eql(0);\n    should(getStats().archiveMap).eql(0);\n  }).timeout(10000);\n\n  it(\"should respond with 404 for unknown font and subset\", async () => {\n    await request(app)\n      .get(\"/api/fonts/unknown-font?subsets=latin\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n\n    should(getStats().urlMap).eql(0);\n    should(getStats().archiveMap).eql(0);\n  }).timeout(10000);\n\n  it(\"should respond with 404 for known font istok-web and unknown subset\", async () => {\n    await request(app)\n      .get(\"/api/fonts/istok-web?subsets=unknownsubset\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  }).timeout(10000);\n});\n\ndescribe(\"GET /api/fonts/:id?download=zip\", () => {\n  afterEach(() => {\n    return reinitStore();\n  });\n\n  it(\"should (concurrently) download istok-web\", async function () {\n    this.timeout(10000);\n    let triggered = 0;\n\n    await Promise.all([\n      request(app)\n        .get(\"/api/fonts/istok-web?download=zip&subsets=latin&formats=woff,woff2\")\n        .timeout(10000)\n        .expect(200)\n        .expect(\"Content-Type\", \"application/zip\")\n        .then(() => {\n          triggered += 1;\n        }),\n      request(app)\n        .get(\"/api/fonts/istok-web?download=zip&subsets=latin&formats=woff,woff2\")\n        .timeout(10000)\n        .expect(200)\n        .expect(\"Content-Type\", \"application/zip\")\n        .then(() => {\n          triggered += 1;\n        }),\n    ]);\n    should(triggered).eql(2);\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n  }).timeout(10000);\n\n  it(\"should (concurrently) download istok-web (subsets and formats mix)\", async function () {\n    this.timeout(10000);\n\n    let triggered = 0;\n\n    const [res1, res2] = await Promise.all([\n      request(app)\n        .get(\"/api/fonts/istok-web?download=zip&subsets=cyrillic-ext,latin,latin-ext&formats=woff,woff2\")\n        .responseType(\"blob\")\n        .timeout(10000)\n        .expect(200)\n        .expect(\"Content-Type\", \"application/zip\")\n        .then((res) => {\n          triggered += 1;\n          return res;\n        }),\n      request(app)\n        .get(\"/api/fonts/istok-web?download=zip&subsets=latin-ext,latin,cyrillic-ext&formats=woff,woff2,eot,ttf,svg\")\n        .responseType(\"blob\")\n        .timeout(10000)\n        .expect(200)\n        .expect(\"Content-Type\", \"application/zip\")\n        .then((res) => {\n          triggered += 1;\n          return res;\n        }),\n    ]);\n    should(triggered).eql(2);\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive1 = await JSZip.loadAsync(<Buffer>res1.body);\n\n    // 8 files in archive1\n    should(_.keys(archive1.files).length).eql(8);\n\n    const archive2 = await JSZip.loadAsync(<Buffer>res2.body);\n\n    // 60 files in archive2\n    should(_.keys(archive2.files).length).eql(20);\n  }).timeout(10000);\n\n  it(\"should (concurrently) download playfair-display (different but unknown subsets resolve to the same key)\", async function () {\n    let triggered = 0;\n\n    this.timeout(30000);\n\n    const [res1, res2] = await Promise.all([\n      request(app)\n        .get(\n          \"/api/fonts/playfair-display?download=zip&subsets=devanagari,vietnamese,cyrillic-ext,latin,greek-ext,greek,cyrillic,latin-ext,hebrew,korean,oriya\"\n        )\n        .responseType(\"blob\")\n        .timeout(30000)\n        .expect(200)\n        .expect(\"Content-Type\", \"application/zip\")\n        .then((res) => {\n          triggered += 1;\n          return res;\n        }),\n      request(app)\n        .get(\"/api/fonts/playfair-display?download=zip&subsets=cyrillic,latin,latin-ext,vietnamese\")\n        .responseType(\"blob\")\n        .timeout(30000)\n        .expect(200)\n        .expect(\"Content-Type\", \"application/zip\")\n        .then((res) => {\n          triggered += 1;\n          return res;\n        }),\n    ]);\n    should(triggered).eql(2);\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive1 = await JSZip.loadAsync(<Buffer>res1.body);\n\n    // 60 files in archive1\n    should(_.keys(archive1.files).length).eql(60);\n\n    const archive2 = await JSZip.loadAsync(<Buffer>res2.body);\n\n    // 60 files in archive2\n    should(_.keys(archive2.files).length).eql(60);\n  }).timeout(10000);\n\n  it(\"should respond with 200 for download attempt of known font istok-web with unspecified subset\", async function () {\n    this.timeout(10000);\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=woff,woff2\")\n      .responseType(\"blob\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", \"application/zip\");\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive = await JSZip.loadAsync(<Buffer>res.body);\n\n    // 4 default variants, 2 formats -> 8 files in archive\n    should(_.keys(archive.files).length).eql(8);\n\n    const files = _.map(_.sortBy(_.keys(archive.files)), (key) => {\n      const file = archive.files[key];\n      return file;\n    });\n\n    should(files[0].name).eql(\"istok-web-v20-latin-700.woff\");\n    should((await fileTypeFromBuffer(await files[0].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[1].name).eql(\"istok-web-v20-latin-700.woff2\");\n    should((await fileTypeFromBuffer(await files[1].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n    should(files[2].name).eql(\"istok-web-v20-latin-700italic.woff\");\n    should((await fileTypeFromBuffer(await files[2].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[3].name).eql(\"istok-web-v20-latin-700italic.woff2\");\n    should((await fileTypeFromBuffer(await files[3].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n    should(files[4].name).eql(\"istok-web-v20-latin-italic.woff\");\n    should((await fileTypeFromBuffer(await files[4].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[5].name).eql(\"istok-web-v20-latin-italic.woff2\");\n    should((await fileTypeFromBuffer(await files[5].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n    should(files[6].name).eql(\"istok-web-v20-latin-regular.woff\");\n    should((await fileTypeFromBuffer(await files[6].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[7].name).eql(\"istok-web-v20-latin-regular.woff2\");\n    should((await fileTypeFromBuffer(await files[7].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n  }).timeout(10000);\n\n  it(\"should respond with 200 for download attempt of known font istok-web with unspecified formats\", async () => {\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&subsets=latin\")\n      .responseType(\"blob\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", \"application/zip\");\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive = await JSZip.loadAsync(<Buffer>res.body);\n\n    // 4 default variants, 5 formats -> 20 files in archive\n    should(_.keys(archive.files).length).eql(20);\n\n    const files = _.map(_.sortBy(_.keys(archive.files)), (key) => {\n      const file = archive.files[key];\n      return file;\n    });\n\n    // _.each(files, (file) => console.log(file.name));\n\n    should(files[0].name).eql(\"istok-web-v20-latin-700.eot\");\n    should((await fileTypeFromBuffer(await files[0].async(\"nodebuffer\")))?.mime).eql(\"application/vnd.ms-fontobject\");\n    should(files[1].name).eql(\"istok-web-v20-latin-700.svg\");\n    should((await fileTypeFromBuffer(await files[1].async(\"nodebuffer\")))?.mime).eql(\"application/xml\");\n    should(files[2].name).eql(\"istok-web-v20-latin-700.ttf\");\n    should((await fileTypeFromBuffer(await files[2].async(\"nodebuffer\")))?.mime).eql(\"font/ttf\");\n    should(files[3].name).eql(\"istok-web-v20-latin-700.woff\");\n    should((await fileTypeFromBuffer(await files[3].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[4].name).eql(\"istok-web-v20-latin-700.woff2\");\n    should((await fileTypeFromBuffer(await files[4].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n    should(files[5].name).eql(\"istok-web-v20-latin-700italic.eot\");\n    should((await fileTypeFromBuffer(await files[5].async(\"nodebuffer\")))?.mime).eql(\"application/vnd.ms-fontobject\");\n    should(files[6].name).eql(\"istok-web-v20-latin-700italic.svg\");\n    should((await fileTypeFromBuffer(await files[6].async(\"nodebuffer\")))?.mime).eql(\"application/xml\");\n    should(files[7].name).eql(\"istok-web-v20-latin-700italic.ttf\");\n    should((await fileTypeFromBuffer(await files[7].async(\"nodebuffer\")))?.mime).eql(\"font/ttf\");\n    should(files[8].name).eql(\"istok-web-v20-latin-700italic.woff\");\n    should((await fileTypeFromBuffer(await files[8].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[9].name).eql(\"istok-web-v20-latin-700italic.woff2\");\n    should((await fileTypeFromBuffer(await files[9].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n    should(files[10].name).eql(\"istok-web-v20-latin-italic.eot\");\n    should((await fileTypeFromBuffer(await files[10].async(\"nodebuffer\")))?.mime).eql(\"application/vnd.ms-fontobject\");\n    should(files[11].name).eql(\"istok-web-v20-latin-italic.svg\");\n    should((await fileTypeFromBuffer(await files[11].async(\"nodebuffer\")))?.mime).eql(\"application/xml\");\n    should(files[12].name).eql(\"istok-web-v20-latin-italic.ttf\");\n    should((await fileTypeFromBuffer(await files[12].async(\"nodebuffer\")))?.mime).eql(\"font/ttf\");\n    should(files[13].name).eql(\"istok-web-v20-latin-italic.woff\");\n    should((await fileTypeFromBuffer(await files[13].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[14].name).eql(\"istok-web-v20-latin-italic.woff2\");\n    should((await fileTypeFromBuffer(await files[14].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n    should(files[15].name).eql(\"istok-web-v20-latin-regular.eot\");\n    should((await fileTypeFromBuffer(await files[15].async(\"nodebuffer\")))?.mime).eql(\"application/vnd.ms-fontobject\");\n    should(files[16].name).eql(\"istok-web-v20-latin-regular.svg\");\n    should((await fileTypeFromBuffer(await files[16].async(\"nodebuffer\")))?.mime).eql(\"application/xml\");\n    should(files[17].name).eql(\"istok-web-v20-latin-regular.ttf\");\n    should((await fileTypeFromBuffer(await files[17].async(\"nodebuffer\")))?.mime).eql(\"font/ttf\");\n    should(files[18].name).eql(\"istok-web-v20-latin-regular.woff\");\n    should((await fileTypeFromBuffer(await files[18].async(\"nodebuffer\")))?.mime).eql(\"font/woff\");\n    should(files[19].name).eql(\"istok-web-v20-latin-regular.woff2\");\n    should((await fileTypeFromBuffer(await files[19].async(\"nodebuffer\")))?.mime).eql(\"font/woff2\");\n  }).timeout(10000);\n\n  it(\"should respond with 200 for download attempt of known font istok-web and empty subsets\", async () => {\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&subsets=\")\n      .responseType(\"blob\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", \"application/zip\");\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive = await JSZip.loadAsync(<Buffer>res.body);\n\n    // defaults to latin with 4 default variants, 5 formats -> 20 files in archive\n    should(_.keys(archive.files).length).eql(20);\n\n    _.each(_.sortBy(_.keys(archive.files)), (key) => {\n      should(key.indexOf(\"istok-web-v20-latin-\")).eql(0);\n    });\n  }).timeout(10000);\n\n  it(\"should respond with 200 for download attempt of known font istok-web and a single unknown format sneaked in\", async () => {\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=woff,woff2,rolf\")\n      .responseType(\"blob\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", \"application/zip\");\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive = await JSZip.loadAsync(<Buffer>res.body);\n\n    // defaults to latin with 4 default variants, 2 formats -> 8 files in archive\n    should(_.keys(archive.files).length).eql(8);\n\n    _.each(_.sortBy(_.keys(archive.files)), (key) => {\n      should(key.indexOf(\"istok-web-v20-latin-\")).eql(0);\n    });\n  }).timeout(10000);\n\n  it(\"should respond with 200 for download attempt of known font istok-web with variants\", async () => {\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=regular\")\n      .responseType(\"blob\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", \"application/zip\");\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive = await JSZip.loadAsync(<Buffer>res.body);\n\n    // defaults to latin with 1 variant, 2 formats -> 2 files in archive\n    should(_.keys(archive.files).length).eql(2);\n\n    _.each(_.sortBy(_.keys(archive.files)), (key) => {\n      should(_.endsWith(key, \".woff\") || _.endsWith(key, \".woff2\")).eql(true);\n      should(key.indexOf(\"regular\") === -1).eql(false);\n    });\n  }).timeout(10000);\n\n  it(\"should respond with 200 for download attempt of known font istok-web with one known, one unknown variant\", async () => {\n    const res = await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=regular,unknownvar\")\n      .responseType(\"blob\")\n      .timeout(10000)\n      .expect(200)\n      .expect(\"Content-Type\", \"application/zip\");\n\n    should(getStats().urlMap).eql(1);\n    should(getStats().archiveMap).eql(1);\n\n    const archive = await JSZip.loadAsync(<Buffer>res.body);\n\n    // defaults to latin with 1 variant, 2 formats -> 2 files in archive\n    should(_.keys(archive.files).length).eql(2);\n\n    _.each(_.sortBy(_.keys(archive.files)), (key) => {\n      should(_.endsWith(key, \".woff\") || _.endsWith(key, \".woff2\")).eql(true);\n      should(key.indexOf(\"regular\") === -1).eql(false);\n    });\n  }).timeout(10000);\n\n  it(\"should respond with 404 for download attempt of known font istok-web with empty variants\", async () => {\n    await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=woff,woff2&variants=\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  }).timeout(10000);\n\n  // https://gwfh.mranftl.com/api/fonts/siemreap?download=zip&subsets=latin,latin-ext&formats=eot,woff,woff2,svg,ttf\n  it(\"should respond with 404 for download attempt of unknown font and unknown subset\", async () => {\n    await request(app)\n      .get(\"/api/fonts/unknown-font?download=zip&subsets=latin&formats=woff,woff2\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  }).timeout(10000);\n\n  it(\"should respond with 404 for download attempt of known font istok-web and unknown subset\", async () => {\n    await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&subsets=unknown&formats=woff,woff2\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  }).timeout(10000);\n\n  it(\"should respond with 404 for download attempt of known font istok-web and unknown format\", async () => {\n    await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=rolf\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  }).timeout(10000);\n\n  it(\"should respond with 404 for download attempt of known font istok-web and empty formats\", async () => {\n    await request(app)\n      .get(\"/api/fonts/istok-web?download=zip&formats=\")\n      .timeout(10000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  }).timeout(10000);\n});\n"
  },
  {
    "path": "server/api/healthy.controller.ts",
    "content": "import { NextFunction, Request, Response } from \"express\";\nimport { getStats } from \"../logic/store\";\n\n// /-/healthy\nexport async function getHealthy(req: Request, res: Response<string>, next: NextFunction) {\n  try {\n    const { fontMap, urlMap, archiveMap, files, urls } = getStats();\n\n    res.type(\"text/plain\");\n\n    return res.send(`${fontMap} fonts available.\n${urlMap} unique subsets loaded (${urls} URLs), ${archiveMap} subset archives fetched (${files} files).`);\n  } catch (e) {\n    next(e);\n  }\n}\n"
  },
  {
    "path": "server/api/healthy.spec.ts",
    "content": "import * as request from \"supertest\";\nimport { app } from \"../app\";\n\ndescribe(\"GET /-/healthy\", () => {\n  it(\"should respond with 200\", async () => {\n    await request(app)\n      .get(\"/-/healthy\")\n      .timeout(4000)\n      .expect(200)\n      .expect(\"Content-Type\", /text\\/plain/);\n  });\n});\n"
  },
  {
    "path": "server/app.spec.ts",
    "content": "import * as request from \"supertest\";\nimport { app } from \"./app\";\n\ndescribe(\"GET /api/not_defined\", () => {\n  it(\"should respond with 404\", async () => {\n    await request(app)\n      .get(\"/api/not_defined\")\n      .timeout(4000)\n      .expect(404)\n      .expect(\"Content-Type\", /text\\/html/);\n  });\n});\n\ndescribe(\"GET /\", () => {\n  it(\"should respond with 200\", async () => {\n    await request(app)\n      .get(\"/\")\n      .timeout(4000)\n      .expect(200)\n      .expect(\"Content-Type\", /text\\/html/);\n  });\n});\n"
  },
  {
    "path": "server/app.ts",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\nrequire(\"source-map-support\").install();\n\nimport * as express from \"express\";\nimport * as http from \"http\";\nimport * as JSZip from \"jszip\";\nimport * as path from \"path\";\nimport { config } from \"./config\";\nimport { initStore } from \"./logic/store\";\nimport { setupRoutes } from \"./routes\";\n\n// use native promises\nJSZip.external.Promise = Promise;\n\nexport const app = express();\n\nexport function ready() {\n  return init;\n}\n\nconst init = (async () => {\n  const server = http.createServer(app);\n  server.timeout = config.TIMEOUT_MS;\n\n  const env = app.get(\"env\");\n\n  // http://expressjs.com/en/api.html\n  app.set(\"x-powered-by\", false);\n\n  if (config.ENABLE_MIDDLEWARE_COMPRESSION) {\n    app.use(require(\"compression\")());\n  }\n\n  if (env === \"production\") {\n    app.use(express.static(path.join(config.ROOT, \"public\")));\n    app.set(\"appPath\", config.ROOT + \"/public\");\n    if (config.ENABLE_MIDDLEWARE_ACCESS_LOG) {\n      app.use(require(\"morgan\")(':remote-addr - :remote-user [:date[clf]] \":method :url HTTP/:http-version\" :status :res[content-length]'));\n    }\n  } else {\n    app.use(require(\"connect-livereload\")());\n    app.use(express.static(path.join(config.ROOT, \".tmp\")));\n    app.use(express.static(path.join(config.ROOT, \"client\")));\n    app.set(\"appPath\", config.ROOT + \"/client\");\n    app.use(require(\"morgan\")(\"dev\"));\n    app.use(require(\"errorhandler\")()); // Error handler - has to be last\n  }\n\n  setupRoutes(app);\n\n  await initStore();\n\n  // Start server\n  server.listen(config.PORT, config.IP, function () {\n    console.log(\n      \"Express server listening on %d, in %s mode (timeout=%dms, compress=%s, accesslog=%s)\",\n      config.PORT,\n      app.get(\"env\"),\n      server.timeout,\n      config.ENABLE_MIDDLEWARE_COMPRESSION,\n      config.ENABLE_MIDDLEWARE_ACCESS_LOG\n    );\n  });\n\n  process.once(\"SIGINT\", function () {\n    console.log(\"SIGINT received, closing server...\");\n    server.close();\n  });\n\n  process.once(\"SIGTERM\", function () {\n    console.log(\"SIGTERM received, closing server...\");\n    server.close();\n  });\n})();\n"
  },
  {
    "path": "server/config.ts",
    "content": "import * as _ from \"lodash\";\nimport * as path from \"path\";\n\nconst env = process.env.NODE_ENV || \"development\";\n\nconst GOOGLE_FONTS_API_KEY = process.env.GOOGLE_FONTS_API_KEY;\n\nif (!_.isString(GOOGLE_FONTS_API_KEY) || _.isEmpty(GOOGLE_FONTS_API_KEY)) {\n  console.error('Error: ENV var \"GOOGLE_FONTS_API_KEY\" must be set!');\n  console.error(\"See https://developers.google.com/fonts/docs/developer_api\");\n  process.exit(1);\n}\n\nexport interface IUserAgents {\n  eot: string;\n  woff: string;\n  woff2: string;\n  svg: string;\n  ttf: string;\n}\n\nexport const config = {\n  ENV: env,\n\n  // Root path of server\n  ROOT: path.normalize(__dirname + \"/..\"),\n\n  // Server port\n  PORT: process.env.PORT ? _.parseInt(process.env.PORT) : env === \"production\" ? 8080 : 9000,\n\n  IP: process.env.IP || undefined,\n\n  // Server port\n  TIMEOUT_MS: process.env.TIMEOUT_MS ? _.parseInt(process.env.TIMEOUT_MS) : 60 * 1000, // 60 seconds\n\n  // Middlewares\n  ENABLE_MIDDLEWARE_ACCESS_LOG: process.env.ENABLE_MIDDLEWARE_ACCESS_LOG === \"true\" ? true : false, // default false\n\n  ENABLE_MIDDLEWARE_COMPRESSION: process.env.ENABLE_MIDDLEWARE_COMPRESSION === \"false\" ? false : true, // default true\n\n  GOOGLE_FONTS_API_KEY,\n\n  GOOGLE_FONTS_USE_TEST_JSON: process.env.GOOGLE_FONTS_USE_TEST_JSON === \"true\" ? true : env === \"test\" ? true : false, // enabled in test, else default false\n\n  CACHE_DIR: process.env.CACHE_DIR || `${path.normalize(__dirname + \"/logic\")}/cachedFonts`,\n\n  USER_AGENTS: <IUserAgents>{\n    // see http://www.dvdprojekt.de/category.php?name=Safari for a list of sample user handlers\n    // test generation through running grunt mochaTest:src\n    eot: process.env.USER_AGENT_EOT || \"Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0)\",\n    woff: process.env.USER_AGENT_WOFF || \"Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0\",\n    // must serve complete woff2 file for one variant (no unicode range support yet!)\n    // see http://www.useragentstring.com/pages/Firefox/\n    // see http://caniuse.com/#search=woff2\n    // see http://caniuse.com/#feat=font-unicode-range\n    // see https://developers.googleblog.com/2015/02/smaller-fonts-with-woff-20-and-unicode.html\n    woff2: process.env.USER_AGENT_WOFF2 || \"Mozilla/5.0 (Windows NT 6.3; rv:39.0) Gecko/20100101 Firefox/39.0\",\n    svg:\n      process.env.USER_AGENT_SVG ||\n      \"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\",\n    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\",\n  },\n};\n"
  },
  {
    "path": "server/logic/core.ts",
    "content": "import * as _ from \"lodash\";\nimport { synchronizedBy } from \"../utils/synchronized\";\nimport { fetchFontSubsetArchive, IFontSubsetArchive } from \"./fetchFontSubsetArchive\";\nimport { fetchFontURLs, IVariantItem } from \"./fetchFontURLs\";\nimport { IFontItem } from \"./fetchGoogleFonts\";\nimport {\n  getFontBundle,\n  getStoredFontItems,\n  getStoredFontSubsetArchive,\n  getStoredVariantItems,\n  IFontBundle,\n  storeFontSubsetArchive,\n  storeVariantItems,\n} from \"./store\";\n\nexport function loadFontItems(): IFontItem[] {\n  return getStoredFontItems();\n}\n\nexport function loadFontBundle(fontID: string, subsets: string[] | null): IFontBundle | null {\n  return getFontBundle(fontID, subsets);\n}\n\nexport async function loadVariantItems(fontBundle: IFontBundle): Promise<IVariantItem[] | null> {\n  return _loadVariantItems(`loadVariantItems__${fontBundle.storeID}`, fontBundle);\n}\nconst _loadVariantItems = synchronizedBy(async function (fontBundle: IFontBundle): Promise<IVariantItem[] | null> {\n  const storedVariantItems = getStoredVariantItems(fontBundle);\n\n  if (!_.isNil(storedVariantItems)) {\n    return storedVariantItems;\n  }\n\n  const { storeID, font, subsets } = fontBundle;\n  const variantItems = await fetchFontURLs(font.family, font.variants, subsets);\n\n  if (variantItems === null) {\n    console.error(`loadVariantItems resolved null for storeID=${storeID}`);\n    return null;\n  }\n\n  // SIDE-EFFECT!\n  storeVariantItems(fontBundle, variantItems);\n\n  return variantItems;\n});\n\nexport async function loadFontSubsetArchive(fontBundle: IFontBundle, variants: IVariantItem[]): Promise<IFontSubsetArchive> {\n  return _loadFontSubsetArchive(`loadFontSubsetArchive__${fontBundle.storeID}`, fontBundle, variants);\n}\nconst _loadFontSubsetArchive = synchronizedBy(async function (\n  fontBundle: IFontBundle,\n  variants: IVariantItem[]\n): Promise<IFontSubsetArchive> {\n  const storedFontSubsetArchive = getStoredFontSubsetArchive(fontBundle);\n\n  if (!_.isNil(storedFontSubsetArchive)) {\n    return storedFontSubsetArchive;\n  }\n\n  const fontSubsetArchive = await fetchFontSubsetArchive(fontBundle.font.id, fontBundle.font.version, fontBundle.subsets, variants);\n\n  if (fontSubsetArchive.files.length === 0) {\n    throw new Error(`No files received for '${fontBundle.storeID}' font subset archive!`);\n  }\n\n  // SIDE-EFFECT!\n  storeFontSubsetArchive(fontBundle, fontSubsetArchive);\n\n  return fontSubsetArchive;\n});\nexport interface ISubsetMap {\n  [subset: string]: boolean;\n}\n\nexport function loadSubsetMap(fontBundle: IFontBundle): ISubsetMap {\n  return _.reduce(\n    fontBundle.font.subsets,\n    (sum, subset) => {\n      sum[subset] = _.includes(fontBundle.subsets, subset);\n      return sum;\n    },\n    {} as ISubsetMap\n  );\n}\n"
  },
  {
    "path": "server/logic/fetchCSS.ts",
    "content": "import * as css from \"css\";\nimport * as _ from \"lodash\";\nimport { IUserAgents } from \"../config\";\nimport { asyncRetry } from \"../utils/asyncRetry\";\nimport axios from \"axios\";\n\nconst RETRIES = 2;\nconst REQUEST_TIMEOUT_MS = 6000;\n\ninterface IResource {\n  src: string | null;\n  fontFamily: string | null;\n  fontStyle: string | null;\n  fontWeight: string | null;\n  url: string;\n}\n\nexport async function fetchCSS(family: string, cssSubsetString: string, type: keyof IUserAgents, userAgent: string): Promise<IResource[]> {\n  const reqPath = `/css?family=${encodeURIComponent(family)}&subset=${cssSubsetString}`;\n  const hostname = \"fonts.googleapis.com\";\n  const url = `http://${hostname}${reqPath}`;\n\n  const txt = await asyncRetry(\n    async () => {\n\n      const res = await axios.get<string>(url, {\n        timeout: REQUEST_TIMEOUT_MS,\n        responseType: \"text\",\n        maxRedirects: 0, // https://github.com/axios/axios/issues/2610\n        headers: {\n          Accept: \"text/css,*/*;q=0.1\",\n          \"User-Agent\": userAgent,\n        }\n      });\n\n      return res.data;\n\n    },\n    { retries: RETRIES }\n  );\n\n  return parseRemoteCSS(txt, type);\n}\n\nfunction parseRemoteCSS(remoteCSS: string, type: string): IResource[] {\n  const parsedCSS = css.parse(remoteCSS);\n\n  if (_.isNil(parsedCSS.stylesheet)) {\n    throw new Error(`parseRemoteCSS: no stylesheets in parsed css for ${type}: ${remoteCSS}`);\n  }\n\n  const resources: IResource[] = [];\n  _.each(parsedCSS.stylesheet.rules, (rule) => {\n    // only font-face rules are relevant...\n    if (rule.type !== \"font-face\") {\n      return;\n    }\n\n    try {\n      const src = getCSSRuleDeclarationPropertyValue(rule, \"src\");\n\n      if (_.isNil(src)) {\n        console.warn(`parseRemoteCSS: no src in parsed css for ${type}: ${remoteCSS}`);\n        return;\n      }\n\n      let matched = src.match(\"http:\\\\/\\\\/[^\\\\)]+\")\n\n      if (_.isNil(matched) || matched.length === 0) {\n\n        // might be https in the future\n        matched = src.match(\"https:\\\\/\\\\/[^\\\\)]+\");\n\n        if (_.isNil(matched) || matched.length === 0) {\n          console.warn(`parseRemoteCSS: no matched url in parsed css for ${type}: ${remoteCSS}`);\n          return;\n        }\n      }\n\n      const url = matched[0];\n\n      // console.log(url);\n\n      const resource: IResource = {\n        src: getCSSRuleDeclarationPropertyValue(rule, \"src\"),\n        fontFamily: getCSSRuleDeclarationPropertyValue(rule, \"font-family\"),\n        fontStyle: getCSSRuleDeclarationPropertyValue(rule, \"font-style\"),\n        fontWeight: getCSSRuleDeclarationPropertyValue(rule, \"font-weight\"),\n        url\n      };\n\n      // push the current rule (= resource) to the resources array\n      resources.push(resource);\n    } catch (e) {\n      console.error(\"cannot load resource of type\", type, remoteCSS, e);\n    }\n  });\n\n  return resources;\n}\n\nfunction getCSSRuleDeclarationPropertyValue(rule: css.Rule, property: string): string | null {\n  return _.get(\n    _.find(rule.declarations, (declaration) => {\n      return _.has(declaration, \"property\") && (<css.Declaration>declaration).property === property;\n    }),\n    \"value\",\n    null\n  );\n}\n"
  },
  {
    "path": "server/logic/fetchFontSubsetArchive.ts",
    "content": "import * as Bluebird from \"bluebird\";\nimport * as fs from \"fs\";\nimport * as JSZip from \"jszip\";\nimport * as _ from \"lodash\";\nimport * as path from \"path\";\nimport { finished } from \"stream/promises\";\nimport { config } from \"../config\";\nimport { asyncRetry } from \"../utils/asyncRetry\";\nimport { IVariantItem } from \"./fetchFontURLs\";\nimport { Readable } from \"stream\";\nimport axios from \"axios\";\n\nconst RETRIES = 2;\nconst REQUEST_TIMEOUT_MS = 6000;\n\nexport interface IFontSubsetArchive {\n  zipPath: string; // absolute path to the zip file\n  files: IFontFile[];\n}\n\nexport interface IFontFile {\n  variant: string;\n  format: string;\n  path: string; // relative path within the zip file\n}\n\nexport async function fetchFontSubsetArchive(\n  fontID: string,\n  fontVersion: string,\n  subsets: string[],\n  variants: IVariantItem[]\n): Promise<IFontSubsetArchive> {\n  const subsetFontArchive: IFontSubsetArchive = {\n    zipPath: path.join(config.CACHE_DIR, `/${fontID}-${fontVersion}-${subsets.join(\"_\")}.zip`),\n    files: [],\n  };\n\n  const archive = new JSZip();\n\n  const streams: (Readable | fs.WriteStream)[] = _.compact(\n    _.flatten(\n      await Bluebird.map(variants, (variant) => {\n        return Bluebird.map(variant.urls, async (variantUrl) => {\n          const filename = `${fontID}-${fontVersion}-${subsets.join(\"_\")}-${variant.id}.${variantUrl.format}`;\n\n          // download the file for type (filename now known)\n          let readable: Readable;\n          try {\n            // console.log(\"fetchFontSubsetArchive...\", variantUrl.format, filename, variantUrl.url);\n            readable = await fetchFontSubsetArchiveStream(variantUrl.url);\n            archive.file(filename, readable);\n          } catch (e) {\n            // if a specific format does not work, silently discard it.\n            console.error(\"fetchFontSubsetArchive discarding\", variantUrl.format, filename, variantUrl.url, e);\n            return null;\n          }\n\n          subsetFontArchive.files.push({\n            variant: variant.id, // variants and format are used to filter them out later!\n            format: variantUrl.format,\n            path: filename,\n          });\n\n          return readable;\n        });\n      })\n    )\n  );\n\n  const target = fs.createWriteStream(subsetFontArchive.zipPath);\n  streams.push(target);\n\n  console.info(`fetchFontSubsetArchive create archive... file=${subsetFontArchive.zipPath}`);\n\n  try {\n    await finished(archive.generateNodeStream({\n      compression: \"DEFLATE\",\n    }).pipe(target));\n\n    console.info(`fetchFontSubsetArchive create archive done! file=${subsetFontArchive.zipPath}`);\n  } catch (e) {\n    console.error(\"fetchFontSubsetArchive archive.generateNodeStream pipe failed\", e);\n    // ensure all fs streams into the archive and the actual zip file are destroyed\n    _.each(streams, (stream, index) => {\n      try {\n        console.warn(`fetchFontSubsetArchive archive.generateNodeStream destroy stream ${index}/${streams.length}...`)\n        stream.destroy();\n      } catch (err) {\n        console.error(\"fetchFontSubsetArchive archive.generateNodeStream pipe failed, stream.destroy failed (catched)\", fontID, subsets, err);\n      }\n    });\n\n    console.error(\"fetchFontSubsetArchive archive.generateNodeStream pipe failed, streams destroyed. Rethrowing err...\", fontID, subsets, e);\n    throw e;\n  }\n\n  return subsetFontArchive;\n}\n\nasync function fetchFontSubsetArchiveStream(url: string): Promise<Readable> {\n  return asyncRetry<Readable>(\n    async () => {\n\n      const res = await axios.get<Readable>(url, {\n        timeout: REQUEST_TIMEOUT_MS,\n        responseType: \"stream\",\n        maxRedirects: 0 // https://github.com/axios/axios/issues/2610\n      });\n\n      return res.data;\n\n    },\n    { retries: RETRIES }\n  );\n}\n"
  },
  {
    "path": "server/logic/fetchFontURLs.ts",
    "content": "import * as Bluebird from \"bluebird\";\nimport * as _ from \"lodash\";\nimport { config, IUserAgents } from \"../config\";\nimport { fetchCSS } from \"./fetchCSS\";\n\nexport interface IVariantURL {\n  format: keyof IUserAgents;\n  url: string;\n}\n\nexport interface IVariantItem {\n  id: string;\n  fontFamily: null | string;\n  fontStyle: null | string;\n  fontWeight: null | string;\n  urls: IVariantURL[];\n}\n\nconst TARGETS = _.map(_.keys(config.USER_AGENTS), (key) => {\n  return {\n    format: <keyof IUserAgents>key,\n    userAgent: <string>config.USER_AGENTS[<keyof IUserAgents>key],\n  };\n});\n\nexport async function fetchFontURLs(fontFamily: string, fontVariants: string[], fontSubsets: string[]): Promise<IVariantItem[] | null> {\n  let variants: IVariantItem[] = [];\n  const cssSubsetString = fontSubsets.join(\",\"); // make the variant string google API compatible...\n\n  await Bluebird.map(fontVariants, async (variant) => {\n    const cssFontFamily = `${fontFamily}:${variant}`;\n\n    const variantItem: IVariantItem = {\n      id: variant,\n      fontFamily: null,\n      fontStyle: null,\n      fontWeight: null,\n      urls: [],\n    };\n\n    await Bluebird.map(TARGETS, async (target) => {\n      const resources = await fetchCSS(cssFontFamily, cssSubsetString, target.format, target.userAgent);\n\n      if (resources.length === 0) {\n        console.warn(\n          `fetchFontURLs: no css ressources encountered for fontFamily='${cssFontFamily}' subset='${cssSubsetString}' format=${target.format}`,\n          resources\n        );\n        return;\n      }\n\n      if (resources.length > 1) {\n        console.warn(\n          `fetchFontURLs: multiple css ressources encountered for fontFamily='${cssFontFamily}' subset='${cssSubsetString}' format=${target.format}`,\n          resources\n        );\n      }\n\n      _.each(resources, (resource) => {\n        // save the format (woff, eot, svg, ttf, usw...)\n        variantItem.urls.push({\n          format: target.format,\n          // rewrite url to use https instead on http!\n          url: resource.url.split(\"http://\").join(\"https://\"), // resource.url.replace(/^http:\\/\\//i, 'https://');\n        });\n\n        // if not defined, also save procedded font-family, fontstyle, font-weight, unicode-range\n        if (_.isNil(variantItem.fontFamily) && !_.isNil(resource.fontFamily)) {\n          variantItem.fontFamily = resource.fontFamily;\n        }\n\n        if (_.isNil(variantItem.fontStyle) && !_.isNil(resource.fontStyle)) {\n          variantItem.fontStyle = resource.fontStyle;\n        }\n\n        if (_.isNil(variantItem.fontWeight) && !_.isNil(resource.fontWeight)) {\n          variantItem.fontWeight = resource.fontWeight;\n        }\n      });\n    });\n\n    variants.push(variantItem);\n  });\n\n  variants = _.sortBy(variants, function ({ fontWeight, fontStyle }) {\n    const styleOrder = fontStyle === \"normal\" ? 0 : 1;\n    return `${fontWeight}-${styleOrder}`;\n  });\n\n  return variants;\n}\n"
  },
  {
    "path": "server/logic/fetchGoogleFonts.ts",
    "content": "import * as fs from \"fs/promises\";\nimport * as _ from \"lodash\";\nimport * as path from \"path\";\nimport * as speakingurl from \"speakingurl\";\nimport { config } from \"../config\";\nimport { asyncRetry } from \"../utils/asyncRetry\";\nimport axios from \"axios\";\n\nconst RETRIES = 2;\nconst REQUEST_TIMEOUT_MS = 10000;\n\nexport interface IFontItem {\n  id: string;\n  family: string;\n  subsets: string[];\n  category: string;\n  version: string;\n  lastModified: string;\n  popularity: number;\n  defSubset: string;\n  defVariant: string;\n  variants: string[];\n}\n\ninterface IGoogleFontsRes {\n  kind: string;\n  items: IGoogleFontsResItem[];\n}\n\ninterface IGoogleFontsResItem {\n  family: string;\n  variants: string[];\n  subsets: string[];\n  version: string;\n  lastModified: string;\n  files: {\n    [key: string]: string;\n  };\n  category: string;\n  kind: \"webfonts#webfont\";\n}\n\n// build up fonts cache via google API...\nexport async function fetchGoogleFonts(): Promise<IFontItem[]> {\n  if (config.GOOGLE_FONTS_USE_TEST_JSON) {\n    const localPath = path.join(config.ROOT, \"test/googlefonts.json\");\n\n    if (config.ENV !== \"test\") {\n      console.warn(`fetchGoogleFonts is using local \"${localPath}\"`);\n    }\n\n    const testJson = await fs.readFile(localPath);\n    return transform(JSON.parse(testJson.toString()));\n  }\n\n  return asyncRetry(\n    async () => {\n\n      const res = await axios.get<IGoogleFontsRes>(`https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=${config.GOOGLE_FONTS_API_KEY}`, {\n        timeout: REQUEST_TIMEOUT_MS,\n        responseType: \"json\",\n        maxRedirects: 0 // https://github.com/axios/axios/issues/2610\n      });\n\n      return transform(res.data);\n\n    },\n    { retries: RETRIES }\n  );\n}\n\nfunction transform(resData: IGoogleFontsRes): IFontItem[] {\n  return _.map(resData.items, (item, index) => {\n    return {\n      id: speakingurl(item.family),\n      family: item.family,\n      variants: item.variants,\n      subsets: item.subsets,\n      category: item.category,\n      version: item.version,\n      lastModified: item.lastModified,\n      popularity: index + 1, // property order by popularity -> index\n      // use latin per default, else first found font\n      defSubset: _.includes(item.subsets, \"latin\") ? \"latin\" : item.subsets[0],\n      defVariant: _.includes(item.variants, \"regular\") ? \"regular\" : item.variants[0],\n    };\n  });\n}\n"
  },
  {
    "path": "server/logic/store.ts",
    "content": "import { mkdir } from \"fs/promises\";\nimport * as _ from \"lodash\";\nimport { config } from \"../config\";\nimport { IFontSubsetArchive } from \"./fetchFontSubsetArchive\";\nimport { IVariantItem } from \"./fetchFontURLs\";\nimport { fetchGoogleFonts, IFontItem } from \"./fetchGoogleFonts\";\n\n// FontBundle holds:\n// * the found stored font from google,\n// * the requested (and found) subsets and\n// * the unique storeID to access Maps in the store.\n// It should be used as the sole way to interact with the store and must be build via store.getFontBundle\nexport interface IFontBundle {\n  storeID: string;\n  subsets: string[];\n  font: IFontItem;\n}\n\nconst fontMap = new Map<string, IFontItem>();\nconst urlMap = new Map<string, IVariantItem[]>();\nconst archiveMap = new Map<string, IFontSubsetArchive>();\n\nexport async function initStore() {\n  await mkdir(config.CACHE_DIR, { recursive: true });\n\n  _.each(await fetchGoogleFonts(), (font: IFontItem) => {\n    fontMap.set(font.id, font);\n  });\n}\n\nexport async function reinitStore() {\n  if (config.ENV !== \"test\") {\n    console.warn(\"reinitStore was called, building fresh stores...\");\n  }\n\n  fontMap.clear();\n  urlMap.clear();\n  archiveMap.clear();\n\n  return initStore();\n}\n\nexport function getStoredFontItems(): IFontItem[] {\n  return Array.from(fontMap.values());\n}\n\nexport function getFontBundle(fontID: string, wantedSubsets: string[] | null): IFontBundle | null {\n  const font = fontMap.get(fontID);\n\n  if (_.isNil(font)) {\n    return null;\n  }\n\n  const match =\n    !_.isArray(wantedSubsets) || wantedSubsets.length === 0\n      ? [font.defSubset] // supply filter with the default subset as defined in googleFontsAPI fetcher (latin or if no found other)\n      : _.intersection(font.subsets, wantedSubsets);\n\n  const subsets = _.sortBy(_.uniq(match));\n\n  if (subsets.length === 0) {\n    return null;\n  }\n\n  return {\n    // not this must be a stable key fully identifying the font, its version and wantedSubsets\n    storeID: `${font.id}@${font.version}__${subsets.join(\"_\")}`,\n    subsets,\n    font,\n  };\n}\n\nexport function getStoredVariantItems({ storeID }: IFontBundle): IVariantItem[] | null {\n  const variants = urlMap.get(storeID);\n  if (_.isNil(variants)) {\n    return null;\n  }\n  return variants;\n}\n\nexport function getStoredFontSubsetArchive({ storeID }: IFontBundle): IFontSubsetArchive | null {\n  const subsetFontArchive = archiveMap.get(storeID);\n  if (_.isNil(subsetFontArchive)) {\n    return null;\n  }\n  return subsetFontArchive;\n}\n\nexport function storeVariantItems({ storeID }: IFontBundle, variants: IVariantItem[]) {\n  const existings = urlMap.get(storeID);\n\n  if (!_.isNil(existings)) {\n    console.warn(\"storeVariantItems: duplicate save of storeID: \", storeID);\n    if (config.ENV === \"test\") {\n      throw new Error(\"storeVariantItems duplicate write\");\n    }\n    return;\n  }\n\n  urlMap.set(storeID, variants);\n}\n\nexport function storeFontSubsetArchive({ storeID }: IFontBundle, subsetFontArchive: IFontSubsetArchive) {\n  const existings = archiveMap.get(storeID);\n\n  if (!_.isNil(existings)) {\n    console.warn(\"storeFontSubsetArchive: duplicate save of storeID: \", storeID);\n    if (config.ENV === \"test\") {\n      throw new Error(\"storeFontSubsetArchive duplicate write\");\n    }\n    return;\n  }\n\n  archiveMap.set(storeID, subsetFontArchive);\n}\n\nexport function getStats() {\n  return {\n    fontMap: fontMap.size,\n    urlMap: urlMap.size,\n    archiveMap: archiveMap.size,\n    urls: _.sumBy(Array.from(urlMap.values()), function (f) {\n      return f.length;\n    }),\n    files: _.sumBy(Array.from(archiveMap.values()), function (archive) {\n      return archive.files.length;\n    }),\n  };\n}\n"
  },
  {
    "path": "server/routes.ts",
    "content": "import * as express from \"express\";\nimport { getApiFonts, getApiFontsById } from \"./api/fonts.controller\";\nimport { getHealthy } from \"./api/healthy.controller\";\n\nexport function setupRoutes(app: express.Express) {\n  app.use(\"/fonts\", express.static(app.get(\"appPath\") + \"/index.html\"));\n  app.use(\"/fonts/\", express.static(app.get(\"appPath\") + \"/index.html\"));\n  app.use(\"/fonts/:id\", express.static(app.get(\"appPath\") + \"/index.html\"));\n\n  app.route(\"/api/fonts\").get(getApiFonts);\n\n  app.route(\"/api/fonts/:id\").get(getApiFontsById);\n\n  app.route(\"/-/healthy\").get(getHealthy);\n\n  // All undefined asset or api routes should return a 404\n  app.route(\"/:url(-|api|auth|components|app|bower_components|assets)/*\").get(function (req, res) {\n    res.status(404).send(\"Not found\");\n  });\n\n  // All other routes should redirect to the index.html\n  app.route(\"/*\").get(function (req, res) {\n    res.redirect(req.baseUrl + \"/\");\n  });\n}\n"
  },
  {
    "path": "server/utils/asyncRetry.spec.ts",
    "content": "import * as Bluebird from \"bluebird\";\nimport * as should from \"should\";\nimport { asyncRetry } from \"./asyncRetry\";\n\ndescribe(\"utils/asyncRetry\", function () {\n  it(\"retry works as expected when last succeeds\", async () => {\n    const RETRIES = 2;\n    let cnt = 0;\n\n    await asyncRetry(\n      async () => {\n        await Bluebird.delay(1);\n\n        cnt += 1;\n        if (cnt <= RETRIES) {\n          throw new Error(\"not yet\");\n        }\n      },\n      { retries: RETRIES }\n    );\n\n    should(cnt).eql(RETRIES + 1);\n  });\n\n  it(\"retry works as expected when all fail with same error\", async () => {\n    const RETRIES = 2;\n    let cnt = 0;\n    let err: AggregateError | null = null;\n\n    try {\n      await asyncRetry(\n        async () => {\n          await Bluebird.delay(1);\n\n          cnt += 1;\n          throw new Error(\"step err\");\n        },\n        { retries: RETRIES }\n      );\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      err = e;\n    }\n\n    // console.log(err);\n    should(cnt).eql(RETRIES + 1);\n    should(err).instanceOf(AggregateError);\n    should(err?.errors.length).eql(1); // unique errors returned by msg\n  });\n\n  it(\"retry works as expected when all fail with different errors\", async () => {\n    const RETRIES = 2;\n    let cnt = 0;\n    let err: AggregateError | null = null;\n\n    try {\n      await asyncRetry(\n        async () => {\n          await Bluebird.delay(1);\n\n          cnt += 1;\n          throw new Error(\"step\" + cnt);\n        },\n        { retries: RETRIES }\n      );\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    } catch (e: any) {\n      err = e;\n    }\n\n    // console.log(err);\n    should(cnt).eql(RETRIES + 1);\n    should(err).instanceOf(AggregateError);\n    should(err?.errors.length).eql(RETRIES + 1);\n  });\n});\n"
  },
  {
    "path": "server/utils/asyncRetry.ts",
    "content": "import * as Bluebird from \"bluebird\";\nimport * as _ from \"lodash\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport async function asyncRetry<T>(fn: () => Promise<T>, options: { retries: number }, errors: any[] = []): Promise<T> {\n  let t: T;\n  try {\n    t = await fn();\n  } catch (e) {\n    if (errors.length >= options.retries) {\n      throw new AggregateError(\n        _.unionBy([...errors, e], \"message\"),\n        `asyncRetry: maximal retries exceeded. retries=${options.retries} errors=${errors.length}`\n      );\n    }\n\n    // 2 ** 0 * 500 = 500ms\n    // 2 ** 1 * 500 = 1000ms  => 1500ms\n    // 2 ** 2 * 500 = 2000ms  => 3500ms\n    const bailoutMS = 2 ** errors.length * 500;\n    // console.error(`asyncRetry: try ${errors.length + 1} failed, retries=${options.retries}. Delaying next try ${bailoutMS}ms`);\n    await Bluebird.delay(bailoutMS);\n\n    // console.warn(`asyncRetry: retrying after ${bailoutMS}ms`);\n    return asyncRetry(fn, options, [...errors, e]);\n  }\n\n  return t;\n}\n"
  },
  {
    "path": "server/utils/synchronized.ts",
    "content": "import * as _ from \"lodash\";\nimport { config } from \"../config\";\n\n// cached promise by key for in-flight request handling\nexport function synchronizedBy<T>(target: () => Promise<T>): (cacheKey: string) => Promise<T>;\nexport function synchronizedBy<A1, T>(target: (arg1: A1) => Promise<T>): (cacheKey: string, arg1: A1) => Promise<T>;\nexport function synchronizedBy<A1, A2, T>(target: (arg1: A1, arg2: A2) => Promise<T>): (cacheKey: string, arg1: A1, arg2: A2) => Promise<T>;\nexport function synchronizedBy<A, T>(target: (...args: A[]) => Promise<T>): (cacheKey: string, ...args: A[]) => Promise<T> {\n  const mutexMap = new Map<string, Promise<T>>();\n\n  return async function (cacheKey: string, ...params: A[]) {\n    let mutexPromise = mutexMap.get(cacheKey);\n\n    if (_.isNil(mutexPromise)) {\n      // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-empty-function\n      let resolveMutexPromise: Function = () => {};\n      // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-empty-function\n      let rejectMutexPromise: Function = () => {};\n\n      mutexPromise = new Promise<T>(function (this: Promise<T>, resolve, reject) {\n        resolveMutexPromise = resolve.bind(this);\n        rejectMutexPromise = reject.bind(this);\n      });\n\n      mutexMap.set(cacheKey, mutexPromise);\n\n      try {\n        const ret = await target(...params);\n\n        resolveMutexPromise(ret);\n      } catch (err) {\n        rejectMutexPromise(err);\n      }\n    } else {\n      if (config.ENV === \"test\") {\n        console.log(\"synchronizedBy cache hit:\", cacheKey);\n      }\n    }\n\n    try {\n      const value = await mutexPromise;\n      // rm from cache again\n      mutexMap.delete(cacheKey);\n      return value;\n    } catch (error) {\n      // rm from cache again\n      mutexMap.delete(cacheKey);\n      throw error;\n    }\n  };\n}\n"
  },
  {
    "path": "test/googlefonts.json",
    "content": "{\n  \"kind\": \"webfonts#webfontList\",\n  \"items\": [\n    {\n      \"family\": \"Arvo\",\n      \"variants\": [\n        \"regular\",\n        \"italic\",\n        \"700\",\n        \"700italic\"\n      ],\n      \"subsets\": [\n        \"latin\"\n      ],\n      \"version\": \"v20\",\n      \"lastModified\": \"2022-09-22\",\n      \"files\": {\n        \"700\": \"http://localhost/font.ttf\",\n        \"regular\": \"http://localhost/font.ttf\",\n        \"italic\": \"http://localhost/font.ttf\",\n        \"700italic\": \"http://localhost/font.ttf\"\n      },\n      \"category\": \"serif\",\n      \"kind\": \"webfonts#webfont\"\n    },\n    {\n      \"family\": \"Istok Web\",\n      \"variants\": [\n        \"regular\",\n        \"italic\",\n        \"700\",\n        \"700italic\"\n      ],\n      \"subsets\": [\n        \"cyrillic\",\n        \"cyrillic-ext\",\n        \"latin\",\n        \"latin-ext\"\n      ],\n      \"version\": \"v20\",\n      \"lastModified\": \"2022-09-22\",\n      \"files\": {\n        \"700\": \"http://localhost/font.ttf\",\n        \"regular\": \"http://localhost/font.ttf\",\n        \"italic\": \"http://localhost/font.ttf\",\n        \"700italic\": \"http://localhost/font.ttf\"\n      },\n      \"category\": \"sans-serif\",\n      \"kind\": \"webfonts#webfont\"\n    },\n    {\n      \"family\": \"Playfair Display\",\n      \"variants\": [\n        \"regular\",\n        \"500\",\n        \"600\",\n        \"700\",\n        \"800\",\n        \"900\",\n        \"italic\",\n        \"500italic\",\n        \"600italic\",\n        \"700italic\",\n        \"800italic\",\n        \"900italic\"\n      ],\n      \"subsets\": [\n        \"cyrillic\",\n        \"latin\",\n        \"latin-ext\",\n        \"vietnamese\"\n      ],\n      \"version\": \"v30\",\n      \"lastModified\": \"2022-09-22\",\n      \"files\": {\n        \"500\": \"http://localhost/font.ttf\",\n        \"600\": \"http://localhost/font.ttf\",\n        \"700\": \"http://localhost/font.ttf\",\n        \"800\": \"http://localhost/font.ttf\",\n        \"900\": \"http://localhost/font.ttf\",\n        \"regular\": \"http://localhost/font.ttf\",\n        \"italic\": \"http://localhost/font.ttf\",\n        \"500italic\": \"http://localhost/font.ttf\",\n        \"600italic\": \"http://localhost/font.ttf\",\n        \"700italic\": \"http://localhost/font.ttf\",\n        \"800italic\": \"http://localhost/font.ttf\",\n        \"900italic\": \"http://localhost/font.ttf\"\n      },\n      \"category\": \"serif\",\n      \"kind\": \"webfonts#webfont\"\n    }\n  ]\n}"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"ESNEXT\",\n        \"module\": \"commonjs\",\n        \"sourceMap\": true,\n        \"outDir\": \"./dist/server\",\n        \"rootDir\": \"./server\",\n        \"strict\": true\n    },\n    \"include\": [\n        \"server/**/*.ts\",\n        \"server/**/*.js\",\n        \"server/**/*.json\"\n    ]\n}"
  }
]