Compare commits

...

No commits in common. "1.0.0-beta1" and "main" have entirely different histories.

163 changed files with 77441 additions and 2868 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true

30
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: ci
on:
push:
branches:
- main
- next
pull_request:
branches:
- main
- next
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'latest'
- name: 📦 Install dependencies
run: npm ci
- name: 🚧 Build project
run: npm run build
- name: 🧪 Test project
run: npm run test

17
.gitignore vendored
View File

@ -1,9 +1,10 @@
.idea
.elasticbeanstalk
node_modules
package-lock.json
region.txt
.reload
*.log
_debug*.*
.ssl/ssl.*
.vscode
.DS_Store
.env
*.map
tsconfig.tsbuildinfo
/node_modules
/lib
/cache
/tmp

View File

@ -1,8 +1,18 @@
.idea
.git
.reload
node_modules
npm-debug.log
tests
debug
_debug*.*
/.idea
/.vscode
.DS_Store
/.env
/.editorconfig
/.prettierrc
*.map
/docker.sh
/Dockerfile
/tsconfig*.*
/vitest.config.*
/.github
/src
/node_modules
/cache
/tmp
/icons
/tests

9
.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"trailingComma": "es5",
"singleQuote": true,
"useTabs": true,
"semi": true,
"quoteProps": "consistent",
"endOfLine": "lf",
"printWidth": 120
}

View File

@ -1,7 +0,0 @@
Put your SSL certificate here to enable SSL support.
Change file names to "ssl".
Files:
* ssl.key
* ssl.crt
* ssl.ca-bundle

89
Dockerfile Normal file
View File

@ -0,0 +1,89 @@
ARG ARCH=amd64
ARG NODE_VERSION=22
ARG OS=bullseye-slim
ARG ICONIFY_API_VERSION=3.2.0
ARG SRC_PATH=./
#### Stage BASE ########################################################################################################
FROM --platform=${ARCH} node:${NODE_VERSION}-${OS} AS base
# This gives node.js apps access to the OS CAs
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
# This handles using special APT sources during build only (it is safe to comment these 3 following lines out):
RUN cp /etc/apt/sources.list /etc/apt/sources.list.original
COPY tmp/sources.list /tmp/sources.list.tmp
RUN ([ -s /tmp/sources.list.tmp ] && mv -f /tmp/sources.list.tmp /etc/apt/sources.list && cat /etc/apt/sources.list) || (cat /etc/apt/sources.list)
# Add temporary CERTs needed during build (it is safe to comment the following 1 line out):
COPY tmp/build-ca-cert.crt /usr/local/share/ca-certificates/build-ca-cert.crt
# Install tools, create data dir, add user and set rights
RUN set -ex && \
apt-get update && \
apt-get install --no-install-recommends -y \
ca-certificates \
bash \
curl \
nano \
git && \
mkdir -p /data/iconify-api && \
apt-get clean && \
rm -rf /tmp/* && \
# Restore the original sources.list
([ -s /etc/apt/sources.list.original ] && mv /etc/apt/sources.list.original /etc/apt/sources.list) && \
# Remove the temporary build CA cert
rm -f /usr/local/share/ca-certificates/build-ca-cert.crt
# Set work directory
WORKDIR /data/iconify-api
#### Stage iconify-api-install #########################################################################################
FROM base AS iconify-api-install
ARG SRC_PATH
# Copy package files, install dependencies
COPY ${SRC_PATH}*.json ./
RUN npm ci
# Copy src and icons
COPY ${SRC_PATH}src/ /data/iconify-api/src/
COPY ${SRC_PATH}icons/ /data/iconify-api/icons/
# Build API
RUN npm run build
#### Stage release #####################################################################################################
FROM iconify-api-install AS release
ARG BUILD_DATE
ARG BUILD_VERSION
ARG BUILD_REF
ARG ICONIFY_API_VERSION
ARG ARCH
ARG TAG_SUFFIX=default
LABEL org.label-schema.build-date=${BUILD_DATE} \
org.label-schema.docker.dockerfile="Dockerfile" \
org.label-schema.license="MIT" \
org.label-schema.name="Iconify API" \
org.label-schema.version=${BUILD_VERSION} \
org.label-schema.description="Node.js version of api.iconify.design" \
org.label-schema.url="https://github.com/iconify/api" \
org.label-schema.vcs-ref=${BUILD_REF} \
org.label-schema.vcs-type="Git" \
org.label-schema.vcs-url="https://github.com/iconify/api" \
org.label-schema.arch=${ARCH} \
authors="Vjacheslav Trushkin"
RUN rm -rf /tmp/*
# Env variables
ENV ICONIFY_API_VERSION=$ICONIFY_API_VERSION
# Expose the listening port of Iconify API
EXPOSE 3000
# Add a healthcheck (default every 30 secs)
HEALTHCHECK CMD curl http://localhost:3000/ || exit 1
CMD ["npm", "run", "start"]

152
README.md Normal file
View File

@ -0,0 +1,152 @@
# Iconify API
This repository contains Iconify API script. It is a HTTP server, written in Node.js that:
- Provides icon data, used by icon components that load icon data on demand (instead of bundling thousands of icons).
- Generates SVG, which you can link to in HTML or stylesheet.
- Provides search engine for hosted icons, which can be used by icon pickers.
## NPM Package
This package is also available at NPM, allowing using API code in custom wrappers.
NPM package contains only compiled files, to build custom Docker image you need to use source files from Git repository, not NPM package.
## Docker
To build a Docker image, run `./docker.sh`.
If you want to customise config, fork this repo, customise source code, then build Docker image and deploy API.
To run a Docker image, run `docker run -d -p 3000:3000 iconify/api` (change first 3000 to port you want to run API on).
NPM commands for working with Docker images:
- `npm run docker:build` - builds Docker image.
- `npm run docker:start` - starts Docker container on port 3000.
- `npm run docker:stop` - stops all Iconify API Docker containers.
- `npm run docker:cleanup` - removes all unused Iconify API Docker containers.
There is no command to remove unused images because of Docker limitations. You need to do it manually from Docker Desktop or command line.
## How to use it
First, you need to install NPM dependencies and run build script:
```
npm install
npm run build
```
Then you can start server:
```
npm run start
```
By default, server will:
- Automatically load latest icons from [`@iconify/json`](https://github.com/iconify/icon-sets).
- Load custom icon sets from `icons` directory.
- Serve data on port 3000.
You can customise API to:
- Serve custom icon sets, loaded from various sources.
- Run on different port.
- Disable search engine if you do not need it, reducing memory usage.
## Port and HTTPS
It is recommended that you do not run API on port 80. Server can handle pretty much anything, but it is still not as good as a dedicated solution such as nginx.
Run API on obscure port, hidden from outside world with firewall rules, use nginx as reverse proxy.
HTTPS is not supported. It is a very resource intensive process, better handled by a dedicated solution such as nginx. Use nginx to run as HTTP and HTTPS server, forward queries to API HTTP server on hidden port such as default port 3000.
## Configuration
There are several ways to change configuration:
- Editing files in `src/config/`, then rebuilding script. This is required for some advanced options, such as using API with custom icons.
- Using environment variables, such as `PORT=3100 npm run start`.
- Using `.env` file to store environment variables.
### Env options
Options that can be changed with environment variables and their default values (you can find all of them in `src/config/app.ts`):
- `HOST=0.0.0.0`: IP address or hostname HTTP server listens on.
- `PORT=3000`: port HTTP server listens on.
- `ICONIFY_SOURCE=full`: source for Iconify icon sets. Set to `full` to use `@iconify/json` package, `split` to use `@iconify-json/*` packages, `none` to use only custom icon sets.
- `REDIRECT_INDEX=https://iconify.design/`: redirect for `/` route. API does not serve any pages, so index page redirects to main website.
- `STATUS_REGION=`: custom text to add to `/version` route response. Iconify API is ran on network of servers, visitor is routed to closest server. It is used to tell which server user is connected to.
- `ENABLE_ICON_LISTS=true`: enables `/collections` route that lists icon sets and `/collection?prefix=whatever` route to get list of icons. Used by icon pickers. Disable it if you are using API only to serve icon data.
- `ENABLE_SEARCH_ENGINE=true`: enables `/search` route. Requires `ENABLE_ICON_LISTS` to be enabled.
- `ALLOW_FILTER_ICONS_BY_STYLE=true`: allows searching for icons based on fill or stroke, such as adding `style=fill` to search query. This feature uses a bit of memory, so it can be disabled. Requires `ENABLE_SEARCH_ENGINE` to be enabled.
### Memory management
By default, API will use memory management functions. It stores only recently used icons in memory, reducing memory usage.
000
If your API gets a lot of traffic (above 1k requests per minute), it is better to not use memory management. With such high number of queries, disc read/write operations might cause degraded performance. API can easily handle 10 times more traffic on a basic VPS if everything is in memory and can be accessed instantly.
See [memory management in full API docs](https://iconify.design/docs/api/hosting-js/config.html).
### Updating icons
Icons are automatically updated when server starts.
In addition to that, API can update icon sets without restarting server.
To enable automatic update, you must set `APP_UPDATE_SECRET` environment variable. Without it, update will not work.
- `ALLOW_UPDATE=true`: enables `/update` route.
- `UPDATE_REQUIRED_PARAM=secret`: key from secret key/value pair. Cannot be empty.
- `APP_UPDATE_SECRET=`: value from secret key/value pair. Cannot be empty.
- `UPDATE_THROTTLE=60`: number of seconds to wait before running update.
To trigger icon sets update, open `/update?foo=bar`, where `foo` is value of `UPDATE_REQUIRED_PARAM`, `bar` is value of `APP_UPDATE_SECRET`.
Update will not be triggered immediately, it will be ran after `UPDATE_THROTTLE` seconds. This is done to prevent multiple checks when update is triggered several times in a row by something like GitHub hooks.
If update is triggered while update process is already running (as in, source was checked for update, but download is still in progress), another update check will be ran after currently running update ends.
Response to `/update` route is always the same, regardless of outcome. This is done to make it impossible to try to guess key/value pair or even see if route is enabled. To see actual result, you need to check console. Successful request and update process will be logged.
### HTTP headers
By default, server sends the following HTTP headers:
- Various CORS headers, allowing access from anywhere.
- Cache headers to cache responses for 604800 seconds (7 days).
To change headers, edit `httpHeaders` variable in `src/config/app.ts`, then rebuild script.
## Node vs PHP
Previous version of API was also available as PHP script. This has been discontinued. Node app performs much faster, can handle thousands of queries per second and uses less memory.
## Full documentation
This file is basic.
Full documentation is available on [Iconify documentation website](https://iconify.design/docs/api/).
## Sponsors
<p align="center">
<a href="https://github.com/sponsors/cyberalien">
<img src='https://cyberalien.github.io/static/sponsors.svg'/>
</a>
</p>
## Licence
Iconify API is licensed under MIT license.
`SPDX-License-Identifier: MIT`
This licence does not apply to icons hosted on API and files generated by API. You can host icons with any license, without any restrictions. Common decency applies, such as not hosting pirated versions of commercial icon sets (not sure why anyone would use commercial icon sets when so many excellent open source icon sets are available, but anyway...).
© 2022-PRESENT Vjacheslav Trushkin

272
app.js
View File

@ -1,272 +0,0 @@
/**
* Main file to run in Node.js
*
* Run ssl.js instead of you want SSL support.
*/
"use strict";
/*
* Configuration
*/
// True if server should include default icons set
const serveDefaultIcons = true;
// Directories with json files for custom icon sets
// Use simple-svg-tools package to create json collections
const customIconDirectories = ['json'];
// HTTP port
// Run ssl.js for SSL support
const port = process.env.PORT || 3000;
// Cache configuration
const cache = 604800, // cache time in seconds
cacheMin = cache, // minimum cache refresh time in seconds
cachePrivate = false; // True if cache is private. Used in Cache-Control header in response
/*
* Main stuff
*/
const fs = require('fs'),
// Express stuff
express = require('express'),
app = express(),
// Debug stuff
version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version,
// Included files
Collections = require('./src/collections'),
// Query parser
parseQuery = require('./src/query');
// Region file to easy identify server in CDN
let region = '';
if (!region.length && process.env.region) {
region = process.env.region;
}
try {
region = fs.readFileSync('region.txt', 'utf8').trim();
} catch (err) {
}
if (region.length > 10 || !region.match(/^[a-z0-9_-]+$/i)) {
region = '';
}
// Reload key
let reloadKey = '';
try {
reloadKey = fs.readFileSync('.reload', 'utf8').trim();
} catch (err) {
}
if (reloadKey.length < 8 || reloadKey.length > 64) {
reloadKey = '';
}
// Icons module
const icons = serveDefaultIcons ? require('simple-svg-icons') : null;
// Collections list
let collections = null,
loading = true;
/**
* Load icons
*
* @returns {Promise}
*/
function loadIcons() {
return new Promise((fulfill, reject) => {
let t = Date.now(),
newCollections = new Collections(console.log);
console.log('Loading collections at ' + (new Date()).toString());
// Load default collections
if (icons !== null) {
Object.keys(icons.collections()).forEach(prefix => {
newCollections.addFile(icons.locate(prefix));
});
}
// Add collections from "json" directory
customIconDirectories.forEach(dir => {
newCollections.addDirectory(dir);
});
newCollections.load().then(() => {
console.log('Loaded in ' + (Date.now() - t) + 'ms');
fulfill(newCollections);
}).catch(err => {
reject(err);
});
});
}
/**
* Parse request
*
* @param {string} prefix
* @param {string} query
* @param {string} ext
* @param {object} req
* @param {object} res
*/
function parseRequest(prefix, query, ext, req, res) {
function parse() {
let result = 404,
collection = collections.find(prefix);
if (collection !== null) {
result = parseQuery(collection, query, ext, req.query);
}
if (typeof result === 'number') {
res.sendStatus(result);
return;
}
// Send cache header
if (
cache &&
(req.get('Pragma') === void 0 || req.get('Pragma').indexOf('no-cache') === -1) &&
(req.get('Cache-Control') === void 0 || req.get('Cache-Control').indexOf('no-cache') === -1)
) {
res.set('Cache-Control', (cachePrivate ? 'private' : 'public') + ', max-age=' + cache + ', min-refresh=' + cacheMin);
if (!cachePrivate) {
res.set('Pragma', 'cache');
}
}
// Check for download
if (result.filename !== void 0 && (req.query.download === '1' || req.query.download === 'true')) {
res.set('Content-Disposition', 'attachment; filename="' + result.filename + '"');
}
// Send data
res.type(result.type).send(result.body);
}
// Parse query
if (collections === null) {
// This means script is still loading
// Attempt to parse query every 100ms for up to 2 seconds
let attempts = 0,
timer = setInterval(function() {
attempts ++;
if (collections === null) {
if (attempts > 20) {
clearInterval(timer);
res.sendStatus(503);
}
} else {
clearInterval(timer);
parse();
}
}, 100);
} else {
parse();
}
}
// Load icons
loadIcons().then(newCollections => {
collections = newCollections;
loading = false;
}).catch(err => {
console.log('Fatal error loading collections:', err);
loading = false;
});
// Disable X-Powered-By header
app.disable('x-powered-by');
// CORS
app.options('/*', (req, res) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding');
res.header('Access-Control-Max-Age', 86400);
res.send(200);
});
// GET 3 part request
app.get(/^\/([a-z0-9-]+)\/([a-z0-9-]+)\.(js|json|svg)$/, (req, res) => {
// prefix/icon.svg
// prefix/icons.json
parseRequest(req.params[0], req.params[1], req.params[2], req, res);
});
// GET 2 part JS/JSON request
app.get(/^\/([a-z0-9-]+)\.(js|json)$/, (req, res) => {
// prefix.json
parseRequest(req.params[0], 'icons', req.params[1], req, res);
});
// GET 2 part SVG request
app.get(/^\/([a-z0-9:-]+)\.svg$/, (req, res) => {
let parts = req.params[0].split(':');
if (parts.length === 2) {
// prefix:icon.svg
parseRequest(parts[0], parts[1], 'svg', req, res);
return;
}
if (parts.length === 1) {
parts = parts[0].split('-');
if (parts.length > 1) {
// prefix-icon.svg
parseRequest(parts.shift(), parts.join('-'), 'svg', req, res);
return;
}
}
res.sendStatus(404);
});
// Debug information and AWS health check
app.get('/version', (req, res) => {
let body = 'SimpleSVG CDN version ' + version + ' (Node';
if (region.length) {
body += ', ' + region;
}
body += ')';
res.send(body);
});
// Reload collections without restarting app
app.get('/reload', (req, res) => {
if (reloadKey.length && req.query && req.query.key && req.query.key === reloadKey) {
// Reload collections
process.nextTick(() => {
if (loading) {
return;
}
loading = true;
loadIcons().then(newCollections => {
collections = newCollections;
loading = false;
}).catch(err => {
console.log('Fatal error loading collections:', err);
loading = false;
});
});
}
res.sendStatus(200);
});
// Redirect home page
app.get('/', (req, res) => {
res.redirect(301, 'https://simplesvg.com/');
});
// Create server
app.listen(port, () => {
console.log('Listening on port ' + port);
});
module.exports = app;

View File

@ -1,4 +0,0 @@
# [START runtime]
runtime: nodejs
env: flex
# [END runtime]

58
docker.sh Executable file
View File

