mirror of https://github.com/iconify/api.git
Compare commits
No commits in common. "1.0.0-rc2" and "main" have entirely different histories.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
.idea
|
||||
.elasticbeanstalk
|
||||
.vscode
|
||||
.DS_Store
|
||||
node_modules
|
||||
config.json
|
||||
*.log
|
||||
_debug*.*
|
||||
.ssl/ssl.*
|
||||
git-repos
|
||||
.env
|
||||
*.map
|
||||
tsconfig.tsbuildinfo
|
||||
/node_modules
|
||||
/lib
|
||||
/cache
|
||||
/tmp
|
||||
|
|
|
|||
27
.npmignore
27
.npmignore
|
|
@ -1,11 +1,18 @@
|
|||
.idea
|
||||
.git
|
||||
.reload
|
||||
/.idea
|
||||
/.vscode
|
||||
.DS_Store
|
||||
config.json
|
||||
node_modules
|
||||
npm-debug.log
|
||||
tests
|
||||
debug
|
||||
_debug*.*
|
||||
git-repos
|
||||
/.env
|
||||
/.editorconfig
|
||||
/.prettierrc
|
||||
*.map
|
||||
/docker.sh
|
||||
/Dockerfile
|
||||
/tsconfig*.*
|
||||
/vitest.config.*
|
||||
/.github
|
||||
/src
|
||||
/node_modules
|
||||
/cache
|
||||
/tmp
|
||||
/icons
|
||||
/tests
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"useTabs": true,
|
||||
"semi": true,
|
||||
"quoteProps": "consistent",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 120
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
392
app.js
392
app.js
|
|
@ -1,392 +0,0 @@
|
|||
/**
|
||||
* Main file to run in Node.js
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* Main stuff
|
||||
*/
|
||||
const fs = require('fs'),
|
||||
util = require('util'),
|
||||
|
||||
// Express stuff
|
||||
express = require('express'),
|
||||
app = express(),
|
||||
|
||||
// Configuration and version
|
||||
version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version,
|
||||
|
||||
// Included files
|
||||
Collections = require('./src/collections'),
|
||||
|
||||
// Query parser
|
||||
parseQuery = require('./src/query');
|
||||
|
||||
// Configuration
|
||||
let config = JSON.parse(fs.readFileSync(__dirname + '/config-default.json', 'utf8'));
|
||||
|
||||
try {
|
||||
let customConfig = fs.readFileSync(__dirname + '/config.json', 'utf8');
|
||||
if (typeof customConfig === 'string') {
|
||||
customConfig = JSON.parse(customConfig);
|
||||
Object.keys(customConfig).forEach(key => {
|
||||
if (typeof config[key] !== typeof customConfig[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof config[key] === 'object') {
|
||||
// merge object
|
||||
Object.assign(config[key], customConfig[key]);
|
||||
} else {
|
||||
// overwrite scalar variables
|
||||
config[key] = customConfig[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
config._dir = __dirname;
|
||||
|
||||
// Enable logging module
|
||||
require('./src/log')(config);
|
||||
|
||||
// Port
|
||||
if (config['env-port'] && process.env.PORT) {
|
||||
config.port = process.env.PORT;
|
||||
}
|
||||
|
||||
// Region file to easy identify server in CDN
|
||||
if (!config['env-region'] && process.env.region) {
|
||||
config.region = process.env.region;
|
||||
}
|
||||
if (config.region.length > 10 || !config.region.match(/^[a-z0-9_-]+$/i)) {
|
||||
config.region = '';
|
||||
config.log('Invalid value for region config variable.', 'config-region', true);
|
||||
}
|
||||
|
||||
// Reload secret key
|
||||
if (config['reload-secret'] === '') {
|
||||
// Add reload-secret to config.json to be able to run /reload?key=your-secret-key that will reload collections without restarting server
|
||||
console.log('reload-secret configuration is empty. You will not be able to update all collections without restarting server.');
|
||||
}
|
||||
|
||||
// Collections list
|
||||
let collections = null,
|
||||
loading = true,
|
||||
anotherReload = false;
|
||||
|
||||
// Modules
|
||||
let dirs = require('./src/dirs')(config),
|
||||
sync = require('./src/sync')(config);
|
||||
|
||||
/**
|
||||
* Load icons
|
||||
*
|
||||
* @param {boolean} firstLoad
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function loadIcons(firstLoad) {
|
||||
return new Promise((fulfill, reject) => {
|
||||
function getCollections() {
|
||||
let t = Date.now(),
|
||||
newCollections = new Collections(config);
|
||||
|
||||
console.log('Loading collections at ' + (new Date()).toString());
|
||||
newCollections.reload(dirs.getRepos()).then(() => {
|
||||
console.log('Loaded in ' + (Date.now() - t) + 'ms');
|
||||
fulfill(newCollections);
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (firstLoad && config.sync && config.sync['sync-on-startup']) {
|
||||
// Synchronize repositories first
|
||||
let promises = [];
|
||||
dirs.keys().forEach(repo => {
|
||||
if (sync.canSync(repo)) {
|
||||
switch (config.sync['sync-on-startup']) {
|
||||
case 'always':
|
||||
break;
|
||||
|
||||
case 'never':
|
||||
return;
|
||||
|
||||
case 'missing':
|
||||
// Check if repository is missing
|
||||
if (sync.time(repo)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
promises.push(sync.sync(repo, true));
|
||||
}
|
||||
});
|
||||
|
||||
if (promises.length) {
|
||||
console.log('Synchronizing repositories before starting...');
|
||||
}
|
||||
Promise.all(promises).then(() => {
|
||||
getCollections();
|
||||
}).catch(err => {
|
||||
console.log(err);
|
||||
getCollections();
|
||||
});
|
||||
} else {
|
||||
getCollections();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reloadIcons(firstLoad) {
|
||||
loading = true;
|
||||
anotherReload = false;
|
||||
loadIcons(false).then(newCollections => {
|
||||
collections = newCollections;
|
||||
loading = false;
|
||||
if (anotherReload) {
|
||||
reloadIcons(false);
|
||||
}
|
||||
}).catch(err => {
|
||||
config.log('Fatal error loading collections:\n' + util.format(err), null, true);
|
||||
loading = false;
|
||||
if (anotherReload) {
|
||||
reloadIcons(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send cache headers
|
||||
*
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
function cacheHeaders(req, res) {
|
||||
if (
|
||||
config.cache && config.cache.timeout &&
|
||||
(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', (config.cache.private ? 'private' : 'public') + ', max-age=' + config.cache.timeout + ', min-refresh=' + config.cache['min-refresh']);
|
||||
if (!config.cache.private) {
|
||||
res.set('Pragma', 'cache');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send result object generated by query parser
|
||||
*
|
||||
* @param {object} result
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
function sendResult(result, req, res) {
|
||||
if (typeof result === 'number') {
|
||||
res.sendStatus(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send cache header
|
||||
cacheHeaders(req, res);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay response
|
||||
*
|
||||
* @param {function} callback
|
||||
* @param res
|
||||
*/
|
||||
function delayResponse(callback, res) {
|
||||
// Attempt to parse query every 250ms for up to 10 seconds
|
||||
let attempts = 0,
|
||||
timer = setInterval(function() {
|
||||
attempts ++;
|
||||
if (collections === null) {
|
||||
if (attempts > 40) {
|
||||
clearInterval(timer);
|
||||
res.sendStatus(503);
|
||||
}
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
callback();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
sendResult(result, req, res);
|
||||
}
|
||||
|
||||
// Parse query
|
||||
if (collections === null) {
|
||||
// This means script is still loading
|
||||
delayResponse(parse, res);
|
||||
} else {
|
||||
parse();
|
||||
}
|
||||
}
|
||||
|
||||
// Load icons
|
||||
loadIcons(true).then(newCollections => {
|
||||
collections = newCollections;
|
||||
loading = false;
|
||||
if (anotherReload) {
|
||||
anotherReload = false;
|
||||
setTimeout(() => {
|
||||
reloadIcons(false);
|
||||
}, 30000);
|
||||
}
|
||||
}).catch(err => {
|
||||
config.log('Fatal error loading collections:\n' + util.format(err), null, true);
|
||||
loading = false;
|
||||
reloadIcons(true);
|
||||
});
|
||||
|
||||
// Disable X-Powered-By header
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// CORS
|
||||
app.options('/*', (req, res) => {
|
||||
if (config.cors) {
|
||||
res.header('Access-Control-Allow-Origin', config.cors.origins);
|
||||
res.header('Access-Control-Allow-Methods', config.cors.methods);
|
||||
res.header('Access-Control-Allow-Headers', config.cors.headers);
|
||||
res.header('Access-Control-Max-Age', config.cors.timeout);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
// Disable crawling
|
||||
app.get('/robots.txt', (req, res) => {
|
||||
res.type('text/plain').send('User-agent: *\nDisallow: /');
|
||||
});
|
||||
|
||||
// Debug information and AWS health check
|
||||
app.get('/version', (req, res) => {
|
||||
let body = 'Iconify API version ' + version + ' (Node';
|
||||
if (config.region.length) {
|
||||
body += ', ' + config.region;
|
||||
}
|
||||
body += ')';
|
||||
res.send(body);
|
||||
});
|
||||
|
||||
// Reload collections without restarting app
|
||||
app.get('/reload', (req, res) => {
|
||||
if (config['reload-secret'].length && req.query && req.query.key && req.query.key === config['reload-secret']) {
|
||||
// Reload collections
|
||||
process.nextTick(() => {
|
||||
if (loading) {
|
||||
anotherReload = true;
|
||||
return;
|
||||
}
|
||||
reloadIcons(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Send 200 response regardless of reload status, so visitor would not know if secret key was correct
|
||||
// Testing should be done by checking new icons that should have been added by reload
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// Update collection without restarting app
|
||||
let syncRequest = (req, res) => {
|
||||
let repo = req.query.repo;
|
||||
|
||||
if (sync.canSync(repo) && sync.validKey(req.query.key)) {
|
||||
if (config.sync['sync-delay']) {
|
||||
console.log('Will start synchronizing repository "' + repo + '" in up to ' + config.sync['sync-delay'] + ' seconds...');
|
||||
}
|
||||
sync.sync(repo, false).then(canLoad => {
|
||||
if (canLoad) {
|
||||
// Refresh all icons
|
||||
if (loading) {
|
||||
anotherReload = true;
|
||||
} else {
|
||||
reloadIcons(false);
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
config.log('Error synchronizing repository "' + repo + '":\n' + util.format(err), 'sync-' + repo, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Send 200 response regardless of reload status, so visitor would not know if secret key was correct
|
||||
// Testing should be done by checking new icons that should have been added by reload
|
||||
res.sendStatus(200);
|
||||
};
|
||||
app.get('/sync', syncRequest);
|
||||
app.post('/sync', syncRequest);
|
||||
|
||||
// Redirect home page
|
||||
app.get('/', (req, res) => {
|
||||
res.redirect(301, config['index-page']);
|
||||
});
|
||||
|
||||
// Create server
|
||||
app.listen(config.port, () => {
|
||||
console.log('Listening on port ' + config.port);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
{
|
||||
"port": 3000,
|
||||
"env-port": true,
|
||||
"region": "",
|
||||
"env-region": true,
|
||||
"reload-secret": "",
|
||||
"custom-icons-dir": "{dir}/json",
|
||||
"serve-default-icons": true,
|
||||
"index-page": "https://iconify.design/",
|
||||
"cache": {
|
||||
"timeout": 604800,
|
||||
"min-refresh": 604800,
|
||||
"private": false
|
||||
},
|
||||
"cors": {
|
||||
"origins": "*",
|
||||
"timeout": 86400,
|
||||
"methods": "GET, OPTIONS",
|
||||
"headers": "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding"
|
||||
},
|
||||
"sync": {
|
||||
"sync-on-startup": "missing",
|
||||
"sync-delay": 60,
|
||||
"repeated-sync-delay": 600,
|
||||
"versions": "{dir}/git-repos/versions.json",
|
||||
"storage": "{dir}/git-repos",
|
||||
"git": "git clone {repo} --depth 1 --no-tags {target}",
|
||||
"secret": "",
|
||||
"iconify": "git@github.com:iconify-design/collections-json.git",
|
||||
"custom": "",
|
||||
"custom-dir": "",
|
||||
"rm": "rm -rf {dir}"
|
||||
},
|
||||
"mail": {
|
||||
"active": false,
|
||||
"throttle": 30,
|
||||
"repeat": 180,
|
||||
"from": "noreply@localhost",
|
||||
"to": "noreply@localhost",
|
||||
"subject": "Iconify API log",
|
||||
"transport": {
|
||||
"host": "smtp.ethereal.email",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"auth": {
|
||||
"user": "username",
|
||||
"pass": "password"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
config.md
190
config.md
|
|
@ -1,190 +0,0 @@
|
|||
# Configuration options
|
||||
|
||||
Default options are in config-default.json
|
||||
|
||||
Do not edit config-default.json unless you are making your own fork of project. All custom config options should be added to config.json. Create empty config.json:
|
||||
|
||||
```
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
then add custom configuration variables to it.
|
||||
|
||||
|
||||
## Server configiration
|
||||
|
||||
#### port
|
||||
|
||||
Port to listen on. Default value is 3000
|
||||
|
||||
#### env-port
|
||||
|
||||
If true, script will check for environment variable "PORT" and if it is set, it will overwrite configuration option "port"
|
||||
|
||||
#### region
|
||||
|
||||
Region string to identify server. Set it if you run multiple servers to easily identify which server you are conneting to.
|
||||
|
||||
To check which server you are connected to, open /version in browser.
|
||||
|
||||
#### env-region
|
||||
|
||||
If true, script will check for environment variable "REGION" and if it is set, it will overwrite configuration option "region"
|
||||
|
||||
#### custom-icons-dir
|
||||
|
||||
Directory with custom json files.
|
||||
|
||||
Use {dir} variable to specify application's directory.
|
||||
|
||||
#### serve-default-icons
|
||||
|
||||
True if default Iconify icons set should be served.
|
||||
|
||||
#### index-page
|
||||
|
||||
URL to redirect browser when browsing main page. Redirection is permanent.
|
||||
|
||||
|
||||
## Browser cache controls
|
||||
|
||||
Cache configiration is stored in "cache" object. Object properties:
|
||||
|
||||
#### timeout
|
||||
|
||||
Cache timeout, in seconds.
|
||||
|
||||
#### min-refresh
|
||||
|
||||
Minimum page refresh timeout. Usually same as "timeout" value.
|
||||
|
||||
#### private
|
||||
|
||||
Set to true if page cache should be treated as private.
|
||||
|
||||
|
||||
## Reloading icon sets
|
||||
|
||||
Iconify API has ability to reload collections without restarting server. That allows to run server uninterrupted during icon sets updates.
|
||||
|
||||
|
||||
#### reload-secret
|
||||
|
||||
To be able reload entire collection you need to set configuration variable reload-secret before starting server. Set value to any string.
|
||||
|
||||
To reload collections follow these steps:
|
||||
|
||||
* Upload new json files on server
|
||||
* Open /reload?key=your-reload-secret in browser
|
||||
|
||||
This will reload all collections.
|
||||
|
||||
Server will respond identically with "ok" message regardless of reload status to prevent visitors from trying to guess your secret key, so few seconds after reload you can verify that icons were reloaded by trying to open one of icons that were supposed to be added or removed.
|
||||
|
||||
|
||||
## Synchronizing icon sets with Git
|
||||
|
||||
In addition to reloading all collections without restarting server, server can pull collections from Git service and reload collections without restarting. This can be used to push collections to server whenever its updated without downtime.
|
||||
|
||||
There are two collections available: iconify and custom.
|
||||
|
||||
All configuration options are in "sync" object in config-default.json. Use {dir} variable in directories to point to application directory.
|
||||
|
||||
To synchronize repository send GET request to /sync?repo=iconify&key=your-sync-key
|
||||
Replace repo with "custom" to synchronize custom repository and key with value of sync.secret
|
||||
|
||||
Server will respond identically with "ok" message regardless of status to prevent visitors from trying to guess your secret key.
|
||||
|
||||
Sync function is meant to be used with GitHub web hooks function. To avoid synchronizing icon sets too often, synchronization is delayed by 60 seconds (configure "sync-delay" option to change it). This way when there are multiple commits submitted within a minute, synchronization is done only once 60 seconds after first commit.
|
||||
|
||||
#### secret
|
||||
|
||||
Secret key. String. This is required configuration option. Put it in config.json, not config-default.json to make sure its not commited by mistake.
|
||||
|
||||
If "secret" is not set, entire synchronization module is disabled.
|
||||
|
||||
#### sync-on-startup
|
||||
|
||||
This option automatically pulls latest repositories when application is started. Possible values:
|
||||
|
||||
* never - disabled
|
||||
* always - always synchronize all available repositories
|
||||
* missing - synchronize only repositories that are missing
|
||||
|
||||
#### sync-delay
|
||||
|
||||
Delay for synchronization, in seconds. See documentation above.
|
||||
|
||||
This option does not affect synchronization on application startup.
|
||||
|
||||
#### repeated-sync-delay
|
||||
|
||||
If synchronization request was sent while synchronization is already in progress, this is amount of time application will wait until initializing next synchronization. Value is in seconds.
|
||||
|
||||
#### versions
|
||||
|
||||
Location of versions.json file that stores information about latest synchronized repositories.
|
||||
|
||||
#### storage
|
||||
|
||||
Location of directory where repositories will be stored.
|
||||
|
||||
#### git
|
||||
|
||||
Git command. You can change it if you need to customize command that is executed to clone repository. {repo} will be replaced with repository URL, {target} will be replaced with target directory.
|
||||
|
||||
#### iconify
|
||||
|
||||
URL of Iconify icons repository.
|
||||
|
||||
#### custom
|
||||
|
||||
URL of custom icons repository.
|
||||
|
||||
#### custom-dir
|
||||
|
||||
Location of json files in custom repository, relative to root directory of repository.
|
||||
|
||||
For example, if json files are located in directory "json" in your repository (like they are in iconify repository), set custom-dir value to "json".
|
||||
|
||||
|
||||
## Logging errors
|
||||
|
||||
Server can automatically email you if something happens, so you don't need to check logs.
|
||||
|
||||
Email configuration is in "mail" object of config.json. To activate email logging set "mail.active" to "true", set correct from and to addresses and SMTP settings.
|
||||
|
||||
#### active
|
||||
|
||||
Set to true to enable logging to email.
|
||||
|
||||
#### throttle
|
||||
|
||||
Number of seconds to delay email sending.
|
||||
|
||||
Default is 30 seconds. All error messages within 30 seconds will be combined to one email instead of sending multiple emails.
|
||||
|
||||
#### repeat
|
||||
|
||||
This option prevents script from sending similar errors too often. Value is number of minutes. Default value is 180 (3 hours).
|
||||
|
||||
#### from
|
||||
|
||||
Sender email address. Set this to valid email address.
|
||||
|
||||
#### to
|
||||
|
||||
Received email address. Set this to valid email address.
|
||||
|
||||
#### subject
|
||||
|
||||
Subject of emails. All emails will have same subject.
|
||||
|
||||
If you are running Iconify API on multiple servers, use different subjects for different servers to identify which server email came from.
|
||||
|
||||
#### transport
|
||||
|
||||
SMTP settings.
|
||||
|
||||
If you are using secure connection, set "secure" to true and "port" to 465, unless you are running SMTP server on different port.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,782 +0,0 @@
|
|||
{
|
||||
"prefix": "arty-animated",
|
||||
"icons": {
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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\"/>"
|
||||
},
|
||||
"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\"/>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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\"/>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"16-double-small-chevron-left": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M72 64l32-32\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-68\"/><path d=\"M24 64l32-32\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-68\"/><path d=\"M72 64l32 32\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-68\"/><path d=\"M24 64l32 32\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-68\"/></g>"
|
||||
},
|
||||
"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\"/>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"16-home": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path stroke-width=\"8\" d=\"M64 124H20V48\" class=\"animation-delay-0 animation-duration-5 animate-stroke stroke-length-153\"/><path stroke-width=\"8\" d=\"M64 124h44V48\" class=\"animation-delay-0 animation-duration-5 animate-stroke stroke-length-153\"/><path d=\"M64 8L8 56\" stroke-width=\"16\" class=\"animation-delay-5 animation-duration-3 animate-stroke stroke-length-102\"/><path d=\"M64 8l56 48\" stroke-width=\"16\" class=\"animation-delay-5 animation-duration-3 animate-stroke stroke-length-102\"/><path stroke-width=\"8\" d=\"M52 76h24v48H52z\" class=\"animation-delay-8 animation-duration-6 animate-stroke stroke-length-230\"/></g>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"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>"
|
||||
},
|
||||
"16-small-chevron-left": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M48 64l32-32\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-68\"/><path d=\"M48 64l32 32\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-68\"/></g>"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"20-double-small-chevron-left": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M88 80l32-32\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-68\"/><path d=\"M40 80l32-32\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-68\"/><path d=\"M88 80l32 32\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-68\"/><path d=\"M40 80l32 32\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-68\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-drop-outline": {
|
||||
"body": "<path d=\"M80 8s-48 56-48 96c0 30 16 48 48 48s48-18 48-48c0-40-48-96-48-96z\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-500\"/>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-drop": {
|
||||
"body": "<g fill=\"none\" fill-rule=\"evenodd\"><path d=\"M80 8s-48 56-48 96c0 30 16 48 48 48s48-18 48-48c0-40-48-96-48-96z\" 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=\"M80 8s-48 56-48 96c0 12.222 2.655 22.452 7.966 30.285l79.942-63.57C106.228 38.598 80 8 80 8z\" fill=\"currentColor\" class=\"animation-delay-9 animation-duration-4 animate-fill\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-filters": {
|
||||
"body": "<g stroke=\"currentColor\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\"><path d=\"M24 8v56\" stroke-width=\"16\" class=\"animation-delay-0 animation-duration-3 animate-stroke stroke-length-68\"/><path d=\"M24 120v32\" stroke-width=\"16\" class=\"animation-delay-3 animation-duration-2 animate-stroke stroke-length-45\"/><path d=\"M12 92h24\" stroke-width=\"24\" class=\"animation-delay-4 animation-duration-1 animate-stroke stroke-length-30\"/><path d=\"M80 8v16\" stroke-width=\"16\" class=\"animation-delay-6 animation-duration-1 animate-stroke stroke-length-20\"/><path d=\"M80 80v72\" stroke-width=\"16\" class=\"animation-delay-6 animation-duration-4 animate-stroke stroke-length-102\"/><path d=\"M68 52h24\" stroke-width=\"24\" class=\"animation-delay-10 animation-duration-1 animate-stroke stroke-length-30\"/><path d=\"M136 8v72\" stroke-width=\"16\" class=\"animation-delay-11 animation-duration-4 animate-stroke stroke-length-102\"/><path d=\"M136 136v16\" stroke-width=\"16\" class=\"animation-delay-15 animation-duration-1 animate-stroke stroke-length-20\"/><path d=\"M124 108h24\" stroke-width=\"24\" class=\"animation-delay-15 animation-duration-1 animate-stroke stroke-length-30\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-grid-3-outline": {
|
||||
"body": "<g transform=\"translate(4 4)\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"8\" fill=\"none\" fill-rule=\"evenodd\"><circle cx=\"12\" cy=\"12\" r=\"12\" class=\"animation-delay-0 animation-duration-2 animate-fill\"/><circle cx=\"76\" cy=\"12\" r=\"12\" class=\"animation-delay-2 animation-duration-2 animate-fill\"/><circle cx=\"140\" cy=\"12\" r=\"12\" class=\"animation-delay-4 animation-duration-2 animate-fill\"/><circle cx=\"12\" cy=\"76\" r=\"12\" class=\"animation-delay-6 animation-duration-2 animate-fill\"/><circle cx=\"76\" cy=\"76\" r=\"12\" class=\"animation-delay-7 animation-duration-2 animate-fill\"/><circle cx=\"140\" cy=\"76\" r=\"12\" class=\"animation-delay-9 animation-duration-2 animate-fill\"/><circle cx=\"12\" cy=\"140\" r=\"12\" class=\"animation-delay-11 animation-duration-2 animate-fill\"/><circle cx=\"76\" cy=\"140\" r=\"12\" class=\"animation-delay-13 animation-duration-2 animate-fill\"/><circle cx=\"140\" cy=\"140\" r=\"12\" class=\"animation-delay-15 animation-duration-2 animate-fill\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-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=\"80\" cy=\"16\" r=\"16\" class=\"animation-delay-2 animation-duration-2 animate-fill\"/><circle cx=\"144\" cy=\"16\" r=\"16\" class=\"animation-delay-4 animation-duration-2 animate-fill\"/><circle cx=\"16\" cy=\"80\" r=\"16\" class=\"animation-delay-6 animation-duration-2 animate-fill\"/><circle cx=\"80\" cy=\"80\" r=\"16\" class=\"animation-delay-7 animation-duration-2 animate-fill\"/><circle cx=\"144\" cy=\"80\" r=\"16\" class=\"animation-delay-9 animation-duration-2 animate-fill\"/><circle cx=\"16\" cy=\"144\" r=\"16\" class=\"animation-delay-11 animation-duration-2 animate-fill\"/><circle cx=\"80\" cy=\"144\" r=\"16\" class=\"animation-delay-13 animation-duration-2 animate-fill\"/><circle cx=\"144\" cy=\"144\" r=\"16\" class=\"animation-delay-15 animation-duration-2 animate-fill\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-home": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path stroke-width=\"8\" d=\"M80 156H20V64\" class=\"animation-delay-0 animation-duration-5 animate-stroke stroke-length-230\"/><path stroke-width=\"8\" d=\"M80 156h60V64\" class=\"animation-delay-0 animation-duration-5 animate-stroke stroke-length-230\"/><path d=\"M80 8L8 72\" stroke-width=\"16\" class=\"animation-delay-5 animation-duration-3 animate-stroke stroke-length-153\"/><path d=\"M80 8l72 64\" stroke-width=\"16\" class=\"animation-delay-5 animation-duration-3 animate-stroke stroke-length-153\"/><path stroke-width=\"8\" d=\"M60 100h40v56H60z\" class=\"animation-delay-8 animation-duration-6 animate-stroke stroke-length-230\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-list-3-outline": {
|
||||
"body": "<g transform=\"translate(4 4)\" stroke=\"currentColor\" stroke-width=\"8\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"12\" class=\"animation-delay-0 animation-duration-3 animate-fill\"/><rect x=\"48\" width=\"104\" height=\"24\" rx=\"12\" class=\"animation-delay-3 animation-duration-3 animate-fill\"/><circle cx=\"12\" cy=\"76\" r=\"12\" class=\"animation-delay-5 animation-duration-3 animate-fill\"/><rect x=\"48\" y=\"64\" width=\"104\" height=\"24\" rx=\"12\" class=\"animation-delay-8 animation-duration-3 animate-fill\"/><circle cx=\"12\" cy=\"140\" r=\"12\" class=\"animation-delay-11 animation-duration-3 animate-fill\"/><rect x=\"48\" y=\"128\" width=\"104\" height=\"24\" rx=\"12\" class=\"animation-delay-13 animation-duration-3 animate-fill\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"20-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 16h80\" stroke=\"currentColor\" stroke-width=\"32\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-4 animation-duration-2 animate-stroke stroke-length-102\"/><circle fill=\"currentColor\" cx=\"16\" cy=\"80\" r=\"16\" class=\"animation-delay-5 animation-duration-4 animate-fill\"/><path d=\"M64 80h80\" stroke=\"currentColor\" stroke-width=\"32\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-9 animation-duration-2 animate-stroke stroke-length-102\"/><circle fill=\"currentColor\" cx=\"16\" cy=\"144\" r=\"16\" class=\"animation-delay-11 animation-duration-4 animate-fill\"/><path d=\"M64 144h80\" stroke=\"currentColor\" stroke-width=\"32\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"animation-delay-14 animation-duration-2 animate-stroke stroke-length-102\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"20-small-chevron-left": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M64 80l32-32\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-68\"/><path d=\"M64 80l32 32\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-68\"/></g>",
|
||||
"width": 160,
|
||||
"height": 160
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"24-double-small-chevron-left": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M104 96l48-48\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-102\"/><path d=\"M40 96l48-48\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-102\"/><path d=\"M104 96l48 48\" class=\"animation-delay-6 animation-duration-6 animate-stroke stroke-length-102\"/><path d=\"M40 96l48 48\" class=\"animation-delay-0 animation-duration-6 animate-stroke stroke-length-102\"/></g>",
|
||||
"width": 192,
|
||||
"height": 192
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"24-home": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\" fill-rule=\"evenodd\"><path stroke-width=\"8\" d=\"M96 188H28V72\" class=\"animation-delay-0 animation-duration-5 animate-stroke stroke-length-230\"/><path stroke-width=\"8\" d=\"M96 188h68V72\" class=\"animation-delay-0 animation-duration-5 animate-stroke stroke-length-230\"/><path d=\"M96 8L8 88\" stroke-width=\"16\" class=\"animation-delay-5 animation-duration-3 animate-stroke stroke-length-153\"/><path d=\"M96 8l88 80\" stroke-width=\"16\" class=\"animation-delay-5 animation-duration-3 animate-stroke stroke-length-153\"/><path stroke-width=\"8\" d=\"M68 108h56v80H68z\" class=\"animation-delay-7 animation-duration-7 animate-stroke stroke-length-345\"/></g>",
|
||||
"width": 192,
|
||||
"height": 192
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"24-small-chevron-left": {
|
||||
"body": "<g stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"16\" fill=\"none\" fill-rule=\"evenodd\"><path d=\"M72 96l48-48\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/><path d=\"M72 96l48 48\" class=\"animation-delay-0 animation-duration-10 animate-stroke stroke-length-102\"/></g>",
|
||||
"width": 192,
|
||||
"height": 192
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"16-arrow-right": {
|
||||
"parent": "16-arrow-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-arrow-up": {
|
||||
"parent": "16-arrow-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-arrow-down": {
|
||||
"parent": "16-arrow-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-arrows-from-2-corners-rotated": {
|
||||
"parent": "16-arrows-from-2-corners",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-arrows-vertical": {
|
||||
"parent": "16-arrows-horizontal",
|
||||
"rotate": 1
|
||||
},
|
||||
"16-arrows-to-2-corners-rotated": {
|
||||
"parent": "16-arrows-to-2-corners",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-caret-down-outline": {
|
||||
"parent": "16-caret-up-outline",
|
||||
"vFlip": true
|
||||
},
|
||||
"16-caret-left-outline": {
|
||||
"parent": "16-caret-up-outline",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-caret-right-outline": {
|
||||
"parent": "16-caret-up-outline",
|
||||
"rotate": 1
|
||||
},
|
||||
"16-caret-down": {
|
||||
"parent": "16-caret-up",
|
||||
"vFlip": true
|
||||
},
|
||||
"16-caret-left": {
|
||||
"parent": "16-caret-up",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-caret-right": {
|
||||
"parent": "16-caret-up",
|
||||
"rotate": 1
|
||||
},
|
||||
"16-carets-horizontal-outline": {
|
||||
"parent": "16-carets-vertical-outline",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-carets-horizontal": {
|
||||
"parent": "16-carets-vertical",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-chevron-right": {
|
||||
"parent": "16-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-chevron-up": {
|
||||
"parent": "16-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-chevron-down": {
|
||||
"parent": "16-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-double-arrow-vertical": {
|
||||
"parent": "16-double-arrow-horizontal",
|
||||
"rotate": 1
|
||||
},
|
||||
"16-double-small-chevron-right": {
|
||||
"parent": "16-double-small-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-double-small-chevron-up": {
|
||||
"parent": "16-double-small-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-double-small-chevron-down": {
|
||||
"parent": "16-double-small-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-filters-horizontal": {
|
||||
"parent": "16-filters",
|
||||
"rotate": 3,
|
||||
"hFlip": true
|
||||
},
|
||||
"16-list-3-outline-rtl": {
|
||||
"parent": "16-list-3-outline",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-list-3-rtl": {
|
||||
"parent": "16-list-3",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-panel-right": {
|
||||
"parent": "16-panel-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-panel-up": {
|
||||
"parent": "16-panel-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-panel-down": {
|
||||
"parent": "16-panel-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-search-rotated": {
|
||||
"parent": "16-search",
|
||||
"rotate": 3
|
||||
},
|
||||
"16-small-chevron-right": {
|
||||
"parent": "16-small-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"16-small-chevron-up": {
|
||||
"parent": "16-small-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"16-small-chevron-down": {
|
||||
"parent": "16-small-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-arrow-right": {
|
||||
"parent": "20-arrow-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-arrow-up": {
|
||||
"parent": "20-arrow-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-arrow-down": {
|
||||
"parent": "20-arrow-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-arrows-from-2-corners-rotated": {
|
||||
"parent": "20-arrows-from-2-corners",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-arrows-vertical": {
|
||||
"parent": "20-arrows-horizontal",
|
||||
"rotate": 1
|
||||
},
|
||||
"20-arrows-to-2-corners-rotated": {
|
||||
"parent": "20-arrows-to-2-corners",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-caret-down-outline": {
|
||||
"parent": "20-caret-up-outline",
|
||||
"vFlip": true
|
||||
},
|
||||
"20-caret-left-outline": {
|
||||
"parent": "20-caret-up-outline",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-caret-right-outline": {
|
||||
"parent": "20-caret-up-outline",
|
||||
"rotate": 1
|
||||
},
|
||||
"20-caret-down": {
|
||||
"parent": "20-caret-up",
|
||||
"vFlip": true
|
||||
},
|
||||
"20-caret-left": {
|
||||
"parent": "20-caret-up",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-caret-right": {
|
||||
"parent": "20-caret-up",
|
||||
"rotate": 1
|
||||
},
|
||||
"20-carets-horizontal-outline": {
|
||||
"parent": "20-carets-vertical-outline",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-carets-horizontal": {
|
||||
"parent": "20-carets-vertical",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-chevron-right": {
|
||||
"parent": "20-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-chevron-up": {
|
||||
"parent": "20-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-chevron-down": {
|
||||
"parent": "20-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-double-arrow-vertical": {
|
||||
"parent": "20-double-arrow-horizontal",
|
||||
"rotate": 1
|
||||
},
|
||||
"20-double-small-chevron-right": {
|
||||
"parent": "20-double-small-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-double-small-chevron-up": {
|
||||
"parent": "20-double-small-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-double-small-chevron-down": {
|
||||
"parent": "20-double-small-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-filters-horizontal": {
|
||||
"parent": "20-filters",
|
||||
"rotate": 3,
|
||||
"hFlip": true
|
||||
},
|
||||
"20-list-3-outline-rtl": {
|
||||
"parent": "20-list-3-outline",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-list-3-rtl": {
|
||||
"parent": "20-list-3",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-panel-right": {
|
||||
"parent": "20-panel-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-panel-up": {
|
||||
"parent": "20-panel-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-panel-down": {
|
||||
"parent": "20-panel-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-search-rotated": {
|
||||
"parent": "20-search",
|
||||
"rotate": 3
|
||||
},
|
||||
"20-small-chevron-right": {
|
||||
"parent": "20-small-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"20-small-chevron-up": {
|
||||
"parent": "20-small-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"20-small-chevron-down": {
|
||||
"parent": "20-small-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-arrow-right": {
|
||||
"parent": "24-arrow-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-arrow-up": {
|
||||
"parent": "24-arrow-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-arrow-down": {
|
||||
"parent": "24-arrow-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-arrows-from-2-corners-rotated": {
|
||||
"parent": "24-arrows-from-2-corners",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-arrows-vertical": {
|
||||
"parent": "24-arrows-horizontal",
|
||||
"rotate": 1
|
||||
},
|
||||
"24-arrows-to-2-corners-rotated": {
|
||||
"parent": "24-arrows-to-2-corners",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-caret-down-outline": {
|
||||
"parent": "24-caret-up-outline",
|
||||
"vFlip": true
|
||||
},
|
||||
"24-caret-left-outline": {
|
||||
"parent": "24-caret-up-outline",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-caret-right-outline": {
|
||||
"parent": "24-caret-up-outline",
|
||||
"rotate": 1
|
||||
},
|
||||
"24-caret-down": {
|
||||
"parent": "24-caret-up",
|
||||
"vFlip": true
|
||||
},
|
||||
"24-caret-left": {
|
||||
"parent": "24-caret-up",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-caret-right": {
|
||||
"parent": "24-caret-up",
|
||||
"rotate": 1
|
||||
},
|
||||
"24-carets-horizontal-outline": {
|
||||
"parent": "24-carets-vertical-outline",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-carets-horizontal": {
|
||||
"parent": "24-carets-vertical",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-chevron-right": {
|
||||
"parent": "24-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-chevron-up": {
|
||||
"parent": "24-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-chevron-down": {
|
||||
"parent": "24-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-double-arrow-vertical": {
|
||||
"parent": "24-double-arrow-horizontal",
|
||||
"rotate": 1
|
||||
},
|
||||
"24-double-small-chevron-right": {
|
||||
"parent": "24-double-small-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-double-small-chevron-up": {
|
||||
"parent": "24-double-small-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-double-small-chevron-down": {
|
||||
"parent": "24-double-small-chevron-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-filters-horizontal": {
|
||||
"parent": "24-filters",
|
||||
"rotate": 3,
|
||||
"hFlip": true
|
||||
},
|
||||
"24-list-3-outline-rtl": {
|
||||
"parent": "24-list-3-outline",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-list-3-rtl": {
|
||||
"parent": "24-list-3",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-panel-right": {
|
||||
"parent": "24-panel-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-panel-up": {
|
||||
"parent": "24-panel-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-panel-down": {
|
||||
"parent": "24-panel-left",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-search-rotated": {
|
||||
"parent": "24-search",
|
||||
"rotate": 3
|
||||
},
|
||||
"24-small-chevron-right": {
|
||||
"parent": "24-small-chevron-left",
|
||||
"hFlip": true
|
||||
},
|
||||
"24-small-chevron-up": {
|
||||
"parent": "24-small-chevron-left",
|
||||
"rotate": 1,
|
||||
"vFlip": true
|
||||
},
|
||||
"24-small-chevron-down": {
|
||||
"parent": "24-small-chevron-left",
|
||||
"rotate": 3
|
||||
}
|
||||
},
|
||||
"width": 128,
|
||||
"height": 128
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017, 2018 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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
65
package.json
65
package.json
|
|
@ -1,27 +1,42 @@
|
|||
{
|
||||
"version": "1.0.0-rc2",
|
||||
"description": "Node.js version of api.iconify.design",
|
||||
"private": true,
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"test": "mocha tests/*_test.js"
|
||||
},
|
||||
"author": "Vjacheslav Trushkin",
|
||||
"license": "MIT",
|
||||
"bugs": "https://github.com/iconify-design/api.js/issues",
|
||||
"homepage": "https://github.com/iconify-design/api.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/iconify-design/api.js.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/json": "^1.0.4",
|
||||
"express": "^4.16.4",
|
||||
"nodemailer": "^4.6.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.2.0",
|
||||
"mocha": "^5.2.0"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
readme.md
31
readme.md
|
|
@ -1,31 +0,0 @@
|
|||
# Iconify.design API
|
||||
|
||||
This code runs on api.iconify.design that is used to serve collections and SVG images.
|
||||
|
||||
PHP version is available at https://github.com/iconify-design/api.php
|
||||
|
||||
|
||||
### How to use it
|
||||
|
||||
To start server simply run
|
||||
|
||||
```
|
||||
node app
|
||||
```
|
||||
|
||||
By default server will be running on port 3000. You can change port and other configuration by adding custom config.json
|
||||
|
||||
File config.json is the same as config-default.json, but contains only values you have customized. See [config.md](config.md)
|
||||
|
||||
It is better to run server on obscure port such as 3000 hidden behind firewall and use nginx reverse proxy. This way you can offload connection handling to nginx and you can easily use SSL, rate limiting and other security features nginx provides.
|
||||
|
||||
|
||||
### Node vs PHP
|
||||
|
||||
Node.js version of server is faster because it loads everything only once on startup. It is a bit harder to setup though because you need to install additional software and make sure server is running (using tools such as "pm2").
|
||||
|
||||
PHP process ends when HTTP request ends, so PHP has to reload lots of things for each request. PHP version has caching to minimize loading times, but it is still nowhere near as fast as Node.js version. The only upside of PHP version is it is easy to setup - simply upload files and you are done.
|
||||
|
||||
Node.js version has one feature that PHP version does not have: ability to send errors by email.
|
||||
|
||||
Use Node.js version if you can for better performance and better error reporting.
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
/**
|
||||
* This file is part of the @iconify/api 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 fs = require('fs');
|
||||
|
||||
const defaultAttributes = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
rotate: 0,
|
||||
hFlip: false,
|
||||
vFlip: false
|
||||
};
|
||||
|
||||
let cache = {};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Add characters and categories
|
||||
if (data.chars !== void 0) {
|
||||
this.chars = data.chars;
|
||||
}
|
||||
if (data.categories !== void 0) {
|
||||
this.categories = data.categories;
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load collection from file
|
||||
*
|
||||
* @param {string} file File or JSON
|
||||
* @param {string} [defaultPrefix]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadFile(file, defaultPrefix) {
|
||||
return new Promise((fulfill, reject) => {
|
||||
// Load file
|
||||
fs.readFile(file, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
let checkCache = typeof defaultPrefix === 'string';
|
||||
|
||||
// Check cache
|
||||
if (checkCache && cache[defaultPrefix] !== void 0 && cache[defaultPrefix].length === data.length) {
|
||||
// If JSON file has same length, assume its the same file. Do not bother with hashing
|
||||
fulfill(cache[defaultPrefix].collection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadJSON(data);
|
||||
if (this.loaded) {
|
||||
if (checkCache) {
|
||||
cache[defaultPrefix] = {
|
||||
length: data.length,
|
||||
collection: this
|
||||
};
|
||||
}
|
||||
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;
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
/**
|
||||
* This file is part of the @iconify/api 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 fs = require('fs');
|
||||
const util = require('util');
|
||||
const Collection = require('./collection');
|
||||
|
||||
/**
|
||||
* Class to represent collection of collections
|
||||
*/
|
||||
class Collections {
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {object} config Application configuration
|
||||
*/
|
||||
constructor(config) {
|
||||
this._config = config;
|
||||
|
||||
this.items = {};
|
||||
this._loadQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add directory to loading queue
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {string} repo
|
||||
*/
|
||||
addDirectory(dir, repo) {
|
||||
console.log('Loading collections for repository "' + repo + '" from directory:', dir);
|
||||
this._loadQueue.push({
|
||||
type: 'dir',
|
||||
dir: dir.slice(-1) === '/' ? dir.slice(0, dir.length - 1) : dir,
|
||||
repo: repo
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file to loading queue
|
||||
*
|
||||
* @param {string} filename
|
||||
* @param {string} repo
|
||||
*/
|
||||
addFile(filename, repo) {
|
||||
this._loadQueue.push({
|
||||
type: 'file',
|
||||
filename: filename,
|
||||
repo: repo
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find collections
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_findCollections(repo) {
|
||||
return new Promise((fulfill, reject) => {
|
||||
let config = this._config,
|
||||
dirs = config._dirs,
|
||||
iconsDir = dirs.iconsDir(repo);
|
||||
|
||||
if (iconsDir === '') {
|
||||
// Nothing to add
|
||||
fulfill();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (repo) {
|
||||
case 'iconify':
|
||||
// Get collections.json
|
||||
let filename = dirs.rootDir(repo) + '/collections.json';
|
||||
fs.readFile(filename, 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
reject('Error locating collections.json for Iconify default icons.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (err) {
|
||||
reject('Error reading contents of' + filename);
|
||||
return;
|
||||
}
|
||||
|
||||
this.addDirectory(iconsDir, repo);
|
||||
this.info = data;
|
||||
|
||||
fulfill();
|
||||
});
|
||||
return;
|
||||
|
||||
default:
|
||||
this.addDirectory(iconsDir, repo);
|
||||
fulfill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all collections and loadQueue
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload(repos) {
|
||||
return new Promise((fulfill, reject) => {
|
||||
let promises = repos.map(repo => this._findCollections(repo));
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
return this.loadQueue();
|
||||
}).then(() => {
|
||||
fulfill(this);
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only one repository
|
||||
*
|
||||
* @param {string} repo Repository name
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadRepo(repo) {
|
||||
return new Promise((fulfill, reject) => {
|
||||
Promise.all(this._findCollections(repo)).then(() => {
|
||||
return this.loadQueue();
|
||||
}).then(() => {
|
||||
fulfill(this);
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue
|
||||
*
|
||||
* Promise will never reject because single file should not break app,
|
||||
* it will log failures instead
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadQueue() {
|
||||
return new Promise((fulfill, reject) => {
|
||||
let promises = [];
|
||||
|
||||
this._loadQueue.forEach(item => {
|
||||
switch (item.type) {
|
||||
case 'dir':
|
||||
promises.push(this._loadDir(item.dir, item.repo));
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
promises.push(this._loadFile(item.filename, item.repo));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(promises).then(res => {
|
||||
let total = 0;
|
||||
res.forEach(count => {
|
||||
if (typeof count === 'number') {
|
||||
total += count;
|
||||
}
|
||||
});
|
||||
console.log('Loaded ' + total + ' icons');
|
||||
fulfill(this);
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load directory
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {string} repo
|
||||
* @returns {Promise}
|
||||
* @private
|
||||
*/
|
||||
_loadDir(dir, repo) {
|
||||
return new Promise((fulfill, reject) => {
|
||||
fs.readdir(dir, (err, files) => {
|
||||
if (err) {
|
||||
this._config.log('Error reading directory: ' + dir + '\n' + util.format(err), 'collections-' + dir, true);
|
||||
fulfill(false);
|
||||
} else {
|
||||
let promises = [];
|
||||
files.forEach(file => {
|
||||
if (file.slice(-5) !== '.json') {
|
||||
return;
|
||||
}
|
||||
promises.push(this._loadFile(dir + '/' + file, repo));
|
||||
});
|
||||
|
||||
// Load all promises
|
||||
Promise.all(promises).then(res => {
|
||||
let total = 0;
|
||||
res.forEach(count => {
|
||||
if (typeof count === 'number') {
|
||||
total += count;
|
||||
}
|
||||
});
|
||||
fulfill(total);
|
||||
}).catch(err => {
|
||||
fulfill(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load file
|
||||
*
|
||||
* @param {string} filename Full filename
|
||||
* @param {string} repo
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_loadFile(filename, repo) {
|
||||
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.repo = repo;
|
||||
collection.loadFile(filename, prefix).then(result => {
|
||||
collection = result;
|
||||
if (!collection.loaded) {
|
||||
this._config.log('Failed to load collection: ' + filename, 'collection-load-' + filename, true);
|
||||
fulfill(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (collection.prefix !== prefix) {
|
||||
this._config.log('Collection prefix does not match: ' + collection.prefix + ' in file ' + filename, 'collection-prefix-' + filename, true);
|
||||
fulfill(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let count = Object.keys(collection.icons).length;
|
||||
if (!count) {
|
||||
this._config.log('Collection is empty: ' + filename, 'collection-empty-' + filename, true);
|
||||
fulfill(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.items[prefix] = collection;
|
||||
console.log('Loaded collection ' + prefix + ' from ' + file + ' (' + count + ' icons)');
|
||||
fulfill(count);
|
||||
}).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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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))));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
99
src/dirs.js
99
src/dirs.js
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* This file is part of the @iconify/api 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";
|
||||
|
||||
let config, _dirs;
|
||||
|
||||
let repos;
|
||||
|
||||
const functions = {
|
||||
/**
|
||||
* Get root directory of repository
|
||||
*
|
||||
* @param {string} repo
|
||||
* @returns {string}
|
||||
*/
|
||||
rootDir: repo => _dirs[repo] === void 0 ? '' : _dirs[repo],
|
||||
|
||||
/**
|
||||
* Get icons directory
|
||||
*
|
||||
* @param {string} repo
|
||||
* @returns {string}
|
||||
*/
|
||||
iconsDir: repo => {
|
||||
let dir;
|
||||
|
||||
switch (repo) {
|
||||
case 'iconify':
|
||||
dir = functions.rootDir(repo);
|
||||
return dir === '' ? '' : dir + '/json';
|
||||
|
||||
default:
|
||||
return functions.rootDir(repo);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set root directory for repository
|
||||
*
|
||||
* @param repo
|
||||
* @param dir
|
||||
*/
|
||||
setRootDir: (repo, dir) => {
|
||||
let extraKey = repo + '-dir';
|
||||
if (config.sync && config.sync[extraKey] !== void 0 && config.sync[extraKey] !== '') {
|
||||
let extra = config.sync[extraKey];
|
||||
if (extra.slice(0, 1) !== '/') {
|
||||
extra = '/' + extra;
|
||||
}
|
||||
if (extra.slice(-1) === '/') {
|
||||
extra = extra.slice(0, extra.length - 1);
|
||||
}
|
||||
dir += extra;
|
||||
}
|
||||
_dirs[repo] = dir;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all repositories
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
keys: () => Object.keys(_dirs),
|
||||
|
||||
/**
|
||||
* Get all repositories
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getRepos: () => repos,
|
||||
};
|
||||
|
||||
module.exports = appConfig => {
|
||||
config = appConfig;
|
||||
_dirs = {};
|
||||
repos = [];
|
||||
|
||||
// Set default directories
|
||||
if (config['serve-default-icons']) {
|
||||
let icons = require('@iconify/json');
|
||||
repos.push('iconify');
|
||||
_dirs['iconify'] = icons.rootDir();
|
||||
}
|
||||
|
||||
if (config['custom-icons-dir']) {
|
||||
repos.push('custom');
|
||||
_dirs['custom'] = config['custom-icons-dir'].replace('{dir}', config._dir);
|
||||
}
|
||||
|
||||
config._dirs = functions;
|
||||
return functions;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : '');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Basic cleanup for parameters
|
||||
*/
|
||||
export function cleanupQueryValue(value: string | undefined) {
|
||||
return value ? value.replace(/['"<>&]/g, '') : undefined;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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 + ')' : '')
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
}
|
||||
98
src/log.js
98
src/log.js
|
|
@ -1,98 +0,0 @@
|
|||
/**
|
||||
* This file is part of the @iconify/api 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 nodemailer = require('nodemailer');
|
||||
|
||||
/**
|
||||
* Inject logging function as config.log()
|
||||
*
|
||||
* @param config
|
||||
*/
|
||||
module.exports = config => {
|
||||
if (config.mail && config.mail.active) {
|
||||
let logged = {},
|
||||
mailError = false,
|
||||
throttled = false,
|
||||
throttledData = [],
|
||||
repeat = Math.max(config.mail.repeat, 15) * 60 * 1000; // convert minutes to ms, no less than 15 minutes
|
||||
|
||||
/**
|
||||
* Send messages queue
|
||||
*/
|
||||
let send = () => {
|
||||
throttled = false;
|
||||
|
||||
// Create transport
|
||||
let transporter = nodemailer.createTransport(config.mail.transport);
|
||||
|
||||
// Mail options
|
||||
let mailOptions = {
|
||||
from: config.mail.from,
|
||||
to: config.mail.to,
|
||||
subject: config.mail.subject,
|
||||
text: throttledData.join('\n\n- - - - - - - - - - -\n\n')
|
||||
};
|
||||
throttledData = [];
|
||||
|
||||
// Send email
|
||||
transporter.sendMail(mailOptions, (err, info) => {
|
||||
if (err) {
|
||||
if (mailError === false) {
|
||||
console.error('Error sending mail (this messages will not show up again on further email errors until app is restarted):');
|
||||
console.error(err);
|
||||
mailError = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
console.log('Logging to email is active. If you do not receive emails with errors, check configuration options.');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {string} [key] Unique key to identify logging message to avoid sending too many duplicate emails
|
||||
* @param {boolean} [copyToConsole] True if log should be copied to console
|
||||
*/
|
||||
config.log = (message, key, copyToConsole) => {
|
||||
if (copyToConsole) {
|
||||
console.error('\x1b[31m' + message + '\x1b[0m');
|
||||
}
|
||||
|
||||
// Do not send same email more than once within "repeat" minutes
|
||||
let time = Date.now() / repeat;
|
||||
if (typeof key === 'string') {
|
||||
if (logged[key] === time) {
|
||||
return;
|
||||
}
|
||||
logged[key] = time;
|
||||
}
|
||||
|
||||
// Throttle
|
||||
throttledData.push(message);
|
||||
if (config.mail.throttle) {
|
||||
if (!throttled) {
|
||||
throttled = true;
|
||||
setTimeout(send, config.mail.throttle * 1000);
|
||||
}
|
||||
} else {
|
||||
send();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.log('Logging to email is not active.');
|
||||
config.log = (message, key, copyToConsole) => {
|
||||
if (copyToConsole) {
|
||||
console.error('\x1b[35m' + message + '\x1b[0m');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate unique hash
|
||||
*/
|
||||
export function hashString(value: string): string {
|
||||
return createHash('md5').update(value).digest('hex');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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('-'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* This file is part of the @iconify/api 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";
|
||||
|
||||
/**
|
||||
* Alternative to Promise.all() that runs each promise after another, not simultaneously
|
||||
*
|
||||
* @param list
|
||||
* @param callback
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
module.exports = (list, callback) => new Promise((fulfill, reject) => {
|
||||
let results = [];
|
||||
|
||||
function next() {
|
||||
let item = list.shift();
|
||||
if (item === void 0) {
|
||||
fulfill(results);
|
||||
return;
|
||||
}
|
||||
|
||||
let promise = callback(item);
|
||||
if (promise === null) {
|
||||
// skip
|
||||
next();
|
||||
return;
|
||||
}
|
||||
promise.then(result => {
|
||||
results.push(result);
|
||||
next();
|
||||
}).catch(err => {
|
||||
reject(err);
|
||||
})
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue