work on weather services; outbound http request caching; styles updates and icons

This commit is contained in:
James Brumond 2023-08-12 19:19:20 -07:00
parent 7c205632cb
commit 85d72b43d2
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
55 changed files with 2593 additions and 234 deletions

View File

@ -69,3 +69,6 @@ color_themes:
more_contrast: more_contrast:
light: Minimal Light light: Minimal Light
dark: Minimal Dark dark: Minimal Dark
outbound_http:
https_only: false
services: { }

View File

@ -13,3 +13,28 @@ session_cookie:
logging: logging:
level: debug level: debug
pretty: true pretty: true
services:
#
# Docs: <https://openweathermap.org/api>
# Access: <https://openweathermap.org/price>
openweathermap.org:
enabled: true
latitude: 45.49607
longitude: -122.67139
api_key: ''
#
# Docs: <https://www.weatherapi.com/docs/>
# Access: <https://www.weatherapi.com/pricing.aspx>
weatherapi.com:
enabled: true
api_key: e18fb4e3257d4adaa6911347231607
# US only; Provides weather forecasts and alerts.
# Docs: <https://www.weather.gov/documentation/services-web-api>
# Access: Free; Set a User-Agent below to identify yourself (see docs)
weather.gov:
enabled: true
latitude: 45.4961
longitude: -122.6714
user_agent: (local.jbrumond.me, james@jbrumond.me)

40
package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@doc-utils/color-themes": "^0.1.15", "@doc-utils/color-themes": "^0.1.16",
"@fastify/compress": "^6.4.0", "@fastify/compress": "^6.4.0",
"@fastify/etag": "^4.2.0", "@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0", "@fastify/formbody": "^7.4.0",
@ -25,6 +25,8 @@
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/jsonld": "^1.5.9",
"@types/luxon": "^3.3.1",
"@types/mustache": "^4.2.2", "@types/mustache": "^4.2.2",
"@types/node": "^20.4.2", "@types/node": "^20.4.2",
"json-schema": "^0.4.0", "json-schema": "^0.4.0",
@ -33,9 +35,9 @@
} }
}, },
"node_modules/@doc-utils/color-themes": { "node_modules/@doc-utils/color-themes": {
"version": "0.1.15", "version": "0.1.16",
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz", "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.16/color-themes-0.1.16.tgz",
"integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w==" "integrity": "sha512-6H0clUE0+mRQXbAHLinLSg8YfG3KkZ9zRhbngUtpwPbUHVt9WrnMkmT9rg2VXb+C+35oM6bRPX3QcJxmZESoLw=="
}, },
"node_modules/@fastify/accept-negotiator": { "node_modules/@fastify/accept-negotiator": {
"version": "1.1.0", "version": "1.1.0",
@ -171,6 +173,18 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/@types/jsonld": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.9.tgz",
"integrity": "sha512-K76ImkErPYL2wGPZpNFSKp6wE+h/APecZLJrU7UfDaGqt/f+D9Rrg1aR7VdRrQ6k5DUNRZ2vn9yACwmpOr9QcA==",
"dev": true
},
"node_modules/@types/luxon": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
"dev": true
},
"node_modules/@types/mustache": { "node_modules/@types/mustache": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz",
@ -2347,9 +2361,9 @@
}, },
"dependencies": { "dependencies": {
"@doc-utils/color-themes": { "@doc-utils/color-themes": {
"version": "0.1.15", "version": "0.1.16",
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz", "resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.16/color-themes-0.1.16.tgz",
"integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w==" "integrity": "sha512-6H0clUE0+mRQXbAHLinLSg8YfG3KkZ9zRhbngUtpwPbUHVt9WrnMkmT9rg2VXb+C+35oM6bRPX3QcJxmZESoLw=="
}, },
"@fastify/accept-negotiator": { "@fastify/accept-negotiator": {
"version": "1.1.0", "version": "1.1.0",
@ -2469,6 +2483,18 @@
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
"optional": true "optional": true
}, },
"@types/jsonld": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.9.tgz",
"integrity": "sha512-K76ImkErPYL2wGPZpNFSKp6wE+h/APecZLJrU7UfDaGqt/f+D9Rrg1aR7VdRrQ6k5DUNRZ2vn9yACwmpOr9QcA==",
"dev": true
},
"@types/luxon": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
"dev": true
},
"@types/mustache": { "@types/mustache": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz",

View File

@ -16,6 +16,8 @@
"author": "James Brumond <https://jbrumond.me>", "author": "James Brumond <https://jbrumond.me>",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/jsonld": "^1.5.9",
"@types/luxon": "^3.3.1",
"@types/mustache": "^4.2.2", "@types/mustache": "^4.2.2",
"@types/node": "^20.4.2", "@types/node": "^20.4.2",
"json-schema": "^0.4.0", "json-schema": "^0.4.0",
@ -23,7 +25,7 @@
"typescript": "^5.1.3" "typescript": "^5.1.3"
}, },
"dependencies": { "dependencies": {
"@doc-utils/color-themes": "^0.1.15", "@doc-utils/color-themes": "^0.1.16",
"@fastify/compress": "^6.4.0", "@fastify/compress": "^6.4.0",
"@fastify/etag": "^4.2.0", "@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0", "@fastify/formbody": "^7.4.0",

View File

@ -2,7 +2,7 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { join as path_join } from 'path'; import { join as path_join } from 'path';
import { parse as parse_yaml } from 'yaml'; import { parse as parse_yaml } from 'yaml';
import { deep_merge } from './utilities/deep-merge'; import { deep_merge } from './utilities/deep';
import { OIDCConfig, validate_oidc_conf } from './security/openid-connect'; import { OIDCConfig, validate_oidc_conf } from './security/openid-connect';
import { PKCECookieConfig, validate_pkce_cookie_conf } from './security/pkce-cookie'; import { PKCECookieConfig, validate_pkce_cookie_conf } from './security/pkce-cookie';
import { SnowflakeConfig, validate_snowflake_conf } from './utilities/snowflake-uid'; import { SnowflakeConfig, validate_snowflake_conf } from './utilities/snowflake-uid';
@ -12,6 +12,8 @@ import { Argon2HashConfig } from './security/argon-hash';
import { SessionCookieConfig, validate_session_cookie_conf } from './security/session'; import { SessionCookieConfig, validate_session_cookie_conf } from './security/session';
import { HttpConfig } from './http/server'; import { HttpConfig } from './http/server';
import { ColorThemeConfig } from './utilities/color-themes'; import { ColorThemeConfig } from './utilities/color-themes';
import { ServicesConfig, validate_services_conf } from './services';
import { OutboundHttpConfig, validate_outbound_http_conf } from './http/outbound';
const conf_dir = process.env.CONF_PATH; const conf_dir = process.env.CONF_PATH;
@ -34,6 +36,22 @@ export interface Conf {
storage: StorageConfig; storage: StorageConfig;
argon2: Argon2HashConfig; argon2: Argon2HashConfig;
color_themes: ColorThemeConfig; color_themes: ColorThemeConfig;
outbound_http: OutboundHttpConfig;
services: ServicesConfig;
}
export type SecretValue = string | { from_env: string; };
export function resolve_secret_value(value: SecretValue) {
if (typeof value === 'string') {
return value;
}
if (! value?.from_env) {
return null;
}
return process.env[value.from_env];
} }
export async function load_conf() : Promise<unknown> { export async function load_conf() : Promise<unknown> {
@ -100,4 +118,20 @@ export function validate_conf(conf: unknown) : asserts conf is Conf {
else { else {
throw new Error('`conf.session_cookie` is missing'); throw new Error('`conf.session_cookie` is missing');
} }
if ('outbound_http' in conf) {
validate_outbound_http_conf(conf.outbound_http);
}
else {
throw new Error('`conf.outbound_http` is missing');
}
if ('services' in conf) {
validate_services_conf(conf.services);
}
else {
throw new Error('`conf.services` is missing');
}
} }

41
src/http-web/assets.ts Normal file
View File

@ -0,0 +1,41 @@
import { Req } from '../http/request';
import { HttpConfig } from '../http/server';
import { HttpWebDependencies } from './server';
import { csp_headers } from '../http/content-security-policy';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { load_template } from '../utilities/mustache';
export function register_asset_endpoints(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: { },
};
endpoint('forms.css', 'text/css; charset=utf-8');
endpoint('popup.css', 'text/css; charset=utf-8');
endpoint('structure.css', 'text/css; charset=utf-8');
endpoint('typography.css', 'text/css; charset=utf-8');
endpoint('color-theme-controls.css', 'text/css; charset=utf-8');
endpoint('color-theme-controls.js', 'text/javascript');
endpoint('weather.gov/styles.css', 'text/css; charset=utf-8');
endpoint('weatherapi.com/styles.css', 'text/css; charset=utf-8');
http_server.get('/themes.css', opts, async (req: Req, res) => {
res.status(200);
res.header('content-type', 'text/css; charset=utf-8');
res.header('cache-control', 'public, max-age=3600');
csp_headers(res, conf.exposed_url);
return color_themes.css;
});
function endpoint(path: string, media_type: string) {
http_server.get('/' + path, opts, async (req: Req, res) => {
res.status(200);
res.header('content-type', media_type);
res.header('cache-control', 'public, max-age=3600');
csp_headers(res, conf.exposed_url);
return load_template(path);
});
}
}

View File

@ -1,20 +0,0 @@
import { Req } from '../../http/request';
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { csp_headers } from '../../http/content-security-policy';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
export function register_color_themes_endpoint(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: { },
};
http_server.get('/themes.css', opts, async (req: Req, res) => {
res.status(200);
res.header('content-type', 'text/css; charset=utf-8');
res.header('cache-control', 'public, max-age=3600');
csp_headers(res, conf.exposed_url);
return color_themes.css;
});
}

View File

@ -1,22 +0,0 @@
import { Req } from '../../http/request';
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { csp_headers } from '../../http/content-security-policy';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { load_template } from '../../utilities/mustache';
export function register_typography_endpoint(http_server: FastifyInstance, conf: HttpConfig, { }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: { },
};
http_server.get('/typography.css', opts, async (req: Req, res) => {
res.status(200);
res.header('content-type', 'text/css; charset=utf-8');
res.header('cache-control', 'no-store');
// res.header('cache-control', 'public, max-age=3600');
csp_headers(res, conf.exposed_url);
return load_template('typography.css');
});
}

View File

@ -1,17 +1,18 @@
import * as sch from '../../utilities/json-schema'; import * as sch from '../../utilities/json-schema';
import { render_login_page } from './login-page';
import { send_html_error } from '../../http/send-error'; import { send_html_error } from '../../http/send-error';
import { redirect_200_refresh } from '../../http/redirects'; import { redirect_200_refresh } from '../../http/redirects';
import { render_template } from '../../utilities/mustache';
import type { HttpConfig } from '../../http/server'; import type { HttpConfig } from '../../http/server';
import type { HttpWebDependencies } from '../server'; import type { HttpWebDependencies } from '../server';
import type { HttpURL, Locale, Timezone } from '../../utilities/types'; import type { HttpURL, Locale, Timezone } from '../../utilities/types';
import type { FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify'; import type { FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify';
import type { UserinfoResponse } from 'openid-client'; import type { UserinfoResponse } from 'openid-client';
import type { ErrorCode, ErrorInfo } from '../../http/send-error';
export function register_login_callback_endpoint(http_server: FastifyInstance, conf: HttpConfig, deps: HttpWebDependencies) { export function register_login_callback_endpoint(http_server: FastifyInstance, conf: HttpConfig, deps: HttpWebDependencies) {
const { logger, pkce_cookie, oidc, storage, snowflake, session, argon2 } = deps; const { logger, pkce_cookie, oidc, storage, snowflake, session } = deps;
const opts: RouteShorthandOptions = { const opts: RouteShorthandOptions = {
schema: { schema: {
response: { response: {
@ -54,13 +55,13 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c
if (! pkce_code_verifier) { if (! pkce_code_verifier) {
log.debug('no pkce code verifier provided'); log.debug('no pkce code verifier provided');
return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_page); return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_failed_page);
} }
const params = oidc.parse_callback_params(req.url); const params = oidc.parse_callback_params(req.url);
if (! params) { if (! params) {
return send_html_error(res, 'oidc_callback_params_invalid', render_login_page); return send_html_error(res, 'oidc_callback_params_invalid', render_login_failed_page);
} }
log.debug({ callback_params: params }, 'received callback params'); log.debug({ callback_params: params }, 'received callback params');
@ -68,7 +69,7 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c
const token_set = await oidc.fetch_token_set(`${conf.exposed_url}/login-callback`, params, pkce_code_verifier); const token_set = await oidc.fetch_token_set(`${conf.exposed_url}/login-callback`, params, pkce_code_verifier);
if (! token_set) { if (! token_set) {
return send_html_error(res, 'oidc_token_fetch_failed', render_login_page); return send_html_error(res, 'oidc_token_fetch_failed', render_login_failed_page);
} }
log.debug('fetched token set; requesting user info'); log.debug('fetched token set; requesting user info');
@ -76,7 +77,7 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c
const user_info = await oidc.fetch_user_info(token_set); const user_info = await oidc.fetch_user_info(token_set);
if (! user_info) { if (! user_info) {
return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_page); return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_failed_page);
} }
log.debug({ oidc_subject: user_info.sub }, 'fetched user info; looking up local user data'); log.debug({ oidc_subject: user_info.sub }, 'fetched user info; looking up local user data');
@ -124,3 +125,19 @@ function get_zoneinfo(user_info: UserinfoResponse) : Timezone {
const from_userinfo = user_info.zoneinfo as Timezone; const from_userinfo = user_info.zoneinfo as Timezone;
return from_userinfo || 'Africa/Abidjan'; return from_userinfo || 'Africa/Abidjan';
} }
function render_login_failed_page(error_code?: ErrorCode, error?: ErrorInfo) {
const view = {
login_error_code: error_code,
login_error: error,
page_title: 'Login Failed',
};
const partial_files = {
controls: 'controls.html.mustache',
color_theme_controls: 'color-theme-controls.html.mustache',
page_content: 'login-failed.html.mustache',
};
return render_template('page.html.mustache', view, { }, partial_files);
}

View File

@ -1,43 +0,0 @@
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { ErrorCode, ErrorInfo } from '../../http/send-error';
import { redirect_303_see_other } from '../../http/redirects';
import { FastifyInstance, FastifyReply, RouteShorthandOptions } from 'fastify';
import { csp_headers } from '../../http/content-security-policy';
import { render_template } from '../../utilities/mustache';
export function register_login_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, session }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: { },
};
http_server.get('/login', opts, async (req, res) => {
try {
await session.check_login(req);
return redirect_303_see_other(res, conf.exposed_url, 'Already logged in');
}
catch (error) {
return send_login_page(res);
}
});
function send_login_page(res: FastifyReply) {
res.status(200);
res.header('content-type', 'text/html; charset=utf-8');
csp_headers(res, conf.exposed_url);
session.reset(res);
pkce_cookie.reset(res);
return render_login_page();
}
}
export function render_login_page(error_code?: ErrorCode, error?: ErrorInfo) {
const view = {
error_code,
error,
};
return render_template('login.html.mustache', view);
}

View File

@ -6,8 +6,9 @@ import { HttpWebDependencies } from './server';
import { csp_headers } from '../http/content-security-policy'; import { csp_headers } from '../http/content-security-policy';
import { FastifyInstance, RouteShorthandOptions } from 'fastify'; import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { render_template } from '../utilities/mustache'; import { render_template } from '../utilities/mustache';
import type { NamedLocation } from '../storage/named-location';
export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, logger }: HttpWebDependencies) { export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, services }: HttpWebDependencies) {
const opts: RouteShorthandOptions = { const opts: RouteShorthandOptions = {
schema: { }, schema: { },
}; };
@ -21,17 +22,47 @@ export function register_root_page_endpoint(http_server: FastifyInstance, conf:
session.reset(res); session.reset(res);
} }
const location: NamedLocation = {
name: 'Home',
latitude: 45.4961,
longitude: -122.6714,
};
const [
{ value: forecast },
{ value: alerts },
{ value: weather }
] = await Promise.all([
services.weather_gov.get_forecast_for_location(location, 'us'),
services.weather_gov.get_alerts_for_location(location),
services.weatherapi_com.get_current_for_location(location),
]);
const rendered_widgets = await Promise.all([
services.weatherapi_com.render_current_weather(location, weather),
services.weather_gov.render_forecast(location, forecast, alerts),
]);
res.status(200); res.status(200);
res.header('cache-control', 'private, no-cache');
res.header('content-type', 'text/html; charset=utf-8'); res.header('content-type', 'text/html; charset=utf-8');
csp_headers(res, conf.exposed_url); csp_headers(res, conf.exposed_url);
return render_root_page(req.session?.user); return render_root_page(req.session?.user, rendered_widgets);
}); });
} }
function render_root_page(user: UserData) { function render_root_page(user: UserData, rendered_widgets: string[]) {
const view = { const view = {
user, user,
page_title: 'Dashboard',
rendered_widgets,
}; };
return render_template('root.html.mustache', view); const partial_files = {
controls: 'controls.html.mustache',
color_theme_controls: 'color-theme-controls.html.mustache',
page_content: 'root.html.mustache',
};
return render_template('page.html.mustache', view, { }, partial_files);
} }