@ -0,0 +1,58 @@
#!/bin/bash -e
# This file is used to build the Docker image
# Examples:
#./docker.sh
#./docker.sh arm64v8
# To test the docker image a command like this can be used:
#docker run --rm -p 3123:3000 --name iconify-api -v $(realpath "../iconify-cache"):/data/iconify-api/cache -v $(realpath "../iconify-config"):/data/iconify-api/src/config iconify/api:latest
#docker run --rm -p 3123:3000 --name iconify-api -v /absolute/path/iconify-cache:/data/iconify-api/cache -v /absolute/path/iconify-config:/data/iconify-api/src/config iconify/api:latest
DOCKER_REPO=iconify/api
ICONIFY_API_REPO=$(realpath "./")
BUILD_SOURCE=$(realpath "./")
SHARED_DIR=$BUILD_SOURCE/../shared
DOCKERFILE=$(realpath "./Dockerfile")
SRC_PATH="./"
if [ -z "$1" ]; then
ARCH=amd64
# ARCH=arm64v8
else
ARCH=$1
fi
echo "Starting to build for arch: $ARCH"
echo "Build BASE dir: $BUILD_SOURCE"
export ICONIFY_API_VERSION=$(grep -oE "\"version\": \"([0-9]+.[0-9]+.[a-z0-9.-]+)" $ICONIFY_API_REPO/package.json | cut -d\" -f4)
echo "Iconify API version: ${ICONIFY_API_VERSION}"
mkdir -p $BUILD_SOURCE/tmp
# If we need a different APT package list during the build, this will fetch it
# This is useful in case a local APT cache is used.
if [ -s $SHARED_DIR/sources-build.list ]; then
cp -f $SHARED_DIR/sources-build.list $BUILD_SOURCE/tmp/sources.list
else
rm -f $BUILD_SOURCE/tmp/sources.list
touch $BUILD_SOURCE/tmp/sources.list
fi
# If we need an extra CA root cert during the build, this will fetch it
# This is useful in case connections go through eg. a Squid proxy to cache npm packages.
if [ -s $SHARED_DIR/build-ca-cert.crt ]; then
cp -f $SHARED_DIR/build-ca-cert.crt $BUILD_SOURCE/tmp/build-ca-cert.crt
else
rm -f $BUILD_SOURCE/tmp/build-ca-cert.crt
touch $BUILD_SOURCE/tmp/build-ca-cert.crt
fi
time docker build --rm=false \
--build-arg ARCH=$ARCH \
--build-arg ICONIFY_API_VERSION=${ICONIFY_API_VERSION} \
--build-arg BUILD_DATE="$(date +"%Y-%m-%dT%H:%M:%SZ")" \
--build-arg TAG_SUFFIX=default \
--build-arg SRC_PATH="$SRC_PATH" \
--file $DOCKERFILE \
--tag ${DOCKER_REPO}:latest --tag ${DOCKER_REPO}:${ICONIFY_API_VERSION} $BUILD_SOURCE
rm -fR $BUILD_SOURCE/tmp

26
icons/README.md Normal file
View File

@ -0,0 +1,26 @@
# Icons
This directory contains custom icon sets and icons.
## Icon sets
Icon sets are stored in IconifyJSON format.
You can use Iconify Tools to create icon sets. See [Iconify Tools documentation](https://docs.iconify.design/tools/tools2/).
Each icon set has prefix. For icon set to be imported, filename must match icon set prefix, for example, `line-md.json` for icon set with `line-md` prefix.
Icon sets that have `info` property and are not marked as hidden, appear in icon sets list and search results (unless those features are disabled).
If you want icon set to be hidden, all you have to do is not add `info` property when creating icon set or remove it.
## Icons
TODO
Currently not supported yet. Create icon sets instead.
## Conflicts
If API serves both custom and Iconify icon sets, it is possible to have conflicting names. If 2 icon sets with identical prefix exist in both sources, custom icon set will be used.
To disable Iconify icon sets, set env variable `ICONIFY_SOURCE` to `none`. You can use `.env` file in root directory.

View File

@ -1,616 +0,0 @@
{
"icons": {
"arty-animated:16-arc-180": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 24c22.143 0 40 17.906 40 40s-17.863 40-40 40h-8\" class=\"animation-delay-0 animation-duration-11 animate-stroke stroke-length-153\"/><path d=\"M40 104l16 16\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-30\"/><path d=\"M40 104l16-16\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-30\"/></g>"
},
"arty-animated:16-arc-270": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 24c22.143 0 40 17.906 40 40s-17.863 40-40 40-40-17.906-40-40v-8\" class=\"animation-delay-0 animation-duration-12 animate-stroke stroke-length-230\"/><path d=\"M24 40L8 56\" class=\"animation-delay-10 animation-duration-1 animate-stroke stroke-length-30\"/><path d=\"M24 40l16 16\" class=\"animation-delay-10 animation-duration-1 animate-stroke stroke-length-30\"/></g>"
},
"arty-animated:16-arc-90": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\"><path d=\"M64 24c22.143 0 40 17.906 40 40v8\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/><path d=\"M104 88l16-16\" class=\"animation-delay-8 animation-duration-3 animate-stroke stroke-length-30\"/><path d=\"M104 88L88 72\" class=\"animation-delay-8 animation-duration-3 animate-stroke stroke-length-30\"/></g>"
},
"arty-animated:16-arrow-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M120 64H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-153\"/><path d=\"M8 64l40 40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 64l40-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/></g>"
},
"arty-animated:16-arrows-from-2-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M48 80H16\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M48 80v32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M80 48V16\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M80 48h32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 120l36-36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M120 8L84 44\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/></g>"
},
"arty-animated:16-arrows-from-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M48 48V16\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M80 80v32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M80 48V16\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M48 80v32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M80 80h32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M48 48H16\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M48 80H16\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M80 48h32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 120l36-36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M120 8L84 44\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M120 120L84 84\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M8 8l36 36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/></g>"
},
"arty-animated:16-arrows-horizontal": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M56 88h56\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M72 40H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M120 88L96 64\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 88l-24 24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 40l24-24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 40l24 24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/></g>"
},
"arty-animated:16-arrows-to-2-corners": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 120V88\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 120h32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 8H88\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 8v32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M52 76l-36 36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M76 52l36-36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/></g>"
},
"arty-animated:16-arrows-to-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 120V88\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 8v32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 120V88\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 8v32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 120h32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 8H88\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 120H88\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 8h32\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M48 80l-36 36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M80 48l36-36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M80 80l36 36\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M48 48L12 12\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/></g>"
},
"arty-animated:16-caret-up-outline": {
"body": "<path d=\"M38 74l26-26 26 26z\" stroke-linecap=\"round\" stroke-width=\"12\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/>"
},
"arty-animated:16-caret-up": {
"body": "<path d=\"M24 80l40-40 40 40z\" fill=\"currentColor\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/>"
},
"arty-animated:16-carets-vertical-outline": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"12\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M38 78l26 26 26-26z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/><path d=\"M38 50l26-26 26 26z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/></g>"
},
"arty-animated:16-carets-vertical": {
"body": "<g fill=\"currentColor\" fill-rule=\"evenodd\"><path d=\"M24 72l40 40 40-40z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/><path d=\"M24 56l40-40 40 40z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/></g>"
},
"arty-animated:16-chevron-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M40 64l48-48\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/><path d=\"M40 64l48 48\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/></g>"
},
"arty-animated:16-close": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 8l112 112\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-230\"/><path d=\"M8 120L120 8\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-230\"/></g>"
},
"arty-animated:16-confirm": {
"body": "<path d=\"M8 64l48 48 64-96\" stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-230\"/>"
},
"arty-animated:16-double-arrow-horizontal": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 64h48\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M64 64H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-68\"/><path d=\"M120 64L96 40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M120 64L96 88\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 64l24-24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 64l24 24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/></g>"
},
"arty-animated:16-drop-outline": {
"body": "<path d=\"M64 8S24 54 24 82c0 22 16 38 40 38s40-16 40-38c0-28-40-74-40-74z\" stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-345\"/>"
},
"arty-animated:16-drop": {
"body": "<g fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 8S24 54 24 82c0 22 16 38 40 38s40-16 40-38c0-28-40-74-40-74z\" stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-345\"/><path d=\"M64 8S24 54 24 82c0 12.676 5.311 23.36 14.404 30.139l57.8-56.002C84.565 33.594 64 8 64 8z\" fill=\"currentColor\" class=\"animation-delay-9 animation-duration-4 animate-fill\"/></g>"
},
"arty-animated:16-filters": {
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M24 8v40\" stroke-width=\"16\" class=\"animation-delay-0 animation-duration-3 animate-stroke stroke-length-45\"/><path d=\"M24 104v16\" stroke-width=\"16\" class=\"animation-delay-3 animation-duration-1 animate-stroke stroke-length-20\"/><path d=\"M12 76h24\" stroke-width=\"24\" class=\"animation-delay-4 animation-duration-2 animate-stroke stroke-length-30\"/><path d=\"M64 8v8\" stroke-width=\"16\" class=\"animation-delay-6 animation-duration-1 animate-stroke stroke-length-9\"/><path d=\"M64 72v48\" stroke-width=\"16\" class=\"animation-delay-6 animation-duration-3 animate-stroke stroke-length-68\"/><path d=\"M52 44h24\" stroke-width=\"24\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-30\"/><path d=\"M104 8v48\" stroke-width=\"16\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-68\"/><path d=\"M104 112v8\" stroke-width=\"16\" class=\"animation-delay-14 animation-duration-1 animate-stroke stroke-length-9\"/><path d=\"M92 84h24\" stroke-width=\"24\" class=\"animation-delay-15 animation-duration-2 animate-stroke stroke-length-30\"/></g>"
},
"arty-animated:16-grid-3-outline": {
"body": "<g transform=\"translate(4 4)\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><circle cx=\"12\" cy=\"12\" r=\"12\" class=\"animation-delay-0 animation-duration-2 animate-fill\"/><circle cx=\"60\" cy=\"12\" r=\"12\" class=\"animation-delay-2 animation-duration-2 animate-fill\"/><circle cx=\"108\" cy=\"12\" r=\"12\" class=\"animation-delay-4 animation-duration-2 animate-fill\"/><circle cx=\"12\" cy=\"60\" r=\"12\" class=\"animation-delay-6 animation-duration-2 animate-fill\"/><circle cx=\"60\" cy=\"60\" r=\"12\" class=\"animation-delay-7 animation-duration-2 animate-fill\"/><circle cx=\"108\" cy=\"60\" r=\"12\" class=\"animation-delay-9 animation-duration-2 animate-fill\"/><circle cx=\"12\" cy=\"108\" r=\"12\" class=\"animation-delay-11 animation-duration-2 animate-fill\"/><circle cx=\"60\" cy=\"108\" r=\"12\" class=\"animation-delay-13 animation-duration-2 animate-fill\"/><circle cx=\"108\" cy=\"108\" r=\"12\" class=\"animation-delay-15 animation-duration-2 animate-fill\"/></g>"
},
"arty-animated:16-grid-3": {
"body": "<g fill=\"currentColor\" fill-rule=\"evenodd\"><circle cx=\"16\" cy=\"16\" r=\"16\" class=\"animation-delay-0 animation-duration-2 animate-fill\"/><circle cx=\"64\" cy=\"16\" r=\"16\" class=\"animation-delay-2 animation-duration-2 animate-fill\"/><circle cx=\"112\" cy=\"16\" r=\"16\" class=\"animation-delay-4 animation-duration-2 animate-fill\"/><circle cx=\"16\" cy=\"64\" r=\"16\" class=\"animation-delay-6 animation-duration-2 animate-fill\"/><circle cx=\"64\" cy=\"64\" r=\"16\" class=\"animation-delay-7 animation-duration-2 animate-fill\"/><circle cx=\"112\" cy=\"64\" r=\"16\" class=\"animation-delay-9 animation-duration-2 animate-fill\"/><circle cx=\"16\" cy=\"112\" r=\"16\" class=\"animation-delay-11 animation-duration-2 animate-fill\"/><circle cx=\"64\" cy=\"112\" r=\"16\" class=\"animation-delay-13 animation-duration-2 animate-fill\"/><circle cx=\"112\" cy=\"112\" r=\"16\" class=\"animation-delay-15 animation-duration-2 animate-fill\"/></g>"
},
"arty-animated:16-list-3-outline": {
"body": "<g transform=\"translate(4 4)\" stroke=\"currentColor\" stroke-width=\"8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><circle cx=\"12\" cy=\"12\" r=\"12\" class=\"animation-delay-0 animation-duration-3 animate-fill\"/><rect x=\"48\" width=\"72\" height=\"24\" rx=\"12\" class=\"animation-delay-3 animation-duration-3 animate-fill\"/><circle cx=\"12\" cy=\"60\" r=\"12\" class=\"animation-delay-5 animation-duration-3 animate-fill\"/><rect x=\"48\" y=\"48\" width=\"72\" height=\"24\" rx=\"12\" class=\"animation-delay-8 animation-duration-3 animate-fill\"/><circle cx=\"12\" cy=\"108\" r=\"12\" class=\"animation-delay-11 animation-duration-3 animate-fill\"/><rect x=\"48\" y=\"96\" width=\"72\" height=\"24\" rx=\"12\" class=\"animation-delay-13 animation-duration-3 animate-fill\"/></g>"
},
"arty-animated:16-list-3": {
"body": "<g fill=\"none\" fill-rule=\"evenodd\"><circle fill=\"currentColor\" cx=\"16\" cy=\"16\" r=\"16\" class=\"animation-delay-0 animation-duration-4 animate-fill\"/><path d=\"M64 16h48\" stroke=\"currentColor\" stroke-width=\"32\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-4 animation-duration-1 animate-stroke stroke-length-68\"/><circle fill=\"currentColor\" cx=\"16\" cy=\"64\" r=\"16\" class=\"animation-delay-5 animation-duration-4 animate-fill\"/><path d=\"M64 64h48\" stroke=\"currentColor\" stroke-width=\"32\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-1 animate-stroke stroke-length-68\"/><circle fill=\"currentColor\" cx=\"16\" cy=\"112\" r=\"16\" class=\"animation-delay-11 animation-duration-4 animate-fill\"/><path d=\"M64 112h48\" stroke=\"currentColor\" stroke-width=\"32\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-14 animation-duration-1 animate-stroke stroke-length-68\"/></g>"
},
"arty-animated:16-panel-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 64l32 32\" stroke-linejoin=\"round\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-68\"/><path d=\"M8 64l32-32\" stroke-linejoin=\"round\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-68\"/><path d=\"M92 64H16\" class=\"animation-delay-7 animation-duration-5 animate-stroke stroke-length-102\"/><path d=\"M120 8v112\" stroke-linejoin=\"round\" class=\"animation-delay-0 animation-duration-7 animate-stroke stroke-length-153\"/></g>"
},
"arty-animated:16-search": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M52 76L8 120\" class=\"animation-delay-10 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M51.92 76.364C44.558 69.06 40 58.965 40 48 40 25.909 57.857 8 80 8s40 17.909 40 40-17.86 40-40 40c-11.186 0-20.866-4.48-28.08-11.636z\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-345\"/></g>"
},
"arty-animated:20-arc-180": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M80 32c26.571 0 48 21.488 48 48s-21.435 48-48 48H64\" class=\"animation-delay-0 animation-duration-11 animate-stroke stroke-length-230\"/><path d=\"M48 128l24 24\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-45\"/><path d=\"M48 128l24-24\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-45\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arc-270": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M80 32c26.571 0 48 21.488 48 48s-21.435 48-48 48-48-21.488-48-48V64\" class=\"animation-delay-0 animation-duration-11 animate-stroke stroke-length-345\"/><path d=\"M32 56L8 80\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-45\"/><path d=\"M32 56l24 24\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-45\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arc-90": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\"><path d=\"M80 32c26.571 0 48 21.488 48 48v16\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-102\"/><path d=\"M128 112l24-24\" class=\"animation-delay-7 animation-duration-4 animate-stroke stroke-length-45\"/><path d=\"M128 112l-24-24\" class=\"animation-delay-7 animation-duration-4 animate-stroke stroke-length-45\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arrow-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M152 80H16\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M8 80l48 48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-102\"/><path d=\"M8 80l48-48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-102\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arrows-from-2-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 96H24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M64 96v40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M96 64V24\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M96 64h40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 152l52-52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M152 8l-52 52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arrows-from-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 64V24\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M96 96v40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M96 64V24\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M64 96v40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M96 96h40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M64 64H24\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M64 96H24\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M96 64h40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 152l52-52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M152 8l-52 52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M152 152l-52-52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M8 8l52 52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arrows-horizontal": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M72 112h72\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M88 48H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M152 112l-32-32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M152 112l-32 32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 48l32-32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 48l32 32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arrows-to-2-corners": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 152v-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 152h40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M152 8h-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M152 8v40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M68 92l-52 52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M92 68l52-52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-arrows-to-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 152v-40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M152 8v40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M152 152v-40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 8v40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 152h40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M152 8h-40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M152 152h-40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M8 8h40\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-45\"/><path d=\"M64 96l-52 52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M96 64l52-52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M96 96l52 52\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M64 64L12 12\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-caret-up-outline": {
"body": "<path d=\"M46 90l34-34 34 34z\" stroke-linecap=\"round\" stroke-width=\"12\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/>",
"width": 160,
"height": 160
},
"arty-animated:20-caret-up": {
"body": "<path d=\"M32 96l48-48 48 48z\" fill=\"currentColor\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/>",
"width": 160,
"height": 160
},
"arty-animated:20-carets-vertical-outline": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"12\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M46 94l34 34 34-34z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/><path d=\"M46 66l34-34 34 34z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-carets-vertical": {
"body": "<g fill=\"currentColor\" fill-rule=\"evenodd\"><path d=\"M32 88l48 48 48-48z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/><path d=\"M32 72l48-48 48 48z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-chevron-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M48 80l64-64\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/><path d=\"M48 80l64 64\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-close": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 8l144 144\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-230\"/><path d=\"M8 152L152 8\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-230\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-confirm": {
"body": "<path d=\"M8 80l64 64 80-128\" stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-345\"/>",
"width": 160,
"height": 160
},
"arty-animated:20-double-arrow-horizontal": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M80 80h64\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M80 80H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M152 80l-32-32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M152 80l-32 32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 80l32-32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 80l32 32\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-panel-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 80l48 48\" stroke-linejoin=\"round\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M8 80l48-48\" stroke-linejoin=\"round\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M124 80H16\" class=\"animation-delay-6 animation-duration-5 animate-stroke stroke-length-153\"/><path d=\"M152 8v144\" stroke-linejoin=\"round\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-230\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:20-search": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M68 92L8 152\" class=\"animation-delay-10 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M70.305 90.037C61.468 81.271 56 69.158 56 56c0-26.51 21.429-48 48-48s48 21.49 48 48-21.433 48-48 48c-13.423 0-25.039-5.376-33.695-13.963z\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-345\"/></g>",
"width": 160,
"height": 160
},
"arty-animated:24-arc-180": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M96 32c35.429 0 64 28.65 64 64s-28.58 64-64 64H80\" class=\"animation-delay-0 animation-duration-11 animate-stroke stroke-length-345\"/><path d=\"M64 160l24 24\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-45\"/><path d=\"M64 160l24-24\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-45\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arc-270": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M96 32c35.429 0 64 28.65 64 64s-28.58 64-64 64-64-28.65-64-64V80\" class=\"animation-delay-0 animation-duration-12 animate-stroke stroke-length-500\"/><path d=\"M32 64L8 88\" class=\"animation-delay-10 animation-duration-1 animate-stroke stroke-length-45\"/><path d=\"M32 64l24 24\" class=\"animation-delay-10 animation-duration-1 animate-stroke stroke-length-45\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arc-90": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M96 32c35.429 0 64 28.65 64 64v16\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-153\"/><path d=\"M160 128l24-24\" class=\"animation-delay-8 animation-duration-3 animate-stroke stroke-length-45\"/><path d=\"M160 128l-24-24\" class=\"animation-delay-8 animation-duration-3 animate-stroke stroke-length-45\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arrow-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M184 96H16\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-230\"/><path d=\"M8 96l56 56\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-102\"/><path d=\"M8 96l56-56\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-102\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arrows-from-2-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M80 112H32\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M80 112v48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M112 80V32\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M112 80h48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M8 184l68-68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M184 8l-68 68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arrows-from-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M80 80V32\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M112 112v48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M112 80V32\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M80 112v48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M112 112h48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M80 80H32\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M80 112H32\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M112 80h48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M8 184l68-68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M184 8l-68 68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M184 184l-68-68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M8 8l68 68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arrows-horizontal": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M88 144h88\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M104 64H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M184 144l-40-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M184 144l-40 40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 64l40-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 64l40 40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arrows-to-2-corners": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 184v-48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M8 184h48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M184 8h-48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M184 8v48\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M84 108l-68 68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M108 84l68-68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-arrows-to-corners": {
"body": "<g stroke-linecap=\"round\" stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 184v-48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M184 8v48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M184 184v-48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M8 8v48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M8 184h48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M184 8h-48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M184 184h-48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M8 8h48\" class=\"animation-delay-9 animation-duration-4 animate-stroke stroke-length-68\"/><path d=\"M80 112l-68 68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M112 80l68-68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M112 112l68 68\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/><path d=\"M80 80L12 12\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-153\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-caret-up-outline": {
"body": "<path d=\"M54 114l42-42 42 42z\" stroke-linecap=\"round\" stroke-width=\"12\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/>",
"width": 192,
"height": 192
},
"arty-animated:24-caret-up": {
"body": "<path d=\"M40 120l56-56 56 56z\" fill=\"currentColor\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/>",
"width": 192,
"height": 192
},
"arty-animated:24-carets-vertical-outline": {
"body": "<g stroke-linecap=\"round\" stroke-width=\"12\" stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M54 109l42 42 42-42z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/><path d=\"M54 81l42-42 42 42z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-carets-vertical": {
"body": "<g fill=\"currentColor\" fill-rule=\"evenodd\"><path d=\"M40 104l56 56 56-56z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/><path d=\"M40 88l56-56 56 56z\" class=\"animation-delay-0 animation-duration-10 animate-fill\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-chevron-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M56 96l80-80\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-153\"/><path d=\"M56 96l80 80\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-153\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-close": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 8l176 176\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-345\"/><path d=\"M8 184L184 8\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-345\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-confirm": {
"body": "<path d=\"M8 104l72 64L184 24\" stroke-linecap=\"round\" stroke-width=\"16\" stroke=\"currentColor\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-345\"/>",
"width": 192,
"height": 192
},
"arty-animated:24-double-arrow-horizontal": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M96 96h80\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M96 96H16\" class=\"animation-delay-0 animation-duration-8 animate-stroke stroke-length-102\"/><path d=\"M184 96l-40-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M184 96l-40 40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 96l40-40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/><path d=\"M8 96l40 40\" stroke-linejoin=\"round\" class=\"animation-delay-8 animation-duration-5 animate-stroke stroke-length-68\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-drop-outline": {
"body": "<path d=\"M96 8S32 84 32 128c0 36 28 56 64 56s64-20 64-56C160 84 96 8 96 8z\" stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-500\"/>",
"width": 192,
"height": 192
},
"arty-animated:24-drop": {
"body": "<g fill=\"none\" fill-rule=\"evenodd\"><path d=\"M96 8S32 84 32 128c0 36 28 56 64 56s64-20 64-56C160 84 96 8 96 8z\" stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-0 animation-duration-9 animate-stroke stroke-length-500\"/><path d=\"M96 8S32 84 32 128c0 20.785 9.333 36.236 24.151 45.584l92.734-85.324C130.558 49.037 96 8 96 8z\" fill=\"currentColor\" class=\"animation-delay-9 animation-duration-4 animate-fill\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-filters": {
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M32 8v72\" stroke-width=\"16\" class=\"animation-delay-0 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M32 136v48\" stroke-width=\"16\" class=\"animation-delay-3 animation-duration-2 animate-stroke stroke-length-68\"/><path d=\"M12 108h40\" stroke-width=\"24\" class=\"animation-delay-4 animation-duration-1 animate-stroke stroke-length-45\"/><path d=\"M96 8v24\" stroke-width=\"16\" class=\"animation-delay-6 animation-duration-1 animate-stroke stroke-length-30\"/><path d=\"M96 88v96\" stroke-width=\"16\" class=\"animation-delay-6 animation-duration-3 animate-stroke stroke-length-153\"/><path d=\"M76 60h40\" stroke-width=\"24\" class=\"animation-delay-10 animation-duration-1 animate-stroke stroke-length-45\"/><path d=\"M160 8v88\" stroke-width=\"16\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M160 152v32\" stroke-width=\"16\" class=\"animation-delay-14 animation-duration-1 animate-stroke stroke-length-45\"/><path d=\"M140 124h40\" stroke-width=\"24\" class=\"animation-delay-15 animation-duration-1 animate-stroke stroke-length-45\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-grid-3-outline": {
"body": "<g transform=\"translate(8 8)\" stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><circle cx=\"16\" cy=\"16\" r=\"16\" class=\"animation-delay-0 animation-duration-2 animate-fill\"/><circle cx=\"88\" cy=\"16\" r=\"16\" class=\"animation-delay-2 animation-duration-2 animate-fill\"/><circle cx=\"160\" cy=\"16\" r=\"16\" class=\"animation-delay-4 animation-duration-2 animate-fill\"/><circle cx=\"16\" cy=\"88\" r=\"16\" class=\"animation-delay-6 animation-duration-2 animate-fill\"/><circle cx=\"88\" cy=\"88\" r=\"16\" class=\"animation-delay-7 animation-duration-2 animate-fill\"/><circle cx=\"160\" cy=\"88\" r=\"16\" class=\"animation-delay-9 animation-duration-2 animate-fill\"/><circle cx=\"16\" cy=\"160\" r=\"16\" class=\"animation-delay-11 animation-duration-2 animate-fill\"/><circle cx=\"88\" cy=\"160\" r=\"16\" class=\"animation-delay-13 animation-duration-2 animate-fill\"/><circle cx=\"160\" cy=\"160\" r=\"16\" class=\"animation-delay-15 animation-duration-2 animate-fill\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-grid-3": {
"body": "<g fill=\"currentColor\" fill-rule=\"evenodd\"><circle cx=\"24\" cy=\"24\" r=\"24\" class=\"animation-delay-0 animation-duration-2 animate-fill\"/><circle cx=\"96\" cy=\"24\" r=\"24\" class=\"animation-delay-2 animation-duration-2 animate-fill\"/><circle cx=\"168\" cy=\"24\" r=\"24\" class=\"animation-delay-4 animation-duration-2 animate-fill\"/><circle cx=\"24\" cy=\"96\" r=\"24\" class=\"animation-delay-6 animation-duration-2 animate-fill\"/><circle cx=\"96\" cy=\"96\" r=\"24\" class=\"animation-delay-7 animation-duration-2 animate-fill\"/><circle cx=\"168\" cy=\"96\" r=\"24\" class=\"animation-delay-9 animation-duration-2 animate-fill\"/><circle cx=\"24\" cy=\"168\" r=\"24\" class=\"animation-delay-11 animation-duration-2 animate-fill\"/><circle cx=\"96\" cy=\"168\" r=\"24\" class=\"animation-delay-13 animation-duration-2 animate-fill\"/><circle cx=\"168\" cy=\"168\" r=\"24\" class=\"animation-delay-15 animation-duration-2 animate-fill\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-list-3-outline": {
"body": "<g transform=\"translate(8 8)\" stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><circle stroke-linejoin=\"round\" cx=\"16\" cy=\"16\" r=\"16\" class=\"animation-delay-0 animation-duration-3 animate-fill\"/><rect x=\"72\" width=\"104\" height=\"32\" rx=\"16\" class=\"animation-delay-3 animation-duration-3 animate-fill\"/><circle stroke-linejoin=\"round\" cx=\"16\" cy=\"88\" r=\"16\" class=\"animation-delay-5 animation-duration-3 animate-fill\"/><rect x=\"72\" y=\"72\" width=\"104\" height=\"32\" rx=\"16\" class=\"animation-delay-8 animation-duration-3 animate-fill\"/><circle stroke-linejoin=\"round\" cx=\"16\" cy=\"160\" r=\"16\" class=\"animation-delay-11 animation-duration-3 animate-fill\"/><rect x=\"72\" y=\"144\" width=\"104\" height=\"32\" rx=\"16\" class=\"animation-delay-13 animation-duration-3 animate-fill\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-list-3": {
"body": "<g fill=\"none\" fill-rule=\"evenodd\"><circle fill=\"currentColor\" cx=\"24\" cy=\"24\" r=\"24\" class=\"animation-delay-0 animation-duration-4 animate-fill\"/><path d=\"M96 24h72\" stroke=\"currentColor\" stroke-width=\"48\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-4 animation-duration-1 animate-stroke stroke-length-102\"/><circle fill=\"currentColor\" cx=\"24\" cy=\"96\" r=\"24\" class=\"animation-delay-5 animation-duration-4 animate-fill\"/><path d=\"M96 96h72\" stroke=\"currentColor\" stroke-width=\"48\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-1 animate-stroke stroke-length-102\"/><circle fill=\"currentColor\" cx=\"24\" cy=\"168\" r=\"24\" class=\"animation-delay-11 animation-duration-4 animate-fill\"/><path d=\"M96 168h72\" stroke=\"currentColor\" stroke-width=\"48\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-14 animation-duration-1 animate-stroke stroke-length-102\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-panel-left": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M8 96l64 64\" stroke-linejoin=\"round\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M8 96l64-64\" stroke-linejoin=\"round\" class=\"animation-delay-11 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M156 96H16\" class=\"animation-delay-6 animation-duration-5 animate-stroke stroke-length-230\"/><path d=\"M184 8v176\" stroke-linejoin=\"round\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-230\"/></g>",
"width": 192,
"height": 192
},
"arty-animated:24-search": {
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" stroke-linecap=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M72 120L8 184\" class=\"animation-delay-11 animation-duration-2 animate-stroke stroke-length-102\"/><path d=\"M75.073 117.383C63.29 105.695 56 89.544 56 72c0-35.346 28.571-64 64-64s64 28.654 64 64c0 35.346-28.577 64-64 64-17.897 0-33.385-7.167-44.927-18.617z\" class=\"animation-delay-0 animation-duration-11 animate-stroke stroke-length-500\"/></g>",
"width": 192,
"height": 192
}
},
"aliases": {
"arty-animated:16-arrow-right": {
"parent": "arty-animated:16-arrow-left",
"hFlip": true
},
"arty-animated:16-arrow-up": {
"parent": "arty-animated:16-arrow-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:16-arrow-down": {
"parent": "arty-animated:16-arrow-left",
"rotate": 3
},
"arty-animated:16-arrows-from-2-corners-rotated": {
"parent": "arty-animated:16-arrows-from-2-corners",
"hFlip": true
},
"arty-animated:16-arrows-vertical": {
"parent": "arty-animated:16-arrows-horizontal",
"rotate": 1
},
"arty-animated:16-arrows-to-2-corners-rotated": {
"parent": "arty-animated:16-arrows-to-2-corners",
"hFlip": true
},
"arty-animated:16-caret-down-outline": {
"parent": "arty-animated:16-caret-up-outline",
"vFlip": true
},
"arty-animated:16-caret-left-outline": {
"parent": "arty-animated:16-caret-up-outline",
"rotate": 1,
"vFlip": true
},
"arty-animated:16-caret-right-outline": {
"parent": "arty-animated:16-caret-up-outline",
"rotate": 1
},
"arty-animated:16-caret-down": {
"parent": "arty-animated:16-caret-up",
"vFlip": true
},
"arty-animated:16-caret-left": {
"parent": "arty-animated:16-caret-up",
"rotate": 1,
"vFlip": true
},
"arty-animated:16-caret-right": {
"parent": "arty-animated:16-caret-up",
"rotate": 1
},
"arty-animated:16-carets-horizontal-outline": {
"parent": "arty-animated:16-carets-vertical-outline",
"rotate": 3
},
"arty-animated:16-carets-horizontal": {
"parent": "arty-animated:16-carets-vertical",
"rotate": 3
},
"arty-animated:16-chevron-right": {
"parent": "arty-animated:16-chevron-left",
"hFlip": true
},
"arty-animated:16-chevron-up": {
"parent": "arty-animated:16-chevron-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:16-chevron-down": {
"parent": "arty-animated:16-chevron-left",
"rotate": 3
},
"arty-animated:16-double-arrow-vertical": {
"parent": "arty-animated:16-double-arrow-horizontal",
"rotate": 1
},
"arty-animated:16-filters-horizontal": {
"parent": "arty-animated:16-filters",
"rotate": 3,
"hFlip": true
},
"arty-animated:16-list-3-outline-rtl": {
"parent": "arty-animated:16-list-3-outline",
"hFlip": true
},
"arty-animated:16-list-3-rtl": {
"parent": "arty-animated:16-list-3",
"hFlip": true
},
"arty-animated:16-panel-right": {
"parent": "arty-animated:16-panel-left",
"hFlip": true
},
"arty-animated:16-panel-up": {
"parent": "arty-animated:16-panel-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:16-panel-down": {
"parent": "arty-animated:16-panel-left",
"rotate": 3
},
"arty-animated:16-search-rotated": {
"parent": "arty-animated:16-search",
"rotate": 3
},
"arty-animated:20-arrow-right": {
"parent": "arty-animated:20-arrow-left",
"hFlip": true
},
"arty-animated:20-arrow-up": {
"parent": "arty-animated:20-arrow-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:20-arrow-down": {
"parent": "arty-animated:20-arrow-left",
"rotate": 3
},
"arty-animated:20-arrows-from-2-corners-rotated": {
"parent": "arty-animated:20-arrows-from-2-corners",
"hFlip": true
},
"arty-animated:20-arrows-vertical": {
"parent": "arty-animated:20-arrows-horizontal",
"rotate": 1
},
"arty-animated:20-arrows-to-2-corners-rotated": {
"parent": "arty-animated:20-arrows-to-2-corners",
"hFlip": true
},
"arty-animated:20-caret-down-outline": {
"parent": "arty-animated:20-caret-up-outline",
"vFlip": true
},
"arty-animated:20-caret-left-outline": {
"parent": "arty-animated:20-caret-up-outline",
"rotate": 1,
"vFlip": true
},
"arty-animated:20-caret-right-outline": {
"parent": "arty-animated:20-caret-up-outline",
"rotate": 1
},
"arty-animated:20-caret-down": {
"parent": "arty-animated:20-caret-up",
"vFlip": true
},
"arty-animated:20-caret-left": {
"parent": "arty-animated:20-caret-up",
"rotate": 1,
"vFlip": true
},
"arty-animated:20-caret-right": {
"parent": "arty-animated:20-caret-up",
"rotate": 1
},
"arty-animated:20-carets-horizontal-outline": {
"parent": "arty-animated:20-carets-vertical-outline",
"rotate": 3
},
"arty-animated:20-carets-horizontal": {
"parent": "arty-animated:20-carets-vertical",
"rotate": 3
},
"arty-animated:20-chevron-right": {
"parent": "arty-animated:20-chevron-left",
"hFlip": true
},
"arty-animated:20-chevron-up": {
"parent": "arty-animated:20-chevron-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:20-chevron-down": {
"parent": "arty-animated:20-chevron-left",
"rotate": 3
},
"arty-animated:20-double-arrow-vertical": {
"parent": "arty-animated:20-double-arrow-horizontal",
"rotate": 1
},
"arty-animated:20-panel-right": {
"parent": "arty-animated:20-panel-left",
"hFlip": true
},
"arty-animated:20-panel-up": {
"parent": "arty-animated:20-panel-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:20-panel-down": {
"parent": "arty-animated:20-panel-left",
"rotate": 3
},
"arty-animated:20-search-rotated": {
"parent": "arty-animated:20-search",
"rotate": 3
},
"arty-animated:24-arrow-right": {
"parent": "arty-animated:24-arrow-left",
"hFlip": true
},
"arty-animated:24-arrow-up": {
"parent": "arty-animated:24-arrow-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:24-arrow-down": {
"parent": "arty-animated:24-arrow-left",
"rotate": 3
},
"arty-animated:24-arrows-from-2-corners-rotated": {
"parent": "arty-animated:24-arrows-from-2-corners",
"hFlip": true
},
"arty-animated:24-arrows-vertical": {
"parent": "arty-animated:24-arrows-horizontal",
"rotate": 1
},
"arty-animated:24-arrows-to-2-corners-rotated": {
"parent": "arty-animated:24-arrows-to-2-corners",
"hFlip": true
},
"arty-animated:24-caret-down-outline": {
"parent": "arty-animated:24-caret-up-outline",
"vFlip": true
},
"arty-animated:24-caret-left-outline": {
"parent": "arty-animated:24-caret-up-outline",
"rotate": 1,
"vFlip": true
},
"arty-animated:24-caret-right-outline": {
"parent": "arty-animated:24-caret-up-outline",
"rotate": 1
},
"arty-animated:24-caret-down": {
"parent": "arty-animated:24-caret-up",
"vFlip": true
},
"arty-animated:24-caret-left": {
"parent": "arty-animated:24-caret-up",
"rotate": 1,
"vFlip": true
},
"arty-animated:24-caret-right": {
"parent": "arty-animated:24-caret-up",
"rotate": 1
},
"arty-animated:24-carets-horizontal-outline": {
"parent": "arty-animated:24-carets-vertical-outline",
"rotate": 3
},
"arty-animated:24-carets-horizontal": {
"parent": "arty-animated:24-carets-vertical",
"rotate": 3
},
"arty-animated:24-chevron-right": {
"parent": "arty-animated:24-chevron-left",
"hFlip": true
},
"arty-animated:24-chevron-up": {
"parent": "arty-animated:24-chevron-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:24-chevron-down": {
"parent": "arty-animated:24-chevron-left",
"rotate": 3
},
"arty-animated:24-double-arrow-vertical": {
"parent": "arty-animated:24-double-arrow-horizontal",
"rotate": 1
},
"arty-animated:24-filters-horizontal": {
"parent": "arty-animated:24-filters",
"rotate": 3,
"hFlip": true
},
"arty-animated:24-list-3-outline-rtl": {
"parent": "arty-animated:24-list-3-outline",
"hFlip": true
},
"arty-animated:24-list-3-rtl": {
"parent": "arty-animated:24-list-3",
"hFlip": true
},
"arty-animated:24-panel-right": {
"parent": "arty-animated:24-panel-left",
"hFlip": true
},
"arty-animated:24-panel-up": {
"parent": "arty-animated:24-panel-left",
"rotate": 1,
"vFlip": true
},
"arty-animated:24-panel-down": {
"parent": "arty-animated:24-panel-left",
"rotate": 3
},
"arty-animated:24-search-rotated": {
"parent": "arty-animated:24-search",
"rotate": 3
}
},
"width": 128,
"height": 128
}

2
license.txt Executable file → Normal file
View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 Vjacheslav Trushkin
Copyright (c) 2022-PRESENT Vjacheslav Trushkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

4665
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,42 @@
{
"name": "simple-svg-website-icons",
"version": "1.0.0-beta1",
"description": "Node.js version of icons.simplesvg.com",
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "mocha tests/*_test.js"
},
"author": "Vjacheslav Trushkin",
"license": "MIT",
"dependencies": {
"express": "^4.16.2",
"simple-svg-icons": "git+https://github.com/simplesvg/icons.git"
},
"devDependencies": {
"chai": "^4.1.2",
"mocha": "^4.0.1"
}
"name": "@iconify/api",
"description": "Iconify API",
"author": "Vjacheslav Trushkin",
"license": "MIT",
"version": "3.2.0",
"type": "module",
"bugs": "https://github.com/iconify/api/issues",
"homepage": "https://github.com/iconify/api",
"repository": {
"type": "git",
"url": "https://github.com/iconify/api.git"
},
"packageManager": "npm@11.6.4",
"engines": {
"node": ">=22.20.0"
},
"scripts": {
"build": "tsc -b",
"test": "vitest --config vitest.config.mjs",
"start": "node --expose-gc lib/index.js",
"docker:build": "./docker.sh",
"docker:start": "docker run -d -p 3000:3000 iconify/api",
"docker:stop": "docker ps -q --filter ancestor=iconify/api | xargs -r docker stop",
"docker:cleanup": "docker ps -q -a --filter ancestor=iconify/api | xargs -r docker rm",
"docker:publish": "docker push iconify/api"
},
"dependencies": {
"@fastify/formbody": "^8.0.2",
"@iconify/tools": "^5.0.0",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"dotenv": "^17.2.3",
"fastify": "^5.6.2"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
"typescript": "^5.9.3",
"vitest": "^4.0.14"
}
}

View File

@ -1,5 +0,0 @@
# SimpleSVG icons website
This code runs on icons.simplesvg.com that is used to serve collections and SVG images.
PHP version is available at https://github.com/simplesvg/website-icons.php

27
renovate.json Normal file
View File

@ -0,0 +1,27 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"rangeStrategy": "bump",
"packageRules": [
{
"matchDepTypes": ["peerDependencies"],
"enabled": false
},
{
"matchPackageNames": ["fastify"],
"allowedVersions": "<4.0.0"
},
{
"matchPackageNames": ["@fastify/formbody"],
"allowedVersions": "<8.0.0"
},
{
"matchPackageNames": ["@iconify/utils"],
"allowedVersions": "<3.0.0"
},
{
"matchPackageNames": ["@iconify/tools"],
"allowedVersions": "<5.0.0"
}
]
}

View File

@ -1,277 +0,0 @@
"use strict";
const fs = require('fs');
const defaultAttributes = {
left: 0,
top: 0,
width: 16,
height: 16,
rotate: 0,
hFlip: false,
vFlip: false
};
/**
* Class to represent one collection of icons
*/
class Collection {
/**
* Constructor
*
* @param {string} [prefix] Optional prefix
*/
constructor(prefix) {
this.prefix = typeof prefix === 'string' ? prefix : null;
this.loaded = false;
}
/**
* Load from JSON data
*
* @param {string|object} data
*/
loadJSON(data) {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (err) {
return;
}
}
// Validate
if (typeof data !== 'object' || data.icons === void 0) {
return;
}
/** @var {{icons, aliases, prefix}} data **/
// DeOptimize
Object.keys(data).forEach(prop => {
switch (typeof data[prop]) {
case 'number':
case 'boolean':
let value = data[prop];
Object.keys(data.icons).forEach(key => {
if (data.icons[key][prop] === void 0) {
data.icons[key][prop] = value;
}
});
delete data[prop];
}
});
// Remove prefix from icons
if (data.prefix === void 0 || data.prefix === '') {
if (this.prefix === null) {
return;
}
let error = false,
sliceLength = this.prefix.length + 1;
['icons', 'aliases'].forEach(prop => {
if (error || data[prop] === void 0) {
return;
}
let newItems = {};
Object.keys(data[prop]).forEach(key => {
if (error || key.length <= sliceLength || key.slice(0, this.prefix.length) !== this.prefix) {
error = true;
return;
}
let newKey = key.slice(sliceLength);
if (data[prop][key].parent !== void 0) {
let parent = data[prop][key].parent;
if (parent.length <= sliceLength || parent.slice(0, this.prefix.length) !== this.prefix) {
error = true;
return;
}
data[prop][key].parent = parent.slice(sliceLength);
}
newItems[newKey] = data[prop][key];
});
data[prop] = newItems;
});
if (error) {
return;
}
} else {
this.prefix = data.prefix;
}
// Add aliases and icons
this.icons = data.icons;
this.aliases = data.aliases === void 0 ? {} : data.aliases;
this.loaded = true;
}
/**
* Load collection from file
*
* @param {string} file File or JSON
* @returns {Promise}
*/
loadFile(file) {
return new Promise((fulfill, reject) => {
// Load file
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
reject(err);
} else {
this.loadJSON(data);
if (this.loaded) {
fulfill(this);
} else {
reject();
}
}
});
});
}
// Functions used by getIcons()
/**
* Check if icon has already been copied
*
* @param {string} name
* @returns {boolean}
* @private
*/
_copied(name) {
return !!(this._result.icons[name] || this._result.aliases[name]);
}
/**
* Copy icon
*
* @param {string} name
* @param {number} iteration
* @returns {boolean}
* @private
*/
_copy(name, iteration) {
if (this._copied(name) || iteration > 5) {
return true;
}
if (this.icons[name] !== void 0) {
this._result.icons[name] = this.icons[name];
return true;
}
if (this.aliases && this.aliases[name] !== void 0) {
if (!this._copy(this.aliases[name].parent, iteration + 1)) {
return false;
}
this._result.aliases[name] = this.aliases[name];
return true;
}
return false;
}
/**
* Get data for selected icons
* This function assumes collection has been loaded. Verification should be done during loading
*
* @param {Array} icons
* @returns {{icons: {}, aliases: {}}}
*/
getIcons(icons) {
this._result = {
prefix: this.prefix,
icons: {},
aliases: {}
};
icons.forEach(icon => this._copy(icon, 0));
return this._result;
}
// Functions used by getIcon()
/**
* Merge icon data with this._result
*
* @param {object} data
* @private
*/
_mergeIcon(data) {
Object.keys(data).forEach(key => {
if (this._result[key] === void 0) {
this._result[key] = data[key];
return;
}
// Merge transformations, ignore the rest because alias overwrites parent items's attributes
switch (key) {
case 'rotate':
this._result.rotate += data.rotate;
break;
case 'hFlip':
case 'vFlip':
this._result[key] = this._result[key] !== data[key];
}
});
}
/**
* Add missing properties to object
*
* @param {object} data
* @returns {object}
* @private
*/
static _addMissingAttributes(data) {
let item = Object.assign({}, defaultAttributes, data);
if (item.inlineTop === void 0) {
item.inlineTop = item.top;
}
if (item.inlineHeight === void 0) {
item.inlineHeight = item.height;
}
if (item.verticalAlign === void 0) {
// -0.143 if icon is designed for 14px height,
// otherwise assume icon is designed for 16px height
item.verticalAlign = item.height % 7 === 0 && item.height % 8 !== 0 ? -0.143 : -0.125;
}
return item;
}
/**
* Get icon data for SVG
* This function assumes collection has been loaded. Verification should be done during loading
*
* @param {string} name
* @returns {object|null}
*/
getIcon(name) {
if (this.icons[name] !== void 0) {
return Collection._addMissingAttributes(this.icons[name]);
}
// Alias
if (this.aliases[name] === void 0) {
return null;
}
this._result = Object.assign({}, this.aliases[name]);
let parent = this.aliases[name].parent,
iteration = 0;
while (iteration < 5) {
if (this.icons[parent] !== void 0) {
// Merge with icon
this._mergeIcon(this.icons[parent]);
return Collection._addMissingAttributes(this._result);
}
if (this.aliases[parent] === void 0) {
return null;
}
this._mergeIcon(this.aliases[parent]);
parent = this.aliases[parent].parent;
iteration ++;
}
return null;
}
}
module.exports = Collection;

View File

@ -1,178 +0,0 @@
"use strict";
const fs = require('fs');
const Collection = require('./collection');
/**
* Class to represent collection of collections
*/
class Collections {
/**
* Constructor
*
* @param {boolean} [log] Optional function for logging loading process
*/
constructor(log) {
this._log = typeof log === 'function' ? log : null;
this.items = {};
this._loadQueue = [];
}
/**
* Add directory to loading queue
*
* @param {string} dir
*/
addDirectory(dir) {
this._loadQueue.push({
type: 'dir',
dir: dir.slice(-1) === '/' ? dir.slice(0, dir.length - 1) : dir
});
}
/**
* Add file to loading queue
*
* @param {string} filename
*/
addFile(filename) {
this._loadQueue.push({
type: 'file',
filename: filename
});
}
/**
* Load queue
*
* Promise will never reject because single file should not break app,
* it will log failures using "log" function from constructor
*
* @returns {Promise}
*/
load() {
return new Promise((fulfill, reject) => {
let promises = [];
this._loadQueue.forEach(item => {
switch (item.type) {
case 'dir':
promises.push(this._loadDir(item.dir));
break;
case 'file':
promises.push(this._loadFile(item.filename));
break;
}
});
Promise.all(promises).then(() => {
fulfill(this);
}).catch(err => {
reject(err);
});
});
}
/**
* Load directory
*
* @param {string} dir
* @returns {Promise}
* @private
*/
_loadDir(dir) {
return new Promise((fulfill, reject) => {
fs.readdir(dir, (err, files) => {
if (err) {
if (this._log !== null) {
this._log('Error loading directory: ' + dir);
}
fulfill(false);
} else {
let promises = [];
files.forEach(file => {
if (file.slice(-5) !== '.json') {
return;
}
promises.push(this._loadFile(dir + '/' + file));
});
// Load all promises
Promise.all(promises).then(res => {
fulfill(true);
}).catch(err => {
fulfill(false);
});
}
});
});
}
/**
* Load file
*
* @param {string} filename Full filename
* @returns {Promise}
*/
_loadFile(filename) {
return new Promise((fulfill, reject) => {
let file = filename.split('/').pop(),
fileParts = file.split('.');
if (fileParts.length !== 2) {
fulfill(false);
return;
}
let prefix = fileParts[0],
collection = new Collection(prefix);
collection.loadFile(filename).then(() => {
if (!collection.loaded) {
if (this._log !== null) {
this._log('Failed to load collection: ' + filename);
}
fulfill(false);
return;
}
if (collection.prefix !== prefix) {
if (this._log !== null) {
this._log('Collection prefix does not match: ' + collection.prefix + ' in file ' + file);
}
fulfill(false);
return;
}
let count = Object.keys(collection.icons).length;
if (!count) {
if (this._log !== null) {
this._log('Collection is empty: ' + file);
}
fulfill(false);
return;
}
this.items[prefix] = collection;
if (this._log !== null) {
this._log('Loaded collection ' + prefix + ' from ' + file + ' (' + count + ' icons)');
}
fulfill(true);
}).catch(() => {
fulfill(false);
});
});
}
/**
* Find collection
*
* @param {string} prefix
* @returns {Collection|null}
*/
find(prefix) {
return this.items[prefix] === void 0 ? null : this.items[prefix];
}
}
module.exports = Collections;

103
src/config/app.ts Normal file
View File

@ -0,0 +1,103 @@
import type { AppConfig } from '../types/config/app.js';
import type { SplitIconSetConfig } from '../types/config/split.js';
import type { MemoryStorageConfig } from '../types/storage.js';
/**
* Main configuration
*/
export const appConfig: AppConfig = {
// Index page
redirectIndex: 'https://iconify.design/docs/api/',
// Region to add to `/version` response
// Used to tell which server is responding when running multiple servers
// Requires `enableVersion` to be enabled
statusRegion: '',
// Cache root directory
cacheRootDir: 'cache',
// Host and port for server
host: '0.0.0.0',
port: 3000,
// Log stuff
log: true,
// Enable update
allowUpdate: true,
// Required parameter to include in `/update` query to trigger update
// Value must match environment variable `APP_UPDATE_SECRET`
updateRequiredParam: 'secret',
// Update check throttling
// Delay to wait between successful update request and actual update
updateThrottle: 60,
// Enables `/version` query
enableVersion: false,
// Enable icon sets and icon lists
// Disable this option if you need API to serve only icon data to save memory
enableIconLists: true,
// Enable icon search
// Requires `enableIconLists` to be enabled
// Disable this option if you do not need search functionality
enableSearchEngine: true,
// Enables filtering icons by style: 'fill' or 'stroke'
// Works only if search engine is enabled
allowFilterIconsByStyle: true,
};
/**
* HTTP headers to send to visitors
*/
export const httpHeaders: string[] = [
// CORS
'Access-Control-Allow-Origin: *',
'Access-Control-Allow-Methods: GET, OPTIONS',
'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding',
'Access-Control-Max-Age: 86400',
'Cross-Origin-Resource-Policy: cross-origin',
// Cache
'Cache-Control: public, max-age=604800, min-refresh=604800, immutable',
];
/**
* Splitting icon sets
*/
export const splitIconSetConfig: SplitIconSetConfig = {
// Average chunk size, in bytes. 0 to disable
chunkSize: 1000000,
// Minimum number of icons in one chunk
minIconsPerChunk: 40,
};
/**
* Storage configuration
*/
export const storageConfig: MemoryStorageConfig = {
// Cache directory, use {cache} to point for relative to cacheRootDir from app config
// Without trailing '/'
cacheDir: '{cache}/storage',
// Maximum number of stored items. 0 to disable
maxCount: 100,
// Minimum delay in milliseconds when data can expire.
// Should be set to at least 10 seconds (10000) to avoid repeated read operations
minExpiration: 20000,
// Timeout in milliseconds to check expired items, > 0 (if disabled, cleanupAfter is not ran)
timer: 60000,
// Number of milliseconds to keep item in storage after last use, > minExpiration
cleanupAfter: 0,
// Asynchronous reading of cache from file system
asyncRead: true,
};

54
src/config/icon-sets.ts Normal file
View File

@ -0,0 +1,54 @@
import { DirectoryDownloader } from '../downloaders/directory.js';
import { createJSONDirectoryImporter } from '../importers/full/directory-json.js';
import { directoryExists } from '../misc/files.js';
import type { Importer } from '../types/importers.js';
import type { ImportedData } from '../types/importers/common.js';
import { fullPackageImporter } from './importers/full-package.js';
import { splitPackagesImporter } from './importers/split-packages.js';
/**
* Sources
*
* Change this function to configure sources for your API instance
*/
export async function getImporters(): Promise<Importer[]> {
// Result
const importers: Importer[] = [];
/**
* Import all icon sets from big package
*
* Uses pre-configured importers. See `importers` sub-directory
*/
type IconifyIconSetsOptions = 'full' | 'split' | 'none';
const iconifyIconSets = (process.env['ICONIFY_SOURCE'] || 'full') as IconifyIconSetsOptions;
switch (iconifyIconSets) {
case 'full':
importers.push(fullPackageImporter);
break;
case 'split':
importers.push(splitPackagesImporter);
break;
}
/**
* Add custom icons from `icons` directory
*/
if (await directoryExists('icons')) {
importers.push(
createJSONDirectoryImporter(new DirectoryDownloader<ImportedData>('icons'), {
// Skip icon sets with mismatched prefix
ignoreInvalidPrefix: false,
// Filter icon sets. Returns true if icon set should be included, false if not.
filter: (prefix) => {
return true;
},
})
);
}
return importers;
}

View File

@ -0,0 +1,40 @@
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import { Importer } from '../../types/importers.js';
import { createIconSetsPackageImporter } from '../../importers/full/json.js';
import { ImportedData } from '../../types/importers/common.js';
import { DirectoryDownloader } from '../../downloaders/directory.js';
import type { RemoteDownloaderOptions } from '../../types/downloaders/remote.js';
import { RemoteDownloader } from '../../downloaders/remote.js';
/**
* Create importer for package
*/
export function createPackageIconSetImporter(
packageName = '@iconify/json',
useRemoteFallback = false,
autoUpdateRemotePackage = false
): Importer {
// Try to locate package
let dir: string | undefined;
try {
const req = createRequire(import.meta.url);
const filename = req.resolve(`${packageName}/package.json`);
dir = filename ? dirname(filename) : undefined;
} catch (err) {
//
}
if (dir) {
return createIconSetsPackageImporter(new DirectoryDownloader<ImportedData>(dir), {});
}
if (!useRemoteFallback) {
throw new Error(`Cannot find package "${packageName}"`);
}
// Try to download it, update if
const npm: RemoteDownloaderOptions = {
downloadType: 'npm',
package: packageName,
};
return createIconSetsPackageImporter(new RemoteDownloader<ImportedData>(npm, autoUpdateRemotePackage));
}

View File

@ -0,0 +1,46 @@
import { RemoteDownloader } from '../../downloaders/remote.js';
import { createIconSetsPackageImporter } from '../../importers/full/json.js';
import type { RemoteDownloaderOptions } from '../../types/downloaders/remote.js';
import type { ImportedData } from '../../types/importers/common.js';
/**
* Importer for all icon sets from `@iconify/json` package
*/
// Source options, select one you prefer
// Import from NPM. Does not require any additonal configuration
const npm: RemoteDownloaderOptions = {
downloadType: 'npm',
package: '@iconify/json',
};
// Import from GitHub. Requires setting GitHub API token in environment variable `GITHUB_TOKEN`
const github: RemoteDownloaderOptions = {
downloadType: 'github',
user: 'iconify',
repo: 'icon-sets',
branch: 'master',
token: process.env['GITHUB_TOKEN'] || '',
};
// Import from GitHub using git client. Does not require any additonal configuration
const git: RemoteDownloaderOptions = {
downloadType: 'git',
remote: 'https://github.com/iconify/icon-sets.git',
branch: 'master',
};
export const fullPackageImporter = createIconSetsPackageImporter(
new RemoteDownloader<ImportedData>(
npm,
// Automatically update on startup: boolean
true
),
{
// Filter icon sets. Returns true if icon set should be included, false if not
filter: (prefix, info) => {
return true;
},
}
);

View File

@ -0,0 +1,41 @@
import { RemoteDownloader } from '../../downloaders/remote.js';
import { createJSONCollectionsListImporter } from '../../importers/collections/collections.js';
import { createJSONPackageIconSetImporter } from '../../importers/icon-set/json-package.js';
import type { IconSetImportedData, ImportedData } from '../../types/importers/common.js';
// Automatically update on startup: boolean
const autoUpdate = true;
/**
* Importer for all icon sets from `@iconify/collections` and `@iconify-json/*` packages
*
* Differences from full importer in `full-package.ts`:
* - Slower to start because it requires downloading many packages
* - Easier to automatically keep up to date because each package is updated separately, using less storage
*/
export const splitPackagesImporter = createJSONCollectionsListImporter(
new RemoteDownloader<ImportedData>(
{
downloadType: 'npm',
package: '@iconify/collections',
},
autoUpdate
),
(prefix) =>
createJSONPackageIconSetImporter(
new RemoteDownloader<IconSetImportedData>(
{
downloadType: 'npm',
package: `@iconify-json/${prefix}`,
},
autoUpdate
),
{ prefix }
),
{
// Filter icon sets. Returns true if icon set should be included, false if not
filter: (prefix, info) => {
return true;
},
}
);

View File

@ -0,0 +1,80 @@
import type { IconifyJSON } from '@iconify/types';
import type { IconSetIconsListIcons, IconSetAPIv2IconsList } from '../../../types/icon-set/extra.js';
/**
* Prepare data for icons list API v2 response
*/
export function prepareAPIv2IconsList(iconSet: IconifyJSON, iconsList: IconSetIconsListIcons): IconSetAPIv2IconsList {
const tags = iconsList.tags;
const uncategorised = iconsList.uncategorised;
if (!tags || !uncategorised) {
throw new Error('prepareAPIv2IconsList() was called with missing data');
}
// Prepare data
const result: IconSetAPIv2IconsList = {
prefix: iconSet.prefix,
total: iconsList.total,
};
const info = iconSet.info;
if (info) {
result.title = info.name;
result.info = info;
}
// Icons without categories
if (uncategorised.length) {
result.uncategorized = uncategorised.map((item) => item[0]);
}
// Categories
if (tags.length) {
const categories = (result.categories = Object.create(null) as Record<string, string[]>);
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
categories[tag.title] = tag.icons.map((icon) => icon[0]);
}
}
// Aliases
const aliases = Object.create(null) as Record<string, string>;
for (const name in iconsList.visible) {
const item = iconsList.visible[name];
if (item[0] !== name) {
aliases[name] = item[0];
}
}
// Hidden icons
const hidden: string[] = [];
for (const name in iconsList.hidden) {
const item = iconsList.hidden[name];
if (item[0] === name) {
hidden.push(name);
} else {
aliases[name] = item[0];
}
}
if (hidden.length) {
result.hidden = hidden;
}
// Aliases
for (const key in aliases) {
result.aliases = aliases;
break;
}
if (iconsList.chars) {
// Add characters map
const chars = (result.chars = Object.create(null) as Record<string, string>);
const sourceChars = iconsList.chars;
for (const key in sourceChars) {
chars[key] = sourceChars[key][0];
}
}
return result;
}

View File

@ -0,0 +1,290 @@
import type { IconifyAliases, IconifyJSON, IconifyOptional } from '@iconify/types';
import { defaultIconProps } from '@iconify/utils/lib/icon/defaults';
import { appConfig } from '../../../config/app.js';
import type {
IconSetIconNames,
IconSetIconsListIcons,
IconSetIconsListTag,
IconStyle,
} from '../../../types/icon-set/extra.js';
import { getIconStyle } from './style.js';
const customisableProps = Object.keys(defaultIconProps) as (keyof IconifyOptional)[];
/**
* Generate icons tree
*/
export function generateIconSetIconsTree(iconSet: IconifyJSON, commonChunks?: string[]): IconSetIconsListIcons {
const iconSetIcons = iconSet.icons;
const iconSetAliases = iconSet.aliases || (Object.create(null) as IconifyAliases);
const checked: Set<string> = new Set();
const visible = Object.create(null) as Record<string, IconSetIconNames>;
const hidden = Object.create(null) as Record<string, IconSetIconNames>;
const failed: Set<string> = new Set();
let total = 0;
// Generate list of tags for each icon
const tags: IconSetIconsListTag[] = [];
const uncategorised: IconSetIconNames[] = [];
const resolvedTags = Object.create(null) as Record<string, Set<IconSetIconsListTag>>;
const categories = iconSet.categories;
if (categories && appConfig.enableIconLists) {
for (const title in categories) {
const items = categories[title];
if (items instanceof Array) {
const icons: IconSetIconNames[] = [];
const tag: IconSetIconsListTag = {
title,
icons,
};
tags.push(tag);
for (let i = 0; i < items.length; i++) {
const name = items[i];
(resolvedTags[name] || (resolvedTags[name] = new Set())).add(tag);
}
}
}
}
// Parse all icons
let detectedIconStyle: IconStyle | undefined | null;
const iconsWithStroke: Set<IconSetIconNames> = new Set();
const iconsWithFill: Set<IconSetIconNames> = new Set();
const checkIconStyle =
appConfig.allowFilterIconsByStyle && appConfig.enableSearchEngine && appConfig.enableIconLists;
for (const name in iconSetIcons) {
const isVisible = !iconSetIcons[name].hidden;
const icon: IconSetIconNames = [name];
(isVisible ? visible : hidden)[name] = icon;
checked.add(name);
if (isVisible) {
total++;
// Check tags
if (appConfig.enableIconLists) {
const iconTags = resolvedTags[name];
if (iconTags) {
// Add icon to each tag
iconTags.forEach((tag) => {
tag.icons.push(icon);
});
} else {
// No tags: uncategorised
uncategorised.push(icon);
}
}
// Check content
if (checkIconStyle) {
const body = iconSetIcons[name].body;
const iconStyle = getIconStyle(body);
if (iconStyle) {
(iconStyle === 'stroke' ? iconsWithStroke : iconsWithFill).add(icon);
}
if (detectedIconStyle === void 0) {
// First item
detectedIconStyle = iconStyle;
} else if (detectedIconStyle && detectedIconStyle !== iconStyle) {
// Different style
detectedIconStyle = null;
}
}
}
}
// Parse all aliases
const resolve = (name: string) => {
if (checked.has(name)) {
// Already checked
return;
}
checked.add(name);
// Mark as failed to avoid loop, will be removed later on success
failed.add(name);
const item = iconSetAliases[name];
if (!item) {
// Failed
return;
}
// Get parent
const parent = item.parent;
if (!checked.has(parent)) {
resolve(parent);
}
// Get parent
if (failed.has(parent)) {
return;
}
// Check if item has transformations
let transformed = false;
for (let i = 0; i < customisableProps.length; i++) {
if (item[customisableProps[i]] !== void 0) {
transformed = true;
break;
}
}
// Check visibility
const parentVisible = !!visible[parent];
let isVisible: boolean;
if (typeof item.hidden === 'boolean') {
isVisible = !item.hidden;
} else {
// Same visibility as parent icon
isVisible = parentVisible;
}
// Add icon
const parentIcon = visible[parent] || hidden[parent];
let icon: IconSetIconNames;
if (transformed || isVisible !== parentVisible) {
// Treat as new icon
icon = [name];
if (isVisible) {
total++;
// Check for categories
if (appConfig.enableIconLists) {
const iconTags = resolvedTags[name];
if (iconTags) {
// Alias has its own categories!
iconTags.forEach((tag) => {
tag.icons.push(icon);
});
} else {
// Copy from parent
const iconTags = resolvedTags[parentIcon[0]];
if (iconTags) {
resolvedTags[name] = iconTags;
iconTags.forEach((tag) => {
tag.icons.push(icon);
});
} else {
uncategorised.push(icon);
}
}
}
// Add style
if (checkIconStyle) {
if (iconsWithFill.has(parentIcon)) {
iconsWithFill.add(icon);
}
if (iconsWithStroke.has(parentIcon)) {
iconsWithStroke.add(icon);
}
}
}
} else {
// Treat as alias: add to parent icon
icon = parentIcon;
icon.push(name);
}
(isVisible ? visible : hidden)[name] = icon;
// Success
failed.delete(name);
};
for (const name in iconSetAliases) {
resolve(name);
}
// Create data
const result: IconSetIconsListIcons = {
total,
visible,
hidden,
failed,
};
// Sort icons in tags
if (appConfig.enableIconLists) {
for (let i = 0; i < tags.length; i++) {
tags[i].icons.sort((a, b) => a[0].localeCompare(b[0]));
}
result.tags = tags.filter((tag) => tag.icons.length > 0);
result.uncategorised = uncategorised.sort((a, b) => a[0].localeCompare(b[0]));
}
// Add characters
if (iconSet.chars) {
const sourceChars = iconSet.chars;
const chars = Object.create(null) as Record<string, IconSetIconNames>;
for (const char in sourceChars) {
const name = sourceChars[char];
const item = visible[name] || hidden[name];
if (item) {
chars[char] = item;
}
}
result.chars = chars;
}
// Generate keywords for all visible icons if:
// - search engine is enabled
// - icon set has info (cannot search icon set if cannot show it)
// - icon set is not marked as hidden
if (appConfig.enableIconLists && appConfig.enableSearchEngine && iconSet.info && !iconSet.info.hidden) {
const keywords = (result.keywords = Object.create(null) as Record<string, Set<IconSetIconNames>>);
for (const name in visible) {
const icon = visible[name];
if (icon[0] !== name) {
// Alias. Another entry for parent icon should be present in `visible` object
continue;
}
const iconKeywords: Set<string> = new Set();
for (let i = 0; i < icon.length; i++) {
const name = icon[i];
// Add keywords
name.split('-').forEach((chunk) => {
if (iconKeywords.has(chunk)) {
return;
}
iconKeywords.add(chunk);
(keywords[chunk] || (keywords[chunk] = new Set())).add(icon);
});
}
// Check for length based on first name
if (commonChunks) {
for (let j = 0; j < commonChunks.length; j++) {
const chunk = commonChunks[j];
if (name.startsWith(chunk + '-') || name.endsWith('-' + chunk)) {
icon._l = name.length - chunk.length - 1;
break;
}
}
}
}
// Icon style
if (checkIconStyle) {
if (detectedIconStyle) {
result.iconStyle = detectedIconStyle;
} else if (iconsWithFill.size || iconsWithStroke.size) {
// Mixed styles: assign to icon object
result.iconStyle = 'mixed';
iconsWithFill.forEach((item) => {
item._is = 'fill';
});
iconsWithStroke.forEach((item) => {
item._is = 'stroke';
});
}
}
}
return result;
}

View File

@ -0,0 +1,39 @@
import type { IconStyle } from '../../../types/icon-set/extra.js';
function getValues(body: string, prop: string): string[] {
const chunks = body.split(prop + '="');
chunks.shift();
return chunks.map((item) => {
const index = item.indexOf('"');
return index > 0 ? item.slice(0, index) : '';
});
}
function hasValues(body: string, prop: string): boolean {
const fills = getValues(body, prop);
for (let i = 0; i < fills.length; i++) {
switch (fills[i].toLowerCase()) {
case '':
case 'none':
case 'transparent':
case 'inherit':
break;
default:
return true;
}
}
return false;
}
/**
* Check if icon uses fill or stroke
*
* Returns null on failure
*/
export function getIconStyle(body: string): IconStyle | null {
const hasStroke = hasValues(body, 'stroke');
const hasFill = hasValues(body, 'fill');
return hasStroke ? (hasFill ? null : 'stroke') : hasFill ? 'fill' : null;
}

View File

@ -0,0 +1,28 @@
import type { IconifyJSON } from '@iconify/types';
import type { IconSetIconsListIcons } from '../../../types/icon-set/extra.js';
/**
* Removes bad items
*/
export function removeBadIconSetItems(data: IconifyJSON, iconsList: IconSetIconsListIcons) {
// Remove bad aliases
const aliases = data.aliases;
if (aliases) {
iconsList.failed.forEach((name) => {
delete aliases[name];
});
}
// Remove bad characters
const chars = iconsList.chars;
if (chars) {
const visible = iconsList.visible;
const hidden = iconsList.hidden;
for (const key in chars) {
if (visible[key] || hidden[key]) {
// Character matches existing icon
delete chars[key];
}
}
}
}

View File

@ -0,0 +1,80 @@
import type { IconifyIcons, IconifyJSON } from '@iconify/types';
import { defaultIconDimensions } from '@iconify/utils/lib/icon/defaults';
import type { SplitIconSetConfig } from '../../../types/config/split.js';
import type { SplitIconifyJSONMainData } from '../../../types/icon-set/split.js';
const iconDimensionProps = Object.keys(defaultIconDimensions) as (keyof typeof defaultIconDimensions)[];
const iconSetMainDataProps: (keyof SplitIconifyJSONMainData)[] = [
'prefix',
'lastModified',
'aliases',
...iconDimensionProps,
];
/**
* Get main data
*/
export function splitIconSetMainData(iconSet: IconifyJSON): SplitIconifyJSONMainData {
const result = {} as SplitIconifyJSONMainData;
for (let i = 0; i < iconSetMainDataProps.length; i++) {
const prop = iconSetMainDataProps[i];
if (iconSet[prop]) {
const value = iconSet[prop as 'prefix'];
if (typeof value === 'object') {
// Make sure object has null as constructor
result[prop as 'prefix'] = Object.create(null);
Object.assign(result[prop as 'prefix'], iconSet[prop as 'prefix']);
} else {
result[prop as 'prefix'] = iconSet[prop as 'prefix'];
}
} else if (prop === 'aliases') {
result[prop] = Object.create(null);
}
}
if (!iconSet.aliases) {
result.aliases = Object.create(null);
}
return result;
}
/**
* Get size of icons without serialising whole thing, used for splitting icon set
*/
export function getIconSetIconsSize(icons: IconifyIcons): number {
let length = 0;
for (const name in icons) {
length += icons[name].body.length;
}
return length;
}
/**
* Split icon set
*/
export function getIconSetSplitChunksCount(icons: IconifyIcons, config: SplitIconSetConfig): number {
const chunkSize = config.chunkSize;
if (!chunkSize) {
return 1;
}
// Calculate split based on icon count
const numIcons = Object.keys(icons).length;
const resultFromCount = Math.floor(numIcons / config.minIconsPerChunk);
if (resultFromCount < 3) {
// Too few icons: don't split
return 1;
}
// Calculate number of chunks from icons size
const size = getIconSetIconsSize(icons);
const resultFromSize = Math.floor(size / chunkSize);
if (resultFromSize < 3) {
// Too small: don't split
return 1;
}
return Math.min(resultFromCount, resultFromSize);
}

View File

@ -0,0 +1,148 @@
import type { IconifyIcons, IconifyJSON } from '@iconify/types';
import { appConfig, splitIconSetConfig, storageConfig } from '../../../config/app.js';
import type { SplitIconSetConfig } from '../../../types/config/split.js';
import type { StorageIconSetThemes, StoredIconSet, StoredIconSetDone } from '../../../types/icon-set/storage.js';
import type { SplitRecord } from '../../../types/split.js';
import type { MemoryStorage, MemoryStorageItem } from '../../../types/storage.js';
import { createSplitRecordsTree, splitRecords } from '../../storage/split.js';
import { createStorage, createStoredItem } from '../../storage/create.js';
import { getIconSetSplitChunksCount, splitIconSetMainData } from './split.js';
import { removeBadIconSetItems } from '../lists/validate.js';
import { prepareAPIv2IconsList } from '../lists/icons-v2.js';
import { generateIconSetIconsTree } from '../lists/icons.js';
import { themeKeys, findIconSetThemes } from './themes.js';
/**
* Storage
*/
export const iconSetsStorage = createStorage<IconifyIcons>(storageConfig);
/**
* Counter for prefixes
*/
let counter = Date.now();
/**
* Split and store icon set
*/
export function storeLoadedIconSet(
iconSet: IconifyJSON,
done: StoredIconSetDone,
// Optional parameters, can be changed if needed
storage: MemoryStorage<IconifyIcons> = iconSetsStorage,
config: SplitIconSetConfig = splitIconSetConfig
) {
let themes: StorageIconSetThemes | undefined;
let themeParts: string[] | undefined;
if (appConfig.enableIconLists) {
// Get themes
if (appConfig.enableIconLists) {
const themesList: StorageIconSetThemes = {};
for (let i = 0; i < themeKeys.length; i++) {
const key = themeKeys[i];
if (iconSet[key]) {
themesList[key as 'prefixes'] = iconSet[key as 'prefixes'];
themes = themesList;
}
}
// Get common parts of icon names for optimised search
if (appConfig.enableSearchEngine) {
const data = findIconSetThemes(iconSet);
if (data.length) {
themeParts = data;
}
}
}
}
// Get icons
const icons = generateIconSetIconsTree(iconSet, themeParts);
removeBadIconSetItems(iconSet, icons);
// Fix icons counter
if (iconSet.info) {
iconSet.info.total = icons.total;
}
// Get common items
const common = splitIconSetMainData(iconSet);
// Get number of chunks
const chunksCount = getIconSetSplitChunksCount(iconSet.icons, config);
// Stored items
const splitItems: SplitRecord<MemoryStorageItem<IconifyIcons>>[] = [];
const storedItems: MemoryStorageItem<IconifyIcons>[] = [];
// Split
const cachePrefix = `${iconSet.prefix}.${counter++}.`;
splitRecords(
iconSet.icons,
chunksCount,
(splitIcons, next, index) => {
// Store data
createStoredItem<IconifyIcons>(storage, splitIcons.data, cachePrefix + index, true, (storedItem) => {
// Create split record for stored item
const storedSplitItem: SplitRecord<typeof storedItem> = {
keyword: splitIcons.keyword,
data: storedItem,
};
storedItems.push(storedItem);
splitItems.push(storedSplitItem);
next();
});
},
() => {
// Create tree
const tree = createSplitRecordsTree(splitItems);
// Generate result
const result: StoredIconSet = {
common,
storage,
items: storedItems,
tree,
icons,
themes,
};
if (iconSet.info) {
result.info = iconSet.info;
}
if (appConfig.enableIconLists) {
result.apiV2IconsCache = prepareAPIv2IconsList(iconSet, icons);
if (appConfig.enableSearchEngine && themeParts?.length) {
result.themeParts = themeParts;
}
}
done(result);
}
);
}
/**
* Promise version of storeLoadedIconSet()
*/
export function asyncStoreLoadedIconSet(
iconSet: IconifyJSON,
// Optional parameters, can be changed if needed
storage: MemoryStorage<IconifyIcons> = iconSetsStorage,
config: SplitIconSetConfig = splitIconSetConfig
): Promise<StoredIconSet> {
return new Promise((fulfill) => {
storeLoadedIconSet(
iconSet,
(data: StoredIconSet) => {
// Purge unused memory if garbage collector global is exposed
try {
global.gc?.();
} catch {}
fulfill(data);
},
storage,
config
);
});
}

View File

@ -0,0 +1,69 @@
import { IconifyJSON } from '@iconify/types';
import { StorageIconSetThemes } from '../../../types/icon-set/storage.js';
/**
* Themes to copy
*/
export const themeKeys: (keyof StorageIconSetThemes)[] = ['prefixes', 'suffixes'];
/**
* Hardcoded list of themes
*
* Should contain only simple items, without '-'
*/
const hardcodedThemes: Set<string> = new Set([
'baseline',
'outline',
'round',
'sharp',
'twotone',
'thin',
'light',
'bold',
'fill',
'duotone',
'linear',
'line',
'solid',
'filled',
'outlined',
]);
/**
* Find icon
*/
export function findIconSetThemes(iconSet: IconifyJSON): string[] {
const results: Set<string> = new Set();
// Add prefixes / suffixes from themes
themeKeys.forEach((key) => {
const items = iconSet[key];
if (items) {
Object.keys(items).forEach((item) => {
if (item) {
results.add(item);
}
});
}
});
// Check all icons and aliases
const names = Object.keys(iconSet.icons).concat(Object.keys(iconSet.aliases || {}));
for (let i = 0; i < names.length; i++) {
const name = names[i];
const parts = name.split('-');
if (parts.length > 1) {
const firstChunk = parts.shift() as string;
const lastChunk = parts.pop() as string;
if (hardcodedThemes.has(firstChunk)) {
results.add(firstChunk);
}
if (hardcodedThemes.has(lastChunk)) {
results.add(lastChunk);
}
}
}
// Return as array, sorted by length
return Array.from(results).sort((a, b) => b.length - a.length);
}

View File

@ -0,0 +1,108 @@
import type { ExtendedIconifyAlias, ExtendedIconifyIcon, IconifyIcons } from '@iconify/types';
import { mergeIconData } from '@iconify/utils/lib/icon/merge';
import type { SplitIconifyJSONMainData } from '../../../types/icon-set/split.js';
import type { StoredIconSet } from '../../../types/icon-set/storage.js';
import { searchSplitRecordsTree } from '../../storage/split.js';
import { getStoredItem } from '../../storage/get.js';
interface PrepareResult {
// Merged properties
props: ExtendedIconifyIcon | ExtendedIconifyAlias;
// Name of icon to merge with
name: string;
}
function prepareAlias(data: SplitIconifyJSONMainData, name: string): PrepareResult {
const aliases = data.aliases;
// Resolve aliases tree
let props: ExtendedIconifyIcon | ExtendedIconifyAlias = aliases[name];
name = props.parent;
while (true) {
const alias = aliases[name];
if (alias) {
// Another alias
props = mergeIconData(alias, props);
name = alias.parent;
} else {
// Icon
return {
props,
name,
};
}
}
}
/**
* Get icon data
*
* Assumes that icon exists and valid. Should validate icon set and load data before running this function
*/
export function getIconData(data: SplitIconifyJSONMainData, name: string, icons: IconifyIcons): ExtendedIconifyIcon {
// Get data
let props: ExtendedIconifyIcon | ExtendedIconifyAlias;
if (icons[name]) {
// Icon: copy as is
props = icons[name];
} else {
// Resolve alias
const result = prepareAlias(data, name);
props = mergeIconData(icons[result.name], result.props);
}
// Add default values
return mergeIconData(data, props) as unknown as ExtendedIconifyIcon;
}
/**
* Get icon data from stored icon set
*/
export function getStoredIconData(
iconSet: StoredIconSet,
name: string,
callback: (data: ExtendedIconifyIcon | null) => void
) {
const common = iconSet.common;
// Get data
let props: ExtendedIconifyIcon | ExtendedIconifyAlias;
if (common.aliases[name]) {
const resolved = prepareAlias(common, name);
props = resolved.props;
name = resolved.name;
} else {
props = {} as ExtendedIconifyAlias;
const charValue = iconSet.icons.chars?.[name]?.[0];
if (charValue) {
// Character
const icons = iconSet.icons;
if (!icons.visible[name] && !icons.hidden[name]) {
// Resolve character instead of alias
name = charValue;
if (common.aliases[name]) {
const resolved = prepareAlias(common, name);
props = resolved.props;
name = resolved.name;
}
}
}
}
// Load icon
const chunk = searchSplitRecordsTree(iconSet.tree, name);
getStoredItem(iconSet.storage, chunk, (data) => {
if (!data || !data[name]) {
// Failed
callback(null);
return;
}
// Merge icon data with aliases
props = mergeIconData(data[name], props);
// Add default values
callback(mergeIconData(common, props) as unknown as ExtendedIconifyIcon);
});
}

View File

@ -0,0 +1,120 @@
import type { IconifyJSON, IconifyAliases, IconifyIcons } from '@iconify/types';
import type { StoredIconSet } from '../../../types/icon-set/storage.js';
import { searchSplitRecordsTreeForSet } from '../../storage/split.js';
import { getStoredItem } from '../../storage/get.js';
/**
* Get list of icons that must be retrieved
*/
export function getIconsToRetrieve(iconSet: StoredIconSet, names: string[], copyTo?: IconifyAliases): Set<string> {
const icons: Set<string> = new Set();
const iconSetData = iconSet.common;
const iconsData = iconSet.icons;
const chars = iconsData.chars;
const aliases = iconSetData.aliases || (Object.create(null) as IconifyAliases);
function resolve(name: string, nested: boolean) {
if (!iconsData.visible[name] && !iconsData.hidden[name]) {
// No such icon: check for character
const charValue = chars?.[name]?.[0];
if (!charValue) {
return;
}
// Resolve character instead of alias
copyTo &&
(copyTo[name] = {
parent: charValue,
});
resolve(charValue, true);
return;
}
// Icon or alias exists
if (!aliases[name]) {
// Icon
icons.add(name);
return;
}
// Alias: copy it
const item = aliases[name];
copyTo && (copyTo[name] = item);
// Resolve parent
resolve(item.parent, true);
}
for (let i = 0; i < names.length; i++) {
resolve(names[i], false);
}
return icons;
}
/**
* Get icons from stored icon set
*/
export function getStoredIconsData(iconSet: StoredIconSet, names: string[], callback: (data: IconifyJSON) => void) {
// Get list of icon names
const aliases = Object.create(null) as IconifyAliases;
const iconNames = Array.from(getIconsToRetrieve(iconSet, names, aliases));
if (!iconNames.length) {
// Nothing to retrieve
callback({
...iconSet.common,
icons: Object.create(null),
aliases,
not_found: names,
});
return;
}
// Get map of chunks to load
const chunks = searchSplitRecordsTreeForSet(iconSet.tree, iconNames);
let pending = chunks.size;
let missing: Set<string> = new Set();
const icons = Object.create(null) as IconifyIcons;
const storage = iconSet.storage;
chunks.forEach((chunkNames, storedItem) => {
getStoredItem(storage, storedItem, (data) => {
// Copy data from chunk
if (!data) {
missing = new Set([...chunkNames, ...missing]);
} else {
for (let i = 0; i < chunkNames.length; i++) {
const name = chunkNames[i];
if (data[name]) {
icons[name] = data[name];
} else {
missing.add(name);
}
}
}
// Check if all chunks have loaded
pending--;
if (!pending) {
const result: IconifyJSON = {
...iconSet.common,
icons,
aliases,
};
// Add missing icons
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (!icons[name] && !aliases[name]) {
missing.add(name);
}
}
if (missing.size) {
result.not_found = Array.from(missing);
}
callback(result);
}
});
});
}

160
src/data/icon-sets.ts Normal file
View File

@ -0,0 +1,160 @@
import type { StoredIconSet } from '../types/icon-set/storage.js';
import type { IconSetEntry, Importer } from '../types/importers.js';
import { updateSearchIndex } from './search.js';
/**
* All importers
*/
let importers: Importer[] | undefined;
export function setImporters(items: Importer[]) {
if (importers) {
throw new Error('Importers can be set only once');
}
importers = items;
}
/**
* All prefixes, sorted
*/
let allPrefixes: string[] = [];
let prefixesWithInfo: string[] = [];
let visiblePrefixes: string[] = [];
/**
* Get all prefixes
*/
type GetPrefixes = 'all' | 'info' | 'visible';
export function getPrefixes(type: GetPrefixes = 'all'): string[] {
switch (type) {
case 'all':
return allPrefixes;
case 'info':
return prefixesWithInfo;
case 'visible':
return visiblePrefixes;
}
}
/**
* All icon sets
*/
export const iconSets = Object.create(null) as Record<string, IconSetEntry>;
/**
* Loaded icon sets
*/
let loadedIconSets: Set<StoredIconSet> = new Set();
/**
* Merge data
*/
export function updateIconSets(): number {
if (!importers) {
return 0;
}
const newLoadedIconSets: Set<StoredIconSet> = new Set();
const newPrefixes: Set<string> = new Set();
const newPrefixesWithInfo: Set<string> = new Set();
const newVisiblePrefixes: Set<string> = new Set();
importers.forEach((importer, importerIndex) => {
const data = importer.data;
if (!data) {
return;
}
data.prefixes.forEach((prefix) => {
const item = data.iconSets[prefix];
if (!item) {
return;
}
// Add to list of loaded icon sets
newLoadedIconSets.add(item);
loadedIconSets.delete(item);
// Add prefix, but delete it first to keep order
newPrefixes.delete(prefix);
newPrefixesWithInfo.delete(prefix);
newVisiblePrefixes.delete(prefix);
newPrefixes.add(prefix);
if (item.info) {
newPrefixesWithInfo.add(prefix);
if (!item.info.hidden) {
newVisiblePrefixes.add(prefix);
}
}
// Set data
iconSets[prefix] = {
importer,
item,
};
});
});
// Replace list of icon sets
if (loadedIconSets.size) {
// Got some icon sets to clean up
const cleanup = loadedIconSets;
// TODO: clean up old icon sets
}
loadedIconSets = newLoadedIconSets;
// Update prefixes
allPrefixes = Array.from(newPrefixes);
prefixesWithInfo = Array.from(newPrefixesWithInfo);
visiblePrefixes = Array.from(newVisiblePrefixes);
// Update search index
updateSearchIndex(allPrefixes, iconSets);
// Purge unused memory if garbage collector global is exposed
try {
global.gc?.();
} catch {}
return allPrefixes.length;
}
/**
* Trigger update
*/
export function triggerIconSetsUpdate(index?: number | null, done?: (success?: boolean) => void) {
if (!importers) {
done?.();
return;
}
console.log('Checking for updates...');
(async () => {
// Clear as much memory as possible by running storage cleanup immediately
try {
global.gc?.();
} catch {}
// Check for updates
let updated = false;
for (let i = 0; i < importers?.length; i++) {
if (typeof index === 'number' && i !== index) {
continue;
}
updated = (await importers[i].checkForUpdate()) || updated;
}
return updated;
})()
.then((updated) => {
console.log(updated ? 'Update complete' : 'Nothing to update');
updateIconSets();
done?.(true);
})
.catch((err) => {
console.error(err);
done?.(false);
});
}

41
src/data/loading.ts Normal file
View File

@ -0,0 +1,41 @@
// Status
let loading = true;
// Queue
type Callback = () => void;
const queue: Callback[] = [];
/**
* Loaded: run queue
*/
export function loaded() {
loading = false;
// Run queue
let callback: Callback | undefined;
while ((callback = queue.shift())) {
try {
callback();
} catch (err) {
console.error(err);
}
}
}
/**
* Get state
*/
export function isLoading() {
return loading;
}
/**
* Run when app is ready
*/
export function runWhenLoaded(callback: Callback) {
if (!loading) {
callback();
} else {
queue.push(callback);
}
}

54
src/data/search.ts Normal file
View File

@ -0,0 +1,54 @@
import { appConfig } from '../config/app.js';
import type { IconSetEntry } from '../types/importers.js';
import type { SearchIndexData } from '../types/search.js';
interface SearchIndex {
data?: SearchIndexData;
}
/**
* Search data
*/
export const searchIndex: SearchIndex = {};
/**
* Update search index
*/
export function updateSearchIndex(
prefixes: string[],
iconSets: Record<string, IconSetEntry>
): SearchIndexData | undefined {
if (!appConfig.enableIconLists || !appConfig.enableSearchEngine) {
// Search engine is disabled
delete searchIndex.data;
return;
}
// Parse all icon sets
const sortedPrefixes: string[] = [];
const keywords = Object.create(null) as Record<string, Set<string>>;
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
const iconSet = iconSets[prefix]?.item;
if (!iconSet) {
continue;
}
const iconSetKeywords = iconSet.icons.keywords;
if (!iconSetKeywords) {
continue;
}
sortedPrefixes.push(prefix);
for (const keyword in iconSetKeywords) {
(keywords[keyword] || (keywords[keyword] = new Set())).add(prefix);
}
}
// Set data
return (searchIndex.data = {
sortedPrefixes,
keywords,
partialCleanup: Date.now(),
});
}

275
src/data/search/index.ts Normal file
View File

@ -0,0 +1,275 @@
import { appConfig } from '../../config/app.js';
import type { IconSetIconNames } from '../../types/icon-set/extra.js';
import type { IconSetEntry } from '../../types/importers.js';
import type { SearchIndexData, SearchKeywordsEntry, SearchParams, SearchResultsData } from '../../types/search.js';
import { getPartialKeywords } from './partial.js';
import { filterSearchPrefixes, filterSearchPrefixesList } from './prefixes.js';
import { splitKeyword } from './split.js';
/**
* Run search
*/
export function search(
params: SearchParams,
data: SearchIndexData,
iconSets: Record<string, IconSetEntry>
): SearchResultsData | undefined {
// Get keywords
const keywords = splitKeyword(params.keyword, params.partial);
if (!keywords) {
return;
}
// Merge params
const fullParams = {
...params,
// Params extracted from query override default params
...keywords.params,
};
// Make sure all keywords exist
keywords.searches = keywords.searches.filter((search) => {
for (let i = 0; i < search.keywords.length; i++) {
if (!data.keywords[search.keywords[i]]) {
// One of required keywords is missing: no point in searching
return false;
}
}
return true;
});
if (!keywords.searches.length) {
return;
}
// Get prefixes
const basePrefixes = filterSearchPrefixes(data, iconSets, fullParams);
// Prepare variables
const addedIcons = Object.create(null) as Record<string, Set<IconSetIconNames>>;
// Results, sorted
interface TemporaryResultItem {
length: number;
partial: boolean;
names: string[];
}
const allMatches: TemporaryResultItem[] = [];
let allMatchesLength = 0;
const getMatchResult = (length: number, partial: boolean): TemporaryResultItem => {
const result = allMatches.find((item) => item.length === length && item.partial === partial);
if (result) {
return result;
}
const newItem: TemporaryResultItem = {
length,
partial,
names: [],
};
allMatches.push(newItem);
return newItem;
};
const limit = params.limit;
const softLimit = params.softLimit;
interface ExtendedSearchKeywordsEntry extends SearchKeywordsEntry {
// Add prefixes cache to avoid re-calculating it for every partial keyword
filteredPrefixes?: Readonly<string[]>;
}
const runSearch = (search: ExtendedSearchKeywordsEntry, isExact: boolean, partial?: string) => {
// Filter prefixes (or get it from cache)
let filteredPrefixes: Readonly<string[]>;
if (search.filteredPrefixes) {
filteredPrefixes = search.filteredPrefixes;
} else {
filteredPrefixes = search.prefixes ? filterSearchPrefixesList(basePrefixes, search.prefixes) : basePrefixes;
// Filter by required keywords
for (let i = 0; i < search.keywords.length; i++) {
filteredPrefixes = filteredPrefixes.filter((prefix) => data.keywords[search.keywords[i]]?.has(prefix));
}
search.filteredPrefixes = filteredPrefixes;
}
if (!filteredPrefixes.length) {
return;
}
// Get keywords
const testKeywords = partial ? search.keywords.concat([partial]) : search.keywords;
const testMatches = search.test ? search.test.concat(testKeywords) : testKeywords;
// Check for partial keyword if testing for exact match
if (partial) {
filteredPrefixes = filteredPrefixes.filter((prefix) => data.keywords[partial]?.has(prefix));
}
// Check icons
for (let prefixIndex = 0; prefixIndex < filteredPrefixes.length; prefixIndex++) {
const prefix = filteredPrefixes[prefixIndex];
const prefixAddedIcons = addedIcons[prefix] || (addedIcons[prefix] = new Set());
const iconSet = iconSets[prefix].item;
const iconSetIcons = iconSet.icons;
const iconSetKeywords = iconSetIcons.keywords;
if (!iconSetKeywords) {
// This should not happen!
continue;
}
// Check icons in current prefix
let matches: IconSetIconNames[] | undefined;
let failed = false;
for (let keywordIndex = 0; keywordIndex < testKeywords.length && !failed; keywordIndex++) {
const keyword = testKeywords[keywordIndex];
const keywordMatches = iconSetKeywords[keyword];
if (!keywordMatches) {
failed = true;
break;
}
if (!matches) {
// Copy all matches
matches = Array.from(keywordMatches);
} else {
// Match previous set
matches = matches.filter((item) => keywordMatches.has(item));
}
}
// Test matched icons
if (!failed && matches) {
for (let matchIndex = 0; matchIndex < matches.length; matchIndex++) {
const item = matches[matchIndex];
if (prefixAddedIcons.has(item)) {
// Already added
continue;
}
// Check style
if (
// Style is set
fullParams.style &&
// Enabled in config
appConfig.allowFilterIconsByStyle &&
// Icon set has mixed style (so it is assigned to icons) -> check icon
iconSetIcons.iconStyle === 'mixed' &&
item._is !== fullParams.style
) {
// Different icon style
continue;
}
// Find icon name that matches all keywords
let length: number | undefined;
const name = item.find((name, index) => {
for (let i = 0; i < testMatches.length; i++) {
if (name.indexOf(testMatches[i]) === -1) {
return false;
}
// Get length
if (!index) {
// First item sets `_l`, unless it didn't match any prefixes/suffixes
length = item._l || name.length;
} else if (iconSet.themeParts) {
// Alias: calculate length
const themeParts = iconSet.themeParts;
for (let partIndex = 0; partIndex < themeParts.length; partIndex++) {
const part = themeParts[partIndex];
if (name.startsWith(part + '-') || name.endsWith('-' + part)) {
length = name.length - part.length - 1;
break;
}
}
}
}
return true;
});
if (name) {
// Add icon
prefixAddedIcons.add(item);
const list = getMatchResult(length || name.length, !isExact);
list.names.push(prefix + ':' + name);
allMatchesLength++;
if (!isExact && allMatchesLength >= limit) {
// Return only if checking for partials and limit reached
return;
}
}
}
}
}
};
const runAllSearches = (isExact: boolean) => {
for (let searchIndex = 0; searchIndex < keywords.searches.length; searchIndex++) {
const search = keywords.searches[searchIndex];
const partial = search.partial;
if (partial) {
// Has partial
if (isExact) {
if (data.keywords[partial]) {
runSearch(search, true, partial);
}
} else {
// Get all partial matches
const keywords = getPartialKeywords(partial, true, data);
if (keywords) {
for (let keywordIndex = 0; keywordIndex < keywords.length; keywordIndex++) {
runSearch(search, false, keywords[keywordIndex]);
}
}
}
} else {
// No partial for this search
if (!isExact) {
continue;
}
// Exact match
runSearch(search, true);
}
// Check limit
if (!isExact && allMatchesLength >= limit) {
return;
}
}
};
// Check all keywords
try {
runAllSearches(true);
if (allMatchesLength < limit) {
runAllSearches(false);
}
} catch (err) {
console.warn('Got exception when searching for:', params);
console.error(err);
}
// Generate results
if (allMatchesLength) {
// Sort matches
allMatches.sort((a, b) => (a.partial !== b.partial ? (a.partial ? 1 : -1) : a.length - b.length));
// Extract results
const results: string[] = [];
const prefixes: Set<string> = new Set();
for (let i = 0; i < allMatches.length && (softLimit || results.length < limit); i++) {
const { names } = allMatches[i];
for (let j = 0; j < names.length && (softLimit || results.length < limit); j++) {
const name = names[j];
results.push(name);
prefixes.add(name.split(':').shift() as string);
}
}
return {
prefixes: Array.from(prefixes),
names: results,
hasMore: results.length >= limit,
};
}
}

View File

@ -0,0 +1,57 @@
import type { PartialKeywords, SearchIndexData } from '../../types/search.js';
import { searchIndex } from '../search.js';
export const minPartialKeywordLength = 2;
/**
* Find partial keywords for keyword
*/
export function getPartialKeywords(
keyword: string,
suffixes: boolean,
data: SearchIndexData | undefined = searchIndex.data
): PartialKeywords | undefined {
// const data = searchIndex.data;
const length = keyword.length;
if (!data || length < minPartialKeywordLength) {
return;
}
// Check cache
const storedItem = (suffixes ? data.partial : data.partialPrefixes)?.[keyword];
if (storedItem) {
return storedItem;
}
// Cache takes a lot of memory, so clean up old cache once every few minutes before generating new item
const time = Date.now();
if (data.partialCleanup < time - 60000) {
delete data.partial;
delete data.partialPrefixes;
data.partialCleanup = time;
}
const storageKey = suffixes ? 'partial' : 'partialPrefixes';
const storage =
data[storageKey] || (data[storageKey] = Object.create(null) as Exclude<SearchIndexData['partial'], undefined>);
// Generate partial list
const prefixMatches: string[] = [];
const suffixMatches: string[] = [];
// Find similar keywords
const keywords = data.keywords;
for (const item in keywords) {
if (item.length > length) {
if (item.slice(0, length) === keyword) {
prefixMatches.push(item);
} else if (suffixes && item.slice(0 - length) === keyword) {
suffixMatches.push(item);
}
}
}
// Sort: shortest matches first
return (storage[keyword] = prefixMatches
.sort((a, b) => (a.length === b.length ? a.localeCompare(b) : a.length - b.length))
.concat(suffixMatches.sort((a, b) => (a.length === b.length ? a.localeCompare(b) : a.length - b.length))));
}

View File

@ -0,0 +1,85 @@
import { appConfig } from '../../config/app.js';
import type { IconSetEntry } from '../../types/importers.js';
import type { SearchIndexData, SearchParams } from '../../types/search.js';
/**
* Filter prefixes by keyword
*/
export function filterSearchPrefixesList(prefixes: readonly string[], filters: string[]): string[] {
const set = new Set(filters);
const hasPartial = !!filters.find((item) => item.slice(-1) === '-');
return prefixes.filter((prefix) => {
if (set.has(prefix)) {
return true;
}
if (hasPartial) {
// Check for partial matches
const parts = prefix.split('-');
let test = '';
while (parts.length > 1) {
test += parts.shift() + '-';
if (set.has(test)) {
return true;
}
}
}
return false;
});
}
/**
* Filter prefixes
*/
export function filterSearchPrefixes(
data: SearchIndexData,
iconSets: Record<string, IconSetEntry>,
params: SearchParams
): Readonly<string[]> {
let prefixes: string[] | undefined;
// Filter by palette
const palette = params.palette;
if (typeof palette === 'boolean') {
prefixes = (prefixes || data.sortedPrefixes).filter((prefix) => {
const info = iconSets[prefix].item.info;
return info?.palette === palette;
});
}
// Filter by style
if (appConfig.allowFilterIconsByStyle) {
const style = params.style;
if (style) {
prefixes = (prefixes || data.sortedPrefixes).filter((prefix) => {
const iconSetStyle = iconSets[prefix].item.icons.iconStyle;
return iconSetStyle === style || iconSetStyle === 'mixed';
});
}
}
// Filter by category
const category = params.category;
if (category) {
prefixes = (prefixes || data.sortedPrefixes).filter(
(prefix) => iconSets[prefix].item.info?.category === category
);
}
// Filter by tag
const tag = params.tag;
if (tag) {
prefixes = (prefixes || data.sortedPrefixes).filter((prefix) => {
const tags = iconSets[prefix].item.info?.tags;
return tags && tags.indexOf(tag) !== -1;
});
}
// Filter by prefix
if (params.prefixes) {
prefixes = filterSearchPrefixesList(prefixes || data.sortedPrefixes, params.prefixes);
}
// TODO: add more filter options
return prefixes || data.sortedPrefixes;
}

489
src/data/search/split.ts Normal file
View File

@ -0,0 +1,489 @@
import { matchIconName } from '@iconify/utils/lib/icon/name';
import { paramToBoolean } from '../../misc/bool.js';
import type { IconStyle } from '../../types/icon-set/extra.js';
import type { SearchKeywords, SearchKeywordsEntry } from '../../types/search.js';
import { minPartialKeywordLength } from './partial.js';
interface SplitOptions {
// Can include prefix
prefix: boolean;
// Can be partial
partial: boolean;
}
interface SplitResultItem {
// Icon set prefix
prefix?: string;
// List of exact matches
keywords: string[];
// Strings to test icon name
test?: string[];
// Partial keyword. It is last chunk of last keyword, which cannot be treated as prefix
partial?: string;
}
type SplitResult = SplitResultItem[];
export function splitKeywordEntries(values: string[], options: SplitOptions): SplitResult | undefined {
const results: SplitResult = [];
let invalid = false;
// Split each entry into arrays
interface Entry {
value: string;
empty: boolean;
}
const splitValues: Entry[][] = [];
values.forEach((item) => {
const entries: Entry[] = [];
let hasValue = false;
const parts = item.split('-');
for (let i = 0; i < parts.length; i++) {
const value = parts[i];
const empty = !value;
if (!empty && !matchIconName.test(value)) {
// Invalid entry
invalid = true;
return;
}
entries.push({
value,
empty,
});
hasValue = hasValue || !empty;
}
splitValues.push(entries);
if (!hasValue) {
invalid = true;
}
});
if (invalid || !splitValues.length) {
// Something went wrong
return;
}
// Convert value to test string, returns undefined if it is a simple keyword
function valuesToString(items: Entry[]): string | undefined {
if (!items.length || (items.length === 1 && !items[0].empty)) {
// Empty or only one keyword
return;
}
return (items[0].empty ? '-' : '') + items.map((item) => item.value).join('-');
}
interface ResultsSet {
keywords: Set<string>;
test: Set<string>;
partial?: string;
}
// Function to add item
function addToSet(items: Entry[], set: ResultsSet, allowPartial: boolean) {
let partial: string | undefined;
// Add keywords
const max = items.length - 1;
for (let i = 0; i <= max; i++) {
const value = items[i];
if (!value.empty) {
if (i === max && allowPartial && value.value.length >= minPartialKeywordLength) {
partial = value.value;
} else {
set.keywords.add(value.value);
}
}
}
// Get test value
const testValue = valuesToString(items);
if (testValue) {
set.test.add(testValue);
}
// Add partial
if (allowPartial && partial) {
if (set.partial && set.partial !== partial) {
console.error('Different partial keywords. This should not be happening!');
}
set.partial = partial;
}
}
// Add results set to result
function addToResult(set: ResultsSet, prefix?: string) {
if (set.keywords.size || set.partial) {
const item: SplitResultItem = {
keywords: Array.from(set.keywords),
prefix,
partial: set.partial,
};
if (set.test.size) {
item.test = Array.from(set.test);
}
results.push(item);
}
}
// Add items
const lastIndex = splitValues.length - 1;
if (options.prefix) {
const firstItem = splitValues[0];
const maxFirstItemIndex = firstItem.length - 1;
// Add with first keyword as prefix
if (lastIndex) {
// Check for empty item. It can only be present at the end of value
const emptyItem = firstItem.find((item) => item.empty);
if (!emptyItem || (maxFirstItemIndex > 0 && emptyItem === firstItem[maxFirstItemIndex])) {
const prefix = firstItem.length > 1 ? valuesToString(firstItem) : firstItem[0].value;
if (prefix) {
// Valid prefix
const set: ResultsSet = {
keywords: new Set(),
test: new Set(),
};
for (let i = 1; i <= lastIndex; i++) {
addToSet(splitValues[i], set, options.partial && i === lastIndex);
}
addToResult(set, prefix);
}
}
}
// Add with first part of first keyword as prefix
// First 2 items cannot be empty
if (maxFirstItemIndex && !firstItem[0].empty && !firstItem[1].empty) {
const modifiedFirstItem = firstItem.slice(0);
const prefix = modifiedFirstItem.shift()!.value;
const set: ResultsSet = {
keywords: new Set(),
test: new Set(),
};
for (let i = 0; i <= lastIndex; i++) {
addToSet(i ? splitValues[i] : modifiedFirstItem, set, options.partial && i === lastIndex);
}
addToResult(set, prefix);
}
}
// Add as is
const set: ResultsSet = {
keywords: new Set(),
test: new Set(),
};
for (let i = 0; i <= lastIndex; i++) {
addToSet(splitValues[i], set, options.partial && i === lastIndex);
}
addToResult(set);
// Merge values
if (splitValues.length > 1) {
// Check which items can be used for merge
// Merge only simple keywords
const validIndexes: Set<number> = new Set();
for (let i = 0; i <= lastIndex; i++) {
const item = splitValues[i];
if (item.length === 1 && !item[0].empty) {
validIndexes.add(i);
}
}
if (validIndexes.size > 1) {
for (let startIndex = 0; startIndex < lastIndex; startIndex++) {
if (!validIndexes.has(startIndex)) {
continue;
}
for (let endIndex = startIndex + 1; endIndex <= lastIndex; endIndex++) {
if (!validIndexes.has(endIndex)) {
// Break loop
break;
}
// Generate new values list
const newSplitValues: Entry[][] = [
...splitValues.slice(0, startIndex),
[
{
value: splitValues
.slice(startIndex, endIndex + 1)
.map((item) => item[0].value)
.join(''),
empty: false,
},
],
...splitValues.slice(endIndex + 1),
];
const newLastIndex = newSplitValues.length - 1;
const set: ResultsSet = {
keywords: new Set(),
test: new Set(),
};
for (let i = 0; i <= newLastIndex; i++) {
addToSet(newSplitValues[i], set, options.partial && i === newLastIndex);
}
addToResult(set);
}
}
}
}
return results;
}
/**
* Handle partial prefix
*/
function addPartialPrefix(prefix: string, set: Set<string>): boolean {
if (prefix.slice(-1) === '*') {
// Wildcard entry
prefix = prefix.slice(0, prefix.length - 1);
if (matchIconName.test(prefix)) {
set.add(prefix);
set.add(prefix + '-');
return true;
}
} else if (prefix.length && matchIconName.test(prefix + 'a')) {
// Add 'a' to allow partial prefixes like 'mdi-'
set.add(prefix);
return true;
}
return false;
}
/**
* Split keyword
*/
export function splitKeyword(keyword: string, allowPartial = true): SearchKeywords | undefined {
const commonPrefixes: Set<string> = new Set();
let palette: boolean | undefined;
let iconStyle: IconStyle | undefined;
// Split by space, check for prefixes and reserved keywords
const keywordChunks = keyword.toLowerCase().trim().split(/\s+/);
const keywords: string[] = [];
let hasPrefixes = false;
let checkPartial = false;
for (let i = 0; i < keywordChunks.length; i++) {
const part = keywordChunks[i];
const prefixChunks = part.split(':') as string[];
if (prefixChunks.length > 2) {
// Too many prefixes: invalidate search query
return;
}
// Check for prefix or reserved keyword
if (prefixChunks.length === 2) {
const keyword = prefixChunks[0];
const value = prefixChunks[1];
let isKeyword = false;
switch (keyword) {
case 'palette': {
palette = paramToBoolean(value);
if (typeof palette === 'boolean') {
isKeyword = true;
}
break;
}
// style:fill, style:stroke
case 'style': {
if (value === 'fill' || value === 'stroke') {
iconStyle = value;
isKeyword = true;
}
break;
}
// fill:true, stroke:true
case 'fill':
case 'stroke': {
if (paramToBoolean(value)) {
iconStyle = keyword;
isKeyword = true;
}
break;
}
case 'prefix':
case 'prefixes': {
// Prefixes
if (hasPrefixes) {
// Already had entry with prefix: invalidate query
return;
}
const values = value.split(',');
let invalid = true;
hasPrefixes = true;
for (let j = 0; j < values.length; j++) {
if (addPartialPrefix(values[j].trim(), commonPrefixes)) {
invalid = false;
}
}
if (invalid) {
// All prefixes are bad: invalidate search query
return;
}
isKeyword = true;
break;
}
}
if (!isKeyword) {
// Icon with prefix
if (hasPrefixes) {
// Already had entry with prefix: invalidate query
return;
}
const values = keyword.split(',');
let invalid = true;
hasPrefixes = true;
for (let j = 0; j < values.length; j++) {
const prefix = values[j].trim();
if (matchIconName.test(prefix)) {
commonPrefixes.add(prefix);
invalid = false;
}
}
if (invalid) {
// All prefixes are bad: invalidate search query
return;
}
if (value.length) {
// Add icon name, unless it is empty: 'mdi:'
// Allow partial if enabled
checkPartial = allowPartial;
keywords.push(value);
}
}
continue;
}
// 1 part
// Check for 'key=value' pairs
const paramChunks = part.split('=');
if (paramChunks.length > 2) {
// Bad query
return;
}
if (paramChunks.length === 2) {
const keyword = paramChunks[0];
const value = paramChunks[1] as string;
switch (keyword) {
// 'palette=true', 'palette=false' -> filter icon sets by palette
case 'palette':
palette = paramToBoolean(value);
if (typeof palette !== 'boolean') {
return;
}
break;
// style=fill, style=stroke
case 'style': {
if (value === 'fill' || value === 'stroke') {
iconStyle = value;
} else {
return;
}
break;
}
// fill=true, stroke=true
// accepts only true as value
case 'fill':
case 'stroke': {
if (paramToBoolean(value)) {
iconStyle = keyword;
} else {
return;
}
break;
}
// 'prefix=material-symbols', 'prefix=material-'
// 'prefixes=material-symbols,material-'
case 'prefix':
case 'prefixes':
if (hasPrefixes) {
// Already had entry with prefix: invalidate query
return;
}
let invalid = true;
const values = value.split(',');
for (let j = 0; j < values.length; j++) {
if (addPartialPrefix(values[j].trim(), commonPrefixes)) {
invalid = false;
}
}
if (invalid) {
// All prefixes are bad: invalidate search query
return;
}
break;
default: {
// Unknown keyword
return;
}
}
continue;
}
// Simple keyword. Allow partial if enabled
checkPartial = allowPartial;
keywords.push(part);
}
if (!keywords.length) {
// No keywords
return;
}
const entries = splitKeywordEntries(keywords, {
prefix: !hasPrefixes && !commonPrefixes.size,
partial: checkPartial,
});
if (!entries) {
return;
}
const searches: SearchKeywordsEntry[] = entries.map((item) => {
return {
...item,
prefixes: item.prefix
? [...commonPrefixes, item.prefix]
: commonPrefixes.size
? [...commonPrefixes]
: undefined,
};
});
const params: SearchKeywords['params'] = {};
if (typeof palette === 'boolean') {
params.palette = palette;
}
if (iconStyle) {
params.style = iconStyle;
}
return {
searches,
params,
};
}

View File

@ -0,0 +1,21 @@
import type { MemoryStorageItem, MemoryStorageCallback } from '../../types/storage.js';
/**
* Run all callbacks from storage
*/
export function runStorageCallbacks<T>(storedItem: MemoryStorageItem<T>, force = false) {
// Get data
const data = storedItem.data;
if (!data && !force) {
return;
}
// Update time
storedItem.lastUsed = Date.now();
// Run all callbacks
let callback: MemoryStorageCallback<T> | undefined;
while ((callback = storedItem.callbacks.shift())) {
callback(data || null);
}
}

128
src/data/storage/cleanup.ts Normal file
View File

@ -0,0 +1,128 @@
import type { MemoryStorage, MemoryStorageItem } from '../../types/storage.js';
import { runStorageCallbacks } from './callbacks.js';
/**
* Stop timer
*/
function stopTimer<T>(storage: MemoryStorage<T>) {
if (storage.timer) {
clearInterval(storage.timer);
delete storage.timer;
}
}
/**
* Clean up stored item
*/
export function cleanupStoredItem<T>(storage: MemoryStorage<T>, storedItem: MemoryStorageItem<T>): boolean {
if (!storedItem.cache?.exists) {
// Cannot be cleaned up
return false;
}
if (storedItem.callbacks.length) {
// Callbacks exist ???
if (storedItem.data) {
runStorageCallbacks(storedItem);
return false;
}
return true;
}
// Cache stored: clean up
delete storedItem.data;
storage.watched.delete(storedItem);
if (!storage.watched.size) {
stopTimer(storage);
}
return true;
}
/**
* Clean up stored items
*/
export function cleanupStorage<T>(storage: MemoryStorage<T>) {
const config = storage.config;
const watched = storage.watched;
// Items with laseUsed > lastUsedLimit cannot be cleaned up
// If not set, allow items to be stored for at least 10 seconds
const lastUsedLimit = Date.now() - (config.minExpiration || 10000);
// Check timer limit
const cleanupAfter = config.cleanupAfter;
if (cleanupAfter) {
const minTimer = Math.min(Date.now() - cleanupAfter, lastUsedLimit);
if (!storage.minLastUsed || storage.minLastUsed < minTimer) {
watched.forEach((item) => {
if (item.lastUsed < minTimer) {
cleanupStoredItem(storage, item);
}
});
}
}
// Check items limit
const maxCount = config.maxCount;
if (maxCount && watched.size > maxCount) {
if (storage.minLastUsed && storage.minLastUsed > lastUsedLimit) {
// Cannot cleanup: minLastUsed set from last check is too high
return;
}
// Sort items
const sortedList = Array.from(watched).sort((item1, item2) => item1.lastUsed - item2.lastUsed);
delete storage.minLastUsed;
// Delete items, sorted by `lastUsed`
for (let i = 0; i < sortedList.length && watched.size > maxCount; i++) {
// Attempt to remove item
const item = sortedList[i];
if (item.lastUsed < lastUsedLimit) {
cleanupStoredItem(storage, item);
} else {
// Ran out of items to delete
storage.minLastUsed = item.lastUsed;
return;
}
}
}
}
/**
* Add storage to cleanup queue
*
* Should be called after writeStoredItem() or loadStoredItem()
*/
export function addStorageToCleanup<T>(storage: MemoryStorage<T>, storedItem: MemoryStorageItem<T>) {
if (!storedItem.data) {
// Nothing to watch
return;
}
const config = storage.config;
const watched = storage.watched;
watched.add(storedItem);
// Set timer
if (!storage.timer) {
const timerDuration = config.timer;
const cleanupAfter = config.cleanupAfter;
if (timerDuration && cleanupAfter) {
storage.timer = setInterval(() => {
// Callback for debugging
config.timerCallback?.();
// Run cleanup
cleanupStorage(storage);
}, timerDuration);
}
}
// Clean up items immediately if there are too many
if (config.maxCount && watched.size >= config.maxCount) {
cleanupStorage(storage);
}
}

View File

@ -0,0 +1,57 @@
import { appConfig } from '../../config/app.js';
import type { MemoryStorage, MemoryStorageConfig, MemoryStorageItem } from '../../types/storage.js';
import { cleanupStoredItem } from './cleanup.js';
import { writeStoredItem } from './write.js';
/**
* Create storage
*/
export function createStorage<T>(config: MemoryStorageConfig): MemoryStorage<T> {
return {
config,
watched: new Set(),
pendingReads: new Set(),
pendingWrites: new Set(),
};
}
/**
* Create item to store
*/
export function createStoredItem<T>(
storage: MemoryStorage<T>,
data: T,
cacheFile: string,
autoCleanup = true,
done?: (storedItem: MemoryStorageItem<T>, err?: NodeJS.ErrnoException) => void
): MemoryStorageItem<T> {
const filename = storage.config.cacheDir.replace('{cache}', appConfig.cacheRootDir) + '/' + cacheFile;
const storedItem: MemoryStorageItem<T> = {
cache: {
filename,
exists: false,
},
data,
callbacks: [],
lastUsed: autoCleanup ? 0 : Date.now(),
};
// Save cache if cleanup is enabled
const storageConfig = storage.config;
if (storageConfig.maxCount || storageConfig.cleanupAfter) {
writeStoredItem(storage, storedItem, (err) => {
if (autoCleanup && !err) {
// Remove item if not used and not failed
if (!storedItem.lastUsed) {
cleanupStoredItem(storage, storedItem);
}
}
done?.(storedItem, err);
});
} else {
done?.(storedItem);
}
return storedItem;
}

24
src/data/storage/get.ts Normal file
View File

@ -0,0 +1,24 @@
import type { MemoryStorageItem, MemoryStorageCallback, MemoryStorage } from '../../types/storage.js';
import { loadStoredItem } from './load.js';
/**
* Get storage data when ready
*/
export function getStoredItem<T>(
storage: MemoryStorage<T>,
storedItem: MemoryStorageItem<T>,
callback: MemoryStorageCallback<T>
) {
if (storedItem.data) {
// Data is already available: run callback
storedItem.lastUsed = Date.now();
callback(storedItem.data);
return;
}
// Add callback to queue
storedItem.callbacks.push(callback);
// Load storage
loadStoredItem(storage, storedItem);
}

63
src/data/storage/load.ts Normal file
View File

@ -0,0 +1,63 @@
import { readFile, readFileSync } from 'node:fs';
import type { MemoryStorage, MemoryStorageItem } from '../../types/storage.js';
import { runStorageCallbacks } from './callbacks.js';
import { addStorageToCleanup } from './cleanup.js';
/**
* Load data
*/
export function loadStoredItem<T>(storage: MemoryStorage<T>, storedItem: MemoryStorageItem<T>) {
const pendingReads = storage.pendingReads;
if (storedItem.data || pendingReads.has(storedItem)) {
// Already loaded or loading
return;
}
const config = storedItem.cache;
if (!config?.exists) {
// Cannot load
return;
}
// Load file
let failed = (error: unknown) => {
console.error(error);
runStorageCallbacks(storedItem, true);
};
let loaded = (dataStr: string) => {
// Loaded
storedItem.data = JSON.parse(dataStr) as T;
runStorageCallbacks(storedItem);
// Add to cleanup queue
addStorageToCleanup(storage, storedItem);
};
if (storage.config.asyncRead) {
// Load asynchronously
pendingReads.add(storedItem);
readFile(config.filename, 'utf8', (err, dataStr) => {
pendingReads.delete(storedItem);
if (err) {
// Failed
failed(err);
return;
}
// Loaded
loaded(dataStr);
});
} else {
// Load synchronously
let dataStr: string;
try {
dataStr = readFileSync(config.filename, 'utf8');
} catch (err) {
// Failed
failed(err);
return;
}
loaded(dataStr);
}
}

149
src/data/storage/split.ts Normal file
View File

@ -0,0 +1,149 @@
import type { SplitDataTree, SplitRecord, SplitRecordCallback } from '../../types/split.js';
/**
* Split records into `count` chunks
*
* Calls `callback` for each chunk, which should call `next` param to continue splitting.
* This is done to store data in cache in small chunks when splitting large icon
* set, allowing memory to be collected after each chunk
*
* Calls `done` when done
*/
export function splitRecords<T>(
data: Record<string, T>,
numChunks: number,
callback: SplitRecordCallback<Record<string, T>>,
done: () => void
) {
const keys = Object.keys(data).sort((a, b) => a.localeCompare(b));
const total = keys.length;
let start = 0;
let index = 0;
const next = () => {
if (index === numChunks) {
// Done
done();
return;
}
const end = index === numChunks - 1 ? total : Math.round((total * (index + 1)) / numChunks);
const keywords = keys.slice(start, end);
// Copy data
const itemData = Object.create(null) as typeof data;
for (let j = 0; j < keywords.length; j++) {
const keyword = keywords[j];
itemData[keyword] = data[keyword];
}
const item: SplitRecord<Record<string, T>> = {
keyword: keywords[0],
data: itemData,
};
start = end;
index++;
// Call callback
callback(item, next, index - 1, numChunks);
};
next();
}
/**
* Create tree for searching split records list
*/
export function createSplitRecordsTree<T>(items: SplitRecord<T>[]): SplitDataTree<T> {
const length = items.length;
const midIndex = Math.floor(length / 2);
const midItem = items[midIndex];
const keyword = midItem.keyword;
// Check if item can be split
const hasNext = length > midIndex + 1;
if (!midIndex && !hasNext) {
// Not split
return {
split: false,
match: midItem.data,
};
}
// Add keyword and current item
const tree: SplitDataTree<T> = {
split: true,
keyword,
match: midItem.data,
};
// Add previous items
if (midIndex) {
tree.prev = createSplitRecordsTree(items.slice(0, midIndex));
}
// Next items
if (hasNext) {
tree.next = createSplitRecordsTree(items.slice(midIndex));
}
return tree;
}
/**
* Find item
*/
export function searchSplitRecordsTree<T>(tree: SplitDataTree<T>, keyword: string): T {
if (!tree.split) {
return tree.match;
}
const match = keyword.localeCompare(tree.keyword);
if (match < 0) {
return tree.prev ? searchSplitRecordsTree(tree.prev, keyword) : tree.match;
}
return match > 0 && tree.next ? searchSplitRecordsTree(tree.next, keyword) : tree.match;
}
/**
* Find multiple items
*/
export function searchSplitRecordsTreeForSet<T>(tree: SplitDataTree<T>, keywords: string[]): Map<T, string[]> {
const map: Map<T, string[]> = new Map();
function search(tree: SplitDataTree<T>, keywords: string[]) {
if (!tree.split) {
// Not split
map.set(tree.match, keywords.concat(map.get(tree.match) || []));
return;
}
const prev: string[] = [];
const next: string[] = [];
const matches: string[] = [];
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
const match = keyword.localeCompare(tree.keyword);
if (match < 0) {
(tree.prev ? prev : matches).push(keyword);
} else {
(match > 0 && tree.next ? next : matches).push(keyword);
}
}
if (tree.prev && prev.length) {
search(tree.prev, prev);
}
if (tree.next && next.length) {
search(tree.next, next);
}
if (matches.length) {
map.set(tree.match, matches.concat(map.get(tree.match) || []));
}
}
search(tree, keywords);
return map;
}

View File

@ -0,0 +1,17 @@
import { rm } from 'node:fs/promises';
import { appConfig } from '../../config/app.js';
import type { MemoryStorage } from '../../types/storage.js';
/**
* Remove old cache
*/
export async function cleanupStorageCache<T>(storage: MemoryStorage<T>) {
const dir = storage.config.cacheDir.replace('{cache}', appConfig.cacheRootDir);
try {
await rm(dir, {
recursive: true,
});
} catch {
//
}
}

67
src/data/storage/write.ts Normal file
View File

@ -0,0 +1,67 @@
import { writeFile, mkdir } from 'node:fs';
import { dirname } from 'node:path';
import type { MemoryStorage, MemoryStorageItem } from '../../types/storage.js';
import { addStorageToCleanup } from './cleanup.js';
const createdDirs: Set<string> = new Set();
/**
* Write storage to file
*/
export function writeStoredItem<T>(
storage: MemoryStorage<T>,
storedItem: MemoryStorageItem<T>,
done?: (err?: NodeJS.ErrnoException) => void
) {
const pendingWrites = storage.pendingWrites;
const data = storedItem.data;
const config = storedItem.cache;
if (!data || !config || pendingWrites.has(storedItem)) {
// Missing content or disabled or already writing
done?.();
return;
}
// Serialise and store data
const dataStr = JSON.stringify(data);
pendingWrites.add(storedItem);
// Create directory if needed, write file
const filename = config.filename;
const dir = dirname(filename);
const write = () => {
// Write file
writeFile(filename, dataStr, 'utf8', (err) => {
pendingWrites.delete(storedItem);
if (err) {
// Error
console.error(err);
} else {
// Success
config.exists = true;
// Data is written, storage can be cleaned up when needed
addStorageToCleanup(storage, storedItem);
}
done?.(err || void 0);
});
};
if (createdDirs.has(dir)) {
write();
} else {
mkdir(
dir,
{
recursive: true,
},
() => {
createdDirs.add(dir);
write();
}
);
}
}

193
src/downloaders/base.ts Normal file
View File

@ -0,0 +1,193 @@
import type { DownloaderStatus, DownloaderType } from '../types/downloaders/base.js';
/**
* loadDataFromDirectory()
*/
type DataUpdated<DataType> = (data: DataType) => Promise<unknown>;
/**
* loadDataFromDirectory()
*/
type LoadData<DataType> = () => Promise<DataType | void | undefined>;
/**
* loadDataFromDirectory()
*/
type LoadDataFromDirectory<DataType> = (path: string) => Promise<DataType | void | undefined>;
/**
* Base downloader class, shared with all child classes
*/
export abstract class BaseDownloader<DataType> {
// Downloader type, set in child class
type!: DownloaderType;
// Downloader status
status: DownloaderStatus = 'pending-init';
// Data
data?: DataType;
// Waiting for reload
// Can be reset in _checkForUpdate() function immediately during check for redundancy
// to avoid running same check multiple times that might happen in edge cases
_pendingReload = false;
/**
* Load data from custom source, should be overwrtten by loader
*
* Used by loaders that do not implement _loadDataFromDirectory()
*/
_loadData?: LoadData<DataType>;
/**
* Load data from directory, should be overwritten by loader
*
* Used by loaders that do not implement _loadData()
*/
_loadDataFromDirectory?: LoadDataFromDirectory<DataType>;
/**
* Function to call when data has been updated
*/
_dataUpdated?: DataUpdated<DataType>;
/**
* Load content. Called when content is ready to be loaded, should be overwritten by child classes
*/
async _loadContent() {
throw new Error('_loadContent() not implemented');
}
/**
* Initialise downloader
*
* Returns true on success, false or reject on fatal error.
*/
async _init(): Promise<boolean> {
throw new Error('_init() not implemented');
}
/**
* Initialise downloader
*
* Returns false on error
*/
async init(): Promise<boolean> {
if (this.status === 'pending-init') {
this.status = 'initialising';
let result: boolean;
try {
result = await this._init();
} catch (err) {
// _init() failed
console.error(err);
this.status = false;
return false;
}
if (result) {
// Check for update if reload is pending
if (this._pendingReload) {
await this._checkForUpdateLoop();
}
// Load content
await this._loadContent();
}
// Update status
this.status = result;
return result;
}
return false;
}
/**
* Check for update
*
* Function should update latest version value before calling done(true)
* All errors should be caught and callbac must finish. In case of error, return done(false)
*/
_checkForUpdate(done: (value: boolean) => void) {
throw new Error('_checkForUpdate() not implemented');
}
/**
* Promise wrapper for _checkForUpdate()
*/
_checkForUpdateLoop(): Promise<boolean> {
return new Promise((fulfill, reject) => {
let updated = false;
let changedStatus = false;
// Change status
if (this.status === true) {
this.status = 'updating';
changedStatus = true;
}
const check = (value: boolean) => {
updated = updated || value;
if (value) {
// Successful update: reload data
this._loadContent()
.then(() => {
check(false);
})
.catch((err) => {
// Failed
if (changedStatus) {
this.status = true;
}
reject(err);
});
return;
}
if (this._pendingReload) {
// Run reload
this._pendingReload = false;
this._checkForUpdate(check);
return;
}
// Done
if (changedStatus) {
this.status = true;
}
fulfill(updated);
};
check(false);
});
}
/**
* Check for update
*/
checkForUpdate(): Promise<boolean> {
return new Promise((fulfill, reject) => {
if (this.status === false) {
fulfill(false);
return;
}
if (this._pendingReload) {
// Already pending: should be handled
fulfill(false);
return;
}
this._pendingReload = true;
if (this.status === true) {
// Check immediately
this._checkForUpdateLoop().then(fulfill).catch(reject);
} else {
// Another action is running
fulfill(false);
}
});
}
}

27
src/downloaders/custom.ts Normal file
View File

@ -0,0 +1,27 @@
import { BaseDownloader } from './base.js';
/**
* Custom downloader
*
* Class extending this downloader must implement:
* - constructor()
* - _init()
* - _checkForUpdate()
* - _loadData()
*/
export class CustomDownloader<DataType> extends BaseDownloader<DataType> {
/**
* Load content
*/
async _loadContent() {
if (!this._loadData) {
throw new Error('Importer does not implement _loadData()');
}
const result = await this._loadData();
if (result) {
this.data = result;
await this._dataUpdated?.(result);
}
}
}

View File

@ -0,0 +1,74 @@
import { directoryExists, hashFiles, listFilesInDirectory } from '../misc/files.js';
import { BaseDownloader } from './base.js';
/**
* Directory downloader
*
* Class extending this downloader must implement:
* - _loadDataFromDirectory()
*/
export class DirectoryDownloader<DataType> extends BaseDownloader<DataType> {
// Source directory
path: string;
// Last hash
_lastHash: string = '';
/**
* Constructor
*/
constructor(path: string) {
super();
this.path = path;
}
/**
* Hash content
*/
async _hashContent(): Promise<string> {
const files = await listFilesInDirectory(this.path);
return hashFiles(files);
}
/**
* Init downloader
*/
async _init() {
if (!(await directoryExists(this.path))) {
return false;
}
this._lastHash = await this._hashContent();
return true;
}
/**
* Check if files were changed
*/
_checkForUpdate(done: (value: boolean) => void): void {
this._hashContent()
.then((hash) => {
const changed = this._lastHash !== hash;
this._lastHash = hash;
done(changed);
})
.catch((err) => {
console.error(err);
done(false);
});
}
/**
* Load content
*/
async _loadContent() {
if (!this._loadDataFromDirectory) {
throw new Error('Importer does not implement _loadDataFromDirectory()');
}
const result = await this._loadDataFromDirectory(this.path);
if (result) {
this.data = result;
await this._dataUpdated?.(result);
}
}
}

139
src/downloaders/remote.ts Normal file
View File

@ -0,0 +1,139 @@
import { directoryExists } from '../misc/files.js';
import type { RemoteDownloaderOptions, RemoteDownloaderVersion } from '../types/downloaders/remote.js';
import { BaseDownloader } from './base.js';
import { downloadRemoteArchive } from './remote/download.js';
import { getRemoteDownloaderCacheKey } from './remote/key.js';
import { getDownloaderVersion, saveDownloaderVersion } from './remote/versions.js';
/**
* Remote downloader
*
* Class extending this downloader must implement:
* - _loadDataFromDirectory()
*/
export class RemoteDownloader<DataType> extends BaseDownloader<DataType> {
// Params
_downloader: RemoteDownloaderOptions;
_autoUpdate: boolean;
// Source directory
_sourceDir?: string;
// Latest version
_version?: RemoteDownloaderVersion;
/**
* Constructor
*/
constructor(downloader: RemoteDownloaderOptions, autoUpdate?: boolean) {
super();
this._downloader = downloader;
this._autoUpdate = !!autoUpdate;
}
/**
* Init downloader
*/
async _init() {
const downloader = this._downloader;
const cacheKey = getRemoteDownloaderCacheKey(downloader);
// Get last stored version
const lastVersion = await getDownloaderVersion(cacheKey, downloader.downloadType);
if (lastVersion && !this._autoUpdate) {
// Keep last version
const directory = lastVersion.contentsDir;
if (await directoryExists(directory)) {
// Keep old version
this._sourceDir = directory;
this._version = lastVersion;
return true;
}
}
// Missing or need to check for update
const version = await downloadRemoteArchive(
downloader,
lastVersion?.downloadType === downloader.downloadType ? lastVersion : void 0
);
if (version === false) {
if (lastVersion) {
// Keep last version
const directory = lastVersion.contentsDir;
if (await directoryExists(directory)) {
// Keep old version
this._sourceDir = directory;
this._version = lastVersion;
return true;
}
}
// Failed
return false;
}
// Use `version`
const directory = version.contentsDir;
if (await directoryExists(directory)) {
await saveDownloaderVersion(cacheKey, version);
this._sourceDir = directory;
this._version = version;
return true;
}
// Failed
return false;
}
/**
* Check for update
*/
_checkForUpdate(done: (value: boolean) => void): void {
const downloader = this._downloader;
// Promise version of _checkForUpdate()
const check = async () => {
const lastVersion = this._version;
// Check for update
const version = await downloadRemoteArchive(
downloader,
lastVersion?.downloadType === downloader.downloadType ? lastVersion : void 0
);
if (version === false) {
// Nothing to update
return false;
}
// Save new version, use it
await saveDownloaderVersion(getRemoteDownloaderCacheKey(downloader), version);
this._sourceDir = version.contentsDir;
this._version = version;
return true;
};
check()
.then(done)
.catch((err) => {
console.error(err);
done(false);
});
}
/**
* Load content
*/
async _loadContent() {
if (!this._loadDataFromDirectory) {
throw new Error('Importer does not implement _loadDataFromDirectory()');
}
const source = this._sourceDir;
const result = source && (await this._loadDataFromDirectory(source));
if (result) {
this.data = result;
await this._dataUpdated?.(result);
}
}
}

View File

@ -0,0 +1,90 @@
import { execAsync } from '@iconify/tools/lib/misc/exec';
import { getGitHubRepoHash } from '@iconify/tools/lib/download/github/hash';
import { getGitLabRepoHash } from '@iconify/tools/lib/download/gitlab/hash';
import { getNPMVersion, getPackageVersion } from '@iconify/tools/lib/download/npm/version';
import { directoryExists } from '../../misc/files.js';
import type {
GitDownloaderOptions,
GitDownloaderVersion,
GitHubDownloaderOptions,
GitHubDownloaderVersion,
GitLabDownloaderOptions,
GitLabDownloaderVersion,
NPMDownloaderOptions,
NPMDownloaderVersion,
} from '../../types/downloaders/remote.js';
/**
* Check git repo for update
*/
export async function isGitUpdateAvailable(
options: GitDownloaderOptions,
oldVersion: GitDownloaderVersion
): Promise<false | GitDownloaderVersion> {
const result = await execAsync(`git ls-remote ${options.remote} --branch ${options.branch}`);
const parts = result.stdout.split(/\s/);
const hash = parts.shift() as string;
if (hash !== oldVersion.hash || !(await directoryExists(oldVersion.contentsDir))) {
const newVerison: GitDownloaderVersion = {
...oldVersion,
hash,
};
return newVerison;
}
return false;
}
/**
* Check GitHub repo for update
*/
export async function isGitHubUpdateAvailable(
options: GitHubDownloaderOptions,
oldVersion: GitHubDownloaderVersion
): Promise<false | GitHubDownloaderVersion> {
const hash = await getGitHubRepoHash(options);
if (hash !== oldVersion.hash || !(await directoryExists(oldVersion.contentsDir))) {
const newVerison: GitHubDownloaderVersion = {
...oldVersion,
hash,
};
return newVerison;
}
return false;
}
/**
* Check GitLab repo for update
*/
export async function isGitLabUpdateAvailable(
options: GitLabDownloaderOptions,
oldVersion: GitLabDownloaderVersion
): Promise<false | GitLabDownloaderVersion> {
const hash = await getGitLabRepoHash(options);
if (hash !== oldVersion.hash || !(await directoryExists(oldVersion.contentsDir))) {
const newVerison: GitLabDownloaderVersion = {
...oldVersion,
hash,
};
return newVerison;
}
return false;
}
/**
* Check NPM package for update
*/
export async function isNPMUpdateAvailable(
options: NPMDownloaderOptions,
oldVersion: NPMDownloaderVersion
): Promise<false | NPMDownloaderVersion> {
const { version } = await getNPMVersion(options);
const dir = oldVersion.contentsDir;
if (version !== oldVersion.version || !(await directoryExists(dir)) || (await getPackageVersion(dir)) !== version) {
const newVerison: NPMDownloaderVersion = {
...oldVersion,
version,
};
return newVerison;
}
return false;
}

View File

@ -0,0 +1,84 @@
import { downloadGitRepo } from '@iconify/tools/lib/download/git';
import { downloadGitHubRepo } from '@iconify/tools/lib/download/github';
import { downloadGitLabRepo } from '@iconify/tools/lib/download/gitlab';
import { downloadNPMPackage } from '@iconify/tools/lib/download/npm';
import { appConfig } from '../../config/app.js';
import type { RemoteDownloaderOptions, RemoteDownloaderVersion } from '../../types/downloaders/remote.js';
import {
isGitHubUpdateAvailable,
isGitLabUpdateAvailable,
isGitUpdateAvailable,
isNPMUpdateAvailable,
} from './check-update.js';
import { getDownloadDirectory } from './target.js';
/**
* Download files from remote archive
*/
export async function downloadRemoteArchive(
options: RemoteDownloaderOptions,
ifModifiedSince?: RemoteDownloaderVersion | null,
key?: string
): Promise<false | RemoteDownloaderVersion> {
const target = getDownloadDirectory(options, key);
switch (options.downloadType) {
case 'git': {
if (ifModifiedSince?.downloadType === 'git' && !(await isGitUpdateAvailable(options, ifModifiedSince))) {
return false;
}
// Download
return await downloadGitRepo({
target,
log: appConfig.log,
...options,
});
}
case 'github': {
if (
ifModifiedSince?.downloadType === 'github' &&
!(await isGitHubUpdateAvailable(options, ifModifiedSince))
) {
return false;
}
// Download
return await downloadGitHubRepo({
target,
log: appConfig.log,
...options,
});
}
case 'gitlab': {
if (
ifModifiedSince?.downloadType === 'gitlab' &&
!(await isGitLabUpdateAvailable(options, ifModifiedSince))
) {
return false;
}
// Download
return await downloadGitLabRepo({
target,
log: appConfig.log,
...options,
});
}
case 'npm': {
if (ifModifiedSince?.downloadType === 'npm' && !(await isNPMUpdateAvailable(options, ifModifiedSince))) {
return false;
}
// Download
return await downloadNPMPackage({
target,
log: appConfig.log,
...options,
});
}
}
}

View File

@ -0,0 +1,21 @@
import { hashString } from '../../misc/hash.js';
import type { RemoteDownloaderOptions } from '../../types/downloaders/remote.js';
/**
* Get cache key
*/
export function getRemoteDownloaderCacheKey(options: RemoteDownloaderOptions): string {
switch (options.downloadType) {
case 'git':
return hashString(`${options.remote}#${options.branch}`);
case 'github':
return `${options.user}-${options.repo}-${options.branch}`;
case 'gitlab':
return `${options.uri ? hashString(options.uri + options.project) : options.project}-${options.branch}`;
case 'npm':
return options.package + (options.tag ? '-' + options.tag : '');
}
}

View File

@ -0,0 +1,24 @@
import { appConfig } from '../../config/app.js';
import type { RemoteDownloaderOptions } from '../../types/downloaders/remote.js';
import { getRemoteDownloaderCacheKey } from './key.js';
/**
* Get directory
*/
export function getDownloadDirectory(options: RemoteDownloaderOptions, key?: string): string {
key = key || getRemoteDownloaderCacheKey(options);
switch (options.downloadType) {
case 'git':
return appConfig.cacheRootDir + '/git/' + key;
case 'github':
return appConfig.cacheRootDir + '/github/' + key;
case 'gitlab':
return appConfig.cacheRootDir + '/github/' + key;
case 'npm':
return appConfig.cacheRootDir + '/npm/' + key;
}
}

View File

@ -0,0 +1,68 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { appConfig } from '../../config/app.js';
import type {
RemoteDownloaderType,
RemoteDownloaderVersion,
RemoteDownloaderVersionMixin,
} from '../../types/downloaders/remote.js';
// Storage
type StoredVersions = Record<string, RemoteDownloaderVersion>;
/**
* Get cache file
*/
function getCacheFile(): string {
return appConfig.cacheRootDir + '/versions.json';
}
/**
* Get data
*/
async function getStoredData(): Promise<StoredVersions> {
try {
return JSON.parse(await readFile(getCacheFile(), 'utf8')) as StoredVersions;
} catch {
return {};
}
}
/**
* Get version
*/
export async function getDownloaderVersion<T extends RemoteDownloaderType>(
key: string,
type: T
): Promise<RemoteDownloaderVersionMixin<T> | null> {
const data = await getStoredData();
const value = data[key];
if (value && value.downloadType === type) {
return value as RemoteDownloaderVersionMixin<T>;
}
return null;
}
/**
* Store downloader version in cache
*/
export async function saveDownloaderVersion(key: string, value: RemoteDownloaderVersion) {
const filename = getCacheFile();
// Create directory for cache, if missing
const dir = dirname(filename);
try {
await mkdir(dir, {
recursive: true,
});
} catch {
//
}
// Update data
const data = await getStoredData();
data[key] = value;
// Store file
await writeFile(filename, JSON.stringify(data, null, '\t'), 'utf8');
}

View File

@ -0,0 +1,10 @@
export function errorText(code: number) {
switch (code) {
case 404:
return 'Not found';
case 400:
return 'Bad request';
}
return 'Internal server error';
}

63
src/http/helpers/json.ts Normal file
View File

@ -0,0 +1,63 @@
import type { FastifyReply } from 'fastify';
const callbackMatch = /^[a-z0-9_.]+$/i;
/**
* Check JSONP query
*/
interface JSONPStatus {
wrap: boolean;
callback: string;
}
export function checkJSONPQuery(
query: Record<string, string>,
forceWrap?: boolean,
defaultCallback?: string
): JSONPStatus | false {
const wrap = typeof forceWrap === 'boolean' ? forceWrap : !!query.callback;
if (wrap) {
const customCallback = query.callback;
if (customCallback) {
if (!customCallback.match(callbackMatch)) {
// Invalid callback
return false;
}
return {
wrap: true,
callback: customCallback,
};
}
// No callback provided
return defaultCallback
? {
wrap: true,
callback: defaultCallback,
}
: false;
}
// Do not wrap
return {
wrap: false,
callback: '',
};
}
/**
* Send JSON response
*/
export function sendJSONResponse(data: unknown, query: Record<string, string>, wrap: JSONPStatus, res: FastifyReply) {
// Generate text
const html = query.pretty ? JSON.stringify(data, null, 4) : JSON.stringify(data);
// Check for JSONP callback
if (wrap.wrap) {
res.type('application/javascript; charset=utf-8');
res.send(wrap.callback + '(' + html + ');');
} else {
res.type('application/json; charset=utf-8');
res.send(html);
}
}

View File

@ -0,0 +1,59 @@
interface MatchPrefixesParams {
// One prefix
prefix?: string;
// Comma separated prefixes
prefixes?: string;
}
/**
* Filter prefixes by name
*
* returnEmpty = true -> if no filter params set, returns empty array
* returnEmpty = false -> if no filter params set, returns all filters
*/
export function filterPrefixesByPrefix(
prefixes: string[],
params: MatchPrefixesParams,
returnEmpty: boolean
): string[] {
const exactMatch = params.prefix;
if (exactMatch) {
// Exact match
return prefixes.indexOf(exactMatch) === -1 ? [] : [exactMatch];
}
const partialMatch = params.prefixes;
if (partialMatch) {
// Split matches by partial and full
const exact: Set<string> = new Set();
const partial: string[] = [];
partialMatch.split(',').forEach((prefix) => {
if (prefix.slice(-1) === '-') {
// Partial prefix: 'mdi-'
partial.push(prefix);
} else {
// Exact match
exact.add(prefix);
}
});
// Filter prefixes
return prefixes.filter((prefix) => {
if (exact.has(prefix)) {
return true;
}
for (let i = 0; i < partial.length; i++) {
const match = partial[i];
if (prefix.slice(0, match.length) === match) {
return true;
}
}
return false;
});
}
// No filters
return returnEmpty ? [] : prefixes;
}

View File

@ -0,0 +1,6 @@
/**
* Basic cleanup for parameters
*/
export function cleanupQueryValue(value: string | undefined) {
return value ? value.replace(/['"<>&]/g, '') : undefined;
}

View File

@ -0,0 +1,45 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { checkJSONPQuery, sendJSONResponse } from './json.js';
import { createIconsDataResponse } from '../responses/icons.js';
type CallbackResult = object | number;
/**
* Handle icons data API response
*/
export function handleIconsDataResponse(
prefix: string,
wrapJS: boolean,
query: FastifyRequest['query'],
res: FastifyReply
) {
const q = (query || {}) as Record<string, string>;
// Check for JSONP
const wrap = checkJSONPQuery(q, wrapJS, 'SimpleSVG._loaderCallback');
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
// Function to send response
const respond = (result: CallbackResult) => {
if (typeof result === 'number') {
res.send(result);
} else {
sendJSONResponse(result, q, wrap, res);
}
};
// Get result
const result = createIconsDataResponse(prefix, q);
if (result instanceof Promise) {
result.then(respond).catch((err) => {
console.error(err);
respond(500);
});
} else {
respond(result);
}
}

44
src/http/helpers/send.ts Normal file
View File

@ -0,0 +1,44 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { checkJSONPQuery, sendJSONResponse } from './json.js';
import { errorText } from './errors.js';
type CallbackResult = object | number;
/**
* Handle JSON API response generated by a callback
*/
export function handleJSONResponse(
req: FastifyRequest,
res: FastifyReply,
callback: (query: Record<string, string>) => CallbackResult | Promise<CallbackResult>
) {
const q = (req.query || {}) as Record<string, string>;
// Check for JSONP
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.code(400).send(errorText(400));
return;
}
// Function to send response
const respond = (result: CallbackResult) => {
if (typeof result === 'number') {
res.code(result).send(errorText(result));
} else {
sendJSONResponse(result, q, wrap, res);
}
};
// Get result
const result = callback(q);
if (result instanceof Promise) {
result.then(respond).catch((err) => {
console.error(err);
respond(500);
});
} else {
respond(result);
}
}

227
src/http/index.ts Normal file
View File

@ -0,0 +1,227 @@
import fastify from 'fastify';
import fastifyFormBody from '@fastify/formbody';
import { appConfig, httpHeaders } from '../config/app.js';
import { runWhenLoaded } from '../data/loading.js';
import { iconNameRoutePartialRegEx, iconNameRouteRegEx, splitIconName } from '../misc/name.js';
import { createAPIv1IconsListResponse } from './responses/collection-v1.js';
import { createAPIv2CollectionResponse } from './responses/collection-v2.js';
import { createCollectionsListResponse } from './responses/collections.js';
import { handleIconsDataResponse } from './helpers/send-icons.js';
import { createKeywordsResponse } from './responses/keywords.js';
import { createLastModifiedResponse } from './responses/modified.js';
import { createAPIv2SearchResponse } from './responses/search.js';
import { generateSVGResponse } from './responses/svg.js';
import { generateUpdateResponse } from './responses/update.js';
import { initVersionResponse, versionResponse } from './responses/version.js';
import { generateIconsStyleResponse } from './responses/css.js';
import { handleJSONResponse } from './helpers/send.js';
import { errorText } from './helpers/errors.js';
/**
* Start HTTP server
*/
export async function startHTTPServer() {
// Create HTP server
const server = fastify({
routerOptions: {
caseSensitive: true,
},
});
// Support `application/x-www-form-urlencoded`
server.register(fastifyFormBody);
// Generate headers to send
interface Header {
key: string;
value: string;
}
const headers: Header[] = [];
httpHeaders.forEach((item) => {
const parts = item.split(':');
if (parts.length > 1) {
headers.push({
key: parts.shift() as string,
value: parts.join(':').trim(),
});
}
});
server.addHook('preHandler', (req, res, done) => {
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
res.header(header.key, header.value);
}
done();
});
// Init various responses
await initVersionResponse();
// Types for common params
interface PrefixParams {
prefix: string;
}
interface NameParams {
name: string;
}
// SVG: /prefix/icon.svg, /prefix:name.svg, /prefix-name.svg
server.get(
'/:prefix(' + iconNameRoutePartialRegEx + ')/:name(' + iconNameRoutePartialRegEx + ').svg',
(req, res) => {
type Params = PrefixParams & NameParams;
const name = req.params as Params;
runWhenLoaded(() => {
generateSVGResponse(name.prefix, name.name, req.query, res);
});
}
);
// SVG: /prefix:name.svg, /prefix-name.svg
server.get('/:name(' + iconNameRouteRegEx + ').svg', (req, res) => {
const name = splitIconName((req.params as NameParams).name);
if (name) {
runWhenLoaded(() => {
generateSVGResponse(name.prefix, name.name, req.query, res);
});
} else {
res.code(404).send(errorText(404));
}
});
// Icons data: /prefix/icons.json, /prefix.json
server.get('/:prefix(' + iconNameRoutePartialRegEx + ')/icons.json', (req, res) => {
runWhenLoaded(() => {
handleIconsDataResponse((req.params as PrefixParams).prefix, false, req.query, res);
});
});
server.get('/:prefix(' + iconNameRoutePartialRegEx + ').json', (req, res) => {
runWhenLoaded(() => {
handleIconsDataResponse((req.params as PrefixParams).prefix, false, req.query, res);
});
});
// Stylesheet: /prefix.css
server.get('/:prefix(' + iconNameRoutePartialRegEx + ').css', (req, res) => {
runWhenLoaded(() => {
generateIconsStyleResponse((req.params as PrefixParams).prefix, req.query, res);
});
});
// Icons data: /prefix/icons.js, /prefix.js
server.get('/:prefix(' + iconNameRoutePartialRegEx + ')/icons.js', (req, res) => {
runWhenLoaded(() => {
handleIconsDataResponse((req.params as PrefixParams).prefix, true, req.query, res);
});
});
server.get('/:prefix(' + iconNameRoutePartialRegEx + ').js', (req, res) => {
runWhenLoaded(() => {
handleIconsDataResponse((req.params as PrefixParams).prefix, true, req.query, res);
});
});
// Last modification time
server.get('/last-modified', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, createLastModifiedResponse);
});
});
if (appConfig.enableIconLists) {
// Icon sets list
server.get('/collections', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, createCollectionsListResponse);
});
});
// Icons list, API v2
server.get('/collection', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, createAPIv2CollectionResponse);
});
});
// Icons list, API v1
server.get('/list-icons', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, (q) => createAPIv1IconsListResponse(q, false));
});
});
server.get('/list-icons-categorized', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, (q) => createAPIv1IconsListResponse(q, true));
});
});
if (appConfig.enableSearchEngine) {
// Search, currently version 2
server.get('/search', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, createAPIv2SearchResponse);
});
});
// Keywords
server.get('/keywords', (req, res) => {
runWhenLoaded(() => {
handleJSONResponse(req, res, createKeywordsResponse);
});
});
}
}
// Update icon sets
server.get('/update', (req, res) => {
generateUpdateResponse(req.query, res);
});
server.post('/update', (req, res) => {
generateUpdateResponse(req.query, res);
});
// Options
server.options('/*', (req, res) => {
res.code(204).header('Content-Length', '0').send();
});
// Robots
server.get('/robots.txt', (req, res) => {
res.send('User-agent: *\nDisallow: /\n');
});
// Version
if (appConfig.enableVersion) {
server.get('/version', (req, res) => {
versionResponse(req.query, res);
});
server.post('/version', (req, res) => {
versionResponse(req.query, res);
});
}
// Redirect
server.get('/', (req, res) => {
res.redirect(appConfig.redirectIndex, 301);
});
// Error handling
server.setNotFoundHandler((req, res) => {
res.statusCode = 404;
console.log('404:', req.url);
// Need to set custom headers because hooks don't work here
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
res.header(header.key, header.value);
}
res.send();
});
// Start it
console.log('Listening on', appConfig.host + ':' + appConfig.port);
server.listen({
host: appConfig.host,
port: appConfig.port,
});
}

