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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
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 { 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>
|
||||||
|
@ -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/
|
||||||
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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");
|
||||||
|
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