View File

@ -9,14 +9,15 @@ import { BaseHttpDependencies, HttpConfig, create_http_server } from '../http/se
import { SnowflakeProvider } from '../utilities/snowflake-uid'; import { SnowflakeProvider } from '../utilities/snowflake-uid';
import { register_csp_report_endpoint } from '../http/content-security-policy'; import { register_csp_report_endpoint } from '../http/content-security-policy';
import { register_asset_endpoints } from './assets';
import { register_root_page_endpoint } from './root-page'; import { register_root_page_endpoint } from './root-page';
import { register_login_page_endpoint } from './authentication/login-page';
import { register_submit_login_endpoint } from './authentication/submit-login'; import { register_submit_login_endpoint } from './authentication/submit-login';
import { register_login_callback_endpoint } from './authentication/login-callback'; import { register_login_callback_endpoint } from './authentication/login-callback';
import { register_logout_endpoint } from './authentication/logout'; import { register_logout_endpoint } from './authentication/logout';
import { ColorThemeProvider } from '../utilities/color-themes'; import { ColorThemeProvider } from '../utilities/color-themes';
import { register_color_themes_endpoint } from './assets/color-themes'; import { OutboundHttpProvider } from '../http/outbound';
import { register_typography_endpoint } from './assets/typography'; import { ServicesProvider } from '../services';
import { RSSFeedReaderProvider } from '../utilities/rss-feeds';
export interface HttpWebDependencies extends BaseHttpDependencies { export interface HttpWebDependencies extends BaseHttpDependencies {
oidc: OIDCProvider; oidc: OIDCProvider;
@ -26,25 +27,24 @@ export interface HttpWebDependencies extends BaseHttpDependencies {
session: SessionProvider; session: SessionProvider;
snowflake: SnowflakeProvider; snowflake: SnowflakeProvider;
color_themes: ColorThemeProvider; color_themes: ColorThemeProvider;
outbound_http: OutboundHttpProvider;
rss_feed_reader: RSSFeedReaderProvider;
services: ServicesProvider;
} }
export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) { export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) {
return create_http_server(conf, deps, { return create_http_server(conf, deps, {
endpoints: [ endpoints: [
register_csp_report_endpoint, register_csp_report_endpoint,
register_asset_endpoints,
// Shared Assets
register_color_themes_endpoint,
register_typography_endpoint,
// Root page
register_root_page_endpoint,
// Login/logout // Login/logout
register_login_page_endpoint,
register_submit_login_endpoint, register_submit_login_endpoint,
register_login_callback_endpoint, register_login_callback_endpoint,
register_logout_endpoint, register_logout_endpoint,
// Pages
register_root_page_endpoint,
], ],
content_parsers: { content_parsers: {
// 'application/ld+json': json_content_parser, // 'application/ld+json': json_content_parser,

200
src/http/outbound.ts Normal file
View File

@ -0,0 +1,200 @@
import pino from 'pino';
import { URL } from 'url';
import { request as http_request, OutgoingHttpHeaders, IncomingHttpHeaders } from 'http';
import { request as https_request } from 'https';
import { create_request_cache_provider } from './request-cache';
import { HttpURL } from '../utilities/types';
import { deep_copy } from '../utilities/deep';
let next_id = 1;
const timeout = 5000;
export interface HttpResult {
url: URL;
// req: ClientRequest;
// res: IncomingMessage;
status: number;
body: string;
headers: IncomingHttpHeaders;
}
export interface OutboundHttpConfig {
https_only: boolean;
}
export function validate_outbound_http_conf(conf: unknown) : asserts conf is OutboundHttpConfig {
// todo: validate config
}
export type OutboundHttpProvider = ReturnType<typeof create_outbound_http_provider>;
export function create_outbound_http_provider(conf: OutboundHttpConfig, logger: pino.Logger) {
const http_log = logger.child({ logger: 'outbound-http' });
const response_cache = create_request_cache_provider({ }, http_log);
return {
req: http_req,
get(url_str: HttpURL, headers?: OutgoingHttpHeaders, skip_cache = false) {
return http_req('GET', url_str, headers, null, skip_cache);
},
head(url_str: HttpURL, headers?: OutgoingHttpHeaders, skip_cache = false) {
return http_req('HEAD', url_str, headers, null, skip_cache);
},
post(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
return http_req('POST', url_str, headers, body, skip_cache);
},
put(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
return http_req('PUT', url_str, headers, body, skip_cache);
},
patch(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
return http_req('PATCH', url_str, headers, body, skip_cache);
},
delete(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
return http_req('DELETE', url_str, headers, body, skip_cache);
},
};
async function http_req(method: string, url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
const url = new URL(url_str);
const make_request
= url.protocol === 'https:' ? https_request
: url.protocol === 'http:' ? http_request
: null;
if (! make_request) {
throw new Error('illegal protocol');
}
if (url.protocol !== 'https:' && conf.https_only) {
throw new Error('non-secure http requests not allowed (by configuration)');
}
if (url.username || url.password) {
throw new Error('urls containing user credentials not allowed');
}
const req_headers = headers ? deep_copy(headers) : { };
const result: HttpResult = {
url,
// req: null,
// res: null,
status: null,
body: null,
headers: null,
};
const path = url.pathname + (url.search || '');
const port = url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
const log = http_log.child({
req_id: next_id++,
method: method,
protocol: url.protocol,
hostname: url.hostname,
port: port,
path: path,
});
const cached = skip_cache
? null
: response_cache.check_for_cached_response(method, url_str, req_headers);
if (cached) {
const revalidation = response_cache.determine_revalidation_needed(cached);
if (! revalidation.must_revalidate) {
log.info('serving request from cache (no revalidation required)');
result.status = cached.status;
result.body = cached.body;
result.headers = deep_copy(cached.res_headers);
return Promise.resolve(result);
}
log.info('cached response found, but requires revalidation');
Object.assign(req_headers, revalidation.headers);
}
log.info('starting outbound http request');
return new Promise<HttpResult>((resolve, reject) => {
const start = Date.now();
const req = make_request({
method: method,
protocol: url.protocol,
hostname: url.hostname,
port: port,
path: path,
headers: req_headers
}, (res) => {
// result.res = res;
result.status = res.statusCode;
log.info({
status: res.statusCode,
duration: Date.now() - start,
}, `headers received`);
switch (result.status) {
case 304:
log.info({ status: cached.status }, 're-using cached response (not modified)');
result.status = cached.status;
result.body = cached.body;
result.headers = deep_copy(cached.res_headers);
response_cache.freshen_cache(cached, res.headers);
resolve(result);
break;
case 500:
case 502:
case 503:
case 504:
if (cached?.cache_info?.cache_control?.stale_if_error) {
log.info({ status: cached.status }, 're-using cached response (response returned an error status, and `stale-if-error` directive was provided)');
result.status = cached.status;
result.body = cached.body;
result.headers = deep_copy(cached.res_headers);
resolve(result);
break;
}
// nobreak
default: {
const start = Date.now();
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
log.info({ duration: Date.now() - start }, `response finished`);
result.body = data;
result.headers = res.headers;
response_cache.add_response_to_cache(method, url_str, req_headers, result);
resolve(result);
});
};
}
});
// result.req = req;
req.setTimeout(timeout, () => {
log.error('request timeout');
req.destroy();
});
req.on('error', (error) => {
log.error('request error ' + error.stack);
reject(error);
});
if (body) {
req.write(body);
}
req.end();
});
}
}

View File

@ -17,8 +17,13 @@ export interface CacheControl {
no_store?: boolean; no_store?: boolean;
no_cache?: boolean; no_cache?: boolean;
max_age?: number; max_age?: number;
s_max_age?: number;
must_revalidate?: boolean; must_revalidate?: boolean;
proxy_revalidate?: boolean; proxy_revalidate?: boolean;
immutable?: boolean;
stale_while_revalidate?: boolean;
stale_if_error?: boolean;
must_understand?: boolean;
} }
export function parse_cache_headers(headers: IncomingHttpHeaders) { export function parse_cache_headers(headers: IncomingHttpHeaders) {
@ -62,12 +67,29 @@ export function parse_cache_headers(headers: IncomingHttpHeaders) {
case 'proxy-revalidate': case 'proxy-revalidate':
result.cache_control.proxy_revalidate = true; result.cache_control.proxy_revalidate = true;
break; break;
case 'immutable':
result.cache_control.immutable = true;
break;
case 'stale-while-revalidate':
result.cache_control.stale_while_revalidate = true;
break;
case 'stale-if-error':
result.cache_control.stale_if_error = true;
break;
case 'must-understand':
result.cache_control.must_understand = true;
break;
default: default:
if (directive.startsWith('max-age=')) { if (directive.startsWith('max-age=')) {
result.cache_control.max_age = parseInt(directive.slice(8), 10); result.cache_control.max_age = parseInt(directive.slice(8), 10);
break; break;
} }
if (directive.startsWith('s-max-age=')) {
result.cache_control.s_max_age = parseInt(directive.slice(10), 10);
break;
}
// todo: log something here about unknown directive // todo: log something here about unknown directive
} }
} }

228
src/http/request-cache.ts Normal file
View File

@ -0,0 +1,228 @@
import { pino } from 'pino';
import { DateTime } from 'luxon';
import { deep_copy } from '../utilities/deep';
import { OutgoingHttpHeaders, IncomingHttpHeaders } from 'http';
import { parse_cache_headers, ParsedCacheHeaders } from './parse-cache-headers';
import type { HttpURL } from '../utilities/types';
import type { HttpResult } from './outbound';
export interface RequestCacheConfig {
// todo: support for configurable limits:
max_records?: number; // max number of cached responses
max_length?: number; // max content-length for one response
evict_after?: number; // period of time unused after which a response should be evicted
}
export interface CachedResponse {
cache_key: string;
cached_time: DateTime;
is_stale: boolean;
url: HttpURL;
method: string;
req_headers: OutgoingHttpHeaders;
status: number;
body: string;
res_headers: IncomingHttpHeaders;
cache_info: ParsedCacheHeaders;
expire_timer: NodeJS.Timeout;
}
export function validate_request_cache_conf(conf: unknown) : asserts conf is RequestCacheConfig {
// todo: validate config
}
export type RequestCacheProvider = ReturnType<typeof create_request_cache_provider>;
export function create_request_cache_provider(conf: RequestCacheConfig, logger: pino.Logger) {
type URLCache = Record<string, CachedResponse>;
const cached_responses: Record<HttpURL, URLCache> = Object.create(null);
const cacheable_methods = new Set([ 'GET', 'HEAD' ]);
const cacheable_status_codes = new Set([
200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
]);
return {
/**
* Should be called before making a new request to check if a cached response already exists
*/
check_for_cached_response(method: string, url_str: HttpURL, headers?: OutgoingHttpHeaders) : Readonly<CachedResponse> {
if (! cached_responses[url_str]) {
return;
}
for (const [cache_key, cached] of Object.entries(cached_responses[url_str])) {
if (cache_key === header_cache_key(method, cached.cache_info.vary, headers)) {
return cached;
}
}
},
/**
* Should be called when a cached response is found, to determine what if any revalidation is
* required before re-using the response
*/
determine_revalidation_needed(cached: CachedResponse) {
const { cache_control, etag, date, last_modified } = cached.cache_info;
const must_revalidate = Boolean(cache_control?.must_revalidate || cache_control?.no_cache);
const headers: OutgoingHttpHeaders = { };
if (etag) {
headers['if-none-match'] = etag;
}
if (last_modified || date) {
headers['if-modified-since'] = last_modified?.toHTTP() ?? date?.toHTTP();
}
else {
headers['if-modified-since'] = cached.cached_time.toHTTP();
}
return {
must_revalidate,
headers,
};
},
/**
* Should be called when receiving a non-`304 Not Modified` response from a request, either
* as a novel request, or during a revalidation request
*/
add_response_to_cache(method: string, url_str: HttpURL, headers: OutgoingHttpHeaders, result: HttpResult) {
if (! cacheable_methods.has(method) || ! cacheable_status_codes.has(result.status)) {
logger.info('method/status not cacheable');
return;
}
const cache_info = parse_cache_headers(result.headers);
if (cache_info.cache_control?.no_store || cache_info.cache_control?.private) {
logger.info(cache_info, 'response contains cache-control directives that prevent caching');
return;
}
const cache_key = header_cache_key(method, cache_info.vary, headers);
unset_expire_timer(url_str, cache_key);
const cached: CachedResponse = {
cache_key,
cached_time: DateTime.now(),
is_stale: false,
url: url_str,
method,
req_headers: deep_copy(headers),
status: result.status,
body: result.body,
res_headers: deep_copy(result.headers),
cache_info,
expire_timer: null,
};
logger.info(cache_info, 'caching response');
store_to_cache(cached, cache_info);
},
/**
* Should be called when receiving a `304 Not Modified` response during a revalidation request
* to update the cache with more recent freshness info
*/
freshen_cache(cached: CachedResponse, headers: IncomingHttpHeaders) {
const cache_info = parse_cache_headers(headers);
unset_expire_timer(cached.url, cached.cache_key);
store_to_cache(cached, cache_info);
},
};
function store_to_cache(cached: CachedResponse, cache_info: ParsedCacheHeaders) {
if (! cached_responses[cached.url]) {
cached_responses[cached.url] = Object.create(null);
}
cached_responses[cached.url][cached.cache_key] = cached;
cached.is_stale = false;
if (cache_info.cache_control?.max_age) {
let ttl_ms = cache_info.cache_control.max_age * 1000;
if (cache_info.age) {
ttl_ms -= cache_info.age * 1000;
}
if (ttl_ms <= 0) {
cached.is_stale = true;
return;
}
cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms);
}
else if (cache_info.expires) {
const ttl_ms = cache_info.expires.toUnixInteger() - Date.now();
if (ttl_ms <= 0) {
cached.is_stale = true;
return;
}
cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms);
}
// If no cache-control or expires header is given, default to the
// recommended 10% of time since last-modified
else if (cache_info.last_modified) {
const last_modified_ago_ms = Date.now() - cache_info.last_modified.toUnixInteger();
if (last_modified_ago_ms <= 0) {
cached.is_stale = true;
return;
}
const ttl_ms = (last_modified_ago_ms / 10) | 0;
cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms);
}
}
function unset_expire_timer(url_str: HttpURL, cache_key: string) {
const cached = cached_responses[url_str]?.[cache_key];
if (cached?.expire_timer) {
clearTimeout(cached.expire_timer);
}
}
function set_expire_timer(url_str: HttpURL, cache_key: string, ttl_ms: number) {
const timer = setTimeout(expire, ttl_ms);
timer.unref();
return timer;
function expire() {
if (cached_responses[url_str]?.[cache_key]) {
cached_responses[url_str][cache_key].is_stale = true;
}
};
}
}
function header_cache_key(method: string, vary: string[], headers: OutgoingHttpHeaders) {
const parts: string[] = [ method ];
if (vary?.length) {
for (const header of vary) {
if (Array.isArray(headers[header])) {
const values = (headers[header] as string[]).sort();
for (const value of values) {
parts.push(`${header}: ${value}`);
}
}
else if (headers[header]) {
parts.push(`${header}: ${headers[header]}`);
}
}
}
return parts.join('\0');
}

View File