View File

@ -0,0 +1,129 @@
import { getPrefixes, iconSets } from '../../data/icon-sets.js';
import type { IconSetAPIv2IconsList } from '../../types/icon-set/extra.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
import type {
APIv1ListIconsBaseResponse,
APIv1ListIconsCategorisedResponse,
APIv1ListIconsResponse,
} from '../../types/server/v1.js';
import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
// Response results, depends on `categorised` option
type PossibleResults = APIv1ListIconsResponse | APIv1ListIconsCategorisedResponse;
/**
* Create API v1 response
*
* This response ignores the following parameters:
* - `aliases` -> always enabled
* - `hidden` -> always enabled
*
* Those parameters are always requested anyway, so does not make sense to re-create data in case they are disabled
*/
export function createAPIv1IconsListResponse(
query: Record<string, string>,
categorised: boolean
): PossibleResults | Record<string, PossibleResults> | number {
function parse(
prefix: string,
iconSet: StoredIconSet,
v2Cache: IconSetAPIv2IconsList
): APIv1ListIconsResponse | APIv1ListIconsCategorisedResponse {
// Generate common data
const base: APIv1ListIconsBaseResponse = {
prefix,
total: v2Cache.total,
};
if (v2Cache.title) {
base.title = v2Cache.title;
}
if (query.info && v2Cache.info) {
base.info = v2Cache.info;
}
if (query.aliases && v2Cache.aliases) {
base.aliases = v2Cache.aliases;
}
if (query.chars && v2Cache.chars) {
base.chars = v2Cache.chars;
}
// Add icons
if (categorised) {
const result = base as APIv1ListIconsCategorisedResponse;
if (v2Cache.categories) {
result.categories = v2Cache.categories;
}
if (v2Cache.uncategorized) {
result.uncategorized = v2Cache.uncategorized;
}
return result;
}
const result = base as APIv1ListIconsResponse;
result.icons = [];
const visible = iconSet.icons.visible;
for (const name in visible) {
if (visible[name][0] === name) {
result.icons.push(name);
}
}
return result;
}
if (query.prefix) {
const prefix = query.prefix;
const iconSet = iconSets[prefix]?.item;
if (!iconSet || !iconSet.apiV2IconsCache) {
return 404;
}
return parse(prefix, iconSet, iconSet.apiV2IconsCache);
}
if (query.prefixes) {
const prefixes = filterPrefixesByPrefix(
getPrefixes(),
{
prefixes: query.prefixes,
},
false
);
// Retrieve all items
interface Item {
prefix: string;
iconSet: StoredIconSet;
v2Cache: IconSetAPIv2IconsList;
}
const items: Item[] = [];
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
const iconSet = iconSets[prefix]?.item;
if (iconSet?.apiV2IconsCache) {
items.push({
prefix,
iconSet,
v2Cache: iconSet.apiV2IconsCache,
});
if (items.length > 10) {
break;
}
}
}
if (!items.length) {
// Empty list
return 404;
}
// Get all items
const result = Object.create(null) as Record<string, PossibleResults>;
for (let i = 0; i < items.length; i++) {
const item = items[i];
result[item.prefix] = parse(item.prefix, item.iconSet, item.v2Cache);
}
return result;
}
// Invalid
return 400;
}

