migrate templates/themes into service template

This commit is contained in:
James Brumond 2023-08-05 14:34:11 -07:00
parent 811629eda9
commit fdfbdaa270
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
16 changed files with 496 additions and 15 deletions

25
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@doc-utils/color-themes": "^0.1.15",
"@fastify/compress": "^6.4.0",
"@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0",
@ -17,6 +18,7 @@
"fastify": "^4.19.2",
"generic-pool": "^3.9.0",
"luxon": "^3.3.0",
"mustache": "^4.2.0",
"openid-client": "^5.4.3",
"pino": "^8.14.1",
"sqlite3": "^5.1.6",
@ -29,6 +31,11 @@
"typescript": "^5.1.3"
}
},
"node_modules/@doc-utils/color-themes": {
"version": "0.1.15",
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz",
"integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w=="
},
"node_modules/@fastify/accept-negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
@ -1379,6 +1386,14 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -2324,6 +2339,11 @@
}
},
"dependencies": {
"@doc-utils/color-themes": {
"version": "0.1.15",
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz",
"integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w=="
},
"@fastify/accept-negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
@ -3394,6 +3414,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="
},
"negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",

View File

@ -22,6 +22,7 @@
"typescript": "^5.1.3"
},
"dependencies": {
"@doc-utils/color-themes": "^0.1.15",
"@fastify/compress": "^6.4.0",
"@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0",
@ -30,6 +31,7 @@
"fastify": "^4.19.2",
"generic-pool": "^3.9.0",
"luxon": "^3.3.0",
"mustache": "^4.2.0",
"openid-client": "^5.4.3",
"pino": "^8.14.1",
"sqlite3": "^5.1.6",

View File

@ -1,21 +1,8 @@
Template project for creating new Node.js / TypeScript services
Configurable, self-hostable web dashboard
---
## Get Started
### Pull down the code
```bash
git init
git pull https://gitea.jbrumond.me/templates/nodejs-typescript-service.git master
```
### Update configuration
- In `package.json`, update any fields like `name`, `description`, `repository`, etc.
## Building from source
@ -36,7 +23,7 @@ APP_PATH="./build" DATA_PATH="./data" CONF_PATH="./conf" node ./build/start.js
## Building container image
```bash
docker build . -f Dockerfile -t nodejs-template-service:latest
docker build . -f Dockerfile -t dashboard:latest
```

View File

@ -11,6 +11,7 @@ import { LoggingConfig, validate_logging_conf } from './logger';
import { Argon2HashConfig } from './security/argon-hash';
import { SessionCookieConfig, validate_session_cookie_conf } from './security/session';
import { HttpConfig } from './http/server';
import { ColorThemeConfig } from './utilities/color-themes';
const conf_dir = process.env.CONF_PATH;
@ -32,6 +33,7 @@ export interface Conf {
snowflake_uid: SnowflakeConfig;
storage: StorageConfig;
argon2: Argon2HashConfig;
color_themes: ColorThemeConfig;
}
export async function load_conf() : Promise<unknown> {

View File

@ -0,0 +1,20 @@
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

@ -0,0 +1,22 @@
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_structure_endpoint(http_server: FastifyInstance, conf: HttpConfig, { }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: { },
};
http_server.get('/structure.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('structure.css');
});
}

View File

@ -0,0 +1,22 @@
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

