mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-15 02:50:10 +01:00
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:
parent
f9929a28cb
commit
8180787c2e
@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "vite --open",
|
||||
"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",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
8
frontend/scripts/postbuild.js
Normal file
8
frontend/scripts/postbuild.js
Normal 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.`);
|
@ -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>
|
||||
);
|
||||
}
|
@ -7,6 +7,7 @@ import CustomMessages from "./CustomMessages";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CustomAppName from "./CustomAppName";
|
||||
import LanguagePreference from "./LanguagePreference";
|
||||
import CustomSiteSettings from "./CustomSiteSettings";
|
||||
|
||||
export default function Appearance() {
|
||||
const { t } = useTranslation();
|
||||
@ -34,6 +35,7 @@ export default function Appearance() {
|
||||
<CustomMessages />
|
||||
<FooterCustomization />
|
||||
<SupportEmail />
|
||||
<CustomSiteSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -49,6 +49,15 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
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: [
|
||||
// Reduces transformation time by 50% and we don't even use this variant, so we can ignore.
|
||||
/@phosphor-icons\/react\/dist\/ssr/
|
||||
|
@ -356,6 +356,14 @@ function adminEndpoints(app) {
|
||||
(await SystemSettings.get({ label: "custom_app_name" }))?.value ||
|
||||
null,
|
||||
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 });
|
||||
} catch (e) {
|
||||
|
@ -63,6 +63,9 @@ developerEndpoints(app, apiRouter);
|
||||
embeddedEndpoints(apiRouter);
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
const { MetaGenerator } = require("./utils/boot/MetaGenerator");
|
||||
const IndexPage = new MetaGenerator();
|
||||
|
||||
app.use(
|
||||
express.static(path.resolve(__dirname, "public"), {
|
||||
extensions: ["js"],
|
||||
@ -75,7 +78,8 @@ if (process.env.NODE_ENV !== "development") {
|
||||
);
|
||||
|
||||
app.use("/", function (_, response) {
|
||||
response.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
IndexPage.generate(response);
|
||||
return;
|
||||
});
|
||||
|
||||
app.get("/robots.txt", function (_, response) {
|
||||
|
@ -6,6 +6,7 @@ const { default: slugify } = require("slugify");
|
||||
const { isValidUrl, safeJsonParse } = require("../utils/http");
|
||||
const prisma = require("../utils/prisma");
|
||||
const { v4 } = require("uuid");
|
||||
const { MetaGenerator } = require("../utils/boot/MetaGenerator");
|
||||
|
||||
function isNullOrNaN(value) {
|
||||
if (value === null) return true;
|
||||
@ -21,6 +22,7 @@ const SystemSettings = {
|
||||
"telemetry_id",
|
||||
"footer_data",
|
||||
"support_email",
|
||||
|
||||
"text_splitter_chunk_size",
|
||||
"text_splitter_chunk_overlap",
|
||||
"agent_search_provider",
|
||||
@ -28,6 +30,10 @@ const SystemSettings = {
|
||||
"agent_sql_connections",
|
||||
"custom_app_name",
|
||||
|
||||
// Meta page customization
|
||||
"meta_page_title",
|
||||
"meta_page_favicon",
|
||||
|
||||
// beta feature flags
|
||||
"experimental_live_file_sync",
|
||||
],
|
||||
@ -122,6 +128,27 @@ const SystemSettings = {
|
||||
if (!["enabled", "disabled"].includes(update)) return "disabled";
|
||||
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 () {
|
||||
const { hasVectorCachedFiles } = require("../utils/files");
|
||||
|
233
server/utils/boot/MetaGenerator.js
Normal file
233
server/utils/boot/MetaGenerator.js
Normal 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;
|
Loading…
Reference in New Issue
Block a user