@ -4,6 +4,7 @@ import { BaseClient, CallbackParamsType, Issuer, TokenSet, TypeOfGenericClient }
import { PKCECookieConfig } from './pkce-cookie'; import { PKCECookieConfig } from './pkce-cookie';
import { redirect_302_found } from '../http/redirects'; import { redirect_302_found } from '../http/redirects';
import pino from 'pino'; import pino from 'pino';
import { SecretValue, resolve_secret_value } from '../conf';
const scopes = 'openid profile email'; const scopes = 'openid profile email';
@ -11,7 +12,7 @@ export interface OIDCConfig {
server_url: string; server_url: string;
signing_algorithm: 'ES512'; signing_algorithm: 'ES512';
client_id: string; client_id: string;
client_secret: string; client_secret: SecretValue;
pkce: PKCECookieConfig; pkce: PKCECookieConfig;
} }
@ -33,7 +34,7 @@ export function create_oidc_provider(conf: OIDCConfig, logger: pino.Logger) {
Client = issuer.Client; Client = issuer.Client;
client = new Client({ client = new Client({
client_id: conf.client_id, client_id: conf.client_id,
client_secret: conf.client_secret, client_secret: resolve_secret_value(conf.client_secret),
id_token_signed_response_alg: conf.signing_algorithm, id_token_signed_response_alg: conf.signing_algorithm,
authorization_signed_response_alg: conf.signing_algorithm, authorization_signed_response_alg: conf.signing_algorithm,
}); });

View File

@ -1,12 +1,13 @@
import { pino } from 'pino'; import { pino } from 'pino';
import { rand } from '../utilities/rand'; import { rand } from '../utilities/rand';
import { FastifyReply } from 'fastify'; import { resolve_secret_value, SecretValue } from '../conf';
import { Req } from '../http/request';
import { invalidate_cookie, parse_req_cookies, set_cookie } from '../http/cookies'; import { invalidate_cookie, parse_req_cookies, set_cookie } from '../http/cookies';
import type { Req } from '../http/request';
import type { FastifyReply } from 'fastify';
import type { Snowflake } from '../utilities/snowflake-uid';
import type { Argon2HashProvider } from './argon-hash'; import type { Argon2HashProvider } from './argon-hash';
import type { SessionData, StorageProvider, UserData } from '../storage'; import type { SessionData, StorageProvider, UserData } from '../storage';
import { Snowflake } from '../utilities/snowflake-uid';
export interface SessionKey { export interface SessionKey {
full_key: string; full_key: string;
@ -20,7 +21,7 @@ export interface SessionCookieConfig {
name: string; name: string;
secure: boolean; secure: boolean;
ttl: number; ttl: number;
pepper: string; pepper: SecretValue;
} }
export function validate_session_cookie_conf(conf: unknown) : asserts conf is SessionCookieConfig { export function validate_session_cookie_conf(conf: unknown) : asserts conf is SessionCookieConfig {
@ -29,6 +30,7 @@ export function validate_session_cookie_conf(conf: unknown) : asserts conf is Se
export function create_session_provider(conf: SessionCookieConfig, logger: pino.Logger, argon2: Argon2HashProvider, storage: StorageProvider) { export function create_session_provider(conf: SessionCookieConfig, logger: pino.Logger, argon2: Argon2HashProvider, storage: StorageProvider) {
const session_logger = logger.child({ logger: 'session' }); const session_logger = logger.child({ logger: 'session' });
const pepper = resolve_secret_value(conf.pepper);
const self = { const self = {
async generate_key() : Promise<SessionKey> { async generate_key() : Promise<SessionKey> {
const bytes = await rand(48); const bytes = await rand(48);
@ -40,7 +42,7 @@ export function create_session_provider(conf: SessionCookieConfig, logger: pino.
return { prefix, raw_key, full_key }; return { prefix, raw_key, full_key };
}, },
hash_key(key: SessionKey) : Promise<string> { hash_key(key: SessionKey) : Promise<string> {
return argon2.hash(conf.pepper + key.raw_key); return argon2.hash(pepper + key.raw_key);
}, },
parse_key(full_key: string) : SessionKey { parse_key(full_key: string) : SessionKey {
const [ prefix, raw_key, ...rest ] = full_key.split('.'); const [ prefix, raw_key, ...rest ] = full_key.split('.');
@ -52,7 +54,7 @@ export function create_session_provider(conf: SessionCookieConfig, logger: pino.
return { prefix, raw_key, full_key }; return { prefix, raw_key, full_key };
}, },
verify_key(key: SessionKey, key_hash: string) : Promise<boolean> { verify_key(key: SessionKey, key_hash: string) : Promise<boolean> {
return argon2.verify(key_hash, conf.pepper + key.raw_key); return argon2.verify(key_hash, pepper + key.raw_key);
}, },
write_to_cookie(res: FastifyReply, key: SessionKey) { write_to_cookie(res: FastifyReply, key: SessionKey) {
const session_expire = new Date(Date.now() + (conf.ttl * 1000)); const session_expire = new Date(Date.now() + (conf.ttl * 1000));

38
src/services/index.ts Normal file
View File

@ -0,0 +1,38 @@
import { pino } from 'pino';
import { create_weather_gov_provider, validate_weather_gov_conf, WeatherGovConfig } from './weather.gov';
import type { SecretValue } from '../conf';
import type { OutboundHttpProvider } from '../http/outbound';
import { create_weatherapi_com_provider, validate_weatherapi_com_conf, WeatherAPIComConfig } from './weatherapi.com';
export interface ServicesConfig {
'weather.gov'?: WeatherGovConfig;
'weatherapi.com'?: WeatherAPIComConfig;
'openweathermap.org'?: {
enabled: boolean;
api_key: SecretValue;
};
}
export type ServicesProvider = ReturnType<typeof create_services_provider>;
export function validate_services_conf(conf: unknown) : asserts conf is ServicesConfig {
validate_weather_gov_conf(conf['weather.gov']);
validate_weatherapi_com_conf(conf['weatherapi.com']);
}
export function create_services_provider(conf: ServicesConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) {
const weather_gov = create_weather_gov_provider(conf['weather.gov'], logger, outbound_http);
const weatherapi_com = create_weatherapi_com_provider(conf['weatherapi.com'], logger, outbound_http);
const ready = Promise.all([
weather_gov.ready,
weatherapi_com.ready,
]);
return {
ready,
weather_gov,
weatherapi_com,
};
}

View File

@ -0,0 +1,184 @@
import pino from 'pino';
import { memo } from '../../utilities/memo';
import { deep_freeze } from '../../utilities/deep';
import { render_forecast } from './render-forecast';
import type { NamedLocation } from '../../storage/named-location';
import type { OutboundHttpProvider } from '../../http/outbound';
import type { WeatherGovPoints, WeatherGovForecast, WeatherGovAlerts } from './interface';
import { HttpURL } from '../../utilities/types';
export interface WeatherGovConfig {
enabled: boolean;
user_agent: string;
}
export function validate_weather_gov_conf(conf: unknown) : asserts conf is WeatherGovConfig {
// todo: validate config
}
export type WeatherGovProvider = ReturnType<typeof create_weather_gov_provider>;
export function create_weather_gov_provider(conf: WeatherGovConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) {
const log = logger.child({ logger: 'weather.gov' });
const get_points_ttl = 1000 * 60 * 60 * 24;
const get_points = memo(get_points_ttl, get_points_nocache, { });
const get_forecast_ttl = 1000 * 60 * 30;
const get_forecast = memo(get_forecast_ttl, get_forecast_nocache, { });
const get_alerts_ttl = 1000 * 30;
const get_alerts = memo(get_alerts_ttl, get_alerts_nocache, { });
return {
ready: Promise.resolve(),
get enabled() {
return conf?.enabled;
},
get_points: if_enabled(get_points),
get_forecast: if_enabled(get_forecast),
get_alerts: if_enabled(get_alerts),
async get_forecast_for_lat_long(latitude: number, longitude: number, units: 'us' | 'si') {
const { value: points } = await get_points(latitude, longitude);
return get_forecast(points.gridId, points.gridX, points.gridY, units, false);
},
async get_hourly_forecast_for_lat_long(latitude: number, longitude: number, units: 'us' | 'si') {
const { value: points } = await get_points(latitude, longitude);
return get_forecast(points.gridId, points.gridX, points.gridY, units, true);
},
async get_forecast_for_location(location: NamedLocation, units: 'us' | 'si') {
const { value: points } = await get_points(location.latitude, location.longitude);
return get_forecast(points.gridId, points.gridX, points.gridY, units, false);
},
async get_hourly_forecast_for_location(location: NamedLocation, units: 'us' | 'si') {
const { value: points } = await get_points(location.latitude, location.longitude);
return get_forecast(points.gridId, points.gridX, points.gridY, units, true);
},
async get_alerts_for_location(location: NamedLocation) {
return get_alerts(location.latitude, location.longitude);
},
render_forecast,
};
function if_enabled<T>(func: T) : T {
if (conf?.enabled) {
return func;
}
return (() => {
throw new Error('weather.gov service not enabled');
}) as T;
}
async function get_points_nocache(latitude: number, longitude: number) : Promise<Cachable<WeatherGovPoints>> {
log.info({ latitude, longitude }, 'fetching points data');
let { status, headers, body } = await outbound_http.get(`https://api.weather.gov/points/${latitude},${longitude}`, {
'accept': 'application/ld+json',
'user-agent': conf.user_agent,
});
// Follow a single redirect if given
if (status === 301) {
({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, {
'accept': 'application/ld+json',
'user-agent': conf.user_agent,
}));
}
if (status === 200) {
const data = JSON.parse(body);
return cacheable(get_points_ttl, data as WeatherGovPoints);
}
const data = headers['content-type'].startsWith('application/problem+json')
? JSON.parse(body)
: { status };
log.error(data, 'failed to fetch points data');
throw new Error('failed to fetch points data');
}
async function get_forecast_nocache(gridId: string, gridX: number, gridY: number, units: 'us' | 'si', hourly = false) : Promise<Cachable<WeatherGovForecast>> {
log.info({ gridId, gridX, gridY }, 'fetching forecast info');
const url = `https://api.weather.gov/gridpoints/${gridId}/${gridX},${gridY}/forecast${hourly ? '/hourly' : ''}?units=${units}`;
let { status, headers, body } = await outbound_http.get(url as HttpURL, {
'accept': 'application/ld+json',
'user-agent': conf.user_agent,
});
// Follow a single redirect if given
if (status === 301) {
({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, {
'accept': 'application/ld+json',
'user-agent': conf.user_agent,
}));
}
if (status === 200) {
const data = JSON.parse(body);
return cacheable(get_forecast_ttl, data as WeatherGovForecast);
}
const data = headers['content-type'].startsWith('application/problem+json')
? JSON.parse(body)
: { status };
log.error(data, 'failed to fetch forecast data');
throw new Error('failed to fetch forecast data');
}
async function get_alerts_nocache(latitude: number, longitude: number) : Promise<Cachable<WeatherGovAlerts>> {
log.info({ latitude, longitude }, 'fetching alerts info');
let { status, headers, body } = await outbound_http.get(`https://api.weather.gov/alerts?active=1&point=${latitude},${longitude}`, {
'accept': 'application/ld+json',
'user-agent': conf.user_agent,
});
// Follow a single redirect if given
if (status === 301) {
({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, {
'accept': 'application/ld+json',
'user-agent': conf.user_agent,
}));
}
if (status === 200) {
const data = JSON.parse(body);
return cacheable(get_alerts_ttl, data as WeatherGovAlerts);
}
const data = headers['content-type'].startsWith('application/problem+json')
? JSON.parse(body)
: { status };
log.error(data, 'failed to fetch alerts data');
throw new Error('failed to fetch alerts data');
}
}
function cacheable<T>(ttl: number, value: T) : Cachable<T> {
const now = new Date();
return deep_freeze({
fetched: now,
expires: new Date(now.getTime() + ttl),
value,
});
}
interface Cachable<T> {
readonly fetched: Date;
readonly expires: Date;
readonly value: Readonly<T>;
}

View File

@ -0,0 +1,103 @@
import { ContextDefinition } from 'jsonld';
import { HttpURL, ISOTimestamp, Timezone, URN } from '../../utilities/types';
export interface WeatherGovMeasure<Unit extends `wmoUnit:${string}` = `wmoUnit:${string}`> {
unitCode: Unit;
value: number;
}
export interface WeatherGovPoints {
'@context': ContextDefinition;
geometry: string;
cwa: string;
forecastOffice: HttpURL;
gridId: string;
gridX: number;
gridY: number;
forecast: HttpURL;
forecastHourly: HttpURL;
forecastGridData: HttpURL;
forecastStations: HttpURL;
relativeLocation: {
city: string;
state: string;
geometry: string;
distance: WeatherGovMeasure<'wmoUnit:m'>;
bearing: WeatherGovMeasure<'wmoUnit:degree_(angle)'>;
};
forecastZone: HttpURL;
county: HttpURL;
fireWeatherZone: HttpURL;
timeZone: Timezone;
radarStation: string;
}
export interface WeatherGovForecast {
'@context': ContextDefinition;
geometry: string;
updated: ISOTimestamp;
units: 'us' | 'si';
forecastGenerator: 'BaselineForecastGenerator' | 'HourlyForecastGenerator';
generatedAt: ISOTimestamp;
updateTime: ISOTimestamp;
validTimes: string;
elevation: WeatherGovMeasure<'wmoUnit:m'>;
periods: WeatherGovForecastPeriod[];
}
export interface WeatherGovForecastPeriod {
number: number;
name: string;
startTime: ISOTimestamp;
endTime: ISOTimestamp;
isDaytime: boolean;
temperature: number;
temperatureUnit: 'F' | 'C';
temperatureTrend: unknown; // todo: what is this?
probabilityOfPrecipitation: WeatherGovMeasure<'wmoUnit:percent'>;
dewPoint: WeatherGovMeasure<'wmoUnit:degC'>;
relativeHumidity: WeatherGovMeasure<'wmoUnit:percent'>;
windSpeed: `${number} mph` | `${number} km/h`;
windDirection: 'N' | 'NNW' | 'NW' | 'WNW' | 'W' | 'NNE' | 'NE' | 'ENE' | 'E'
| 'S' | 'SSW' | 'SW' | 'WSW' | 'W' | 'SSE' | 'SE' | 'ESE' | 'E';
icon: HttpURL;
shortForecast: string;
detailedForecast: string;
}
export interface WeatherGovAlerts {
'@context': ContextDefinition;
'@graph': {
'@id': HttpURL;
'@type': 'wx:Alert',
id: URN<`oid:${string}`>;
areaDesc: string;
geometry: string;
geocode: {
SAME: `${number}`[];
UGC: string[];
};
affectedZones: HttpURL[];
references: unknown[]; // todo: what is this?
sent: ISOTimestamp;
effective: ISOTimestamp;
onset: ISOTimestamp;
expires: ISOTimestamp;
ends: ISOTimestamp;
status: 'Actual' | 'Exercise' | 'System' | 'Test' | 'Draft';
messageType: 'Alert' | 'Update' | 'Cancel';
category: string;
severity: 'Extreme' | 'Severe' | 'Moderate' | 'Minor' | 'Unknown';
certainty: 'Observed' | 'Likely' | 'Possible' | 'Unlikely' | 'Unknown';
urgency: 'Immediate' | 'Expected' | 'Future' | 'Past' | 'Unknown';
event: string;
sender: string;
senderName: string;
headline: string;
description: string;
instruction: string;
response: string;
parameters: Record<string, string[]>;
}[];
}

View File

@ -0,0 +1,44 @@
import { icons } from '../../utilities/icons';
import { render_template } from '../../utilities/mustache';
import type { NamedLocation } from '../../storage/named-location';
import type { WeatherGovAlerts, WeatherGovForecast, WeatherGovForecastPeriod } from './interface';
export function render_forecast(location: NamedLocation, forecast: WeatherGovForecast, alerts: WeatherGovAlerts) {
let forecast_today: WeatherGovForecastPeriod[];
let forecast_future: WeatherGovForecastPeriod[];
const forecast_days: WeatherGovForecastPeriod[] = [ ];
const forecast_nights: WeatherGovForecastPeriod[] = [ ];
if (forecast.periods[0].isDaytime) {
forecast_today = forecast.periods.slice(0, 2);
forecast_future = forecast.periods.slice(2);
}
else {
forecast_today = forecast.periods.slice(0, 1);
forecast_future = forecast.periods.slice(1);
}
for (const period of forecast_future) {
if (period.isDaytime) {
forecast_days.push(period);
}
else {
forecast_nights.push(period);
}
}
const view = {
location,
forecast_today,
forecast_future,
forecast_days,
forecast_nights,
icons,
alerts: alerts['@graph'],
};
return render_template('weather.gov/forecast.html.mustache', view);
}

View File

@ -0,0 +1,96 @@
import pino from 'pino';
import { memo } from '../../utilities/memo';
import { deep_freeze } from '../../utilities/deep';
// import { render_forecast } from './render-forecast';
import { render_current_weather } from './render-current-weather';
import { SecretValue, resolve_secret_value } from '../../conf';
import type { OutboundHttpProvider } from '../../http/outbound';
import type { WeatherAPIComCurrentWeather, WeatherAPIComLocation } from './interface';
import type { NamedLocation } from '../../storage/named-location';
export interface WeatherAPIComConfig {
enabled: boolean;
api_key: SecretValue;
}
export function validate_weatherapi_com_conf(conf: unknown) : asserts conf is WeatherAPIComConfig {
// todo: validate config
}
export type WeatherAPIComProvider = ReturnType<typeof create_weatherapi_com_provider>;
export function create_weatherapi_com_provider(conf: WeatherAPIComConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) {
const log = logger.child({ logger: 'weatherapi.com' });
const api_key = resolve_secret_value(conf.api_key);
const get_current_ttl = 1000;
// const get_current_ttl = 1000 * 60 * 3;
const get_current = memo(get_current_ttl, get_current_nocache, { });
// const get_forecast_ttl = 1000 * 60 * 30;
// const get_forecast = memo(get_forecast_ttl, get_forecast_nocache, { });
// const get_alerts_ttl = 1000 * 30;
// const get_alerts = memo(get_alerts_ttl, get_alerts_nocache, { });
return {
ready: Promise.resolve(),
get enabled() {
return conf?.enabled;
},
get_current: if_enabled(get_current),
get_current_for_location(location: NamedLocation) {
return get_current(`${location.latitude},${location.longitude}`);
},
render_current_weather,
};
function if_enabled<T>(func: T) : T {
if (conf?.enabled) {
return func;
}
return (() => {
throw new Error('weatherapi.com service not enabled');
}) as T;
}
async function get_current_nocache(query: string) : Promise<Cachable<WeatherAPIComCurrentWeather>> {
log.info({ query }, 'fetching current weather');
let { status, headers, body } = await outbound_http.get(`https://api.weatherapi.com/v1/current.json?key=${api_key}&q=${query}`, {
'accept': 'application/json',
});
if (status === 200) {
const data = JSON.parse(body);
return cacheable(get_current_ttl, data as WeatherAPIComCurrentWeather);
}
const data = headers['content-type'].startsWith('application/json')
? JSON.parse(body)
: { status };
log.error(data, 'failed to fetch current weather');
throw new Error('failed to fetch current weather');
}
}
function cacheable<T>(ttl: number, value: T) : Cachable<T> {
const now = new Date();
return deep_freeze({
fetched: now,
expires: new Date(now.getTime() + ttl),
value,
});
}
interface Cachable<T> {
readonly fetched: Date;
readonly expires: Date;
readonly value: Readonly<T>;
}

View File

@ -0,0 +1,108 @@
import { HttpURLSchemeless, ISODate, LocalDateTime, Timezone } from '../../utilities/types';
export interface WeatherAPIComLocation {
name: string;
region: string;
country: string
lat: number;
lon: number;
tz_id: Timezone;
localtime_epoch: number;
localtime: LocalDateTime;
}
export interface WeatherAPIComCurrentWeather {
location: WeatherAPIComLocation;
current: WeatherAPIComWeather & {
last_updated_epoch: number;
last_updated: LocalDateTime;
};
}
export interface WeatherAPIComForecast extends WeatherAPIComCurrentWeather {
forecast: {
forecastday: {
date: ISODate;
date_epoch: number;
day: {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: WeatherAPIComCondition;
uv: number;
};
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: `${bigint}`;
is_moon_up: 0 | 1;
is_sun_up: 0 | 1;
};
hour: WeatherAPIComHourForecast[];
}[];
};
}
export interface WeatherAPIComCondition {
text: string;
icon: HttpURLSchemeless;
code: number;
}
export interface WeatherAPIComWeather {
temp_c: number;
temp_f: number;
is_day: number;
condition: WeatherAPIComCondition;
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
}
export interface WeatherAPIComHourForecast extends WeatherAPIComWeather {
time_epoch: number;
time: LocalDateTime;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
}

View File

@ -0,0 +1,15 @@
import { icons } from '../../utilities/icons';
import { render_template } from '../../utilities/mustache';
import type { NamedLocation } from '../../storage/named-location';
import type { WeatherAPIComCurrentWeather } from './interface';
export function render_current_weather(location: NamedLocation, weather: WeatherAPIComCurrentWeather) {
const view = {
location,
weather,
icons,
};
return render_template('weatherapi.com/current.html.mustache', view);
}

View File

@ -12,6 +12,9 @@ import { create_session_provider } from './security/session';
import { create_http_metadata_server } from './http-metadata/server'; import { create_http_metadata_server } from './http-metadata/server';
import { HttpWebDependencies, create_http_web_server } from './http-web/server'; import { HttpWebDependencies, create_http_web_server } from './http-web/server';
import { create_color_theme_provider } from './utilities/color-themes'; import { create_color_theme_provider } from './utilities/color-themes';
import { create_outbound_http_provider } from './http/outbound';
import { create_services_provider } from './services';
import { create_rss_feed_reader_provider } from './utilities/rss-feeds';
main(); main();
@ -39,11 +42,15 @@ async function main() {
const argon2 = create_argon_hash_provider(conf.argon2); const argon2 = create_argon_hash_provider(conf.argon2);
const session = create_session_provider(conf.session_cookie, logger, argon2, storage); const session = create_session_provider(conf.session_cookie, logger, argon2, storage);
const color_themes = create_color_theme_provider(conf.color_themes); const color_themes = create_color_theme_provider(conf.color_themes);
const outbound_http = create_outbound_http_provider(conf.outbound_http, logger);
const rss_feed_reader = create_rss_feed_reader_provider(logger, outbound_http);
const services = create_services_provider(conf.services, logger, outbound_http);
// Wait for any async init steps // Wait for any async init steps
await oidc.ready; await oidc.ready;
await storage.ready; await storage.ready;
await color_themes.ready; await color_themes.ready;
await services.ready;
// Perform any cleanup steps before starting up // Perform any cleanup steps before starting up
await storage.cleanup_old_sessions(); await storage.cleanup_old_sessions();
@ -57,6 +64,9 @@ async function main() {
argon2, argon2,
session, session,
color_themes, color_themes,
outbound_http,
rss_feed_reader,
services,
}; };
// Create the main web server // Create the main web server

View File

@ -0,0 +1,6 @@
export interface NamedLocation {
name: string;
latitude: number;
longitude: number;
}

View File

@ -23,6 +23,9 @@ export function create_color_theme_provider(conf: ColorThemeConfig) {
get ready() { get ready() {
return ready; return ready;
}, },
get themes() {
return structuredClone(conf);
},
get css() { get css() {
return css; return css;
}, },

View File

@ -1,13 +0,0 @@
export function deep_merge<T>(host: T, donor: T) : T {
for (const [key, value] of Object.entries(donor)) {
if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') {
deep_merge(host[key], value);
continue;
}
host[key] = value;
}
return host;
}

41
src/utilities/deep.ts Normal file
View File

@ -0,0 +1,41 @@
export function deep_merge<T>(host: T, donor: T) : T {
for (const [key, value] of Object.entries(donor)) {
if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') {
deep_merge(host[key], value);
continue;
}
host[key] = value;
}
return host;
}
export function deep_freeze<T>(obj: T) : Readonly<T> {
for (const value of Object.values(obj)) {
if (value == null) {
continue;
}
else if (typeof value === 'object' || typeof value === 'function') {
deep_freeze(value);
}
}
return Object.freeze(obj);
}
export function deep_copy<T>(obj: T) : T {
return structuredClone(obj);
// try {
// return structuredClone(obj);
// }
// catch (error) {
// if (error instanceof ReferenceError) {
// return JSON.parse(JSON.stringify(obj));
// }
// }
}

22
src/utilities/icons.ts Normal file
View File

@ -0,0 +1,22 @@
export const icons: Record<string, string> = Object.create(null);
const whitespace = /[\s\t\n]+/g;
const feather_icons: Record<string, string> = require('../../vendor/feather-icons/icons.json');
for (const [name, contents] of Object.entries(feather_icons)) {
icons[name] = `
<svg xmlns="http://www.w3.org/2000/svg"
class="icon ${name}"
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentcolor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>${contents}</svg>
`.replace(whitespace, ' ').trim();
}
Object.freeze(icons);

View File

@ -51,7 +51,8 @@ export function memo<T extends Func>(ttl: number, func: T, opts: MemoParams<T> =
} }
function set_expire(key: string, args: Params<T>, stored: number) { function set_expire(key: string, args: Params<T>, stored: number) {
setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl); const timer = setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl);
timer.unref();
} }
function revalidate(key: string, args: Params<T>, stored: number) { function revalidate(key: string, args: Params<T>, stored: number) {

View File

@ -0,0 +1,50 @@
import pino from 'pino';
import type { HttpURL } from './types';
import type { OutboundHttpProvider } from '../http/outbound';
import { XMLParser, XMLValidator, X2jOptions } from 'fast-xml-parser';
export type RSSFeedReaderProvider = ReturnType<typeof create_rss_feed_reader_provider>;
export function create_rss_feed_reader_provider(logger: pino.Logger, outbound_http: OutboundHttpProvider) {
const log = logger.child({ logger: 'rss-feed-reader' });
return {
async read_feed(feed_url: HttpURL, skip_cache = false) {
const req_headers = {
accept: 'application/rss+xml; q=1.0, text/xml; q=0.9',
};
const { status, headers, body } = await outbound_http.get(feed_url, req_headers, skip_cache);
if (status === 200) {
const content_type = headers['content-type'] || '';
if (content_type.startsWith('text/xml') || content_type.startsWith('application/rss+xml')) {
const opts: Partial<X2jOptions> = { };
const result = XMLValidator.validate(body, opts);
if (typeof result === 'object' && result.err) {
log.error(result.err, 'RSS feed XML validation error');
throw_error('received invalid XML response from server');
}
const xmlParser = new XMLParser(opts);
return xmlParser.parse(body);
}
else {
throw_error('received invalid content type from server');
}
}
else {
throw_error('received a non-200 response code');
}
},
};
function throw_error(message: string) : never {
throw new Error(`failed to fetch RSS feed content; ${message}`);
}
}

View File

@ -17,8 +17,16 @@ export type ISOZoneOffset = 'Z' | `${'+' | '-' | ''}${number}:${number}`;
export type ISOTimestamp = `${ISODate}T${ISOTime}${ISOZoneOffset}`; export type ISOTimestamp = `${ISODate}T${ISOTime}${ISOZoneOffset}`;
export type TimeShort = `${number}:${number}`;
export type LocalDateTime = `${ISODate} ${TimeShort}`;
export type Timezone = `${string}/${string}`; export type Timezone = `${string}/${string}`;
export type Locale = `${string}-${string}`; export type Locale = `${string}-${string}`;
export type HttpURL = `http${'s' | ''}://${string}`; export type HttpURL = `http${'s' | ''}://${string}`;
export type HttpURLSchemeless = `//${string}`;
export type URN<T extends string = string> = `urn:${T}`;

View File

@ -0,0 +1,60 @@
* {
box-sizing: border-box;
}
fieldset.radio {
width: 30rem;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
column-gap: 2%;
border-color: var(--theme-border-input);
}
fieldset.radio.theme {
margin-block: 1rem 2rem;
}
@media screen and (max-width: 500px) {
fieldset.radio {
width: calc(100vw - 5rem);
}
}
label {
width: 32%;
height: 2rem;
display: block;
position: relative;
}
input {
appearance: none;
display: block;
background: transparent;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
input:not(:checked) {
cursor: pointer;
background: var(--theme-bg-button-primary);
}
input:checked {
background: var(--theme-bg-button-primary-hover);
}
span {
z-index: 2;
position: relative;
pointer-events: none;
text-align: center;
display: block;
height: 100%;
line-height: 2rem;
}

View File

@ -0,0 +1,7 @@
<a class="popup-open color-theme-contrast" href="#popup-color-theme-contrast">Colors and Contrast</a>
<div class="popup" id="popup-color-theme-contrast" role="dialog">
<div class="popup-content">
<a class="popup-close" href="#">Close</a>
<color-theme-and-contrast-controls></color-theme-and-contrast-controls>
</div>
</div>

View File

@ -0,0 +1,130 @@
(() => {
const color_theme = 'color_theme';
const color_contrast = 'color_contrast';
const color_theme_attr = 'data-color-theme';
const color_contrast_attr = 'data-color-contrast';
const override_theme = localStorage.getItem(color_theme);
const override_contrast = localStorage.getItem(color_contrast);
if (override_theme) {
document.body.setAttribute(color_theme_attr, override_theme);
}
if (override_contrast) {
document.body.setAttribute(color_contrast_attr, override_contrast);
}
const template = `
<link rel="stylesheet" href="/typography.css">
<link rel="stylesheet" href="/color-theme-controls.css">
<div class="popup" title="Color Theme and Contrast Controls">
<fieldset class="theme radio">
<legend>Color Theme</legend>
<label>
<input type="radio" name="color_theme" value="auto" ${checked_if(! override_theme)}>
<span>Auto</span>
</label>
<label>
<input type="radio" name="color_theme" value="light" ${checked_if(override_theme === 'light')}>
<span>Light</span>
</label>
<label>
<input type="radio" name="color_theme" value="dark" ${checked_if(override_theme === 'dark')}>
<span>Dark</span>
</label>
</fieldset>
<fieldset class="contrast radio">
<legend>Color Contrast</legend>
<label>
<input type="radio" name="color_contrast" value="less" ${checked_if(override_contrast === 'less')}>
<span>Less Contrast</span>
</label>
<label>
<input type="radio" name="color_contrast" value="default" ${checked_if(! override_contrast)}>
<span>Default</span>
</label>
<label>
<input type="radio" name="color_contrast" value="more" ${checked_if(override_contrast === 'more')}>
<span>More Contrast</span>
</label>
</fieldset>
</div>
`;
customElements.define('color-theme-and-contrast-controls',
class ColorThemeToggleButton extends HTMLElement {
#popup = null;
#theme_inputs = null;
#contrast_inputs = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = template;
this.#popup = this.shadowRoot.querySelector('.popup');
this.#theme_inputs = this.#popup.querySelectorAll('.theme input');
this.#contrast_inputs = this.#popup.querySelectorAll('.contrast input');
}
connectedCallback() {
this.#popup.addEventListener('change', this.#onChange);
}
disconnectedCallback() {
this.#popup.removeEventListener('change', this.#onChange);
}
#onChange = () => {
const theme = this.#theme_input_value;
const contrast = this.#contrast_input_value;
if (theme === 'auto') {
localStorage.removeItem(color_theme);
document.body.removeAttribute(color_theme_attr);
}
else {
localStorage.setItem(color_theme, theme);
document.body.setAttribute(color_theme_attr, theme);
}
if (contrast === 'default') {
localStorage.removeItem(color_contrast);
document.body.removeAttribute(color_contrast_attr);
}
else {
localStorage.setItem(color_contrast, contrast);
document.body.setAttribute(color_contrast_attr, theme);
}
};
get #theme_input_value() {
return radio_value(this.#theme_inputs, 'auto');
}
get #contrast_input_value() {
return radio_value(this.#contrast_inputs, 'auto');
}
}
);
function checked_if(condition) {
return condition ? 'checked' : '';
}
function radio_value(inputs, default_value) {
inputs = [ ...inputs ];
for (const input of inputs) {
if (input.checked) {
return input.value;
}
}
return default_value;
}
})();

View File

@ -0,0 +1,22 @@
<aside class="controls">
<div>
{{# user }}
<p>Logged in as
{{# user.name }}{{ user.name }}{{/ user.name }}
{{^ user.name }}{{ user.username }}{{/ user.name }}
-
</p>
<form action="/logout" method="POST">
<button type="submit">Logout</button>
</form>
{{/ user }}
{{^ user }}
<form action="/login" method="POST">
<button type="submit">Login with OpenID Connect</button>
</form>
{{/ user }}
</div>
{{> color_theme_controls }}
</aside>

143
templates/forms.css Normal file
View File

@ -0,0 +1,143 @@
fieldset {
border-color: var(--theme-border-input);
}
button, input:is([type='button'], [type='submit'], [type='reset']) {
appearance: none;
cursor: pointer;
border: 0;
padding: 0.5rem 1rem;
}
/*
label {
margin-block-start: 1rem;
}
label:not(:first-of-type) {
margin-block-start: 2rem;
}
label.radio {
display: flex;
margin-block: 1rem;
justify-content: flex-start;
}
input,
textarea {
display: block;
margin-block-start: 0.35rem;
margin-block-end: 1rem;
border: 0.125rem var(--theme-border-input) solid;
border-radius: 0.25rem;
background-color: var(--theme-bg-input);
padding: 0.5rem;
width: 20rem;
}
input[disabled],
textarea[disabled] {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
input[type='text']:invalid,
input[type='password']:invalid,
textarea:invalid {
border-color: var(--theme-border-input-invalid);
}
input[type='text'][readonly] {
color: var(--theme-text-light);
border-color: var(--theme-line);
}
input[type='checkbox'] {
width: 1rem;
height: 1rem;
padding: 0;
}
input[type='checkbox'] + label {
margin-inline-start: 0.5rem;
margin-inline-end: 2rem;
}
input[type='radio'] {
flex: 0 0 2rem;
margin: 0.2rem;
}
textarea {
line-height: 1.7rem;
font-family: var(--font-monospace);
}
::-webkit-input-placeholder {
color: var(--theme-text-light);
}
button {
padding: 0.5rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
margin-block-start: 1rem;
margin-inline-end: 1rem;
cursor: pointer;
user-select: none;
appearance: none;
color: var(--theme-text-button-primary);
background: var(--theme-bg-button-primary);
text-decoration: none;
font-size: 1rem;
font-family: var(--font-body);
font-weight: 700;
line-height: 2rem;
display: flex;
align-items: center;
}
button[disabled] {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
select {
display: block;
margin-block-start: 0.35rem;
margin-block-end: 1rem;
border: 0.125rem var(--theme-border-input) solid;
border-radius: 0.25rem;
background-color: var(--theme-bg-input);
padding: 0.5rem;
width: 20rem;
cursor: pointer;
}
select[disabled] {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
select option {
background-color: var(--theme-bg-input);
padding: 0.5rem;
}
:is(table, .table) :is(input, select) {
margin-block: 0;
}
*/

View File

@ -0,0 +1,3 @@
<section data-widget="local-clock" title="Local-time clock">
{{! }}
</section>

View File

@ -0,0 +1,5 @@
<aside class="error">
<h2>Login Failed</h2>
<p><b>Error Code:</b> {{ login_error_code }}</p>
<p><b>Error Message:</b> {{ login_error.message }}</p>
</aside>

View File

@ -1,21 +0,0 @@
<!doctype html>
<html>
<head>
<title>Login</title>
<link rel="stylesheet" type="text/css" href="/themes.css">
<link rel="stylesheet" type="text/css" href="/typography.css">
</head>
<body>
<form action="/login" method="POST">
<button type="submit">Login with OpenID Connect</button>
</form>
{{# error_code }}
<div>
<h4>Login failed</h4>
<b>Error Code:</b> {{ error_code }}<br />
<b>Error Message:</b> {{ error.message }}
</div>
{{/ error_code }}
</body>
</html>

View File

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<title>{{ page_title }}</title>
<link rel="stylesheet" href="/themes.css">
<link rel="stylesheet" href="/typography.css">
<link rel="stylesheet" href="/forms.css">
<link rel="stylesheet" href="/popup.css">
<link rel="stylesheet" href="/structure.css">
<script src="/color-theme-controls.js" async></script>
</head>
<body>
{{> controls }}
{{> page_content }}
</body>
</html>

37
templates/popup.css Normal file
View File

@ -0,0 +1,37 @@
.popup {
z-index: 1;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
user-select: none;
pointer-events: none;
display: flex;
opacity: 0;
transition: opacity 0.125s linear;
background: var(--theme-bg-popup-mask);
}
.popup:target {
opacity: 1;
user-select: unset;
pointer-events: unset;
}
.popup .popup-content {
margin: auto;
position: relative;
background: var(--theme-bg-main);
border: 0.125rem solid var(--theme-border-input);
border-radius: 1rem;
padding: 2rem;
}
.popup .popup-close {
position: absolute;
top: 1rem;
right: 2rem;
font-size: 0.8rem;
}

View File

@ -1,22 +1,9 @@
<!doctype html> <header>
<html>
<head>
<title>Dashboard</title>
<link rel="stylesheet" type="text/css" href="/themes.css">
<link rel="stylesheet" type="text/css" href="/typography.css">
</head>
<body>
<h1>Dashboard</h1> <h1>Dashboard</h1>
</header>
{{# user }} <main>
<p>Logged in as {{ user.name }} ({{ user.username }})</p> {{# rendered_widgets }}
<form action="/logout" method="POST"> {{{ . }}}
<button type="submit">Logout</button> {{/ rendered_widgets }}
</form> </main>
{{/ user }}
{{^ user }}
<a href="/login">Login Page</a>
{{/ user }}
</body>
</html>

64
templates/structure.css Normal file
View File

@ -0,0 +1,64 @@
* {
box-sizing: border-box;
}
html, body {
background: var(--theme-bg-main);
}
/* ===== Primary Controls ===== */
aside.controls {
position: absolute;
top: 1rem;
right: 1rem;
text-align: right;
}
aside.controls :is(a, p, button) {
display: inline;
font-size: 0.8rem;
margin-block: 0.5rem;
}
aside.controls form {
display: contents;
}
aside.controls button {
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-text-link);
text-decoration: underline;
}
aside.controls button:hover {
background: transparent;
}
/* ===== Error Box ===== */
aside.error {
margin-block: 4rem;
margin-inline: 1rem;
padding: 1rem;
border: 0.1rem solid var(--theme-border-error-box);
background: var(--theme-bg-error-box);
}
aside.error h2 {
font-size: 1.2rem;
margin-block-start: 0;
}
aside.error p {
margin-block: 0;
color: var(--theme-text-error-box);
}

View File

@ -3,76 +3,60 @@
color-scheme: light dark; color-scheme: light dark;
} }
{{! ===== Default Themes ===== }}
body { body {
{{> default_light }} {{> default_light }}
} }
body[data-color-scheme='dark'] { body[data-color-theme='dark'] {
{{> default_dark }} {{> default_dark }}
} }
{{# less_contrast }}
body[data-color-contrast='less'] {
{{> less_contrast_light }}
}
body[data-color-theme='dark'][data-color-contrast='less'] {
{{> less_contrast_dark }}
}
{{/ less_contrast }}
{{# more_contrast }}
body[data-color-contrast='more'] {
{{> more_contrast_light }}
}
body[data-color-theme='dark'][data-color-contrast='more'] {
{{> more_contrast_dark }}
}
{{/ more_contrast }}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
{{> default_dark }} {{> default_dark }}
} }
body[data-color-scheme='light'] { body[data-color-theme='light'] {
{{> default_light }} {{> default_light }}
} }
}
{{# less_contrast }}
body[data-color-contrast='less'] {
{{! ===== High Contrast Themes ===== }}
{{# more_contrast }}
@media (prefers-contrast: more) {
body {
{{> more_contrast_light }}
}
body[data-color-scheme='dark'] {
{{> more_contrast_dark }}
}
@media (prefers-color-scheme: dark) {
body {
{{> more_contrast_dark }}
}
body[data-color-scheme='light'] {
{{> more_contrast_light }}
}
}
}
{{/ more_contrast }}
{{! ===== Low Contrast Themes ===== }}
{{# less_contrast }}
@media (prefers-contrast: less) {
body {
{{> less_contrast_light }}
}
body[data-color-scheme='dark'] {
{{> less_contrast_dark }} {{> less_contrast_dark }}
} }
@media (prefers-color-scheme: dark) { body[data-color-theme='light'][data-color-contrast='less'] {
body { {{> less_contrast_light }}
{{> less_contrast_dark }}
}
body[data-color-scheme='light'] {
{{> less_contrast_light }}
}
} }
{{/ less_contrast }}
{{# more_contrast }}
body[data-color-contrast='more'] {
{{> more_contrast_dark }}
}
body[data-color-theme='light'][data-color-contrast='more'] {
{{> more_contrast_light }}
}
{{/ more_contrast }}
} }
{{/ less_contrast }}

View File

@ -1,17 +1,36 @@
:root { :root {
--font-heading: 'Open Sans', sans-serif; --font-heading: Verdana, sans-serif;
--font-body: 'Open Sans', sans-serif; --font-body: Verdana, sans-serif;
--font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; --font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 16px;
font-family: var(--font-body); font-family: var(--font-body);
} }
@media screen and (max-width: 800px) {
:root {
font-size: 14px;
}
}
@media screen and (min-width: 2000px) {
:root {
font-size: 18px;
}
}
/* ===== Font Families ===== */
h1, h2, h3, h4, h5, h6, h1, h2, h3, h4, h5, h6,
th, dt { th, dt {
font-family: var(--font-heading); font-family: var(--font-heading);
} }
p, td, dd, figcaption, li, blockquote { p, td, dd, figcaption, li, blockquote,
input, textarea, select, option, optgroup, legend, fieldset, label, button {
font-family: var(--font-body); font-family: var(--font-body);
} }
@ -24,3 +43,110 @@ b, i, u, q,
strong, em, mark, cite { strong, em, mark, cite {
font-family: inherit; font-family: inherit;
} }
/* ===== Font Sizes ===== */
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 1.8rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.2rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.8rem;
}
th, td, dt, dd,
p, figcaption, li, blockquote,
pre,
input, textarea, select, option, optgroup, legend, fieldset, label, button {
font-size: 1rem;
}
a, span,
b, i, u, q,
strong, em, mark, cite,
code, samp {
font-size: inherit;
}
/* ===== Colors ===== */
::selection {
color: var(--theme-text-selection);
background: var(--theme-bg-text-selection);
}
h1, h2, h3, h4, h5, h6,
th, dt {
color: var(--theme-text-heading)
}
p, td, dd, figcaption, li, blockquote,
input, textarea, select, option, optgroup, legend, fieldset, label {
color: var(--theme-text-body)
}
button, input:is([type='button'], [type='submit'], [type='reset']) {
color: var(--theme-text-button-primary);
background: var(--theme-bg-button-primary);
}
:is(button, input:is([type='button'], [type='submit'], [type='reset'])):hover {
background: var(--theme-bg-button-primary-hover);
}
:is(button, input:is([type='button'], [type='submit'], [type='reset'])).secondary {
color: var(--theme-text-button-secondary);
background: var(--theme-bg-button-secondary);
}
:is(button, input:is([type='button'], [type='submit'], [type='reset'])).secondary:hover {
background: var(--theme-bg-button-secondary-hover);
}
pre, code, samp {
color: var(--theme-code-normal);
}
a {
color: var(--theme-text-link);
}
a:active {
color: var(--theme-text-link-active);
}
a:visited {
color: var(--theme-text-link-visited);
}
mark {
color: var(--theme-text-highlight);
background: var(--theme-bg-text-highlight);
}
span,
b, i, u, q,
strong, em, cite {
color: inherit;
}

View File

@ -0,0 +1,78 @@
<section data-widget="weather-gov-forecast" title="Weather Forecast for {{ location.name }}">
<link rel="stylesheet" href="/weather.gov/styles.css">
<div class="flex-row">
<div class="today">
{{# forecast_today }}
<div class="{{# isDaytime }}day{{/ isDaytime }}{{^ isDaytime }}night{{/ isDaytime }}">
<h2>{{ name }}</h2>
<p class="condition">{{ shortForecast }}</p>
<p class="temp">
{{{ icons.thermometer }}}
<span>{{ temperature }}<sup>{{ temperatureUnit }}</sup></span>
</p>
<p class="wind">
{{{ icons.wind }}}
<span>{{ windSpeed }} <!-- / {{ windDirection }} --></span>
</p>
</div>
{{/ forecast_today }}
</div>
<table class="future">
<tr class="day">
{{# forecast_days }}
<td>
{{# . }}
<h3>{{ name }}</h3>
<p class="condition">{{ shortForecast }}</p>
<p class="temp">
{{{ icons.thermometer }}}
<span>{{ temperature }}<sup>{{ temperatureUnit }}</sup></span>
</p>
<p class="wind">
{{{ icons.wind }}}
<span>{{ windSpeed }} <!-- / {{ windDirection }} --></span>
</p>
{{/ . }}
</td>
{{/ forecast_days }}
</tr>
<tr class="night">
{{# forecast_nights }}
<td>
{{# . }}
<h3>{{ name }}</h3>
<p class="condition">{{ shortForecast }}</p>
<p class="temp">
{{{ icons.thermometer }}}
<span>{{ temperature }}<sup>{{ temperatureUnit }}</sup></span>
</p>
<p class="wind">
{{{ icons.wind }}}
<span>{{ windSpeed }} <!-- / {{ windDirection }} --></span>
</p>
{{/ . }}
</td>
{{/ forecast_nights }}
</tr>
</table>
</div>
{{# alerts.length }}
<ul class="alerts" title="Alerts">
{{# alerts }}
<li>
<h2>{{ event }}</h2>
<p class="headline">{{ headline }}</p>
<p class="description">{{ description }}</p>
<p class="instruction">{{ instruction }}</p>
</li>
{{/ alerts }}
</ul>
{{/ alerts.length }}
<p class="powered-by">
Powered by <a href="https://www.weather.gov/documentation/services-web-api" rel="external nofollow noreferrer">weather.gov</a>
</p>
</section>

View File

@ -0,0 +1,83 @@
[data-widget='weather-gov-forecast'] {
padding: 1rem;
max-width: 70rem;
}
[data-widget='weather-gov-forecast'] .alerts {
margin-block: 1rem;
padding-inline: 1rem;
border: 0.1rem solid var(--theme-border-error-box);
background: var(--theme-bg-error-box);
list-style: none;
}
[data-widget='weather-gov-forecast'] .alerts li {
margin-block: 1rem;
}
[data-widget='weather-gov-forecast'] .alerts p {
color: var(--theme-text-error-box);
}
[data-widget='weather-gov-forecast'] .alerts p.headline {
font-weight: 700;
}
[data-widget='weather-gov-forecast'] .flex-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
[data-widget='weather-gov-forecast'] .today {
display: inline-flex;
flex-direction: column;
row-gap: 2rem;
padding: 1rem;
margin-block-end: 1rem;
background: var(--theme-bg-light);
}
[data-widget='weather-gov-forecast'] svg.icon {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
}
[data-widget='weather-gov-forecast'] h2 {
font-size: 1.2rem;
margin-block: 0;
}
[data-widget='weather-gov-forecast'] .today p {
font-size: 1rem;
}
[data-widget='weather-gov-forecast'] .today svg.icon {
width: 1.2rem;
height: 1.2rem;
}
[data-widget='weather-gov-forecast'] h3 {
font-size: 0.8rem;
margin-block-end: 0;
}
[data-widget='weather-gov-forecast'] p {
font-size: 0.8rem;
margin-block: 0.25rem;
}
[data-widget='weather-gov-forecast'] p:is(.wind, .temp) {
display: flex;
column-gap: 0.5rem;
}
[data-widget='weather-gov-forecast'] table {
margin-block-end: 1rem;
}
[data-widget='weather-gov-forecast'] td {
padding-inline: 1rem;
}

View File

@ -0,0 +1,21 @@
<section data-widget="weatherapi-com-current" title="Current Weather for {{ location.name }}">
<link rel="stylesheet" href="/weatherapi.com/styles.css">
<p class="condition">
<span>{{ weather.current.condition.text }}</span>
</p>
<p class="temp">
{{{ icons.thermometer }}}
<span>{{ weather.current.temp_f }}<sup>F</sup> / {{ weather.current.temp_c }}<sup>C</sup></span>
</p>
<p class="wind">
{{{ icons.wind }}}
<span>{{ weather.current.wind_mph }} mph / {{ weather.current.wind_kph }} km/h / {{ weather.current.wind_dir }}</span>
</p>
<p class="powered-by">
Powered by <a href="https://www.weatherapi.com/docs/" rel="external nofollow noreferrer">WeatherAPI.com</a>
</p>
</section>

View File

@ -0,0 +1,32 @@
[data-widget='weatherapi-com-current'] {
width: 20rem;
padding: 1rem;
}
[data-widget='weatherapi-com-current'] svg.icon {
width: 1.2rem;
height: 1.2rem;
flex: 0 0 1.2rem;
}
[data-widget='weatherapi-com-current'] h2 {
font-size: 1.2rem;
margin-block: 0;
}
[data-widget='weatherapi-com-current'] p:is(.condition, .wind, .temp) {
display: flex;
column-gap: 0.5rem;
font-size: 1rem;
margin-block: 0.25rem;
}
[data-widget='weatherapi-com-current'] p.condition {
margin-block-end: 1rem;
}
[data-widget='weatherapi-com-current'] p.powered-by {
font-size: 0.8rem;
margin-block-start: 1rem;
}

289
vendor/feather-icons/icons.json vendored Normal file
View File

@ -0,0 +1,289 @@
{
"activity": "<polyline points=\"22 12 18 12 15 21 9 3 6 12 2 12\"></polyline>",
"airplay": "<path d=\"M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1\"></path><polygon points=\"12 15 17 21 7 21 12 15\"></polygon>",
"alert-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line>",
"alert-octagon": "<polygon points=\"7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2\"></polygon><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line>",
"alert-triangle": "<path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"></path><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"></line><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line>",
"align-center": "<line x1=\"18\" y1=\"10\" x2=\"6\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"18\" y1=\"18\" x2=\"6\" y2=\"18\"></line>",
"align-justify": "<line x1=\"21\" y1=\"10\" x2=\"3\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"21\" y1=\"18\" x2=\"3\" y2=\"18\"></line>",
"align-left": "<line x1=\"17\" y1=\"10\" x2=\"3\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"17\" y1=\"18\" x2=\"3\" y2=\"18\"></line>",
"align-right": "<line x1=\"21\" y1=\"10\" x2=\"7\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"21\" y1=\"18\" x2=\"7\" y2=\"18\"></line>",
"anchor": "<circle cx=\"12\" cy=\"5\" r=\"3\"></circle><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"8\"></line><path d=\"M5 12H2a10 10 0 0 0 20 0h-3\"></path>",
"aperture": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"14.31\" y1=\"8\" x2=\"20.05\" y2=\"17.94\"></line><line x1=\"9.69\" y1=\"8\" x2=\"21.17\" y2=\"8\"></line><line x1=\"7.38\" y1=\"12\" x2=\"13.12\" y2=\"2.06\"></line><line x1=\"9.69\" y1=\"16\" x2=\"3.95\" y2=\"6.06\"></line><line x1=\"14.31\" y1=\"16\" x2=\"2.83\" y2=\"16\"></line><line x1=\"16.62\" y1=\"12\" x2=\"10.88\" y2=\"21.94\"></line>",
"archive": "<polyline points=\"21 8 21 21 3 21 3 8\"></polyline><rect x=\"1\" y=\"3\" width=\"22\" height=\"5\"></rect><line x1=\"10\" y1=\"12\" x2=\"14\" y2=\"12\"></line>",
"arrow-down-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"8 12 12 16 16 12\"></polyline><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"16\"></line>",
"arrow-down-left": "<line x1=\"17\" y1=\"7\" x2=\"7\" y2=\"17\"></line><polyline points=\"17 17 7 17 7 7\"></polyline>",
"arrow-down-right": "<line x1=\"7\" y1=\"7\" x2=\"17\" y2=\"17\"></line><polyline points=\"17 7 17 17 7 17\"></polyline>",
"arrow-down": "<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"></line><polyline points=\"19 12 12 19 5 12\"></polyline>",
"arrow-left-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 8 8 12 12 16\"></polyline><line x1=\"16\" y1=\"12\" x2=\"8\" y2=\"12\"></line>",
"arrow-left": "<line x1=\"19\" y1=\"12\" x2=\"5\" y2=\"12\"></line><polyline points=\"12 19 5 12 12 5\"></polyline>",
"arrow-right-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 16 16 12 12 8\"></polyline><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
"arrow-right": "<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line><polyline points=\"12 5 19 12 12 19\"></polyline>",
"arrow-up-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"16 12 12 8 8 12\"></polyline><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"8\"></line>",
"arrow-up-left": "<line x1=\"17\" y1=\"17\" x2=\"7\" y2=\"7\"></line><polyline points=\"7 17 7 7 17 7\"></polyline>",
"arrow-up-right": "<line x1=\"7\" y1=\"17\" x2=\"17\" y2=\"7\"></line><polyline points=\"7 7 17 7 17 17\"></polyline>",
"arrow-up": "<line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"></line><polyline points=\"5 12 12 5 19 12\"></polyline>",
"at-sign": "<circle cx=\"12\" cy=\"12\" r=\"4\"></circle><path d=\"M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94\"></path>",
"award": "<circle cx=\"12\" cy=\"8\" r=\"7\"></circle><polyline points=\"8.21 13.89 7 23 12 20 17 23 15.79 13.88\"></polyline>",
"bar-chart-2": "<line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"></line><line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"></line><line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"></line>",
"bar-chart": "<line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"10\"></line><line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"4\"></line><line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"16\"></line>",
"battery-charging": "<path d=\"M5 18H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3.19M15 6h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3.19\"></path><line x1=\"23\" y1=\"13\" x2=\"23\" y2=\"11\"></line><polyline points=\"11 6 7 12 13 12 9 18\"></polyline>",
"battery": "<rect x=\"1\" y=\"6\" width=\"18\" height=\"12\" rx=\"2\" ry=\"2\"></rect><line x1=\"23\" y1=\"13\" x2=\"23\" y2=\"11\"></line>",
"bell-off": "<path d=\"M13.73 21a2 2 0 0 1-3.46 0\"></path><path d=\"M18.63 13A17.89 17.89 0 0 1 18 8\"></path><path d=\"M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14\"></path><path d=\"M18 8a6 6 0 0 0-9.33-5\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
"bell": "<path d=\"M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9\"></path><path d=\"M13.73 21a2 2 0 0 1-3.46 0\"></path>",
"bluetooth": "<polyline points=\"6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5\"></polyline>",
"bold": "<path d=\"M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z\"></path><path d=\"M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z\"></path>",
"book-open": "<path d=\"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z\"></path><path d=\"M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z\"></path>",
"book": "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path><path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>",
"bookmark": "<path d=\"M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path>",
"box": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>",
"briefcase": "<rect x=\"2\" y=\"7\" width=\"20\" height=\"14\" rx=\"2\" ry=\"2\"></rect><path d=\"M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16\"></path>",
"calendar": "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"16\" y1=\"2\" x2=\"16\" y2=\"6\"></line><line x1=\"8\" y1=\"2\" x2=\"8\" y2=\"6\"></line><line x1=\"3\" y1=\"10\" x2=\"21\" y2=\"10\"></line>",
"camera-off": "<line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line><path d=\"M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56\"></path>",
"camera": "<path d=\"M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z\"></path><circle cx=\"12\" cy=\"13\" r=\"4\"></circle>",
"cast": "<path d=\"M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6\"></path><line x1=\"2\" y1=\"20\" x2=\"2.01\" y2=\"20\"></line>",
"check-circle": "<path d=\"M22 11.08V12a10 10 0 1 1-5.93-9.14\"></path><polyline points=\"22 4 12 14.01 9 11.01\"></polyline>",
"check-square": "<polyline points=\"9 11 12 14 22 4\"></polyline><path d=\"M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11\"></path>",
"check": "<polyline points=\"20 6 9 17 4 12\"></polyline>",
"chevron-down": "<polyline points=\"6 9 12 15 18 9\"></polyline>",
"chevron-left": "<polyline points=\"15 18 9 12 15 6\"></polyline>",
"chevron-right": "<polyline points=\"9 18 15 12 9 6\"></polyline>",
"chevron-up": "<polyline points=\"18 15 12 9 6 15\"></polyline>",
"chevrons-down": "<polyline points=\"7 13 12 18 17 13\"></polyline><polyline points=\"7 6 12 11 17 6\"></polyline>",
"chevrons-left": "<polyline points=\"11 17 6 12 11 7\"></polyline><polyline points=\"18 17 13 12 18 7\"></polyline>",
"chevrons-right": "<polyline points=\"13 17 18 12 13 7\"></polyline><polyline points=\"6 17 11 12 6 7\"></polyline>",
"chevrons-up": "<polyline points=\"17 11 12 6 7 11\"></polyline><polyline points=\"17 18 12 13 7 18\"></polyline>",
"chrome": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"4\"></circle><line x1=\"21.17\" y1=\"8\" x2=\"12\" y2=\"8\"></line><line x1=\"3.95\" y1=\"6.06\" x2=\"8.54\" y2=\"14\"></line><line x1=\"10.88\" y1=\"21.94\" x2=\"15.46\" y2=\"14\"></line>",
"circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle>",
"clipboard": "<path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"></path><rect x=\"8\" y=\"2\" width=\"8\" height=\"4\" rx=\"1\" ry=\"1\"></rect>",
"clock": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 6 12 12 16 14\"></polyline>",
"cloud-drizzle": "<line x1=\"8\" y1=\"19\" x2=\"8\" y2=\"21\"></line><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"15\"></line><line x1=\"16\" y1=\"19\" x2=\"16\" y2=\"21\"></line><line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"15\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"></line><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"17\"></line><path d=\"M20 16.58A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25\"></path>",
"cloud-lightning": "<path d=\"M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9\"></path><polyline points=\"13 11 9 17 15 17 11 23\"></polyline>",
"cloud-off": "<path d=\"M22.61 16.95A5 5 0 0 0 18 10h-1.26a8 8 0 0 0-7.05-6M5 5a8 8 0 0 0 4 15h9a5 5 0 0 0 1.7-.3\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
"cloud-rain": "<line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"21\"></line><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"21\"></line><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"23\"></line><path d=\"M20 16.58A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25\"></path>",
"cloud-snow": "<path d=\"M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25\"></path><line x1=\"8\" y1=\"16\" x2=\"8.01\" y2=\"16\"></line><line x1=\"8\" y1=\"20\" x2=\"8.01\" y2=\"20\"></line><line x1=\"12\" y1=\"18\" x2=\"12.01\" y2=\"18\"></line><line x1=\"12\" y1=\"22\" x2=\"12.01\" y2=\"22\"></line><line x1=\"16\" y1=\"16\" x2=\"16.01\" y2=\"16\"></line><line x1=\"16\" y1=\"20\" x2=\"16.01\" y2=\"20\"></line>",
"cloud": "<path d=\"M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z\"></path>",
"code": "<polyline points=\"16 18 22 12 16 6\"></polyline><polyline points=\"8 6 2 12 8 18\"></polyline>",
"codepen": "<polygon points=\"12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5 12 2\"></polygon><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"15.5\"></line><polyline points=\"22 8.5 12 15.5 2 8.5\"></polyline><polyline points=\"2 15.5 12 8.5 22 15.5\"></polyline><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"8.5\"></line>",
"codesandbox": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"7.5 4.21 12 6.81 16.5 4.21\"></polyline><polyline points=\"7.5 19.79 7.5 14.6 3 12\"></polyline><polyline points=\"21 12 16.5 14.6 16.5 19.79\"></polyline><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>",
"coffee": "<path d=\"M18 8h1a4 4 0 0 1 0 8h-1\"></path><path d=\"M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z\"></path><line x1=\"6\" y1=\"1\" x2=\"6\" y2=\"4\"></line><line x1=\"10\" y1=\"1\" x2=\"10\" y2=\"4\"></line><line x1=\"14\" y1=\"1\" x2=\"14\" y2=\"4\"></line>",
"columns": "<path d=\"M12 3h7a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-7m0-18H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7m0-18v18\"></path>",
"command": "<path d=\"M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z\"></path>",
"compass": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polygon points=\"16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76\"></polygon>",
"copy": "<rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path>",
"corner-down-left": "<polyline points=\"9 10 4 15 9 20\"></polyline><path d=\"M20 4v7a4 4 0 0 1-4 4H4\"></path>",
"corner-down-right": "<polyline points=\"15 10 20 15 15 20\"></polyline><path d=\"M4 4v7a4 4 0 0 0 4 4h12\"></path>",
"corner-left-down": "<polyline points=\"14 15 9 20 4 15\"></polyline><path d=\"M20 4h-7a4 4 0 0 0-4 4v12\"></path>",
"corner-left-up": "<polyline points=\"14 9 9 4 4 9\"></polyline><path d=\"M20 20h-7a4 4 0 0 1-4-4V4\"></path>",
"corner-right-down": "<polyline points=\"10 15 15 20 20 15\"></polyline><path d=\"M4 4h7a4 4 0 0 1 4 4v12\"></path>",
"corner-right-up": "<polyline points=\"10 9 15 4 20 9\"></polyline><path d=\"M4 20h7a4 4 0 0 0 4-4V4\"></path>",
"corner-up-left": "<polyline points=\"9 14 4 9 9 4\"></polyline><path d=\"M20 20v-7a4 4 0 0 0-4-4H4\"></path>",
"corner-up-right": "<polyline points=\"15 14 20 9 15 4\"></polyline><path d=\"M4 20v-7a4 4 0 0 1 4-4h12\"></path>",
"cpu": "<rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\" ry=\"2\"></rect><rect x=\"9\" y=\"9\" width=\"6\" height=\"6\"></rect><line x1=\"9\" y1=\"1\" x2=\"9\" y2=\"4\"></line><line x1=\"15\" y1=\"1\" x2=\"15\" y2=\"4\"></line><line x1=\"9\" y1=\"20\" x2=\"9\" y2=\"23\"></line><line x1=\"15\" y1=\"20\" x2=\"15\" y2=\"23\"></line><line x1=\"20\" y1=\"9\" x2=\"23\" y2=\"9\"></line><line x1=\"20\" y1=\"14\" x2=\"23\" y2=\"14\"></line><line x1=\"1\" y1=\"9\" x2=\"4\" y2=\"9\"></line><line x1=\"1\" y1=\"14\" x2=\"4\" y2=\"14\"></line>",
"credit-card": "<rect x=\"1\" y=\"4\" width=\"22\" height=\"16\" rx=\"2\" ry=\"2\"></rect><line x1=\"1\" y1=\"10\" x2=\"23\" y2=\"10\"></line>",
"crop": "<path d=\"M6.13 1L6 16a2 2 0 0 0 2 2h15\"></path><path d=\"M1 6.13L16 6a2 2 0 0 1 2 2v15\"></path>",
"crosshair": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"22\" y1=\"12\" x2=\"18\" y2=\"12\"></line><line x1=\"6\" y1=\"12\" x2=\"2\" y2=\"12\"></line><line x1=\"12\" y1=\"6\" x2=\"12\" y2=\"2\"></line><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"18\"></line>",
"database": "<ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"></ellipse><path d=\"M21 12c0 1.66-4 3-9 3s-9-1.34-9-3\"></path><path d=\"M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5\"></path>",
"delete": "<path d=\"M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z\"></path><line x1=\"18\" y1=\"9\" x2=\"12\" y2=\"15\"></line><line x1=\"12\" y1=\"9\" x2=\"18\" y2=\"15\"></line>",
"disc": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"3\"></circle>",
"divide-circle": "<line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"16\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"8\"></line><circle cx=\"12\" cy=\"12\" r=\"10\"></circle>",
"divide-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"16\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"8\"></line>",
"divide": "<circle cx=\"12\" cy=\"6\" r=\"2\"></circle><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line><circle cx=\"12\" cy=\"18\" r=\"2\"></circle>",
"dollar-sign": "<line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"23\"></line><path d=\"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\"></path>",
"download-cloud": "<polyline points=\"8 17 12 21 16 17\"></polyline><line x1=\"12\" y1=\"12\" x2=\"12\" y2=\"21\"></line><path d=\"M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29\"></path>",
"download": "<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>",
"dribbble": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M8.56 2.75c4.37 6.03 6.02 9.42 8.03 17.72m2.54-15.38c-3.72 4.35-8.94 5.66-16.88 5.85m19.5 1.9c-3.5-.93-6.63-.82-8.94 0-2.58.92-5.01 2.86-7.44 6.32\"></path>",
"droplet": "<path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"></path>",
"edit-2": "<path d=\"M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z\"></path>",
"edit-3": "<path d=\"M12 20h9\"></path><path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\"></path>",
"edit": "<path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path><path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path>",
"external-link": "<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path><polyline points=\"15 3 21 3 21 9\"></polyline><line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>",
"eye-off": "<path d=\"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
"eye": "<path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path><circle cx=\"12\" cy=\"12\" r=\"3\"></circle>",
"facebook": "<path d=\"M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z\"></path>",
"fast-forward": "<polygon points=\"13 19 22 12 13 5 13 19\"></polygon><polygon points=\"2 19 11 12 2 5 2 19\"></polygon>",
"feather": "<path d=\"M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z\"></path><line x1=\"16\" y1=\"8\" x2=\"2\" y2=\"22\"></line><line x1=\"17.5\" y1=\"15\" x2=\"9\" y2=\"15\"></line>",
"figma": "<path d=\"M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z\"></path><path d=\"M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z\"></path><path d=\"M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z\"></path><path d=\"M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z\"></path><path d=\"M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z\"></path>",
"file-minus": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path><polyline points=\"14 2 14 8 20 8\"></polyline><line x1=\"9\" y1=\"15\" x2=\"15\" y2=\"15\"></line>",
"file-plus": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path><polyline points=\"14 2 14 8 20 8\"></polyline><line x1=\"12\" y1=\"18\" x2=\"12\" y2=\"12\"></line><line x1=\"9\" y1=\"15\" x2=\"15\" y2=\"15\"></line>",
"file-text": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path><polyline points=\"14 2 14 8 20 8\"></polyline><line x1=\"16\" y1=\"13\" x2=\"8\" y2=\"13\"></line><line x1=\"16\" y1=\"17\" x2=\"8\" y2=\"17\"></line><polyline points=\"10 9 9 9 8 9\"></polyline>",
"file": "<path d=\"M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z\"></path><polyline points=\"13 2 13 9 20 9\"></polyline>",
"film": "<rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"2.18\" ry=\"2.18\"></rect><line x1=\"7\" y1=\"2\" x2=\"7\" y2=\"22\"></line><line x1=\"17\" y1=\"2\" x2=\"17\" y2=\"22\"></line><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"2\" y1=\"7\" x2=\"7\" y2=\"7\"></line><line x1=\"2\" y1=\"17\" x2=\"7\" y2=\"17\"></line><line x1=\"17\" y1=\"17\" x2=\"22\" y2=\"17\"></line><line x1=\"17\" y1=\"7\" x2=\"22\" y2=\"7\"></line>",
"filter": "<polygon points=\"22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3\"></polygon>",
"flag": "<path d=\"M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z\"></path><line x1=\"4\" y1=\"22\" x2=\"4\" y2=\"15\"></line>",
"folder-minus": "<path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path><line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\"></line>",
"folder-plus": "<path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path><line x1=\"12\" y1=\"11\" x2=\"12\" y2=\"17\"></line><line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\"></line>",
"folder": "<path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path>",
"framer": "<path d=\"M5 16V9h14V2H5l14 14h-7m-7 0l7 7v-7m-7 0h7\"></path>",
"frown": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M16 16s-1.5-2-4-2-4 2-4 2\"></path><line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line><line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>",
"gift": "<polyline points=\"20 12 20 22 4 22 4 12\"></polyline><rect x=\"2\" y=\"7\" width=\"20\" height=\"5\"></rect><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"7\"></line><path d=\"M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z\"></path><path d=\"M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z\"></path>",
"git-branch": "<line x1=\"6\" y1=\"3\" x2=\"6\" y2=\"15\"></line><circle cx=\"18\" cy=\"6\" r=\"3\"></circle><circle cx=\"6\" cy=\"18\" r=\"3\"></circle><path d=\"M18 9a9 9 0 0 1-9 9\"></path>",
"git-commit": "<circle cx=\"12\" cy=\"12\" r=\"4\"></circle><line x1=\"1.05\" y1=\"12\" x2=\"7\" y2=\"12\"></line><line x1=\"17.01\" y1=\"12\" x2=\"22.96\" y2=\"12\"></line>",
"git-merge": "<circle cx=\"18\" cy=\"18\" r=\"3\"></circle><circle cx=\"6\" cy=\"6\" r=\"3\"></circle><path d=\"M6 21V9a9 9 0 0 0 9 9\"></path>",
"git-pull-request": "<circle cx=\"18\" cy=\"18\" r=\"3\"></circle><circle cx=\"6\" cy=\"6\" r=\"3\"></circle><path d=\"M13 6h3a2 2 0 0 1 2 2v7\"></path><line x1=\"6\" y1=\"9\" x2=\"6\" y2=\"21\"></line>",
"github": "<path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\"></path>",
"gitlab": "<path d=\"M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z\"></path>",
"globe": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>",
"grid": "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"></rect><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"></rect><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"></rect><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"></rect>",
"hard-drive": "<line x1=\"22\" y1=\"12\" x2=\"2\" y2=\"12\"></line><path d=\"M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\"></path><line x1=\"6\" y1=\"16\" x2=\"6.01\" y2=\"16\"></line><line x1=\"10\" y1=\"16\" x2=\"10.01\" y2=\"16\"></line>",
"hash": "<line x1=\"4\" y1=\"9\" x2=\"20\" y2=\"9\"></line><line x1=\"4\" y1=\"15\" x2=\"20\" y2=\"15\"></line><line x1=\"10\" y1=\"3\" x2=\"8\" y2=\"21\"></line><line x1=\"16\" y1=\"3\" x2=\"14\" y2=\"21\"></line>",
"headphones": "<path d=\"M3 18v-6a9 9 0 0 1 18 0v6\"></path><path d=\"M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z\"></path>",
"heart": "<path d=\"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z\"></path>",
"help-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\"></path><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line>",
"hexagon": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path>",
"home": "<path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path><polyline points=\"9 22 9 12 15 12 15 22\"></polyline>",
"image": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"></circle><polyline points=\"21 15 16 10 5 21\"></polyline>",
"inbox": "<polyline points=\"22 12 16 12 14 15 10 15 8 12 2 12\"></polyline><path d=\"M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\"></path>",
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12.01\" y2=\"8\"></line>",
"instagram": "<rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"5\" ry=\"5\"></rect><path d=\"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z\"></path><line x1=\"17.5\" y1=\"6.5\" x2=\"17.51\" y2=\"6.5\"></line>",
"italic": "<line x1=\"19\" y1=\"4\" x2=\"10\" y2=\"4\"></line><line x1=\"14\" y1=\"20\" x2=\"5\" y2=\"20\"></line><line x1=\"15\" y1=\"4\" x2=\"9\" y2=\"20\"></line>",
"key": "<path d=\"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4\"></path>",
"layers": "<polygon points=\"12 2 2 7 12 12 22 7 12 2\"></polygon><polyline points=\"2 17 12 22 22 17\"></polyline><polyline points=\"2 12 12 17 22 12\"></polyline>",
"layout": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"3\" y1=\"9\" x2=\"21\" y2=\"9\"></line><line x1=\"9\" y1=\"21\" x2=\"9\" y2=\"9\"></line>",
"life-buoy": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"4\"></circle><line x1=\"4.93\" y1=\"4.93\" x2=\"9.17\" y2=\"9.17\"></line><line x1=\"14.83\" y1=\"14.83\" x2=\"19.07\" y2=\"19.07\"></line><line x1=\"14.83\" y1=\"9.17\" x2=\"19.07\" y2=\"4.93\"></line><line x1=\"14.83\" y1=\"9.17\" x2=\"18.36\" y2=\"5.64\"></line><line x1=\"4.93\" y1=\"19.07\" x2=\"9.17\" y2=\"14.83\"></line>",
"link-2": "<path d=\"M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3\"></path><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
"link": "<path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path>",
"linkedin": "<path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\"></path><rect x=\"2\" y=\"9\" width=\"4\" height=\"12\"></rect><circle cx=\"4\" cy=\"4\" r=\"2\"></circle>",
"list": "<line x1=\"8\" y1=\"6\" x2=\"21\" y2=\"6\"></line><line x1=\"8\" y1=\"12\" x2=\"21\" y2=\"12\"></line><line x1=\"8\" y1=\"18\" x2=\"21\" y2=\"18\"></line><line x1=\"3\" y1=\"6\" x2=\"3.01\" y2=\"6\"></line><line x1=\"3\" y1=\"12\" x2=\"3.01\" y2=\"12\"></line><line x1=\"3\" y1=\"18\" x2=\"3.01\" y2=\"18\"></line>",
"loader": "<line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"6\"></line><line x1=\"12\" y1=\"18\" x2=\"12\" y2=\"22\"></line><line x1=\"4.93\" y1=\"4.93\" x2=\"7.76\" y2=\"7.76\"></line><line x1=\"16.24\" y1=\"16.24\" x2=\"19.07\" y2=\"19.07\"></line><line x1=\"2\" y1=\"12\" x2=\"6\" y2=\"12\"></line><line x1=\"18\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"4.93\" y1=\"19.07\" x2=\"7.76\" y2=\"16.24\"></line><line x1=\"16.24\" y1=\"7.76\" x2=\"19.07\" y2=\"4.93\"></line>",
"lock": "<rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"></path>",
"log-in": "<path d=\"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4\"></path><polyline points=\"10 17 15 12 10 7\"></polyline><line x1=\"15\" y1=\"12\" x2=\"3\" y2=\"12\"></line>",
"log-out": "<path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path><polyline points=\"16 17 21 12 16 7\"></polyline><line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line>",
"mail": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"></path><polyline points=\"22,6 12,13 2,6\"></polyline>",
"map-pin": "<path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"></path><circle cx=\"12\" cy=\"10\" r=\"3\"></circle>",
"map": "<polygon points=\"1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6\"></polygon><line x1=\"8\" y1=\"2\" x2=\"8\" y2=\"18\"></line><line x1=\"16\" y1=\"6\" x2=\"16\" y2=\"22\"></line>",
"maximize-2": "<polyline points=\"15 3 21 3 21 9\"></polyline><polyline points=\"9 21 3 21 3 15\"></polyline><line x1=\"21\" y1=\"3\" x2=\"14\" y2=\"10\"></line><line x1=\"3\" y1=\"21\" x2=\"10\" y2=\"14\"></line>",
"maximize": "<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path>",
"meh": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"8\" y1=\"15\" x2=\"16\" y2=\"15\"></line><line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line><line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>",
"menu": "<line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"></line><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"></line><line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"></line>",
"message-circle": "<path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path>",
"message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"></path>",
"mic-off": "<line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line><path d=\"M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6\"></path><path d=\"M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23\"></path><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"23\"></line><line x1=\"8\" y1=\"23\" x2=\"16\" y2=\"23\"></line>",
"mic": "<path d=\"M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z\"></path><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"></path><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"23\"></line><line x1=\"8\" y1=\"23\" x2=\"16\" y2=\"23\"></line>",
"minimize-2": "<polyline points=\"4 14 10 14 10 20\"></polyline><polyline points=\"20 10 14 10 14 4\"></polyline><line x1=\"14\" y1=\"10\" x2=\"21\" y2=\"3\"></line><line x1=\"3\" y1=\"21\" x2=\"10\" y2=\"14\"></line>",
"minimize": "<path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path>",
"minus-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
"minus-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
"minus": "<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>",
"monitor": "<rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"21\" x2=\"16\" y2=\"21\"></line><line x1=\"12\" y1=\"17\" x2=\"12\" y2=\"21\"></line>",
"moon": "<path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"></path>",
"more-horizontal": "<circle cx=\"12\" cy=\"12\" r=\"1\"></circle><circle cx=\"19\" cy=\"12\" r=\"1\"></circle><circle cx=\"5\" cy=\"12\" r=\"1\"></circle>",
"more-vertical": "<circle cx=\"12\" cy=\"12\" r=\"1\"></circle><circle cx=\"12\" cy=\"5\" r=\"1\"></circle><circle cx=\"12\" cy=\"19\" r=\"1\"></circle>",
"mouse-pointer": "<path d=\"M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z\"></path><path d=\"M13 13l6 6\"></path>",
"move": "<polyline points=\"5 9 2 12 5 15\"></polyline><polyline points=\"9 5 12 2 15 5\"></polyline><polyline points=\"15 19 12 22 9 19\"></polyline><polyline points=\"19 9 22 12 19 15\"></polyline><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"22\"></line>",
"music": "<path d=\"M9 18V5l12-2v13\"></path><circle cx=\"6\" cy=\"18\" r=\"3\"></circle><circle cx=\"18\" cy=\"16\" r=\"3\"></circle>",
"navigation-2": "<polygon points=\"12 2 19 21 12 17 5 21 12 2\"></polygon>",
"navigation": "<polygon points=\"3 11 22 2 13 21 11 13 3 11\"></polygon>",
"octagon": "<polygon points=\"7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2\"></polygon>",
"package": "<line x1=\"16.5\" y1=\"9.4\" x2=\"7.5\" y2=\"4.21\"></line><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>",
"paperclip": "<path d=\"M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48\"></path>",
"pause-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"10\" y1=\"15\" x2=\"10\" y2=\"9\"></line><line x1=\"14\" y1=\"15\" x2=\"14\" y2=\"9\"></line>",
"pause": "<rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"></rect><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"></rect>",
"pen-tool": "<path d=\"M12 19l7-7 3 3-7 7-3-3z\"></path><path d=\"M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z\"></path><path d=\"M2 2l7.586 7.586\"></path><circle cx=\"11\" cy=\"11\" r=\"2\"></circle>",
"percent": "<line x1=\"19\" y1=\"5\" x2=\"5\" y2=\"19\"></line><circle cx=\"6.5\" cy=\"6.5\" r=\"2.5\"></circle><circle cx=\"17.5\" cy=\"17.5\" r=\"2.5\"></circle>",
"phone-call": "<path d=\"M15.05 5A5 5 0 0 1 19 8.95M15.05 1A9 9 0 0 1 23 8.94m-1 7.98v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
"phone-forwarded": "<polyline points=\"19 1 23 5 19 9\"></polyline><line x1=\"15\" y1=\"5\" x2=\"23\" y2=\"5\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
"phone-incoming": "<polyline points=\"16 2 16 8 22 8\"></polyline><line x1=\"23\" y1=\"1\" x2=\"16\" y2=\"8\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
"phone-missed": "<line x1=\"23\" y1=\"1\" x2=\"17\" y2=\"7\"></line><line x1=\"17\" y1=\"1\" x2=\"23\" y2=\"7\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
"phone-off": "<path d=\"M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91\"></path><line x1=\"23\" y1=\"1\" x2=\"1\" y2=\"23\"></line>",
"phone-outgoing": "<polyline points=\"23 7 23 1 17 1\"></polyline><line x1=\"16\" y1=\"8\" x2=\"23\" y2=\"1\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
"phone": "<path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
"pie-chart": "<path d=\"M21.21 15.89A10 10 0 1 1 8 2.83\"></path><path d=\"M22 12A10 10 0 0 0 12 2v10z\"></path>",
"play-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polygon points=\"10 8 16 12 10 16 10 8\"></polygon>",
"play": "<polygon points=\"5 3 19 12 5 21 5 3\"></polygon>",
"plus-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"16\"></line><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
"plus-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"16\"></line><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
"plus": "<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"></line><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>",
"pocket": "<path d=\"M4 3h16a2 2 0 0 1 2 2v6a10 10 0 0 1-10 10A10 10 0 0 1 2 11V5a2 2 0 0 1 2-2z\"></path><polyline points=\"8 10 12 14 16 10\"></polyline>",
"power": "<path d=\"M18.36 6.64a9 9 0 1 1-12.73 0\"></path><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"12\"></line>",
"printer": "<polyline points=\"6 9 6 2 18 2 18 9\"></polyline><path d=\"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2\"></path><rect x=\"6\" y=\"14\" width=\"12\" height=\"8\"></rect>",
"radio": "<circle cx=\"12\" cy=\"12\" r=\"2\"></circle><path d=\"M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14\"></path>",
"refresh-ccw": "<polyline points=\"1 4 1 10 7 10\"></polyline><polyline points=\"23 20 23 14 17 14\"></polyline><path d=\"M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15\"></path>",
"refresh-cw": "<polyline points=\"23 4 23 10 17 10\"></polyline><polyline points=\"1 20 1 14 7 14\"></polyline><path d=\"M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15\"></path>",
"repeat": "<polyline points=\"17 1 21 5 17 9\"></polyline><path d=\"M3 11V9a4 4 0 0 1 4-4h14\"></path><polyline points=\"7 23 3 19 7 15\"></polyline><path d=\"M21 13v2a4 4 0 0 1-4 4H3\"></path>",
"rewind": "<polygon points=\"11 19 2 12 11 5 11 19\"></polygon><polygon points=\"22 19 13 12 22 5 22 19\"></polygon>",
"rotate-ccw": "<polyline points=\"1 4 1 10 7 10\"></polyline><path d=\"M3.51 15a9 9 0 1 0 2.13-9.36L1 10\"></path>",
"rotate-cw": "<polyline points=\"23 4 23 10 17 10\"></polyline><path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\"></path>",
"rss": "<path d=\"M4 11a9 9 0 0 1 9 9\"></path><path d=\"M4 4a16 16 0 0 1 16 16\"></path><circle cx=\"5\" cy=\"19\" r=\"1\"></circle>",
"save": "<path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\"></path><polyline points=\"17 21 17 13 7 13 7 21\"></polyline><polyline points=\"7 3 7 8 15 8\"></polyline>",
"scissors": "<circle cx=\"6\" cy=\"6\" r=\"3\"></circle><circle cx=\"6\" cy=\"18\" r=\"3\"></circle><line x1=\"20\" y1=\"4\" x2=\"8.12\" y2=\"15.88\"></line><line x1=\"14.47\" y1=\"14.48\" x2=\"20\" y2=\"20\"></line><line x1=\"8.12\" y1=\"8.12\" x2=\"12\" y2=\"12\"></line>",
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>",
"send": "<line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\"></line><polygon points=\"22 2 15 22 11 13 2 9 22 2\"></polygon>",
"server": "<rect x=\"2\" y=\"2\" width=\"20\" height=\"8\" rx=\"2\" ry=\"2\"></rect><rect x=\"2\" y=\"14\" width=\"20\" height=\"8\" rx=\"2\" ry=\"2\"></rect><line x1=\"6\" y1=\"6\" x2=\"6.01\" y2=\"6\"></line><line x1=\"6\" y1=\"18\" x2=\"6.01\" y2=\"18\"></line>",
"settings": "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle><path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>",
"share-2": "<circle cx=\"18\" cy=\"5\" r=\"3\"></circle><circle cx=\"6\" cy=\"12\" r=\"3\"></circle><circle cx=\"18\" cy=\"19\" r=\"3\"></circle><line x1=\"8.59\" y1=\"13.51\" x2=\"15.42\" y2=\"17.49\"></line><line x1=\"15.41\" y1=\"6.51\" x2=\"8.59\" y2=\"10.49\"></line>",
"share": "<path d=\"M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8\"></path><polyline points=\"16 6 12 2 8 6\"></polyline><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"15\"></line>",
"shield-off": "<path d=\"M19.69 14a6.9 6.9 0 0 0 .31-2V5l-8-3-3.16 1.18\"></path><path d=\"M4.73 4.73L4 5v7c0 6 8 10 8 10a20.29 20.29 0 0 0 5.62-4.38\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
"shield": "<path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"></path>",
"shopping-bag": "<path d=\"M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z\"></path><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"></line><path d=\"M16 10a4 4 0 0 1-8 0\"></path>",
"shopping-cart": "<circle cx=\"9\" cy=\"21\" r=\"1\"></circle><circle cx=\"20\" cy=\"21\" r=\"1\"></circle><path d=\"M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6\"></path>",
"shuffle": "<polyline points=\"16 3 21 3 21 8\"></polyline><line x1=\"4\" y1=\"20\" x2=\"21\" y2=\"3\"></line><polyline points=\"21 16 21 21 16 21\"></polyline><line x1=\"15\" y1=\"15\" x2=\"21\" y2=\"21\"></line><line x1=\"4\" y1=\"4\" x2=\"9\" y2=\"9\"></line>",
"sidebar": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"9\" y1=\"3\" x2=\"9\" y2=\"21\"></line>",
"skip-back": "<polygon points=\"19 20 9 12 19 4 19 20\"></polygon><line x1=\"5\" y1=\"19\" x2=\"5\" y2=\"5\"></line>",
"skip-forward": "<polygon points=\"5 4 15 12 5 20 5 4\"></polygon><line x1=\"19\" y1=\"5\" x2=\"19\" y2=\"19\"></line>",
"slack": "<path d=\"M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z\"></path><path d=\"M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z\"></path><path d=\"M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z\"></path><path d=\"M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z\"></path><path d=\"M14 14.5c0-.83.67-1.5 1.5-1.5h5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-5c-.83 0-1.5-.67-1.5-1.5z\"></path><path d=\"M15.5 19H14v1.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z\"></path><path d=\"M10 9.5C10 8.67 9.33 8 8.5 8h-5C2.67 8 2 8.67 2 9.5S2.67 11 3.5 11h5c.83 0 1.5-.67 1.5-1.5z\"></path><path d=\"M8.5 5H10V3.5C10 2.67 9.33 2 8.5 2S7 2.67 7 3.5 7.67 5 8.5 5z\"></path>",
"slash": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"4.93\" y1=\"4.93\" x2=\"19.07\" y2=\"19.07\"></line>",
"sliders": "<line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"14\"></line><line x1=\"4\" y1=\"10\" x2=\"4\" y2=\"3\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"3\"></line><line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"></line><line x1=\"20\" y1=\"12\" x2=\"20\" y2=\"3\"></line><line x1=\"1\" y1=\"14\" x2=\"7\" y2=\"14\"></line><line x1=\"9\" y1=\"8\" x2=\"15\" y2=\"8\"></line><line x1=\"17\" y1=\"16\" x2=\"23\" y2=\"16\"></line>",
"smartphone": "<rect x=\"5\" y=\"2\" width=\"14\" height=\"20\" rx=\"2\" ry=\"2\"></rect><line x1=\"12\" y1=\"18\" x2=\"12.01\" y2=\"18\"></line>",
"smile": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M8 14s1.5 2 4 2 4-2 4-2\"></path><line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line><line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>",
"speaker": "<rect x=\"4\" y=\"2\" width=\"16\" height=\"20\" rx=\"2\" ry=\"2\"></rect><circle cx=\"12\" cy=\"14\" r=\"4\"></circle><line x1=\"12\" y1=\"6\" x2=\"12.01\" y2=\"6\"></line>",
"square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect>",
"star": "<polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"></polygon>",
"stop-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><rect x=\"9\" y=\"9\" width=\"6\" height=\"6\"></rect>",
"sun": "<circle cx=\"12\" cy=\"12\" r=\"5\"></circle><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"></line><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"></line><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"></line><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"></line><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"></line><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"></line><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"></line>",
"sunrise": "<path d=\"M17 18a5 5 0 0 0-10 0\"></path><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"9\"></line><line x1=\"4.22\" y1=\"10.22\" x2=\"5.64\" y2=\"11.64\"></line><line x1=\"1\" y1=\"18\" x2=\"3\" y2=\"18\"></line><line x1=\"21\" y1=\"18\" x2=\"23\" y2=\"18\"></line><line x1=\"18.36\" y1=\"11.64\" x2=\"19.78\" y2=\"10.22\"></line><line x1=\"23\" y1=\"22\" x2=\"1\" y2=\"22\"></line><polyline points=\"8 6 12 2 16 6\"></polyline>",
"sunset": "<path d=\"M17 18a5 5 0 0 0-10 0\"></path><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"2\"></line><line x1=\"4.22\" y1=\"10.22\" x2=\"5.64\" y2=\"11.64\"></line><line x1=\"1\" y1=\"18\" x2=\"3\" y2=\"18\"></line><line x1=\"21\" y1=\"18\" x2=\"23\" y2=\"18\"></line><line x1=\"18.36\" y1=\"11.64\" x2=\"19.78\" y2=\"10.22\"></line><line x1=\"23\" y1=\"22\" x2=\"1\" y2=\"22\"></line><polyline points=\"16 5 12 9 8 5\"></polyline>",
"table": "<path d=\"M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18\"></path>",
"tablet": "<rect x=\"4\" y=\"2\" width=\"16\" height=\"20\" rx=\"2\" ry=\"2\"></rect><line x1=\"12\" y1=\"18\" x2=\"12.01\" y2=\"18\"></line>",
"tag": "<path d=\"M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z\"></path><line x1=\"7\" y1=\"7\" x2=\"7.01\" y2=\"7\"></line>",
"target": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"6\"></circle><circle cx=\"12\" cy=\"12\" r=\"2\"></circle>",
"terminal": "<polyline points=\"4 17 10 11 4 5\"></polyline><line x1=\"12\" y1=\"19\" x2=\"20\" y2=\"19\"></line>",
"thermometer": "<path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"></path>",
"thumbs-down": "<path d=\"M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17\"></path>",
"thumbs-up": "<path d=\"M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3\"></path>",
"toggle-left": "<rect x=\"1\" y=\"5\" width=\"22\" height=\"14\" rx=\"7\" ry=\"7\"></rect><circle cx=\"8\" cy=\"12\" r=\"3\"></circle>",
"toggle-right": "<rect x=\"1\" y=\"5\" width=\"22\" height=\"14\" rx=\"7\" ry=\"7\"></rect><circle cx=\"16\" cy=\"12\" r=\"3\"></circle>",
"tool": "<path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"></path>",
"trash-2": "<polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"></path><line x1=\"10\" y1=\"11\" x2=\"10\" y2=\"17\"></line><line x1=\"14\" y1=\"11\" x2=\"14\" y2=\"17\"></line>",
"trash": "<polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"></path>",
"trello": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><rect x=\"7\" y=\"7\" width=\"3\" height=\"9\"></rect><rect x=\"14\" y=\"7\" width=\"3\" height=\"5\"></rect>",
"trending-down": "<polyline points=\"23 18 13.5 8.5 8.5 13.5 1 6\"></polyline><polyline points=\"17 18 23 18 23 12\"></polyline>",
"trending-up": "<polyline points=\"23 6 13.5 15.5 8.5 10.5 1 18\"></polyline><polyline points=\"17 6 23 6 23 12\"></polyline>",
"triangle": "<path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"></path>",
"truck": "<rect x=\"1\" y=\"3\" width=\"15\" height=\"13\"></rect><polygon points=\"16 8 20 8 23 11 23 16 16 16 16 8\"></polygon><circle cx=\"5.5\" cy=\"18.5\" r=\"2.5\"></circle><circle cx=\"18.5\" cy=\"18.5\" r=\"2.5\"></circle>",
"tv": "<rect x=\"2\" y=\"7\" width=\"20\" height=\"15\" rx=\"2\" ry=\"2\"></rect><polyline points=\"17 2 12 7 7 2\"></polyline>",
"twitch": "<path d=\"M21 2H3v16h5v4l4-4h5l4-4V2zm-10 9V7m5 4V7\"></path>",
"twitter": "<path d=\"M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z\"></path>",
"type": "<polyline points=\"4 7 4 4 20 4 20 7\"></polyline><line x1=\"9\" y1=\"20\" x2=\"15\" y2=\"20\"></line><line x1=\"12\" y1=\"4\" x2=\"12\" y2=\"20\"></line>",
"umbrella": "<path d=\"M23 12a11.05 11.05 0 0 0-22 0zm-5 7a3 3 0 0 1-6 0v-7\"></path>",
"underline": "<path d=\"M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3\"></path><line x1=\"4\" y1=\"21\" x2=\"20\" y2=\"21\"></line>",
"unlock": "<rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect><path d=\"M7 11V7a5 5 0 0 1 9.9-1\"></path>",
"upload-cloud": "<polyline points=\"16 16 12 12 8 16\"></polyline><line x1=\"12\" y1=\"12\" x2=\"12\" y2=\"21\"></line><path d=\"M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3\"></path><polyline points=\"16 16 12 12 8 16\"></polyline>",
"upload": "<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"17 8 12 3 7 8\"></polyline><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line>",
"user-check": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><polyline points=\"17 11 19 13 23 9\"></polyline>",
"user-minus": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"23\" y1=\"11\" x2=\"17\" y2=\"11\"></line>",
"user-plus": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"20\" y1=\"8\" x2=\"20\" y2=\"14\"></line><line x1=\"23\" y1=\"11\" x2=\"17\" y2=\"11\"></line>",
"user-x": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"18\" y1=\"8\" x2=\"23\" y2=\"13\"></line><line x1=\"23\" y1=\"8\" x2=\"18\" y2=\"13\"></line>",
"user": "<path d=\"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2\"></path><circle cx=\"12\" cy=\"7\" r=\"4\"></circle>",
"users": "<path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"9\" cy=\"7\" r=\"4\"></circle><path d=\"M23 21v-2a4 4 0 0 0-3-3.87\"></path><path d=\"M16 3.13a4 4 0 0 1 0 7.75\"></path>",
"video-off": "<path d=\"M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
"video": "<polygon points=\"23 7 16 12 23 17 23 7\"></polygon><rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\" ry=\"2\"></rect>",
"voicemail": "<circle cx=\"5.5\" cy=\"11.5\" r=\"4.5\"></circle><circle cx=\"18.5\" cy=\"11.5\" r=\"4.5\"></circle><line x1=\"5.5\" y1=\"16\" x2=\"18.5\" y2=\"16\"></line>",
"volume-1": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon><path d=\"M15.54 8.46a5 5 0 0 1 0 7.07\"></path>",
"volume-2": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon><path d=\"M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07\"></path>",
"volume-x": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon><line x1=\"23\" y1=\"9\" x2=\"17\" y2=\"15\"></line><line x1=\"17\" y1=\"9\" x2=\"23\" y2=\"15\"></line>",
"volume": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon>",
"watch": "<circle cx=\"12\" cy=\"12\" r=\"7\"></circle><polyline points=\"12 9 12 12 13.5 13.5\"></polyline><path d=\"M16.51 17.35l-.35 3.83a2 2 0 0 1-2 1.82H9.83a2 2 0 0 1-2-1.82l-.35-3.83m.01-10.7l.35-3.83A2 2 0 0 1 9.83 1h4.35a2 2 0 0 1 2 1.82l.35 3.83\"></path>",
"wifi-off": "<line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line><path d=\"M16.72 11.06A10.94 10.94 0 0 1 19 12.55\"></path><path d=\"M5 12.55a10.94 10.94 0 0 1 5.17-2.39\"></path><path d=\"M10.71 5.05A16 16 0 0 1 22.58 9\"></path><path d=\"M1.42 9a15.91 15.91 0 0 1 4.7-2.88\"></path><path d=\"M8.53 16.11a6 6 0 0 1 6.95 0\"></path><line x1=\"12\" y1=\"20\" x2=\"12.01\" y2=\"20\"></line>",
"wifi": "<path d=\"M5 12.55a11 11 0 0 1 14.08 0\"></path><path d=\"M1.42 9a16 16 0 0 1 21.16 0\"></path><path d=\"M8.53 16.11a6 6 0 0 1 6.95 0\"></path><line x1=\"12\" y1=\"20\" x2=\"12.01\" y2=\"20\"></line>",
"wind": "<path d=\"M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2\"></path>",
"x-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line>",
"x-octagon": "<polygon points=\"7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2\"></polygon><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line>",
"x-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line>",
"x": "<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"></line><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"></line>",
"youtube": "<path d=\"M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z\"></path><polygon points=\"9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02\"></polygon>",
"zap-off": "<polyline points=\"12.41 6.75 13 2 10.57 4.92\"></polyline><polyline points=\"18.57 12.91 21 10 15.66 10\"></polyline><polyline points=\"8 8 3 14 12 14 11 22 16 16\"></polyline><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
"zap": "<polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"></polygon>",
"zoom-in": "<circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line><line x1=\"11\" y1=\"8\" x2=\"11\" y2=\"14\"></line><line x1=\"8\" y1=\"11\" x2=\"14\" y2=\"11\"></line>",
"zoom-out": "<circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line><line x1=\"8\" y1=\"11\" x2=\"14\" y2=\"11\"></line>"
}

24
vendor/feather-icons/license vendored Normal file
View File

@ -0,0 +1,24 @@
https://github.com/feathericons/feather/blob/master/LICENSE
---
The MIT License (MIT)
Copyright (c) 2013-2017 Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

6
vendor/feather-icons/readme.md vendored Normal file
View File

@ -0,0 +1,6 @@
<https://github.com/feathericons/feather>
`icons.json` here is sourced from `dist/icons.json` from the bundle, version 4.29.0.
This is intentionally not installed from `npm install feather-icons` because that package includes all of `core-js` as a dependency (which this project gets zero benefit from and is very large, impacting container image size).