mirror of
https://github.com/wassname/talk.git
synced 2026-07-14 11:18:50 +08:00
[CORL-127] Custom CSS (#2194)
* feat: moved html-webpack-plugin to custom server templates in production * fix: fixed templates * fix: removed sri for the time being * fix: fixed up tests for new field name
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import fs from "fs";
|
||||
import logger from "talk-server/logger";
|
||||
|
||||
export interface Asset {
|
||||
src: string;
|
||||
integrity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entrypoint is the version of the entrypoint that has collected entries with
|
||||
* integrity values.
|
||||
*/
|
||||
export type Entrypoint = Record<string, Asset[]>;
|
||||
|
||||
/**
|
||||
* RawEntrypoint is the entrypoint entry generated by the webpack plugin.
|
||||
*/
|
||||
export type RawEntrypoint = Record<string, string[]>;
|
||||
|
||||
/**
|
||||
* Manifest is the full raw manifest that is generated by the webpack plugin.
|
||||
*/
|
||||
export type Manifest = {
|
||||
/**
|
||||
* entrypoints are generated by the webpack plugin for each of the entrypoints
|
||||
* with their required chunks.
|
||||
*/
|
||||
entrypoints: Record<string, RawEntrypoint>;
|
||||
} & Record<string, Asset>;
|
||||
|
||||
/**
|
||||
* Entrypoints will parse the manifest provided by the `webpack-assets-manifest`
|
||||
* plugin and make their assets available for each entrypoint.
|
||||
*/
|
||||
export default class Entrypoints {
|
||||
private entrypoints = new Map<string, Entrypoint>();
|
||||
|
||||
constructor(manifest: Manifest) {
|
||||
for (const entry in manifest.entrypoints) {
|
||||
if (!manifest.entrypoints.hasOwnProperty(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grab the entrypoint that contains the list of all the assets
|
||||
// for this entrypoint.
|
||||
const entrypoint: Entrypoint = {};
|
||||
|
||||
// Itterate over the extension's in the entrypoint.
|
||||
for (const extension in manifest.entrypoints[entry]) {
|
||||
if (!manifest.entrypoints[entry].hasOwnProperty(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the extension in the entrypoint.
|
||||
entrypoint[extension] = [];
|
||||
|
||||
// Grab the files in the extension.
|
||||
const assets = manifest.entrypoints[entry][extension];
|
||||
|
||||
// Itterate over the src field for each of the files.
|
||||
for (const src of assets) {
|
||||
// Search for the entry in the assets.
|
||||
for (const name in manifest) {
|
||||
if (name !== "entrypoints" && !manifest.hasOwnProperty(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grab the asset.
|
||||
const asset = manifest[name];
|
||||
|
||||
// Check to see if the asset is a match.
|
||||
if (asset.src === src) {
|
||||
entrypoint[extension].push({
|
||||
integrity: asset.integrity,
|
||||
// Prefix all the sources with a `/`.
|
||||
src: "/" + asset.src,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.entrypoints.set(entry, entrypoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get(name: string): Readonly<Entrypoint> {
|
||||
const entrypoint = this.entrypoints.get(name);
|
||||
if (!entrypoint) {
|
||||
throw new Error(`Entrypoint ${name} does not exist in the manifest`);
|
||||
}
|
||||
|
||||
return entrypoint;
|
||||
}
|
||||
|
||||
public static fromFile(filepath: string): Entrypoints | null {
|
||||
try {
|
||||
// Load the manifest.
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(filepath, { encoding: "utf8" })
|
||||
);
|
||||
|
||||
// Create and return the entrypoints.
|
||||
return new Entrypoints(manifest);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "could not load the manifest");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,15 +102,8 @@ function configureApplication(options: AppOptions) {
|
||||
function setupViews(options: AppOptions) {
|
||||
const { parent } = options;
|
||||
|
||||
// Configure the default views directories.
|
||||
const views = [
|
||||
// Load the templates compiled by Webpack.
|
||||
path.resolve(
|
||||
path.join(__dirname, "..", "..", "..", "..", "dist", "static")
|
||||
),
|
||||
// Load the templates generated by the server.
|
||||
path.join(__dirname, "views"),
|
||||
];
|
||||
// Configure the default views directory.
|
||||
const views = path.join(__dirname, "views");
|
||||
parent.set("views", views);
|
||||
|
||||
// Reconfigure nunjucks.
|
||||
@@ -119,13 +112,9 @@ function setupViews(options: AppOptions) {
|
||||
// caching.
|
||||
watch: options.config.get("env") === "development",
|
||||
noCache: options.config.get("env") === "development",
|
||||
// Trim blocks of whitespace.
|
||||
trimBlocks: true,
|
||||
lstripBlocks: true,
|
||||
});
|
||||
|
||||
// assign the nunjucks engine to .njk and .html files.
|
||||
parent.engine("njk", cons.nunjucks);
|
||||
parent.engine("html", cons.nunjucks);
|
||||
|
||||
// set .html as the default extension.
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import express from "express";
|
||||
import { minify } from "html-minifier";
|
||||
|
||||
import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
import { Entrypoint } from "../helpers/entrypoints";
|
||||
|
||||
export interface ClientTargetHandlerOptions {
|
||||
/**
|
||||
* view is the name of the template to render.
|
||||
* entrypoint is the entrypoint entry to load.
|
||||
*/
|
||||
view: string;
|
||||
entrypoint: Entrypoint;
|
||||
|
||||
/**
|
||||
* enableCustomCSS will insert the custom CSS into the template if it is
|
||||
* available on the Tenant.
|
||||
*/
|
||||
enableCustomCSS?: boolean;
|
||||
|
||||
/**
|
||||
* cacheDuration is the cache duration that a given request should be cached for.
|
||||
@@ -22,7 +30,8 @@ export interface ClientTargetHandlerOptions {
|
||||
|
||||
export function createClientTargetRouter({
|
||||
staticURI,
|
||||
view,
|
||||
entrypoint,
|
||||
enableCustomCSS = false,
|
||||
cacheDuration = "1h",
|
||||
}: ClientTargetHandlerOptions) {
|
||||
// Create a router.
|
||||
@@ -32,7 +41,25 @@ export function createClientTargetRouter({
|
||||
router.use(cacheHeadersMiddleware(cacheDuration));
|
||||
|
||||
// Wildcard display all the client routes under the provided prefix.
|
||||
router.get("/*", (req, res) => res.render(view, { staticURI }));
|
||||
router.get("/*", (req, res, next) =>
|
||||
res.render(
|
||||
"client",
|
||||
{ staticURI, entrypoint, enableCustomCSS },
|
||||
(err, html) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// Send back the HTML minified.
|
||||
res.send(
|
||||
minify(html, {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express, { Router } from "express";
|
||||
import path from "path";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
@@ -9,6 +10,7 @@ import logger from "talk-server/logger";
|
||||
|
||||
import { cspTenantMiddleware } from "talk-server/app/middleware/csp/tenant";
|
||||
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
|
||||
import Entrypoints from "../helpers/entrypoints";
|
||||
import { createAPIRouter } from "./api";
|
||||
import { createClientTargetRouter } from "./client";
|
||||
|
||||
@@ -28,67 +30,89 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
|
||||
const staticURI = app.config.get("static_uri");
|
||||
|
||||
// Add the embed targets.
|
||||
router.use(
|
||||
"/embed/stream",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "stream",
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/embed/auth",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "auth",
|
||||
cacheDuration: false,
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/embed/auth/callback",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "auth-callback",
|
||||
cacheDuration: false,
|
||||
})
|
||||
// Load the entrypoint manifest.
|
||||
const manifest = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"static",
|
||||
"asset-manifest.json"
|
||||
);
|
||||
const entrypoints = Entrypoints.fromFile(manifest);
|
||||
|
||||
// Add the standalone targets.
|
||||
router.use(
|
||||
"/admin",
|
||||
// If we aren't already installed, redirect the user to the install page.
|
||||
installedMiddleware({
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "admin",
|
||||
cacheDuration: false,
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/install",
|
||||
// If we're already installed, redirect the user to the admin page.
|
||||
installedMiddleware({
|
||||
redirectIfInstalled: true,
|
||||
redirectURL: "/admin",
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "install",
|
||||
cacheDuration: false,
|
||||
})
|
||||
);
|
||||
if (entrypoints) {
|
||||
// Add the embed targets.
|
||||
router.use(
|
||||
"/embed/stream",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
enableCustomCSS: true,
|
||||
entrypoint: entrypoints.get("stream"),
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/embed/auth",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("auth"),
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/embed/auth/callback",
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("authCallback"),
|
||||
})
|
||||
);
|
||||
|
||||
// Handle the root path.
|
||||
router.get(
|
||||
"/",
|
||||
// Redirect the user to the install page if they are not, otherwise redirect
|
||||
// them to the admin.
|
||||
installedMiddleware({ tenantCache: app.tenantCache }),
|
||||
(req, res, next) => res.redirect("/admin")
|
||||
);
|
||||
// Add the standalone targets.
|
||||
router.use(
|
||||
"/admin",
|
||||
// If we aren't already installed, redirect the user to the install page.
|
||||
installedMiddleware({
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("admin"),
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/install",
|
||||
// If we're already installed, redirect the user to the admin page.
|
||||
installedMiddleware({
|
||||
redirectIfInstalled: true,
|
||||
redirectURL: "/admin",
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
cacheDuration: false,
|
||||
entrypoint: entrypoints.get("install"),
|
||||
})
|
||||
);
|
||||
|
||||
// Handle the root path.
|
||||
router.get(
|
||||
"/",
|
||||
// Redirect the user to the install page if they are not, otherwise redirect
|
||||
// them to the admin.
|
||||
installedMiddleware({ tenantCache: app.tenantCache }),
|
||||
(req, res, next) => res.redirect("/admin")
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ manifest },
|
||||
"could not load the generated manifest, client routes will remain un-mounted"
|
||||
);
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% import "macros.html" as macros %}
|
||||
|
||||
{% extends "templates/base.html" %}
|
||||
|
||||
{% block title %}Talk{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
<script type="application/javascript" id="config">{{ staticURI | dump | safe }}</script>
|
||||
{% endblock %}
|
||||
|
||||
{# Include all the styles from the entrypoint #}
|
||||
{% if entrypoint.css or enableCustomCSS %}
|
||||
{% block css %}
|
||||
{% if entrypoint.css %}
|
||||
{% for asset in entrypoint.css %}
|
||||
{{ macros.css(asset.src, asset.integrity, staticURI) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if enableCustomCSS %}
|
||||
{# Custom CSS is included after the CSS block so that its overrides will apply #}
|
||||
{% include "partials/customCSS.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block html %}
|
||||
<div id="app"></div>
|
||||
{% endblock %}
|
||||
|
||||
{# Include all the scripts from the entrypoint #}
|
||||
{% if entrypoint.js %}
|
||||
{% block js %}
|
||||
{% for asset in entrypoint.js %}
|
||||
{{ macros.js(asset.src, asset.integrity, staticURI) }}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% macro css(src, integrity = '', prefix = '') %}
|
||||
<link type="text/css" rel="stylesheet" href="{{ prefix }}{{ src }}"/>
|
||||
{# TODO: evaluate when to enable SRI, non-SSL connections cause issues #}
|
||||
{# {% if integrity %}
|
||||
<link type="text/css" rel="stylesheet" href="{{ prefix }}{{ src }}" integrity="{{ integrity }}" crossorigin="anonymous"/>
|
||||
{% else %}
|
||||
<link type="text/css" rel="stylesheet" href="{{ prefix }}{{ src }}"/>
|
||||
{% endif %} #}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro js(src, integrity = '', prefix = '') %}
|
||||
<script type="application/javascript" src="{{ prefix }}{{ src }}"></script>
|
||||
{# TODO: evaluate when to enable SRI, non-SSL connections cause issues #}
|
||||
{# {% if false %}
|
||||
<script type="application/javascript" src="{{ prefix }}{{ src }}" integrity="{{ integrity }}" crossorigin="anonymous"></script>
|
||||
{% else %}
|
||||
<script type="application/javascript" src="{{ prefix }}{{ src }}"></script>
|
||||
{% endif %} #}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% import "../macros.html" as macros %}
|
||||
|
||||
{% if tenant and tenant.customCSSURL %}
|
||||
{{ macros.css(tenant.customCSSURL) }}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{# Meta tags #}
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{# Title #}
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
|
||||
{# CSS #}
|
||||
{% block css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
{% block html %}
|
||||
<div id="root"></div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
{% block js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -935,9 +935,9 @@ type Settings {
|
||||
autoCloseStream: Boolean! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
customCssUrl is the URL of the custom CSS used to display on the frontend.
|
||||
customCSSURL is the URL of the custom CSS used to display on the frontend.
|
||||
"""
|
||||
customCssUrl: String
|
||||
customCSSURL: String
|
||||
|
||||
"""
|
||||
closedTimeout is the amount of time (in seconds) from the createdAt timestamp
|
||||
@@ -2201,9 +2201,9 @@ input SettingsInput {
|
||||
autoCloseStream: Boolean
|
||||
|
||||
"""
|
||||
customCssUrl is the URL of the custom CSS used to display on the frontend.
|
||||
customCSSURL is the URL of the custom CSS used to display on the frontend.
|
||||
"""
|
||||
customCssUrl: String
|
||||
customCSSURL: String
|
||||
|
||||
"""
|
||||
closedTimeout is the amount of seconds from the createdAt timestamp that a
|
||||
|
||||
@@ -80,7 +80,11 @@ export interface Auth {
|
||||
}
|
||||
|
||||
export interface Settings extends ModerationSettings {
|
||||
customCssUrl?: string;
|
||||
/**
|
||||
* customCSSURL is the URL that can be specified by the Tenant to describe a
|
||||
* URL that contains custom styles to be applied to the Stream.
|
||||
*/
|
||||
customCSSURL?: string;
|
||||
|
||||
/**
|
||||
* editCommentWindowLength is the length of time (in seconds) after a comment
|
||||
|
||||
Reference in New Issue
Block a user