Plugins config (#147)

* refactor: rename configs

* refactor: create plugin config

* refactor: universal config

* fix: engine plugins default list
This commit is contained in:
Artemy Egorov 2024-04-27 22:15:19 +03:00 committed by GitHub
parent c04ea407ae
commit 6e9e9a6cc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 110 additions and 103 deletions

View File

@ -9,7 +9,7 @@
"@fastify/swagger": "^8.14.0", "@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^3.0.0", "@fastify/swagger-ui": "^3.0.0",
"@fastify/view": "^9.0.0", "@fastify/view": "^9.0.0",
"@txtdot/plugins": "^1.0.0", "@txtdot/plugins": "^1.1.1",
"@txtdot/sdk": "^1.1.1", "@txtdot/sdk": "^1.1.1",
"axios": "^1.6.8", "axios": "^1.6.8",
"dompurify": "^3.1.0", "dompurify": "^3.1.0",

View File

@ -21,8 +21,8 @@ importers:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.0 version: 9.0.0
'@txtdot/plugins': '@txtdot/plugins':
specifier: ^1.0.0 specifier: ^1.1.1
version: 1.0.0 version: 1.1.1
'@txtdot/sdk': '@txtdot/sdk':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@ -310,8 +310,8 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@txtdot/plugins@1.0.0': '@txtdot/plugins@1.1.1':
resolution: {integrity: sha512-RJLVNHFBxiEUo8my6eSgkfrSpxPDIUYdGsI0hfw8lyI5aZ30/OjhFfzfK0Jsf1b7xxw5acZn1My0yrF9RpdHBA==} resolution: {integrity: sha512-rCRCzi18xdFK/JpdB+dQF1PaN+w6tNhVJum5YJu05PY0vsz5Rgx9ct7HmPi8W01Ek+iwec8GUeP1IKkUDHF6BQ==}
'@txtdot/sdk@1.1.1': '@txtdot/sdk@1.1.1':
resolution: {integrity: sha512-H1YYXdDX3sjZdBfb+osDlDVBVlW8lNeyAIl0j7DhwbOmW3bNCFI9pdMiWGDIyotH6UqOncFP0u+jBugW/5N4Wg==} resolution: {integrity: sha512-H1YYXdDX3sjZdBfb+osDlDVBVlW8lNeyAIl0j7DhwbOmW3bNCFI9pdMiWGDIyotH6UqOncFP0u+jBugW/5N4Wg==}
@ -1666,7 +1666,7 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@txtdot/plugins@1.0.0': '@txtdot/plugins@1.1.1':
dependencies: dependencies:
'@mozilla/readability': 0.5.0 '@mozilla/readability': 0.5.0
'@txtdot/sdk': 1.1.1 '@txtdot/sdk': 1.1.1

View File

@ -13,22 +13,19 @@ import proxyRoute from './routes/browser/proxy';
import parseRoute from './routes/api/parse'; import parseRoute from './routes/api/parse';
import rawHtml from './routes/api/raw-html'; import rawHtml from './routes/api/raw-html';
import packageJSON from './package';
import errorHandler from './errors/handler'; import errorHandler from './errors/handler';
import getConfig from './config/main';
import redirectRoute from './routes/browser/redirect'; import redirectRoute from './routes/browser/redirect';
import dynConfig from './config/dynamic.config';
import configurationRoute from './routes/browser/configuration'; import configurationRoute from './routes/browser/configuration';
import config from './config';
class App { class App {
async init() { async init() {
const config = getConfig();
const fastify = Fastify({ const fastify = Fastify({
logger: true, logger: true,
trustProxy: config.reverse_proxy, trustProxy: config.env.reverse_proxy,
connectionTimeout: config.timeout, connectionTimeout: config.env.timeout,
}); });
fastify.setErrorHandler(errorHandler); fastify.setErrorHandler(errorHandler);
@ -44,14 +41,14 @@ class App {
}, },
}); });
if (config.swagger) { if (config.env.swagger) {
dynConfig.addRoute('/doc'); config.dyn.addRoute('/doc');
await fastify.register(fastifySwagger, { await fastify.register(fastifySwagger, {
swagger: { swagger: {
info: { info: {
title: 'TXTDot API', title: 'TXTDot API',
description: packageJSON.description, description: config.package.description,
version: packageJSON.version, version: config.package.version,
}, },
}, },
}); });
@ -59,20 +56,20 @@ class App {
} }
fastify.addHook('onRoute', (route) => { fastify.addHook('onRoute', (route) => {
dynConfig.addRoute(route.url); config.dyn.addRoute(route.url);
}); });
fastify.register(indexRoute); fastify.register(indexRoute);
fastify.register(getRoute); fastify.register(getRoute);
fastify.register(configurationRoute); fastify.register(configurationRoute);
config.third_party.searx_url && fastify.register(redirectRoute); config.env.third_party.searx_url && fastify.register(redirectRoute);
config.proxy.enabled && fastify.register(proxyRoute); config.env.proxy.enabled && fastify.register(proxyRoute);
fastify.register(parseRoute); fastify.register(parseRoute);
fastify.register(rawHtml); fastify.register(rawHtml);
fastify.listen({ host: config.host, port: config.port }, (err) => { fastify.listen({ host: config.env.host, port: config.env.port }, (err) => {
err && console.log(err); err && console.log(err);
}); });
} }