View File

@ -0,0 +1,43 @@
import { iconSets } from '../../data/icon-sets.js';
import type { APIv2CollectionResponse } from '../../types/server/v2.js';
/**
* Send API v2 response
*
* This response ignores the following parameters:
* - `aliases` -> always enabled
* - `hidden` -> always enabled
*
* Those parameters are always requested anyway, so does not make sense to re-create data in case they are disabled
*/
export function createAPIv2CollectionResponse(q: Record<string, string>): APIv2CollectionResponse | number {
// Get icon set
const prefix = q.prefix;
if (!prefix || !iconSets[prefix]) {
return 404;
}
const iconSet = iconSets[prefix].item;
const apiV2IconsCache = iconSet.apiV2IconsCache;
if (!apiV2IconsCache) {
// Disabled
return 404;
}
// Generate response
const response: APIv2CollectionResponse = {
...apiV2IconsCache,
...iconSet.themes,
};
if (!q.info) {
// Delete info
delete response.info;
}
if (!q.chars) {
// Remove characters map
delete response.chars;
}
return response;
}

View File

@ -0,0 +1,27 @@
import { getPrefixes, iconSets } from '../../data/icon-sets.js';
import type { APIv2CollectionsResponse } from '../../types/server/v2.js';
import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
/**
* Send response
*
* Request and responses are the same for v2 and v3
*
* Ignored parameters:
* - hidden (always enabled)
*/
export function createCollectionsListResponse(q: Record<string, string>): APIv2CollectionsResponse {
// Filter prefixes
const prefixes = filterPrefixesByPrefix(getPrefixes('info'), q, false);
const response = Object.create(null) as APIv2CollectionsResponse;
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
const info = iconSets[prefix]?.item.info;
if (info) {
response[prefix] = info;
}
}
return response;
}