@ -14,6 +14,10 @@ import { register_login_page_endpoint } from './authentication/login-page';
import { register_submit_login_endpoint } from './authentication/submit-login';
import { register_login_callback_endpoint } from './authentication/login-callback';
import { register_logout_endpoint } from './authentication/logout';
import { ColorThemeProvider } from '../utilities/color-themes';
import { register_color_themes_endpoint } from './assets/color-themes';
import { register_structure_endpoint } from './assets/structure';
import { register_typography_endpoint } from './assets/typography';
export interface HttpWebDependencies extends BaseHttpDependencies {
oidc: OIDCProvider;
@ -22,6 +26,7 @@ export interface HttpWebDependencies extends BaseHttpDependencies {
argon2: Argon2HashProvider;
session: SessionProvider;
snowflake: SnowflakeProvider;
color_themes: ColorThemeProvider;
}
export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) {
@ -29,6 +34,11 @@ export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependenci
endpoints: [
register_csp_report_endpoint,
// Shared Assets
register_color_themes_endpoint,
register_structure_endpoint,
register_typography_endpoint,
// Root page
register_root_page_endpoint,

View File

@ -11,6 +11,7 @@ import { create_session_provider } from './security/session';
import { create_http_metadata_server } from './http-metadata/server';
import { HttpWebDependencies, create_http_web_server } from './http-web/server';
import { create_color_theme_provider } from './utilities/color-themes';
main();
@ -37,6 +38,7 @@ async function main() {
const snowflake = create_snowflake_provider(conf.snowflake_uid);
const argon2 = create_argon_hash_provider(conf.argon2);
const session = create_session_provider(conf.session_cookie, logger, argon2, storage);
const color_themes = create_color_theme_provider(conf.color_themes);
// Wait for any async init steps
await oidc.ready;
@ -53,6 +55,7 @@ async function main() {
storage,
argon2,
session,
color_themes,
};
// Create the main web server

View File

@ -0,0 +1,65 @@
import { ColorTheme, load_theme } from '@doc-utils/color-themes';
import { render_template } from './mustache';
export interface ColorThemeConfig {
default: LightAndDarkTheme;
less_contrast?: LightAndDarkTheme;
more_contrast?: LightAndDarkTheme;
}
export interface LightAndDarkTheme {
light: string;
dark: string;
}
export type ColorThemeProvider = ReturnType<typeof create_color_theme_provider>;
export function create_color_theme_provider(conf: ColorThemeConfig) {
let css: string;
const ready = setup();
return {
get ready() {
return ready;
},
get css() {
return css;
},
};
async function setup() {
const view = {
more_contrast: conf.more_contrast != null,
less_contrast: conf.less_contrast != null,
};
const partials = {
default_light: render_theme_css_properties(await load(conf.default.light)),
default_dark: render_theme_css_properties(await load(conf.default.dark)),
more_contrast_light: render_theme_css_properties(await load(conf.more_contrast?.light)),
more_contrast_dark: render_theme_css_properties(await load(conf.more_contrast?.dark)),
less_contrast_light: render_theme_css_properties(await load(conf.less_contrast?.light)),
less_contrast_dark: render_theme_css_properties(await load(conf.less_contrast?.dark)),
};
css = await render_template('themes.css.mustache', view, partials);
}
}
function load(theme: string) {
if (theme) {
return load_theme(theme);
}
return null;
}
function render_theme_css_properties(theme: ColorTheme) {
return Object
.entries(theme.colors)
.map(([ name, color ]) => {
return `--theme-${name.replace(/_/g, '-')}: ${color};`;
})
.join('\n') + '\n';
}

32
src/utilities/mustache.ts Normal file
View File

@ -0,0 +1,32 @@
import { render } from 'mustache';
import { promises as fs } from 'fs';
import { resolve as path_resolve } from 'path';
const templates: Record<string, Promise<string>> = Object.create(null);
const template_path = path_resolve(__dirname, '../../templates');
export async function render_template(file: string, view: any, partials: Record<string, string> = Object.create(null), partial_files: Record<string, string> = Object.create(null)) {
const template = await load_template(file);
const partial_promises: Promise<void>[] = [ ];
for (const [key, file] of Object.entries(partial_files)) {
const promise = load_template(file).then((template) => {
partials[key] = template;
});
partial_promises.push(promise);
}
await Promise.all(partial_promises);
return render(template, view, partials);
}
export function load_template(file: string) {
if (! templates[file]) {
const path = path_resolve(template_path, file);
templates[file] = fs.readFile(path, 'utf8');
}
return templates[file];
}

View File

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

17
templates/structure.css Normal file
View File

@ -0,0 +1,17 @@
* {
box-sizing: border-box;
}
/* ===== Forms ===== */
button, input:is([type='button'], [type='submit'], [type='reset']) {
appearance: none;
cursor: pointer;
border: 0;
padding: 0.5rem 1rem;
}

View File

@ -0,0 +1,78 @@
:root {
color-scheme: light dark;
}
{{! ===== Default Themes ===== }}
body {
{{> default_light }}
}
body[data-color-scheme='dark'] {
{{> default_dark }}
}
@media (prefers-color-scheme: dark) {
body {
{{> default_dark }}
}
body[data-color-scheme='light'] {
{{> default_light }}
}
}
{{! ===== 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 }}
}
@media (prefers-color-scheme: dark) {
body {
{{> less_contrast_dark }}
}
body[data-color-scheme='light'] {
{{> less_contrast_light }}
}
}
}
{{/ less_contrast }}

151
templates/typography.css Normal file
View File

@ -0,0 +1,151 @@
:root {
--font-heading: Verdana, sans-serif;
--font-body: Verdana, sans-serif;
--font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-family: var(--font-body);
font-size: 16px;
}
@media screen and (max-width: 800px) {
:root {
font-size: 14px;
}
}
@media screen and (min-width: 2000px) {
:root {
font-size: 20px;
}
}
/* ===== Font Families ===== */
h1, h2, h3, h4, h5, h6,
th, dt {
font-family: var(--font-heading);
}
p, td, dd, figcaption, li, blockquote,
input, legend, fieldset, label, button {
font-family: var(--font-body);
}
pre, code, samp {
font-family: var(--font-monospace);
}
a, span,
b, i, u, q,
strong, em, mark, cite {
font-family: inherit;
}
/* ===== Font Sizes ===== */
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.8rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.2rem;
}
h6 {
font-size: 1rem;
}
th, td, dt, dd,
p, figcaption, li, blockquote,
pre,
input, 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, 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;
}