Enable editing and setting of meta-tag information (#1892)

* Enable editing and setting of meta-tag information

* cleanup

* tmp build for testing

* finally always refresh

* unset workflow

* dev build

* rm tmp build
This commit is contained in:
Timothy Carambat 2024-07-19 15:58:43 -07:00 committed by GitHub
parent f9929a28cb
commit 8180787c2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 441 additions and 30 deletions

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -28,11 +28,11 @@
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" /> <link rel="apple-touch-icon" href="/favicon.png" />
</head> </head>
<body> <body>
<div id="root" class="h-screen"></div> <div id="root" class="h-screen"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

View File

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "vite --open", "start": "vite --open",
"dev": "NODE_ENV=development vite --debug --host=0.0.0.0", "dev": "NODE_ENV=development vite --debug --host=0.0.0.0",
"build": "vite build", "build": "vite build && node scripts/postbuild.js",
"lint": "yarn prettier --ignore-path ../.prettierignore --write ./src", "lint": "yarn prettier --ignore-path ../.prettierignore --write ./src",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@ -0,0 +1,8 @@
import { renameSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
console.log(`Running frontend post build script...`)
renameSync(path.resolve(__dirname, '../dist/index.html'), path.resolve(__dirname, '../dist/_index.html'));
console.log(`index.html renamed to _index.html so SSR of the index page can be assumed.`);

View File

@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import Admin from "@/models/admin";
import showToast from "@/utils/toast";
export default function CustomSiteSettings() {
const [hasChanges, setHasChanges] = useState(false);
const [settings, setSettings] = useState({
title: null,
faviconUrl: null,
});
useEffect(() => {
Admin.systemPreferences().then(({ settings }) => {
setSettings({
title: settings?.meta_page_title,
faviconUrl: settings?.meta_page_favicon,
});
});
}, []);
async function handleSiteSettingUpdate(e) {
e.preventDefault();
await Admin.updateSystemPreferences({
meta_page_title: settings.title ?? null,
meta_page_favicon: settings.faviconUrl ?? null,
});
showToast(
"Site preferences updated! They will reflect on page reload.",
"success",
{ clear: true }
);
setHasChanges(false);
return;
}
return (
<form
className="mb-6"
onChange={() => setHasChanges(true)}
onSubmit={handleSiteSettingUpdate}
>
<div className="flex flex-col border-t border-white/30 pt-4 gap-y-2">
<div className="flex flex-col gap-y-1">
<h2 className="text-base leading-6 font-bold text-white">
Custom Site Settings
</h2>
<p className="text-xs leading-[18px] font-base text-white/60">
Change the content of the browser tab for customization and
branding.
</p>
</div>
<div className="w-fit">
<div className="flex flex-col gap-y-1">
<h2 className="text-sm leading-6 text-white">Tab Title</h2>
<p className="text-xs leading-[18px] font-base text-white/60">
Set a custom tab title when the app is open in a browser.
</p>
</div>
<div className="flex items-center gap-x-4">
<input
name="meta_page_title"
type="text"
className="border-none bg-zinc-900 mt-3 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 max-w-[400px] placeholder:text-white/20"
placeholder="AnythingLLM | Your personal LLM trained on anything"
autoComplete="off"
onChange={(e) => {
setSettings((prev) => {
return { ...prev, title: e.target.value };
});
}}
value={
settings.title ??
"AnythingLLM | Your personal LLM trained on anything"
}
/>
</div>
</div>
<div className="w-fit">
<div className="flex flex-col gap-y-1">
<h2 className="text-sm leading-6 text-white">Tab Favicon</h2>
<p className="text-xs leading-[18px] font-base text-white/60">
Define a url to an image to use for your favicon
</p>
</div>
<div className="flex items-center gap-x-2">
<img
src={settings.faviconUrl ?? "/favicon.png"}
onError={(e) => (e.target.src = "/favicon.png")}
className="h-10 w-10 rounded-lg mt-2.5"
/>
<input
name="meta_page_favicon"
type="url"
className="border-none bg-zinc-900 mt-3 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 max-w-[400px] placeholder:text-white/20"
placeholder="url to your image"
onChange={(e) => {
setSettings((prev) => {
return { ...prev, faviconUrl: e.target.value };
});
}}
autoComplete="off"
value={settings.faviconUrl ?? ""}
/>
</div>
</div>
{hasChanges && (
<button
type="submit"
className="border-none transition-all mt-6 w-fit duration-300 border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
Save
</button>
)}
</div>
</form>
);
}

View File

@ -7,6 +7,7 @@ import CustomMessages from "./CustomMessages";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import CustomAppName from "./CustomAppName"; import CustomAppName from "./CustomAppName";
import LanguagePreference from "./LanguagePreference"; import LanguagePreference from "./LanguagePreference";
import CustomSiteSettings from "./CustomSiteSettings";
export default function Appearance() { export default function Appearance() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -34,6 +35,7 @@ export default function Appearance() {
<CustomMessages /> <CustomMessages />
<FooterCustomization /> <FooterCustomization />
<SupportEmail /> <SupportEmail />
<CustomSiteSettings />
</div> </div>
</div> </div>
</div> </div>

View File

@ -49,6 +49,15 @@ export default defineConfig({
}, },
build: { build: {
rollupOptions: { rollupOptions: {
output: {
// These settings ensure the primary JS and CSS file references are always index.{js,css}
// so we can SSR the index.html as text response from server/index.js without breaking references each build.
entryFileNames: 'index.js',
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'index.css') return `index.css`;
return assetInfo.name;
},
},
external: [ external: [
// Reduces transformation time by 50% and we don't even use this variant, so we can ignore. // Reduces transformation time by 50% and we don't even use this variant, so we can ignore.
/@phosphor-icons\/react\/dist\/ssr/ /@phosphor-icons\/react\/dist\/ssr/

View File

@ -356,6 +356,14 @@ function adminEndpoints(app) {
(await SystemSettings.get({ label: "custom_app_name" }))?.value || (await SystemSettings.get({ label: "custom_app_name" }))?.value ||
null, null,
feature_flags: (await SystemSettings.getFeatureFlags()) || {}, feature_flags: (await SystemSettings.getFeatureFlags()) || {},
meta_page_title: await SystemSettings.getValueOrFallback(
{ label: "meta_page_title" },
null
),
meta_page_favicon: await SystemSettings.getValueOrFallback(
{ label: "meta_page_favicon" },
null
),
}; };
response.status(200).json({ settings }); response.status(200).json({ settings });
} catch (e) { } catch (e) {

View File

@ -63,6 +63,9 @@ developerEndpoints(app, apiRouter);
embeddedEndpoints(apiRouter); embeddedEndpoints(apiRouter);
if (process.env.NODE_ENV !== "development") { if (process.env.NODE_ENV !== "development") {
const { MetaGenerator } = require("./utils/boot/MetaGenerator");
const IndexPage = new MetaGenerator();
app.use( app.use(
express.static(path.resolve(__dirname, "public"), { express.static(path.resolve(__dirname, "public"), {
extensions: ["js"], extensions: ["js"],
@ -75,7 +78,8 @@ if (process.env.NODE_ENV !== "development") {
); );
app.use("/", function (_, response) { app.use("/", function (_, response) {
response.sendFile(path.join(__dirname, "public", "index.html")); IndexPage.generate(response);
return;
}); });
app.get("/robots.txt", function (_, response) { app.get("/robots.txt", function (_, response) {

View File

@ -6,6 +6,7 @@ const { default: slugify } = require("slugify");
const { isValidUrl, safeJsonParse } = require("../utils/http"); const { isValidUrl, safeJsonParse } = require("../utils/http");
const prisma = require("../utils/prisma"); const prisma = require("../utils/prisma");
const { v4 } = require("uuid"); const { v4 } = require("uuid");
const { MetaGenerator } = require("../utils/boot/MetaGenerator");
function isNullOrNaN(value) { function isNullOrNaN(value) {
if (value === null) return true; if (value === null) return true;
@ -21,6 +22,7 @@ const SystemSettings = {
"telemetry_id", "telemetry_id",
"footer_data", "footer_data",
"support_email", "support_email",
"text_splitter_chunk_size", "text_splitter_chunk_size",
"text_splitter_chunk_overlap", "text_splitter_chunk_overlap",
"agent_search_provider", "agent_search_provider",
@ -28,6 +30,10 @@ const SystemSettings = {
"agent_sql_connections", "agent_sql_connections",
"custom_app_name", "custom_app_name",
// Meta page customization
"meta_page_title",
"meta_page_favicon",
// beta feature flags // beta feature flags
"experimental_live_file_sync", "experimental_live_file_sync",
], ],
@ -122,6 +128,27 @@ const SystemSettings = {
if (!["enabled", "disabled"].includes(update)) return "disabled"; if (!["enabled", "disabled"].includes(update)) return "disabled";
return String(update); return String(update);
}, },
meta_page_title: (newTitle) => {
try {
if (typeof newTitle !== "string" || !newTitle) return null;
return String(newTitle);
} catch {
return null;
} finally {
new MetaGenerator().clearConfig();
}
},
meta_page_favicon: (faviconUrl) => {
if (!faviconUrl) return null;
try {
const url = new URL(faviconUrl);
return url.toString();
} catch {
return null;
} finally {
new MetaGenerator().clearConfig();
}
},
}, },
currentSettings: async function () { currentSettings: async function () {
const { hasVectorCachedFiles } = require("../utils/files"); const { hasVectorCachedFiles } = require("../utils/files");

View File

@ -0,0 +1,233 @@
/**
* @typedef MetaTagDefinition
* @property {('link'|'meta')} tag - the type of meta tag element
* @property {{string:string}|null} props - the inner key/values of a meta tag
* @property {string|null} content - Text content to be injected between tags. If null self-closing.
*/
/**
* This class serves the default index.html page that is not present when built in production.
* and therefore this class should not be called when in development mode since it is unused.
* All this class does is basically emulate SSR for the meta-tag generation of the root index page.
* Since we are an SPA, we can just render the primary page and the known entrypoints for the index.{js,css}
* we can always start at the right place and dynamically load in lazy-loaded as we typically normally would
* and we dont have any of the overhead that would normally come with having the rewrite the whole app in next or something.
* Lastly, this class is singleton, so once instantiate the same refernce is shared for as long as the server is alive.
* the main function is `.generate()` which will return the index HTML. These settings are stored in the #customConfig
* static property and will not be reloaded until the page is loaded AND #customConfig is explicity null. So anytime a setting
* for meta-props is updated you should get this singleton class and call `.clearConfig` so the next page load will show the new props.
*/
class MetaGenerator {
name = "MetaGenerator";
/** @type {MetaGenerator|null} */
static _instance = null;
/** @type {MetaTagDefinition[]|null} */
#customConfig = null;
constructor() {
if (MetaGenerator._instance) return MetaGenerator._instance;
MetaGenerator._instance = this;
}
#log(text, ...args) {
console.log(`\x1b[36m[${this.name}]\x1b[0m ${text}`, ...args);
}
#defaultMeta() {
return [
{
tag: "link",
props: { type: "image/svg+xml", href: "/favicon.png" },
content: null,
},
{
tag: "title",
props: null,
content: "AnythingLLM | Your personal LLM trained on anything",
},
{
tag: "meta",
props: {
name: "title",
content: "AnythingLLM | Your personal LLM trained on anything",
},
},
{
tag: "meta",
props: {
description: "title",
content: "AnythingLLM | Your personal LLM trained on anything",
},
},
// <!-- Facebook -->
{ tag: "meta", props: { property: "og:type", content: "website" } },
{
tag: "meta",
props: { property: "og:url", content: "https://useanything.com" },
},
{
tag: "meta",
props: {
property: "og:title",
content: "AnythingLLM | Your personal LLM trained on anything",
},
},
{
tag: "meta",
props: {
property: "og:description",
content: "AnythingLLM | Your personal LLM trained on anything",
},
},
{
tag: "meta",
props: {
property: "og:image",
content:
"https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png",
},
},
// <!-- Twitter -->
{
tag: "meta",
props: { property: "twitter:card", content: "summary_large_image" },
},
{
tag: "meta",
props: { property: "twitter:url", content: "https://useanything.com" },
},
{
tag: "meta",
props: {
property: "twitter:title",
content: "AnythingLLM | Your personal LLM trained on anything",
},
},
{
tag: "meta",
props: {
property: "twitter:description",
content: "AnythingLLM | Your personal LLM trained on anything",
},
},
{
tag: "meta",
props: {
property: "twitter:image",
content:
"https://raw.githubusercontent.com/Mintplex-Labs/anything-llm/master/images/promo.png",
},
},
{ tag: "link", props: { rel: "icon", href: "/favicon.png" } },
{ tag: "link", props: { rel: "apple-touch-icon", href: "/favicon.png" } },
];
}
/**
* Assembles Meta tags as one large string
* @param {MetaTagDefinition[]} tagArray
* @returns {string}
*/
#assembleMeta() {
const output = [];
for (const tag of this.#customConfig) {
let htmlString;
htmlString = `<${tag.tag} `;
if (tag.props !== null) {
for (const [key, value] of Object.entries(tag.props))
htmlString += `${key}="${value}" `;
}
if (tag.content) {
htmlString += `>${tag.content}</${tag.tag}>`;
} else {
htmlString += `>`;
}
output.push(htmlString);
}
return output.join("\n");
}
#validUrl(faviconUrl = null) {
if (faviconUrl === null) return "/favicon.png";
try {
const url = new URL(faviconUrl);
return url.toString();
} catch {
return "/favicon.png";
}
}
async #fetchConfg() {
this.#log(`fetching custome meta tag settings...`);
const { SystemSettings } = require("../../models/systemSettings");
const customTitle = await SystemSettings.getValueOrFallback(
{ label: "meta_page_title" },
null
);
const faviconURL = await SystemSettings.getValueOrFallback(
{ label: "meta_page_favicon" },
null
);
// If nothing defined - assume defaults.
if (customTitle === null && faviconURL === null) {
this.#customConfig = this.#defaultMeta();
} else {
this.#customConfig = [
{
tag: "link",
props: { rel: "icon", href: this.#validUrl(faviconURL) },
},
{
tag: "title",
props: null,
content:
customTitle ??
"AnythingLLM | Your personal LLM trained on anything",
},
];
}
return this.#customConfig;
}
/**
* Clears the current config so it can be refetched on the server for next render.
*/
clearConfig() {
this.#customConfig = null;
}
/**
*
* @param {import('express').Response} response
* @param {number} code
*/
async generate(response, code = 200) {
if (this.#customConfig === null) await this.#fetchConfg();
response.status(code).send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
${this.#assembleMeta()}
<script type="module" crossorigin src="/index.js"></script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="root" class="h-screen"></div>
</body>
</html>`);
}
}
module.exports.MetaGenerator = MetaGenerator;