154
src/http/responses/css.ts Normal file
View File

@ -0,0 +1,154 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { stringToColor } from '@iconify/utils/lib/colors';
import { getIconsCSS } from '@iconify/utils/lib/css/icons';
import type { IconCSSIconSetOptions } from '@iconify/utils/lib/css/types';
import { getStoredIconsData } from '../../data/icon-set/utils/get-icons.js';
import { iconSets } from '../../data/icon-sets.js';
import { paramToBoolean } from '../../misc/bool.js';
import { errorText } from '../helpers/errors.js';
import { cleanupQueryValue } from '../helpers/query.js';
/**
* Check selector for weird stuff
*/
function checkSelector(value: string | undefined): boolean {
if (typeof value !== 'string') {
return false;
}
const cleanValue = value.replaceAll('{name}', '').replaceAll('{prefix}', '');
return cleanValue.indexOf('{') === -1 && cleanValue.indexOf('}') === -1;
}
/**
* Generate icons style
*/
export function generateIconsStyleResponse(prefix: string, query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;
const names = q.icons?.split(',');
if (!names || !names.length) {
// Missing or invalid icons parameter
res.code(404).send(errorText(404));
return;
}
// Get icon set
const iconSet = iconSets[prefix];
if (!iconSet) {
// No such icon set
res.code(404).send(errorText(404));
return;
}
// Get icons
getStoredIconsData(iconSet.item, names, (data) => {
// Options
const options: IconCSSIconSetOptions = {};
const qOptions = q as IconCSSIconSetOptions;
if (typeof qOptions.format === 'string') {
const format = qOptions.format;
switch (format) {
case 'compact':
case 'compressed':
case 'expanded':
options.format = format;
}
}
// 'color': string
// Sets color for monotone images
const color = cleanupQueryValue(qOptions.color);
if (typeof color === 'string' && stringToColor(color)) {
options.color = color;
}
// 'mode': string
// Forces mode
// Alias for 'background': 'bg'
const mode = qOptions.mode;
if (mode) {
switch (mode) {
case 'background':
case 'mask':
options.mode = mode;
default:
if ((mode as string) === 'bg') {
options.mode = 'background';
}
}
}
// 'forceSquare': boolean
// Forces icon to be square, regardless of width/height ratio
// Aliases: 'square', 'force-square'
const forceSquare = paramToBoolean(q.square || q.forceSquare || q['force-square'], void 0);
if (typeof forceSquare === 'boolean') {
options.forceSquare = forceSquare;
}
// 'pseudoSelector': boolean
// Adds `content: '';` to common selector. Useful when selector is a pseudo-selector
// Aliases: 'pseudo', 'pseudo-selector'
const pseudoSelector = paramToBoolean(q.pseudo || q.pseudoSelector || q['pseudo-selector'], void 0);
if (typeof pseudoSelector === 'boolean') {
options.pseudoSelector = pseudoSelector;
}
// 'commonSelector': string
// Common selector for all requested icons
// Alias: 'common'
const commonSelector = cleanupQueryValue(qOptions.commonSelector || q.common);
if (checkSelector(commonSelector)) {
options.commonSelector = commonSelector;
}
// 'iconSelector': string
// Icon selector
// Alias: 'selector'
const iconSelector = cleanupQueryValue(qOptions.iconSelector || q.selector);
if (checkSelector(iconSelector)) {
options.iconSelector = iconSelector;
}
// 'overrideSelector': string
// Selector for rules in icon that override common rules
// Alias: 'override'
const overrideSelector = cleanupQueryValue(qOptions.overrideSelector || q.override);
if (checkSelector(overrideSelector)) {
options.overrideSelector = overrideSelector;
}
// 'varName': string
// Variable name
// Alias: 'var'
const varName = q.varName || q.var;
if (typeof varName === 'string' && varName.match(/^[a-z0-9_-]*$/)) {
if (!varName || varName === 'null' || !paramToBoolean(varName, true)) {
options.varName = null;
} else {
options.varName = varName;
}
}
// Generate css
const css = getIconsCSS(
{
// Data
...data,
// Info to detect palette
info: iconSet.item.info,
},
names,
options
);
// Send CSS, optionally as attachment
if (q.download) {
res.header('Content-Disposition', 'attachment; filename="' + prefix + '.css"');
}
res.type('text/css; charset=utf-8').send(css);
});
}