View File

@ -1,4 +1,4 @@
class DynConfigService { class DynConfig {
public routes: Set<string> = new Set(); public routes: Set<string> = new Set();
constructor() {} constructor() {}
addRoute(route: string) { addRoute(route: string) {
@ -6,5 +6,5 @@ class DynConfigService {
} }
} }
const config = new DynConfigService(); const dyn_config = new DynConfig();
export default config; export default dyn_config;

View File

@ -1,6 +1,6 @@
import { config } from 'dotenv'; import { config as dconfig } from 'dotenv';
export class ConfigService { class EnvConfig {
public readonly host: string; public readonly host: string;
public readonly port: number; public readonly port: number;
public readonly timeout: number; public readonly timeout: number;
@ -10,7 +10,7 @@ export class ConfigService {
public readonly third_party: ThirdPartyConfig; public readonly third_party: ThirdPartyConfig;
constructor() { constructor() {
config(); dconfig();
this.host = process.env.HOST || '0.0.0.0'; this.host = process.env.HOST || '0.0.0.0';
this.port = Number(process.env.PORT) || 8080; this.port = Number(process.env.PORT) || 8080;
@ -37,6 +37,8 @@ export class ConfigService {
return value === 'true' || value === '1'; return value === 'true' || value === '1';
} }
} }
const env_config = new EnvConfig();
export default env_config;
interface ProxyConfig { interface ProxyConfig {
enabled: boolean; enabled: boolean;

13
src/config/index.ts Normal file
View File

@ -0,0 +1,13 @@
import dyn_config from './dynConfig';
import env_config from './envConfig';
import package_config from './packageConfig';
import plugin_config from './pluginConfig';
const config = {
dyn: dyn_config,
env: env_config,
plugin: plugin_config,
package: package_config,
};
export default config;

View File

@ -1,12 +0,0 @@
import { ConfigService } from './config.service';
let configSvc: ConfigService | undefined;
export default function getConfig(): ConfigService {
if (configSvc) {
return configSvc;
}
configSvc = new ConfigService();
return configSvc;
}

View File

@ -0,0 +1,3 @@
import * as package_config from '../../package.json';
export default package_config;

View File

@ -0,0 +1,12 @@
import { IAppConfig } from '../types/appConfig';
import { engineList } from '@txtdot/plugins';
/**
* Configuration of plugins
* Here you can add your own plugins
*/
const plugin_config: IAppConfig = {
engines: [...engineList],
};
export default plugin_config;

View File

@ -1,15 +1,15 @@
import axios, { oaxios } from '../types/axios'; import axios, { oaxios } from './types/axios';
import micromatch from 'micromatch'; import micromatch from 'micromatch';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { NotHtmlMimetypeError } from '../errors/main'; import { NotHtmlMimetypeError } from './errors/main';
import { decodeStream, parseEncodingName } from '../utils/http'; import { decodeStream, parseEncodingName } from './utils/http';
import replaceHref from '../utils/replace-href'; import replaceHref from './utils/replace-href';
import { parseHTML } from 'linkedom'; import { parseHTML } from 'linkedom';
import getConfig from '../config/main';
import { Engine } from '@txtdot/sdk'; import { Engine } from '@txtdot/sdk';
import { HandlerInput, IHandlerOutput } from '@txtdot/sdk/dist/types/handler'; import { HandlerInput, IHandlerOutput } from '@txtdot/sdk/dist/types/handler';
import config from './config';
interface IEngineId { interface IEngineId {
[key: string]: number; [key: string]: number;
@ -35,7 +35,7 @@ export class Distributor {
): Promise<IHandlerOutput> { ): Promise<IHandlerOutput> {
const urlObj = new URL(remoteUrl); const urlObj = new URL(remoteUrl);
const webder_url = getConfig().third_party.webder_url; const webder_url = config.env.third_party.webder_url;
const response = webder_url const response = webder_url
? await oaxios.get( ? await oaxios.get(

View File

@ -5,7 +5,7 @@ import { getFastifyError } from './validation';
import { TxtDotError } from '@txtdot/sdk/dist/types/errors'; import { TxtDotError } from '@txtdot/sdk/dist/types/errors';
import { IGetSchema } from '../types/requests/browser'; import { IGetSchema } from '../types/requests/browser';
import getConfig from '../config/main'; import config from '../config';
export default function errorHandler( export default function errorHandler(
error: Error, error: Error,
@ -58,7 +58,7 @@ function htmlErrorHandler(error: Error, reply: FastifyReply, url: string) {
code: error.code, code: error.code,
description: error.description, description: error.description,
proxyBtn: proxyBtn:
error instanceof NotHtmlMimetypeError && getConfig().proxy.enabled, error instanceof NotHtmlMimetypeError && config.env.proxy.enabled,
}); });
} }

View File

@ -1,4 +1,4 @@
import getConfig from '../config/main'; import config from '../config';
import { TxtDotError } from '@txtdot/sdk/dist/types/errors'; import { TxtDotError } from '@txtdot/sdk/dist/types/errors';
export class LocalResourceError extends TxtDotError { export class LocalResourceError extends TxtDotError {
@ -23,7 +23,7 @@ export class NotHtmlMimetypeError extends TxtDotError {
421, 421,
'NotHtmlMimetypeError', 'NotHtmlMimetypeError',
'Received non-HTML content, ' + 'Received non-HTML content, ' +
(getConfig().proxy.enabled (config.env.proxy.enabled
? 'use proxy instead of parser.' ? 'use proxy instead of parser.'
: 'proxying is disabled by the instance admin.') : 'proxying is disabled by the instance admin.')
); );

View File

@ -1,11 +0,0 @@
import { Distributor } from './distributor';
import { engines } from '@txtdot/plugins';
const distributor = new Distributor();
distributor.engine(engines.StackOverflow);
distributor.engine(engines.SearX);
distributor.engine(engines.Readability);
export const engineList = distributor.list;
export default distributor;

View File

@ -1,3 +0,0 @@
import * as config from '../package.json';
export default config;

11
src/plugin_manager.ts Normal file
View File

@ -0,0 +1,11 @@
import { Distributor } from './distributor';
import plugin_config from './config/pluginConfig';
const distributor = new Distributor();
for (const engine of plugin_config.engines) {
distributor.engine(engine);
}
export const engineList = distributor.list;
export { distributor };

View File

@ -6,7 +6,7 @@ import {
parseSchema, parseSchema,
} from '../../types/requests/api'; } from '../../types/requests/api';
import distributor from '../../handlers/main'; import { distributor } from '../../plugin_manager';
import { generateRequestUrl } from '../../utils/generate'; import { generateRequestUrl } from '../../utils/generate';
export default async function parseRoute(fastify: FastifyInstance) { export default async function parseRoute(fastify: FastifyInstance) {

View File

@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
import { IParseSchema, rawHtmlSchema } from '../../types/requests/api'; import { IParseSchema, rawHtmlSchema } from '../../types/requests/api';
import distributor from '../../handlers/main'; import { distributor } from '../../plugin_manager';
import { generateRequestUrl } from '../../utils/generate'; import { generateRequestUrl } from '../../utils/generate';
export default async function rawHtml(fastify: FastifyInstance) { export default async function rawHtml(fastify: FastifyInstance) {

View File

@ -1,19 +1,15 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import packageJSON from '../../package'; import { distributor } from '../../plugin_manager';
import distributor from '../../handlers/main';
import { indexSchema } from '../../types/requests/browser'; import { indexSchema } from '../../types/requests/browser';
import getConfig from '../../config/main'; import config from '../../config';
import dynConfig from '../../config/dynamic.config';
export default async function configurationRoute(fastify: FastifyInstance) { export default async function configurationRoute(fastify: FastifyInstance) {
fastify.get('/configuration', { schema: indexSchema }, async (_, reply) => { fastify.get('/configuration', { schema: indexSchema }, async (_, reply) => {
return reply.view('/templates/configuration.ejs', { return reply.view('/templates/configuration.ejs', {
packageJSON,
engines: distributor.fallback, engines: distributor.fallback,
dynConfig, config,
config: getConfig(),
}); });
}); });
} }

View File

@ -1,10 +1,9 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { GetSchema, IGetSchema } from '../../types/requests/browser'; import { GetSchema, IGetSchema } from '../../types/requests/browser';
import distributor from '../../handlers/main'; import { distributor } from '../../plugin_manager';
import { generateRequestUrl } from '../../utils/generate'; import { generateRequestUrl } from '../../utils/generate';
import config from '../../config';
import getConfig from '../../config/main';
export default async function getRoute(fastify: FastifyInstance) { export default async function getRoute(fastify: FastifyInstance) {
fastify.get<IGetSchema>( fastify.get<IGetSchema>(
@ -32,7 +31,7 @@ export default async function getRoute(fastify: FastifyInstance) {
return reply.view('/templates/get.ejs', { return reply.view('/templates/get.ejs', {
parsed, parsed,
remoteUrl, remoteUrl,
config: getConfig(), config,
}); });
} }
} }

View File

@ -1,17 +1,14 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import packageJSON from '../../package'; import { engineList } from '../../plugin_manager';
import { engineList } from '../../handlers/main';
import { indexSchema } from '../../types/requests/browser'; import { indexSchema } from '../../types/requests/browser';
import config from '../../config';
import getConfig from '../../config/main';
export default async function indexRoute(fastify: FastifyInstance) { export default async function indexRoute(fastify: FastifyInstance) {
fastify.get('/', { schema: indexSchema }, async (_, reply) => { fastify.get('/', { schema: indexSchema }, async (_, reply) => {
return reply.view('/templates/index.ejs', { return reply.view('/templates/index.ejs', {
packageJSON,
engineList, engineList,
config: getConfig(), config,
}); });
}); });
} }

View File

@ -2,8 +2,8 @@ import { FastifyInstance } from 'fastify';
import { IProxySchema, ProxySchema } from '../../types/requests/browser'; import { IProxySchema, ProxySchema } from '../../types/requests/browser';
import axios from '../../types/axios'; import axios from '../../types/axios';
import sharp from 'sharp'; import sharp from 'sharp';
import getConfig from '../../config/main';
import { UnsupportedMimetypeError } from '../../errors/main'; import { UnsupportedMimetypeError } from '../../errors/main';
import config from '../../config';
export default async function proxyRoute(fastify: FastifyInstance) { export default async function proxyRoute(fastify: FastifyInstance) {
fastify.get<IProxySchema>( fastify.get<IProxySchema>(
@ -21,7 +21,7 @@ export default async function proxyRoute(fastify: FastifyInstance) {
} }
); );
if (getConfig().proxy.img_compress) if (config.env.proxy.img_compress)
fastify.get<IProxySchema>( fastify.get<IProxySchema>(
'/proxy/img', '/proxy/img',
{ schema: ProxySchema }, { schema: ProxySchema },

5
src/types/appConfig.ts Normal file
View File

@ -0,0 +1,5 @@
import { Engine } from '@txtdot/sdk';
export interface IAppConfig {
engines: Engine[];
}

View File

@ -1,6 +1,6 @@
import { FastifySchema, FastifyRequest } from 'fastify'; import { FastifySchema, FastifyRequest } from 'fastify';
import { IApiError, errorResponseSchema } from '../../errors/api'; import { IApiError, errorResponseSchema } from '../../errors/api';
import { engineList } from '../../handlers/main'; import { engineList } from '../../plugin_manager';
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
import { handlerSchema } from '@txtdot/sdk/dist/types/handler'; import { handlerSchema } from '@txtdot/sdk/dist/types/handler';

View File

@ -1,5 +1,5 @@
import { FastifySchema } from 'fastify'; import { FastifySchema } from 'fastify';
import { engineList } from '../../handlers/main'; import { engineList } from '../../plugin_manager';
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
export interface IGetSchema { export interface IGetSchema {

View File

@ -1,5 +1,5 @@
import config from '../config';
import { generateParserUrl, generateProxyUrl } from './generate'; import { generateParserUrl, generateProxyUrl } from './generate';
import getConfig from '../config/main';
export default function replaceHref( export default function replaceHref(
dom: Window, dom: Window,
@ -21,9 +21,7 @@ export default function replaceHref(
modifyLinks(doc.querySelectorAll('a[href]'), 'href', parserUrl); modifyLinks(doc.querySelectorAll('a[href]'), 'href', parserUrl);
modifyLinks(doc.querySelectorAll('frame,iframe'), 'src', parserUrl); modifyLinks(doc.querySelectorAll('frame,iframe'), 'src', parserUrl);
const config = getConfig(); if (config.env.proxy.enabled) {
if (config.proxy.enabled) {
modifyLinks( modifyLinks(
doc.querySelectorAll('video,audio,embed,track,source'), doc.querySelectorAll('video,audio,embed,track,source'),
'src', 'src',
@ -33,7 +31,7 @@ export default function replaceHref(
modifyLinks( modifyLinks(
doc.querySelectorAll('img,image'), doc.querySelectorAll('img,image'),
'src', 'src',
config.proxy.img_compress ? imgProxyUrl : proxyUrl config.env.proxy.img_compress ? imgProxyUrl : proxyUrl
); );
modifyLinks(doc.getElementsByTagName('object'), 'data', proxyUrl); modifyLinks(doc.getElementsByTagName('object'), 'data', proxyUrl);

View File

@ -1,4 +1,4 @@
<% search = config.third_party.searx_url %> <% search = config.env.third_party.searx_url %>
<% <%
@ -19,7 +19,7 @@ if (search) {
<div class="input"> <div class="input">
<input type="submit" id="submit" class="button" value="Go"> <input type="submit" id="submit" class="button" value="Go">
</div> </div>
<input type="hidden" name="url" value="<%= config.third_party.searx_url %>/search"/> <input type="hidden" name="url" value="<%= config.env.third_party.searx_url %>/search"/>
</form> </form>
<% <%
} }

View File

@ -2,12 +2,12 @@
<a class="button secondary" href="/">Home</a> <a class="button secondary" href="/">Home</a>
<a class="button secondary" href="<%= remoteUrl %>">Original page</a> <a class="button secondary" href="<%= remoteUrl %>">Original page</a>
<% <%
if (config.third_party.searx_url) { if (config.env.third_party.searx_url) {
%> %>
<form class="form-search" action="/redirect" method="get"> <form class="form-search" action="/redirect" method="get">
<input type="text" name="q" id="search" placeholder="Search"> <input type="text" name="q" id="search" placeholder="Search">
<input class="button" type="submit" value="Go"/> <input class="button" type="submit" value="Go"/>
<input type="hidden" name="url" value="<%= config.third_party.searx_url %>/search"/> <input type="hidden" name="url" value="<%= config.env.third_party.searx_url %>/search"/>
</form> </form>
<% <%
} }

View File

@ -1,5 +1,5 @@
<% <%
if (config.third_party.searx_url) { if (config.env.third_party.searx_url) {
%><link rel="stylesheet" href="/static/search.css"> %><link rel="stylesheet" href="/static/search.css">
<link rel="stylesheet" href="/static/form-inputs.css"><% <link rel="stylesheet" href="/static/form-inputs.css"><%
} }

View File

@ -17,7 +17,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="<%= packageJSON.description %>"> <meta name="description" content="<%= config.package.description %>">
<title>txt. configuration</title> <title>txt. configuration</title>
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/configuration.css"> <link rel="stylesheet" href="/static/configuration.css">
@ -29,11 +29,11 @@
<div class="menu"> <div class="menu">
<a class="button secondary" href="/">Home</a> <a class="button secondary" href="/">Home</a>
</div> </div>
<p><%= packageJSON.description %></p> <p><%= config.package.description %></p>
</header> </header>
<div class="configuration"> <div class="configuration">
<h2>Configuration</h2> <h2>Configuration</h2>
<pre> version: <%= packageJSON.version %><%= to_pretty(config) %></pre> <pre> version: <%= config.package.version %><%= to_pretty(config.env) %></pre>
<h2>Available engines</h2> <h2>Available engines</h2>
<ol> <ol>
<% <%
@ -44,7 +44,7 @@
</ol> </ol>
<h2>Available routes</h2> <h2>Available routes</h2>
<% <%
for (const route of dynConfig.routes) { for (const route of config.dyn.routes) {
%><a class="button secondary" href="<%= route %>"><%= route %></a><% %><a class="button secondary" href="<%= route %>"><%= route %></a><%
} }
%> %>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="<%= packageJSON.description %>"> <meta name="description" content="<%= config.package.description %>">
<title>txt. main page</title> <title>txt. main page</title>
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/index.css"> <link rel="stylesheet" href="/static/index.css">
@ -16,11 +16,11 @@
<header> <header>
<h1>txt<span class="dot">.</span></h1> <h1>txt<span class="dot">.</span></h1>
<div class="menu"> <div class="menu">
<a href="https://github.com/TxtDot/txtdot/releases/tag/v<%= packageJSON.version %>" class="button secondary">v<%= packageJSON.version %></a> <a href="https://github.com/TxtDot/txtdot/releases/tag/v<%= config.package.version %>" class="button secondary">v<%= config.package.version %></a>
<a href="https://github.com/txtdot/txtdot" class="button secondary">GitHub</a> <a href="https://github.com/txtdot/txtdot" class="button secondary">GitHub</a>
<a href="https://txtdot.github.io/documentation" class="button secondary">Docs</a> <a href="https://txtdot.github.io/documentation" class="button secondary">Docs</a>
</div> </div>
<p><%= packageJSON.description %></p> <p><%= config.package.description %></p>
</header> </header>
<%- include('./components/form-main.ejs') %> <%- include('./components/form-main.ejs') %>
</main> </main>