diff --git a/backend/helpers/appShell.ts b/backend/helpers/appShell.ts index 470a9c210..dd0d171a7 100644 --- a/backend/helpers/appShell.ts +++ b/backend/helpers/appShell.ts @@ -71,6 +71,16 @@ const HOME_PATH = 'home' /** The element the injected copy is wrapped in. `frontend/index.html` styles it; `main.js` removes it. */ const PRERENDER_ID = 'wiki-prerender' +/** + * The `` : '' + ] + .filter(Boolean) + .join('\n '), + body: theme?.injectBody?.trim() ?? '' + } +} + /** * The document to answer a request for the app shell with. * * Every request gets a head describing the page at its URL; which page that is, and what else travels * with it, is what `fragmentsForCrawler` and `fragmentsForBrowser` differ about. * + * The site's own theme injections travel with it (`themeInjections`), which is what puts the CSS + * override and the head and body HTML from **Admin → Theme** into the document — the head ones after + * everything describing the page, so that an override is the last stylesheet in the document. + * * Only the public half is cached, and the shell is never cached: the shell is re-read per request so * that `npm run build` in `frontend/` takes effect immediately, which a cached whole document would - * have delayed by the TTL, and the two string insertions that combine them are nothing next to a - * database read. Both insertions use a replacer function rather than a replacement string — a page - * containing `$&` would otherwise rewrite itself as it was inserted. + * have delayed by the TTL, and the string insertions that combine them are nothing next to a database + * read. Every insertion uses a replacer function rather than a replacement string — a page, or an + * injection, containing `$&` would otherwise rewrite itself as it was inserted. * * @param shell The compiled `assets/index.html`, as read for this request */ @@ -459,9 +512,11 @@ export async function renderAppShell( where there is not, so that a shell built without one is enriched rather than silently skipped. */ const withoutTitle = shell.replace(/[ \t]*[\s\S]*?<\/title>\n?/i, '') + const injected = themeInjections(siteId) + const head = [fragments.head, injected.head].filter(Boolean).join('\n ') const html = withoutTitle - .replace('</head>', () => ` ${fragments.head}\n </head>`) - .replace('</body>', () => `${fragments.body}</body>`) + .replace('</head>', () => ` ${head}\n </head>`) + .replace('</body>', () => `${fragments.body}${injected.body}</body>`) return { html, status: fragments.status, robots: fragments.robots } } diff --git a/backend/locales/en.json b/backend/locales/en.json index f504a6109..a110a9d82 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1145,7 +1145,7 @@ "admin.theme.baseFont": "Base Font", "admin.theme.baseFontHint": "The font used across the site for the interface.", "admin.theme.bodyHtmlInjection": "Body HTML Injection", - "admin.theme.bodyHtmlInjectionHint": "HTML code to be injected just before the closing body tag.", + "admin.theme.bodyHtmlInjectionHint": "HTML code to be injected just before the closing body tag. Applied the next time a page is loaded.", "admin.theme.codeBlocks": "Code Blocks", "admin.theme.codeBlocksAppearance": "Code Blocks Appearance", "admin.theme.codeBlocksAppearanceHint": "The color theme used to display code blocks on pages.", @@ -1155,7 +1155,7 @@ "admin.theme.contentWidth": "Content Width", "admin.theme.contentWidthHint": "Should the content use all available viewport space or stay centered.", "admin.theme.cssOverride": "CSS Override", - "admin.theme.cssOverrideHint": "CSS code to inject after system default CSS. Injecting too much CSS code can result in poor page load performance! CSS will automatically be minified.", + "admin.theme.cssOverrideHint": "CSS code to inject after system default CSS. Injecting too much CSS code can result in poor page load performance!", "admin.theme.cssOverrideWarning": "{caution} When adding styles for page content, you must scope them to the {cssClass} class. Omitting this could break the layout of the editor!", "admin.theme.cssOverrideWarningCaution": "CAUTION:", "admin.theme.darkMode": "Dark Mode", @@ -1166,7 +1166,7 @@ "admin.theme.downloadThemes": "Download Themes", "admin.theme.fonts": "Fonts", "admin.theme.headHtmlInjection": "Head HTML Injection", - "admin.theme.headHtmlInjectionHint": "HTML code to be injected just before the closing head tag. Usually for script tags.", + "admin.theme.headHtmlInjectionHint": "HTML code to be injected just before the closing head tag. Usually for script tags. Applied the next time a page is loaded.", "admin.theme.headerColor": "Header Color", "admin.theme.headerColorHint": "The background color for the site top header. Does not apply to the administration area.", "admin.theme.iconset": "Icon Set", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0f9c59b97..52aa71c10 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -136,6 +136,39 @@ async function applyTheme() { // -> Highlight.js Theme await applyCodeBlocksTheme() + + // -> CSS Override. Last, so that it is the last stylesheet in the document + applyCssOverride() +} + +/** + * The CSS override from Admin → Theme, as its own element at the end of the head. + * + * The server already puts this element into the document it serves, so a reader never sees the wiki + * unstyled while the bundle loads — see `themeInjections` in `backend/helpers/appShell.ts`, and note + * that the id is shared with it. This is the same element written again from the site store, which is + * what makes the field take effect the moment it is saved rather than on the next hard navigation; the + * server's copy is removed first, so there is never more than one of it. + * + * Applied after the code blocks theme for the reason the admin area states: an override goes after the + * wiki's own styles. Both are appended to the head, so the last one appended is the one that wins. + * + * The head and body HTML injections have no counterpart here, on purpose. They are usually a `<script>` + * — an analytics snippet, a tag manager — and a copy of one the document has already run would run it a + * second time. Those two belong to the document and apply when it is next loaded. + */ +function applyCssOverride() { + document.querySelector('#theme-css-override')?.remove() + + const css = siteStore.theme.injectCSS?.trim() + if (!css) { + return + } + + const styleEl = document.createElement('style') + styleEl.id = 'theme-css-override' + styleEl.textContent = css + document.head.appendChild(styleEl) } /**