View File

@ -0,0 +1,63 @@
import { IconifyJSON } from '@iconify/types';
import { getStoredIconsData } from '../../data/icon-set/utils/get-icons.js';
import { iconSets } from '../../data/icon-sets.js';
/**
* Generate icons data
*/
export function createIconsDataResponse(
prefix: string,
q: Record<string, string | string[]>
): number | IconifyJSON | Promise<IconifyJSON | number> {
const iconNames = q.icons;
const names = typeof iconNames === 'string' ? iconNames.split(',') : iconNames;
if (!names || !names.length) {
// Missing or invalid icons parameter
return 404;
}
// Get icon set
const iconSet = iconSets[prefix];
if (!iconSet) {
// No such icon set
return 404;
}
// Get icons, possibly sync
let syncData: IconifyJSON | undefined;
let resolveData: undefined | ((data: IconifyJSON) => void);
getStoredIconsData(iconSet.item, names, (data) => {
// Send data
if (resolveData) {
resolveData(data);
} else {
syncData = data;
}
});
if (syncData) {
return syncData;
}
return new Promise((resolve) => {
resolveData = resolve;
});
}
/**
* Awaitable version of createIconsDataResponse()
*/
export function createIconsDataResponseAsync(
prefix: string,
q: Record<string, string | string[]>
): Promise<IconifyJSON | number> {
return new Promise((resolve, reject) => {
const result = createIconsDataResponse(prefix, q);
if (result instanceof Promise) {
result.then(resolve).catch(reject);
} else {
resolve(result);
}
});
}

View File

@ -0,0 +1,65 @@
import { matchIconName } from '@iconify/utils/lib/icon/name';
import { searchIndex } from '../../data/search.js';
import { getPartialKeywords } from '../../data/search/partial.js';
import type { APIv3KeywordsQuery, APIv3KeywordsResponse } from '../../types/server/keywords.js';
/**
* Find full keywords for partial keyword
*/
export function createKeywordsResponse(q: Record<string, string>): number | APIv3KeywordsResponse {
// Check if search data is available
const searchIndexData = searchIndex.data;
if (!searchIndexData) {
return 404;
}
const keywords = searchIndexData.keywords;
// Get params
let test: string;
let suffixes: boolean;
let invalid: true | undefined;
let failed = false;
if (typeof q.prefix === 'string') {
// Keywords should start with prefix
test = q.prefix;
suffixes = false;
} else if (typeof q.keyword === 'string') {
// All keywords that contain keyword
test = q.keyword;
suffixes = true;
} else {
// Invalid query
return 400;
}
test = test.toLowerCase().trim();
// Check if keyword is invalid
if (!matchIconName.test(test)) {
invalid = true;
} else {
// Get only last part of complex keyword
// Testing complex keywords is not recommended, mix of parts is not checked
const parts = test.split('-');
if (parts.length > 1) {
test = parts.pop() as string;
suffixes = false;
for (let i = 0; i < parts.length; i++) {
if (keywords[parts[i]] === void 0) {
// One of keywords is missing
failed = true;
}
}
}
}
// Generate result
const response: APIv3KeywordsResponse = {
...(q as unknown as APIv3KeywordsQuery),
invalid,
exists: failed ? false : keywords[test] !== void 0,
matches: failed || invalid ? [] : getPartialKeywords(test, suffixes, searchIndexData)?.slice(0) || [],
};
return response;
}

View File

@ -0,0 +1,30 @@
import { getPrefixes, iconSets } from '../../data/icon-sets.js';
import type { APIv3LastModifiedResponse } from '../../types/server/modified.js';
import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
/**
* Get last modified time for all icon sets
*/
export function createLastModifiedResponse(q: Record<string, string>): number | APIv3LastModifiedResponse {
// Filter prefixes
const prefixes = filterPrefixesByPrefix(getPrefixes(), q, false);
// Generate result
const lastModified = Object.create(null) as Record<string, number>;
const response: APIv3LastModifiedResponse = {
lastModified,
};
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
const item = iconSets[prefix];
if (item) {
const value = item.item.common.lastModified;
if (value) {
lastModified[prefix] = value;
}
}
}
return response;
}

View File

@ -0,0 +1,118 @@
import { iconSets } from '../../data/icon-sets.js';
import { searchIndex } from '../../data/search.js';
import { search } from '../../data/search/index.js';
import { paramToBoolean } from '../../misc/bool.js';
import type { SearchParams } from '../../types/search.js';
import type { APIv2SearchParams, APIv2SearchResponse } from '../../types/server/v2.js';
const minSearchLimit = 32;
const maxSearchLimit = 999;
const defaultSearchLimit = minSearchLimit * 2;
/**
* Send API v2 response
*/
export function createAPIv2SearchResponse(q: Record<string, string>): number | APIv2SearchResponse {
// Check if search data is available
const searchIndexData = searchIndex.data;
if (!searchIndexData) {
return 404;
}
// Get query
const keyword = q.query;
if (!keyword) {
return 400;
}
// Convert to params
const params: SearchParams = {
keyword,
limit: defaultSearchLimit,
};
const v2Query = q as unknown as Record<keyof APIv2SearchParams, string>;
// Get limits
if (v2Query.limit) {
const limit = parseInt(v2Query.limit);
if (!limit) {
return 400;
}
params.limit = Math.max(minSearchLimit, Math.min(limit, maxSearchLimit));
}
if (v2Query.min) {
const limit = parseInt(v2Query.min);
if (!limit) {
return 400;
}
params.limit = Math.max(minSearchLimit, Math.min(limit, maxSearchLimit));
params.softLimit = true;
}
let start = 0;
if (v2Query.start) {
start = parseInt(v2Query.start);
if (isNaN(start) || start < 0 || start >= params.limit) {
return 400;
}
}
// Get prefixes
if (v2Query.prefixes) {
params.prefixes = v2Query.prefixes.split(',');
} else if (v2Query.prefix) {
params.prefixes = [v2Query.prefix];
} else if (v2Query.collection) {
params.prefixes = [v2Query.collection];
}
// Category
if (v2Query.category) {
params.category = v2Query.category;
}
// Disable partial
if (v2Query.similar) {
const similar = paramToBoolean(v2Query.similar);
if (typeof similar === 'boolean') {
params.partial = similar;
}
}
// Run query
const searchResults = search(params, searchIndexData, iconSets);
let response: APIv2SearchResponse;
if (searchResults) {
// Generate result
response = {
icons: searchResults.names.slice(start),
total: searchResults.names.length,
limit: params.limit,
start,
collections: Object.create(null),
request: v2Query,
};
// Add icon sets
for (let i = 0; i < searchResults.prefixes.length; i++) {
const prefix = searchResults.prefixes[i];
const info = iconSets[prefix]?.item.info;
if (info) {
response.collections[prefix] = info;
}
}
} else {
// No matches
response = {
icons: [],
total: 0,
limit: params.limit,
start,
collections: Object.create(null),
request: v2Query,
};
}
return response;
}

90
src/http/responses/svg.ts Normal file
View File

@ -0,0 +1,90 @@
import { iconToHTML } from '@iconify/utils/lib/svg/html';
import { iconToSVG } from '@iconify/utils/lib/svg/build';
import { flipFromString } from '@iconify/utils/lib/customisations/flip';
import { rotateFromString } from '@iconify/utils/lib/customisations/rotate';
import { defaultIconDimensions } from '@iconify/utils/lib/icon/defaults';
import { defaultIconCustomisations, IconifyIconCustomisations } from '@iconify/utils/lib/customisations/defaults';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getStoredIconData } from '../../data/icon-set/utils/get-icon.js';
import { iconSets } from '../../data/icon-sets.js';
import { errorText } from '../helpers/errors.js';
import { cleanupQueryValue } from '../helpers/query.js';
/**
* Generate SVG
*/
export function generateSVGResponse(prefix: string, name: string, query: FastifyRequest['query'], res: FastifyReply) {
// Get icon set
const iconSetItem = iconSets[prefix]?.item;
if (!iconSetItem) {
// No such icon set
res.code(404).send(errorText(404));
return;
}
// Check if icon exists
const icons = iconSetItem.icons;
if (!(icons.visible[name] || icons.hidden[name]) && !iconSetItem.icons.chars?.[name]) {
// No such icon
res.code(404).send(errorText(404));
return;
}
// Get icon
getStoredIconData(iconSetItem, name, (data) => {
if (!data) {
// Invalid icon
res.code(404).send(errorText(404));
return;
}
const q = (query || {}) as Record<string, string>;
// Clean up customisations
const customisations: IconifyIconCustomisations = {};
// Dimensions
customisations.width = cleanupQueryValue(q.width) || defaultIconCustomisations.width;
customisations.height = cleanupQueryValue(q.height) || defaultIconCustomisations.height;
// Rotation
customisations.rotate = q.rotate ? rotateFromString(q.rotate, 0) : 0;
// Flip
if (q.flip) {
flipFromString(customisations, q.flip);
}
// Generate SVG
const svg = iconToSVG(data, customisations);
let body = svg.body;
if (q.box) {
// Add bounding box
body =
'<rect x="' +
(data.left || 0) +
'" y="' +
(data.top || 0) +
'" width="' +
(data.width || defaultIconDimensions.width) +
'" height="' +
(data.height || defaultIconDimensions.height) +
'" fill="rgba(255, 255, 255, 0)" />' +
body;
}
let html = iconToHTML(body, svg.attributes);
// Change color
const color = cleanupQueryValue(q.color);
if (color && html.indexOf('currentColor') !== -1 && color.indexOf('"') === -1) {
html = html.split('currentColor').join(color);
}
// Send SVG, optionally as attachment
if (q.download) {
res.header('Content-Disposition', 'attachment; filename="' + name + '.svg"');
}
res.type('image/svg+xml; charset=utf-8').send(html);
});
}

View File

@ -0,0 +1,64 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { appConfig } from '../../config/app.js';
import { triggerIconSetsUpdate } from '../../data/icon-sets.js';
import { runWhenLoaded } from '../../data/loading.js';
let pendingUpdate = false;
let lastError = 0;
const envKey = 'APP_UPDATE_SECRET';
function logError(msg: string) {
const time = Date.now();
// Do not log error too often
if (time > lastError + 3600000) {
lastError = time;
console.error(msg);
}
}
function checkKey(query: Record<string, string>): boolean {
if (appConfig.updateRequiredParam) {
const expectedValue = process.env[envKey];
if (!expectedValue) {
// Missing env variable
logError(`Cannot process update request: missing env variable "${envKey}"`);
return false;
}
const value = query[appConfig.updateRequiredParam];
if (value !== expectedValue) {
return false;
}
// Success
return true;
}
// No param
logError(
'Auto-update can be triggered by anyone. Set `updateRequiredParam` config or UPDATE_REQUIRED_PARAM env variable to require secret to trigger update'
);
return true;
}
/**
* Generate icons data
*/
export function generateUpdateResponse(query: FastifyRequest['query'], res: FastifyReply) {
if (appConfig.allowUpdate && checkKey((query || {}) as Record<string, string>) && !pendingUpdate) {
pendingUpdate = true;
runWhenLoaded(() => {
const delay = appConfig.updateThrottle;
console.log('Will check for update in', delay, 'seconds...');
setTimeout(() => {
triggerIconSetsUpdate();
pendingUpdate = false;
}, delay * 1000);
});
}
// Send same message regardless of status
res.send('ok');
}

View File

@ -0,0 +1,28 @@
import { readFile } from 'node:fs/promises';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { appConfig } from '../../config/app.js';
let version: string | undefined;
/**
* Get version
*/
export async function initVersionResponse() {
try {
const packageContent = JSON.parse(await readFile('package.json', 'utf8'));
if (typeof packageContent.version === 'string') {
version = packageContent.version;
}
} catch {}
}
/**
* Send response
*/
export function versionResponse(query: FastifyRequest['query'], res: FastifyReply) {
res.send(
'Iconify API' +
(version ? ' version ' + version : '') +
(appConfig.statusRegion ? ' (' + appConfig.statusRegion + ')' : '')
);
}

View File

@ -0,0 +1,125 @@
import type { BaseDownloader } from '../../downloaders/base.js';
import { maybeAwait } from '../../misc/async.js';
import type {
BaseCollectionsImporter,
CreateIconSetImporter,
CreateIconSetImporterResult,
} from '../../types/importers/collections.js';
import type { ImportedData } from '../../types/importers/common.js';
/**
* Base collections list importer
*/
export function createBaseCollectionsListImporter<Downloader extends BaseDownloader<ImportedData>>(
instance: Downloader,
createIconSetImporter: CreateIconSetImporter
): Downloader & BaseCollectionsImporter {
const obj = instance as Downloader & BaseCollectionsImporter;
// Importers
const importers: Record<string, CreateIconSetImporterResult> = Object.create(null);
// Import status
let importing = false;
// Import each icon set
const importIconSets = async (prefixes: string[]): Promise<ImportedData> => {
importing = true;
// Reuse old data
const data: ImportedData = obj.data || {
prefixes,
iconSets: Object.create(null),
};
const iconSets = data.iconSets;
// Parse each prefix
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
let importer = importers[prefix];
if (!importer) {
// New item
importer = importers[prefix] = await maybeAwait(createIconSetImporter(prefix));
importer._dataUpdated = async (iconSetData) => {
data.iconSets[prefix] = iconSetData;
if (!importing) {
// Call _dataUpdated() if icon set was updated outside of importIconSets()
obj._dataUpdated?.(data);
}
};
await importer.init();
// Data should have been updated in init()
continue;
}
// Item already exists: check for update
await importer.checkForUpdate();
}
// Change status
importing = false;
return {
prefixes,
iconSets,
};
};
// Import from directory
obj._loadDataFromDirectory = async (path: string) => {
if (!obj._loadCollectionsListFromDirectory) {
throw new Error('Importer does not implement _loadCollectionsListFromDirectory()');
}
const prefixes = await obj._loadCollectionsListFromDirectory(path);
if (prefixes) {
return await importIconSets(prefixes);
}
};
// Custom import
obj._loadData = async () => {
if (!obj._loadCollectionsList) {
throw new Error('Importer does not implement _loadCollectionsList()');
}
const prefixes = await obj._loadCollectionsList();
if (prefixes) {
return await importIconSets(prefixes);
}
};
// Check for update
const checkCollectionsForUpdate = obj.checkForUpdate.bind(obj);
const checkIconSetForUpdate = async (prefix: string): Promise<boolean> => {
const importer = importers[prefix];
if (importer) {
return await importer.checkForUpdate();
}
console.error(`Cannot check "${prefix}" for update: no such icon set`);
return false;
};
// Check everything for update
obj.checkForUpdate = async (): Promise<boolean> => {
let result = await checkCollectionsForUpdate();
const prefixes = obj.data?.prefixes.slice(0) || [];
for (let i = 0; i < prefixes.length; i++) {
const importer = importers[prefixes[i]];
if (importer) {
result = (await importer.checkForUpdate()) || result;
}
}
return result;
};
// Set instance properties
const baseData: BaseCollectionsImporter = {
type: 'collections',
checkCollectionsForUpdate,
checkIconSetForUpdate,
};
Object.assign(obj, baseData);
return obj;
}

View File

@ -0,0 +1,54 @@
import { readFile } from 'node:fs/promises';
import type { IconifyInfo } from '@iconify/types';
import { matchIconName } from '@iconify/utils/lib/icon/name';
import type { BaseDownloader } from '../../downloaders/base.js';
import type { BaseCollectionsImporter, CreateIconSetImporter } from '../../types/importers/collections.js';
import type { ImportedData } from '../../types/importers/common.js';
import { createBaseCollectionsListImporter } from './base.js';
interface JSONCollectionsListImporterOptions {
// File to load
filename?: string;
// Icon set filter
filter?: (prefix: string, info: IconifyInfo) => boolean;
}
/**
* Create importer for `collections.json`
*/
export function createJSONCollectionsListImporter<Downloader extends BaseDownloader<ImportedData>>(
downloader: Downloader,
createIconSetImporter: CreateIconSetImporter,
options?: JSONCollectionsListImporterOptions
): Downloader & BaseCollectionsImporter {
const obj = createBaseCollectionsListImporter(downloader, createIconSetImporter);
// Load data
obj._loadCollectionsListFromDirectory = async (path: string) => {
let prefixes: string[];
let data: Record<string, IconifyInfo>;
const filename = options?.filename || '/collections.json';
try {
data = JSON.parse(await readFile(path + filename, 'utf8')) as Record<string, IconifyInfo>;
prefixes = Object.keys(data).filter((prefix) => matchIconName.test(prefix));
if (!(prefixes instanceof Array)) {
console.error(`Error loading "${filename}": invalid data`);
return;
}
} catch (err) {
console.error(err);
return;
}
// Filter keys
const filter = options?.filter;
if (filter) {
prefixes = prefixes.filter((prefix) => filter(prefix, data[prefix]));
}
return prefixes;
};
return obj;
}

View File

@ -0,0 +1,29 @@
import { CustomDownloader } from '../../downloaders/custom.js';
import type { BaseCollectionsImporter, CreateIconSetImporter } from '../../types/importers/collections.js';
import type { ImportedData } from '../../types/importers/common.js';
import { createBaseCollectionsListImporter } from './base.js';
/**
* Create importer for hardcoded list of icon sets
*/
export function createHardcodedCollectionsListImporter(
prefixes: string[],
createIconSetImporter: CreateIconSetImporter
): CustomDownloader<ImportedData> & BaseCollectionsImporter {
const obj = createBaseCollectionsListImporter(new CustomDownloader<ImportedData>(), createIconSetImporter);
// Add methods that aren't defined in custom downloader
obj._init = async () => {
return prefixes.length > 0;
};
obj._checkForUpdate = (done: (value: boolean) => void) => {
done(false);
};
obj._loadCollectionsList = async () => {
return prefixes;
};
return obj;
}

View File

@ -0,0 +1,47 @@
import { readFile } from 'node:fs/promises';
import { quicklyValidateIconSet } from '@iconify/utils/lib/icon-set/validate-basic';
import { asyncStoreLoadedIconSet } from '../../data/icon-set/store/storage.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
import { prependSlash } from '../../misc/files.js';
export interface IconSetJSONOptions {
// Ignore bad prefix?
// false -> skip icon sets with mismatched prefix
// true -> import icon set with mismatched prefix
ignoreInvalidPrefix?: boolean;
}
/**
* Reusable function for importing icon set from JSON file
*/
export async function importIconSetFromJSON(
prefix: string,
path: string,
filename: string,
options: IconSetJSONOptions = {}
): Promise<StoredIconSet | undefined> {
try {
const data = quicklyValidateIconSet(JSON.parse(await readFile(path + prependSlash(filename), 'utf8')));
if (!data) {
console.error(`Error loading "${prefix}" icon set: failed to validate`);
return;
}
if (data.prefix !== prefix) {
if (!options.ignoreInvalidPrefix) {
if (options.ignoreInvalidPrefix === void 0) {
// Show warning if option is not set
console.error(
`Error loading "${prefix}" icon set: bad prefix (enable ignoreInvalidPrefix option in importer to import icon set)`
);
}
return;
}
data.prefix = prefix;
}
// TODO: handle metadata from raw icon set data
return await asyncStoreLoadedIconSet(data);
} catch (err) {
console.error(err);
}
}

View File

@ -0,0 +1,78 @@
import { readFile } from 'node:fs/promises';
import { quicklyValidateIconSet } from '@iconify/utils/lib/icon-set/validate-basic';
import { asyncStoreLoadedIconSet } from '../../data/icon-set/store/storage.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
import { appConfig } from '../../config/app.js';
export interface IconSetJSONPackageOptions {
// Ignore bad prefix?
ignoreInvalidPrefix?: boolean;
}
/**
* Reusable function for importing icon set from `@iconify-json/*` package
*/
export async function importIconSetFromJSONPackage(
prefix: string,
path: string,
options: IconSetJSONPackageOptions = {}
): Promise<StoredIconSet | undefined> {
try {
const data = quicklyValidateIconSet(JSON.parse(await readFile(path + '/icons.json', 'utf8')));
if (!data) {
console.error(`Error loading "${prefix}" icon set: failed to validate`);
return;
}
if (data.prefix !== prefix) {
if (!options.ignoreInvalidPrefix) {
console.error(
`Error loading "${prefix}" icon set: bad prefix (enable ignoreInvalidPrefix option in importer to skip this check)`
);
return;
}
data.prefix = prefix;
}
// Check for characters map
try {
const chars = JSON.parse(await readFile(path + '/chars.json', 'utf8'));
if (typeof chars === 'object') {
for (const key in chars) {
data.chars = chars;
break;
}
}
} catch {
//
}
// Check for data needed for icons list
if (appConfig.enableIconLists) {
// Info
try {
const info = JSON.parse(await readFile(path + '/info.json', 'utf8'));
if (info.prefix === prefix) {
data.info = info;
}
} catch {
//
}
// Categories, themes
try {
const metadata = JSON.parse(await readFile(path + '/metadata.json', 'utf8'));
if (typeof metadata === 'object') {
Object.assign(data, metadata);
}
} catch {
//
}
}
const result = await asyncStoreLoadedIconSet(data);
return result;
} catch (err) {
console.error(err);
}
}

View File

@ -0,0 +1,70 @@
import { readdir, stat } from 'node:fs/promises';
import { matchIconName } from '@iconify/utils/lib/icon/name';
import type { BaseDownloader } from '../../downloaders/base.js';
import { DirectoryDownloader } from '../../downloaders/directory.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
import type { BaseCollectionsImporter, CreateIconSetImporter } from '../../types/importers/collections.js';
import type { ImportedData } from '../../types/importers/common.js';
import { createJSONIconSetImporter } from '../icon-set/json.js';
import { createBaseCollectionsListImporter } from '../collections/base.js';
interface JSONDirectoryImporterOptions {
// Icon set filter
filter?: (prefix: string) => boolean;
}
/**
* Create importer for all .json files in directory
*/
export function _createJSONDirectoryImporter<Downloader extends BaseDownloader<ImportedData>>(
downloader: Downloader,
options?: JSONDirectoryImporterOptions
): Downloader & BaseCollectionsImporter {
// Path to import from
let importPath: string | undefined;
// Function to create importer
const createIconSetImporter: CreateIconSetImporter = (prefix) => {
if (!importPath) {
throw new Error('Importer called before path was set');
}
return createJSONIconSetImporter(new DirectoryDownloader<StoredIconSet>(importPath), {
prefix,
filename: `/${prefix}.json`,
});
};
const obj = createBaseCollectionsListImporter(downloader, createIconSetImporter);
// Load data
obj._loadCollectionsListFromDirectory = async (path: string) => {
importPath = path;
let prefixes: string[] = [];
try {
const files = await readdir(path);
for (let i = 0; i < files.length; i++) {
const file = files[i];
const parts = file.split('.');
if (parts.length !== 2 || parts.pop() !== 'json' || !matchIconName.test(parts[0])) {
continue;
}
const data = await stat(path + '/' + file);
if (data.isFile()) {
prefixes.push(parts[0]);
}
}
} catch (err) {
console.error(err);
return;
}
// Filter prefixes
const filter = options?.filter;
if (filter) {
prefixes = prefixes.filter(filter);
}
return prefixes;
};
return obj;
}

View File

@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import { matchIconName } from '@iconify/utils/lib/icon/name';
import type { BaseDownloader } from '../../downloaders/base.js';
import { DirectoryDownloader } from '../../downloaders/directory.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
import type { BaseCollectionsImporter, CreateIconSetImporter } from '../../types/importers/collections.js';
import type { ImportedData } from '../../types/importers/common.js';
import { createJSONIconSetImporter } from '../icon-set/json.js';
import { createBaseCollectionsListImporter } from '../collections/base.js';
interface IconSetsPackageImporterOptions {
// Icon set filter
filter?: (prefix: string) => boolean;
}
/**
* Create importer for all .json files in directory
*/
export function _createIconSetsPackageImporter<Downloader extends BaseDownloader<ImportedData>>(
downloader: Downloader,
options?: IconSetsPackageImporterOptions
): Downloader & BaseCollectionsImporter {
// Path to import from
let importPath: string | undefined;
// Function to create importer
const createIconSetImporter: CreateIconSetImporter = (prefix) => {
if (!importPath) {
throw new Error('Importer called before path was set');
}
return createJSONIconSetImporter(new DirectoryDownloader<StoredIconSet>(importPath), {
prefix,
filename: `/json/${prefix}.json`,
});
};
const obj = createBaseCollectionsListImporter(downloader, createIconSetImporter);
// Load data
obj._loadCollectionsListFromDirectory = async (path: string) => {
importPath = path;
let prefixes: string[];
try {
const data = JSON.parse(await readFile(path + '/collections.json', 'utf8')) as Record<string, unknown>;
prefixes = Object.keys(data).filter((prefix) => matchIconName.test(prefix));
if (!(prefixes instanceof Array)) {
console.error(`Error loading "collections.json": invalid data`);
return;
}
} catch (err) {
console.error(err);
return;
}
// Filter keys
const filter = options?.filter;
if (filter) {
prefixes = prefixes.filter(filter);
}
return prefixes;
};
return obj;
}

View File

@ -0,0 +1,84 @@
import type { BaseDownloader } from '../../downloaders/base.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
import type { ImportedData } from '../../types/importers/common.js';
import type { BaseFullImporter } from '../../types/importers/full.js';
/**
* Base full importer
*/
export function createBaseImporter<Downloader extends BaseDownloader<ImportedData>>(
instance: Downloader
): Downloader & BaseFullImporter {
const obj = instance as Downloader & BaseFullImporter;
// Import status
let importing = false;
// Import each icon set
type ImportIconSetCallback = (prefix: string) => Promise<StoredIconSet | void | undefined>;
const importIconSets = async (prefixes: string[], callback: ImportIconSetCallback): Promise<ImportedData> => {
importing = true;
// Reuse old data
const data: ImportedData = obj.data || {
prefixes,
iconSets: Object.create(null),
};
const iconSets = data.iconSets;
// Parse each prefix
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
const iconSetData = await callback(prefix);
if (iconSetData) {
data.iconSets[prefix] = iconSetData;
}
}
// Change status
importing = false;
return {
prefixes,
iconSets,
};
};
// Import from directory
obj._loadDataFromDirectory = async (path: string) => {
if (!obj._loadCollectionsListFromDirectory) {
throw new Error('Importer does not implement _loadCollectionsListFromDirectory()');
}
const loader = obj._loadIconSetFromDirectory;
if (!loader) {
throw new Error('Importer does not implement _loadIconSetFromDirectory()');
}
const prefixes = await obj._loadCollectionsListFromDirectory(path);
if (prefixes) {
return await importIconSets(prefixes, (prefix) => loader(prefix, path));
}
};
// Custom import
obj._loadData = async () => {
if (!obj._loadCollectionsList) {
throw new Error('Importer does not implement _loadCollectionsList()');
}
const loader = obj._loadIconSet;
if (!loader) {
throw new Error('Importer does not implement _loadIconSet()');
}
const prefixes = await obj._loadCollectionsList();
if (prefixes) {
return await importIconSets(prefixes, (prefix) => loader(prefix));
}
};
// Set instance properties
const baseData: BaseFullImporter = {
type: 'full',
};
Object.assign(obj, baseData);
return obj;
}

View File

@ -0,0 +1,57 @@
import { readdir, stat } from 'node:fs/promises';
import { matchIconName } from '@iconify/utils/lib/icon/name';
import type { BaseDownloader } from '../../downloaders/base.js';
import type { ImportedData } from '../../types/importers/common.js';
import type { BaseFullImporter } from '../../types/importers/full.js';
import { createBaseImporter } from './base.js';
import { type IconSetJSONOptions, importIconSetFromJSON } from '../common/icon-set-json.js';
interface JSONDirectoryImporterOptions extends IconSetJSONOptions {
// Icon set filter
filter?: (prefix: string) => boolean;
}
/**
* Create importer for all .json files in directory
*/
export function createJSONDirectoryImporter<Downloader extends BaseDownloader<ImportedData>>(
downloader: Downloader,
options: JSONDirectoryImporterOptions = {}
): Downloader & BaseFullImporter {
const obj = createBaseImporter(downloader);
// Load data
obj._loadCollectionsListFromDirectory = async (path: string) => {
let prefixes: string[] = [];
try {
const files = await readdir(path);
for (let i = 0; i < files.length; i++) {
const file = files[i];
const parts = file.split('.');
if (parts.length !== 2 || parts.pop() !== 'json' || !matchIconName.test(parts[0])) {
continue;
}
const data = await stat(path + '/' + file);
if (data.isFile()) {
prefixes.push(parts[0]);
}
}
} catch (err) {
console.error(err);
return;
}
// Filter prefixes
const filter = options?.filter;
if (filter) {
prefixes = prefixes.filter(filter);
}
return prefixes;
};
// Load icon set
obj._loadIconSetFromDirectory = (prefix: string, path: string) =>
importIconSetFromJSON(prefix, path, '/' + prefix + '.json', options);
return obj;
}

View File

@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import type { IconifyInfo } from '@iconify/types';
import { matchIconName } from '@iconify/utils/lib/icon/name';
import type { BaseDownloader } from '../../downloaders/base.js';
import type { ImportedData } from '../../types/importers/common.js';
import type { BaseFullImporter } from '../../types/importers/full.js';
import { createBaseImporter } from './base.js';
import { IconSetJSONOptions, importIconSetFromJSON } from '../common/icon-set-json.js';
interface IconSetsPackageImporterOptions extends IconSetJSONOptions {
// Icon set filter
filter?: (prefix: string, info: IconifyInfo) => boolean;
}
/**
* Create importer for all .json files in directory
*/
export function createIconSetsPackageImporter<Downloader extends BaseDownloader<ImportedData>>(
downloader: Downloader,
options: IconSetsPackageImporterOptions = {}
): Downloader & BaseFullImporter {
const obj = createBaseImporter(downloader);
// Load collections list
obj._loadCollectionsListFromDirectory = async (path: string) => {
// Log version
try {
const packageJSON = JSON.parse(await readFile(path + '/package.json', 'utf8'));
if (packageJSON.name && packageJSON.version) {
console.log(`Loading ${packageJSON.name} ${packageJSON.version}`);
}
} catch {
//
}
// Get prefixes
let prefixes: string[];
let data: Record<string, IconifyInfo>;
try {
data = JSON.parse(await readFile(path + '/collections.json', 'utf8')) as Record<string, IconifyInfo>;
prefixes = Object.keys(data).filter((prefix) => matchIconName.test(prefix));
if (!(prefixes instanceof Array)) {
console.error(`Error loading "collections.json": invalid data`);
return;
}
} catch (err) {
console.error(err);
return;
}
// Filter keys
const filter = options?.filter;
if (filter) {
prefixes = prefixes.filter((prefix) => filter(prefix, data[prefix]));
}
return prefixes;
};
// Load icon set
obj._loadIconSetFromDirectory = async (prefix: string, path: string) =>
importIconSetFromJSON(prefix, path, '/json/' + prefix + '.json', options);
return obj;
}

View File

@ -0,0 +1,32 @@
import type { BaseDownloader } from '../../downloaders/base.js';
import type { BaseIconSetImporter } from '../../types/importers/icon-set.js';
import type { IconSetImportedData } from '../../types/importers/common.js';
import { IconSetJSONPackageOptions, importIconSetFromJSONPackage } from '../common/json-package.js';
interface JSONPackageIconSetImporterOptions extends IconSetJSONPackageOptions {
// Icon set prefix
prefix: string;
}
/**
* Create importer for `@iconify-json/*` package
*/
export function createJSONPackageIconSetImporter<Downloader extends BaseDownloader<IconSetImportedData>>(
instance: Downloader,
options: JSONPackageIconSetImporterOptions
): Downloader & BaseIconSetImporter {
const obj = instance as Downloader & BaseIconSetImporter;
const prefix = options.prefix;
// Set static data
const baseData: BaseIconSetImporter = {
type: 'icon-set',
prefix,
};
Object.assign(obj, baseData);
// Load data
obj._loadDataFromDirectory = (path: string) => importIconSetFromJSONPackage(prefix, path, options);
return obj;
}

View File

@ -0,0 +1,35 @@
import type { BaseDownloader } from '../../downloaders/base.js';
import type { BaseIconSetImporter } from '../../types/importers/icon-set.js';
import type { IconSetImportedData } from '../../types/importers/common.js';
import { IconSetJSONOptions, importIconSetFromJSON } from '../common/icon-set-json.js';
interface JSONIconSetImporterOptions extends IconSetJSONOptions {
// Icon set prefix
prefix: string;
// File to load from
filename: string;
}
/**
* Create importer for .json file
*/
export function createJSONIconSetImporter<Downloader extends BaseDownloader<IconSetImportedData>>(
instance: Downloader,
options: JSONIconSetImporterOptions
): Downloader & BaseIconSetImporter {
const obj = instance as Downloader & BaseIconSetImporter;
const prefix = options.prefix;
// Set instance properties
const baseData: BaseIconSetImporter = {
type: 'icon-set',
prefix,
};
Object.assign(obj, baseData);
// Load data
obj._loadDataFromDirectory = (path: string) => importIconSetFromJSON(prefix, path, options.filename, options);
return obj;
}

24
src/index.ts Normal file
View File

@ -0,0 +1,24 @@
import { config } from 'dotenv';
import { loaded } from './data/loading.js';
import { startHTTPServer } from './http/index.js';
import { loadEnvConfig } from './misc/load-config.js';
import { initAPI } from './init.js';
(async () => {
// Configure environment
config();
loadEnvConfig();
// Start HTTP server
startHTTPServer();
// Init API
await initAPI();
// Loaded
loaded();
})()
.then(() => {
console.log('API startup process complete');
})
.catch(console.error);

36
src/init.ts Normal file
View File

@ -0,0 +1,36 @@
import { getImporters } from './config/icon-sets.js';
import { iconSetsStorage } from './data/icon-set/store/storage.js';
import { setImporters, updateIconSets } from './data/icon-sets.js';
import { cleanupStorageCache } from './data/storage/startup.js';
import { Importer } from './types/importers.js';
interface InitOptions {
// Cleanup storage cache
cleanup?: boolean;
// Importers
importers?: Importer[];
}
/**
* Init API
*/
export async function initAPI(options: InitOptions = {}) {
// Reset old cache
if (options.cleanup !== false) {
await cleanupStorageCache(iconSetsStorage);
}
// Get all importers and load data
let importers = options.importers;
if (!importers) {
importers = await getImporters();
}
for (let i = 0; i < importers.length; i++) {
await importers[i].init();
}
// Update
setImporters(importers);
updateIconSets();
}

11
src/misc/async.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Handle sync/async code
*/
export async function maybeAwait<T>(value: T | Promise<T>): Promise<T> {
if (value instanceof Promise) {
return value;
}
return new Promise((fulfill) => {
fulfill(value);
});
}

18
src/misc/bool.ts Normal file
View File

@ -0,0 +1,18 @@
/**
* Convert string to boolean
*/
export function paramToBoolean(value: string, defaultValue?: boolean): boolean | undefined {
switch (value) {
case 'true':
case 'yes':
case '1':
return true;
case 'false':
case 'no':
case '0':
return false;
}
return defaultValue;
}

56
src/misc/files.ts Normal file
View File

@ -0,0 +1,56 @@
import { stat } from 'node:fs/promises';
import { scanDirectory } from '@iconify/tools/lib/misc/scan';
import type { FileEntry } from '../types/files.js';
import { hashString } from './hash.js';
/**
* List all files in directory
*/
export async function listFilesInDirectory(path: string): Promise<FileEntry[]> {
const files = await scanDirectory(path, (ext, file, subdir, path, stat) => {
const filename = subdir + file + ext;
const item: FileEntry = {
filename,
ext,
file,
path: subdir,
mtime: stat.mtimeMs,
size: stat.size,
};
return item;
});
files.sort((a, b) => a.filename.localeCompare(b.filename));
return files;
}
/**
* Hash files to quickly check if files were changed
*
* Does not check file contents, checking last modification time should be enough
*/
export function hashFiles(files: FileEntry[]): string {
const hashData = files.map(({ filename, mtime, size }) => {
return { filename, mtime, size };
});
return hashString(JSON.stringify(hashData));
}
/**
* Check if directory exists
*/
export async function directoryExists(dir: string): Promise<boolean> {
try {
const stats = await stat(dir);
return stats.isDirectory();
} catch {
return false;
}
}
/**
* Add '/' to start of filename
*/
export function prependSlash(filename: string): string {
return filename.slice(0, 1) === '/' ? filename : '/' + filename;
}

8
src/misc/hash.ts Normal file
View File

@ -0,0 +1,8 @@
import { createHash } from 'crypto';
/**
* Generate unique hash
*/
export function hashString(value: string): string {
return createHash('md5').update(value).digest('hex');
}

56
src/misc/load-config.ts Normal file
View File

@ -0,0 +1,56 @@
import { appConfig, splitIconSetConfig, storageConfig } from '../config/app.js';
import { paramToBoolean } from './bool.js';
interface ConfigurableItem {
config: unknown;
prefix: string;
}
const config: ConfigurableItem[] = [
{
config: appConfig,
prefix: '',
},
{
config: splitIconSetConfig,
prefix: 'SPLIT_',
},
{
config: storageConfig,
prefix: 'STORAGE_',
},
];
/**
* Load config from environment
*/
export function loadEnvConfig(env = process.env) {
config.forEach(({ config, prefix }) => {
const cfg = config as Record<string, unknown>;
for (const key in cfg) {
const envKey = prefix + key.replace(/[A-Z]/g, (letter) => '_' + letter.toLowerCase()).toUpperCase();
const value = env[envKey];
if (value !== void 0) {
const defaultValue = cfg[key];
switch (typeof defaultValue) {
case 'boolean': {
cfg[key] = paramToBoolean(value.toLowerCase(), cfg[key] as boolean);
break;
}
case 'number': {
const num = parseInt(value);
if (!isNaN(num)) {
cfg[key] = num;
}
break;
}
case 'string':
cfg[key] = value;
break;
}
}
}
});
}

31
src/misc/name.ts Normal file
View File

@ -0,0 +1,31 @@
interface SplitIconName {
prefix: string;
name: string;
}
// 2 part icon name
export const iconNameRouteRegEx = '^[a-z0-9-]+:?[a-z0-9-]+$';
// 1 part of icon name
export const iconNameRoutePartialRegEx = '^[a-z0-9-]+$';
/**
* Split icon name
*/
export function splitIconName(value: string): SplitIconName | undefined {
let parts = value.split(/[/:]/);
if (parts.length === 2) {
return {
prefix: parts[0],
name: parts[1],
};
}
parts = value.split('-');
if (parts.length > 1) {
return {
prefix: parts.shift() as string,
name: parts.join('-'),
};
}
}

View File

@ -1,76 +0,0 @@
"use strict";
const generateSVG = require('./svg');
/**
* Regexp for checking callback attribute
*
* @type {RegExp}
* @private
*/
const _callbackMatch = /^[a-z0-9_.]+$/i;
/**
* Generate data for query
*
* @param {Collection} collection
* @param {string} query Query string after last / without extension
* @param {string} ext Extension
* @param {object} params Parameters
* @returns {number|object}
*/
module.exports = (collection, query, ext, params) => {
switch (ext) {
case 'svg':
// Generate SVG
// query = icon name
let icon = collection.getIcon(query);
if (icon === null) {
return 404;
}
return {
filename: query + '.svg',
type: 'image/svg+xml; charset=utf-8',
body: generateSVG(icon, params)
};
case 'js':
case 'json':
if (query !== 'icons' || typeof params.icons !== 'string') {
return 404;
}
let result = collection.getIcons(params.icons.split(','));
if (!Object.keys(result.icons).length) {
return 404;
}
if (!Object.keys(result.aliases).length) {
delete result.aliases;
}
result = JSON.stringify(result);
if (ext === 'js') {
let callback;
if (params.callback !== void 0) {
callback = params.callback;
if (!callback.match(_callbackMatch)) {
return 400;
}
} else {
callback = 'SimpleSVG._loaderCallback';
}
return {
type: 'application/javascript; charset=utf-8',
body: callback + '(' + result + ')'
};
}
return {
type: 'application/json; charset=utf-8',
body: result
};
default:
return 404;
}
};

View File

@ -1,64 +0,0 @@
/**
* This file is part of the simple-svg-cdn package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
const _unitsSplit = /(-?[0-9.]*[0-9]+[0-9.]*)/g,
_unitsTest = /^-?[0-9.]*[0-9]+[0-9.]*$/g;
/**
* Calculate second dimension when only 1 dimension is set
*
* @param {string|number} size One dimension (such as width)
* @param {number} ratio Width/height ratio.
* If size == width, ratio = height/width
* If size == height, ratio = width/height
* @param {number} [precision] Floating number precision in result to minimize output. Default = 100
* @return {string|number|null} Another dimension, null on error
*/
module.exports = (size, ratio, precision) => {
if (ratio === 1) {
return size;
}
precision = precision === void 0 ? 100 : precision;
if (typeof size === 'number') {
return Math.ceil(size * ratio * precision) / precision;
}
// split code into sets of strings and numbers
let split = size.split(_unitsSplit);
if (split === null || !split.length) {
return null;
}
let results = [],
code = split.shift(),
isNumber = _unitsTest.test(code),
num;
while (true) {
if (isNumber) {
num = parseFloat(code);
if (isNaN(num)) {
results.push(code);
} else {
results.push(Math.ceil(num * ratio * precision) / precision);
}
} else {
results.push(code);
}
// next
code = split.shift();
if (code === void 0) {
return results.join('');
}
isNumber = !isNumber;
}
};

View File

@ -1,62 +0,0 @@
/**
* This file is part of the simple-svg-cdn package.
*
* (c) Vjacheslav Trushkin <cyberalien@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
"use strict";
/**
* Unique id counter
*
* @type {number}
*/
let idCounter = 0;
/**
* Replace IDs in SVG output with unique IDs
* Fast replacement without parsing XML, assuming commonly used patterns.
*
* @param {string} body
* @return {string}
*/
module.exports = body => {
let regex = /\sid="(\S+)"/g,
ids = [],
match, prefix;
function strReplace(search, replace, subject) {
let pos = 0;
while ((pos = subject.indexOf(search, pos)) !== -1) {
subject = subject.slice(0, pos) + replace + subject.slice(pos + search.length);
pos += replace.length;
}
return subject;
}
// Find all IDs
while (match = regex.exec(body)) {
ids.push(match[1]);
}
if (!ids.length) {
return body;
}
prefix = 'SimpleSVGId-' + Date.now().toString(16) + '-' + (Math.random() * 0x1000000 | 0).toString(16) + '-';
// Replace with unique ids
ids.forEach(function(id) {
let newID = prefix + idCounter;
idCounter ++;
body = strReplace('="' + id + '"', '="' + newID + '"', body);
body = strReplace('="#' + id + '"', '="#' + newID + '"', body);
body = strReplace('(#' + id + ')', '(#' + newID + ')', body);
});
return body;
};

View File

@ -1,272 +0,0 @@
"use strict";
const replaceIDs = require('./svg-ids');
const calculateDimension = require('./svg-dimensions');
/**
* Get preserveAspectRatio attribute value
*
* @param {object} align
* @return {string}
* @private
*/
function _align(align) {
let result;
switch (align.horizontal) {
case 'left':
result = 'xMin';
break;
case 'right':
result = 'xMax';
break;
default:
result = 'xMid';
}
switch (align.vertical) {
case 'top':
result += 'YMin';
break;
case 'bottom':
result += 'YMax';
break;
default:
result += 'YMid';
}
result += align.slice ? ' slice' : ' meet';
return result;
}
/**
* Generate SVG
*
* @param {object} item Icon data
* @param {object} props Query string
* @returns {string}
*/
module.exports = (item, props) => {
// Set data
let align = {
horizontal: 'center',
vertical: 'middle',
slice: false
};
let transform = {
rotate: item.rotate,
hFlip: item.hFlip,
vFlip: item.vFlip
};
let style = '';
let attributes = {};
// Get width/height
let inline = props.inline === true || props.inline === 'true' || props.inline === '1';
let box = {
left: item.left,
top: inline ? item.inlineTop : item.top,
width: item.width,
height: inline ? item.inlineHeight : item.height
};
// Transformations
['hFlip', 'vFlip'].forEach(key => {
if (props[key] !== void 0 && (props[key] === true || props[key] === 'true' || props[key] === '1')) {
transform[key] = !transform[key];
}
});
if (props.flip !== void 0) {
props.flip.toLowerCase().split(/[\s,]+/).forEach(value => {
switch (value) {
case 'horizontal':
transform.hFlip = !transform.hFlip;
break;
case 'vertical':
transform.vFlip = !transform.vFlip;
}
});
}
if (props.rotate !== void 0) {
let value = props.rotate;
if (typeof value === 'number') {
transform.rotate += value;
} else if (typeof value === 'string') {
let units = value.replace(/^-?[0-9.]*/, '');
if (units === '') {
value = parseInt(value);
if (!isNaN(value)) {
transform.rotate += value;
}
} else if (units !== value) {
let split = false;
switch (units) {
case '%':
// 25% -> 1, 50% -> 2, ...
split = 25;
break;
case 'deg':
// 90deg -> 1, 180deg -> 2, ...
split = 90;
}
if (split) {
value = parseInt(value.slice(0, value.length - units.length));
if (!isNaN(value)) {
transform.rotate += Math.round(value / split);
}
}
}
}
}
// Apply transformations to box
let transformations = [],
tempValue;
if (transform.hFlip) {
if (transform.vFlip) {
transform.rotate += 2;
} else {
// Horizontal flip
transformations.push('translate(' + (box.width + box.left) + ' ' + (0 - box.top) + ')');
transformations.push('scale(-1 1)');
box.top = box.left = 0;
}
} else if (transform.vFlip) {
// Vertical flip
transformations.push('translate(' + (0 - box.left) + ' ' + (box.height + box.top) + ')');
transformations.push('scale(1 -1)');
box.top = box.left = 0;
}
switch (transform.rotate % 4) {
case 1:
// 90deg
tempValue = box.height / 2 + box.top;
transformations.unshift('rotate(90 ' + tempValue + ' ' + tempValue + ')');
// swap width/height and x/y
if (box.left !== 0 || box.top !== 0) {
tempValue = box.left;
box.left = box.top;
box.top = tempValue;
}
if (box.width !== box.height) {
tempValue = box.width;
box.width = box.height;
box.height = tempValue;
}
break;
case 2:
// 180deg
transformations.unshift('rotate(180 ' + (box.width / 2 + box.left) + ' ' + (box.height / 2 + box.top) + ')');
break;
case 3:
// 270deg
tempValue = box.width / 2 + box.left;
transformations.unshift('rotate(-90 ' + tempValue + ' ' + tempValue + ')');
// swap width/height and x/y
if (box.left !== 0 || box.top !== 0) {
tempValue = box.left;
box.left = box.top;
box.top = tempValue;
}
if (box.width !== box.height) {
tempValue = box.width;
box.width = box.height;
box.height = tempValue;
}
break;
}
// Calculate dimensions
// Values for width/height: null = default, 'auto' = from svg, false = do not set
// Default: if both values aren't set, height defaults to '1em', width is calculated from height
let customWidth = props.width ? props.width : null;
let customHeight = props.height ? props.height : null;
let width, height;
if (customWidth === null && customHeight === null) {
customHeight = '1em';
}
if (customWidth !== null && customHeight !== null) {
width = customWidth;
height = customHeight;
} else if (customWidth !== null) {
width = customWidth;
height = calculateDimension(width, box.height / box.width);
} else {
height = customHeight;
width = calculateDimension(height, box.width / box.height);
}
if (width !== false) {
attributes.width = width === 'auto' ? box.width : width;
}
if (height !== false) {
attributes.height = height === 'auto' ? box.height : height;
}
// Add vertical-align for inline icon
if (inline && item.verticalAlign !== 0) {
style += 'vertical-align: ' + item.verticalAlign + 'em;';
}
// Check custom alignment
if (props.align !== void 0) {
props.align.toLowerCase().split(/[\s,]+/).forEach(value => {
switch (value) {
case 'left':
case 'right':
case 'center':
align.horizontal = value;
break;
case 'top':
case 'bottom':
case 'middle':
align.vertical = value;
break;
case 'crop':
align.slice = true;
break;
case 'meet':
align.slice = false;
}
});
}
// Add 360deg transformation to style to prevent subpixel rendering bug
style += '-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);';
// Style attribute
attributes.style = style;
// Generate viewBox and preserveAspectRatio attributes
attributes.preserveAspectRatio = _align(align);
attributes.viewBox = box.left + ' ' + box.top + ' ' + box.width + ' ' + box.height;
// Generate body
let body = replaceIDs(item.body);
if (props.color !== void 0) {
body = body.replace(/currentColor/g, props.color);
}
if (transformations.length) {
body = '<g transform="' + transformations.join(' ') + '">' + body + '</g>';
}
let svg = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"';
Object.keys(attributes).forEach(attr => {
svg += ' ' + attr + '="' + attributes[attr] + '"';
});
svg += '>' + body + '</svg>';
return svg;
};

Some files were not shown because too many files have changed in this diff Show More