feat: site-wide banner

This commit is contained in:
NGPixel
2026-08-17 21:00:02 -04:00
parent d0c5a8bfa9
commit d39e063371
13 changed files with 376 additions and 14 deletions
+18
View File
@@ -34,6 +34,24 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
footerExtra: {
type: 'string'
},
banner: {
type: 'object',
description:
'A notice shown above the contents of every page of this site, styled as a caution admonition. The content is markdown, and is rendered by the app rather than stored as HTML.',
properties: {
isEnabled: {
type: 'boolean'
},
title: {
type: 'string',
maxLength: 255
},
content: {
type: 'string',
maxLength: 8192
}
}
},
pageExtensions: {
type: 'array',
items: {
+5
View File
@@ -18,6 +18,7 @@ const SITE_CONFIG_KEYS = [
'company',
'contentLicense',
'footerExtra',
'banner',
'pageExtensions',
'logoText',
'sitemap',
@@ -262,6 +263,7 @@ async function routes(app: FastifyInstance) {
company?: string
contentLicense?: string
footerExtra?: string
banner?: { isEnabled?: boolean; title?: string; content?: string }
pageExtensions?: string[]
logoText?: boolean
sitemap?: boolean
@@ -329,6 +331,9 @@ async function routes(app: FastifyInstance) {
footerExtra: {
type: 'string'
},
banner: {
$ref: 'Site#/properties/banner'
},
pageExtensions: {
type: 'array',
items: {
+7
View File
@@ -297,6 +297,13 @@
"admin.general.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.",
"admin.general.allowSearch": "Allow Search",
"admin.general.allowSearchHint": "Can users search for content they have read access to?",
"admin.general.banner": "Site-wide Banner",
"admin.general.bannerContent": "Banner Contents",
"admin.general.bannerContentHint": "The text of the banner. Basic markdown is supported.",
"admin.general.bannerEnabled": "Show Banner",
"admin.general.bannerEnabledHint": "Display a notice at the top of every page of this site.",
"admin.general.bannerTitle": "Banner Title",
"admin.general.bannerTitleHint": "Heading shown above the banner contents. Leave empty to hide.",
"admin.general.companyName": "Company / Organization Name",
"admin.general.companyNameHint": "Name to use when displaying copyright notice in the footer. Leave empty to hide.",
"admin.general.contentLicense": "Content License",
+10
View File
@@ -99,6 +99,11 @@ class Sites {
company: '',
contentLicense: '',
footerExtra: '',
banner: {
isEnabled: false,
title: '',
content: ''
},
pageExtensions: ['md', 'html', 'txt'],
discoverable: false,
defaults: {
@@ -351,6 +356,11 @@ class Sites {
company: '',
contentLicense: '',
footerExtra: '',
banner: {
isEnabled: false,
title: '',
content: ''
},
pageExtensions: ['md', 'html', 'txt'],
discoverable: false,
defaults: {
+13 -2
View File
@@ -128,6 +128,9 @@ import { useSiteStore } from '@/stores/site'
* `<block-name prop="value">` — the element the component registers itself as.
*/
/** Blocks the editor's side toolbar inserts directly, so the picker leaves them out. */
const TOOLBAR_BLOCKS = ['tabs']
// STORES
const siteStore = useSiteStore()
@@ -148,8 +151,16 @@ const state = reactive({
// COMPUTED
/** Only blocks this site has switched on: the rest cannot render, so offering them is a trap. */
const blocks = computed(() => state.blocks.filter((block) => block.isEnabled))
/**
* Blocks offered here: the ones this site has switched on, minus the ones the editor inserts itself.
*
* A block that is off cannot render, so offering it is a trap. Tabs is on but has its own button in
* the editor's side toolbar, which inserts the very same markup — listing it here as well is a second
* way to the same place.
*/
const blocks = computed(() =>
state.blocks.filter((block) => block.isEnabled && !TOOLBAR_BLOCKS.includes(block.block))
)
const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : ''))
+195
View File
@@ -0,0 +1,195 @@
<template>
<!--
Two classes doing two different jobs. `page-contents` is what draws the markdown INSIDE the notice
-- its paragraphs, lists, links and code -- exactly as the same markdown would read on a page, and
is also what zeroes the notice's own outer margins (`> :first-child`, `> :last-child`).
The notice itself is drawn by `site-banner-alert` in the style block below, which is a COPY of the
caution admonition rather than a use of it: a banner is chrome an administrator raises over a
whole site, an admonition is something an author wrote in one page, and the two only happen to
look alike today. Restyle either without touching the other.
-->
<div class="page-contents site-banner" v-if="html" v-html="html" />
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { escape } from 'es-toolkit/string'
import { useSiteStore } from '@/stores/site'
/**
* The site-wide banner, above the contents of every page.
*
* Site configuration rather than content: it is set in the admin area's General section, is the same
* on every page, and so never goes through the page renderer -- which renders ONE page, on the server,
* and stores the result. This renders here instead, on every view, which is also what lets a banner
* raised during an incident appear without every page being re-rendered.
*
* Only the inline parts of markdown are worth anything in a notice a few lines long, but the full
* block grammar comes free with the parser, so the whole of markdown-it is what runs -- with `html`
* OFF, unlike the page renderer. An administrator writing a banner is not writing a page, and
* markdown-it's own link validation (`javascript:` and friends) is then the only escape left to
* worry about, which it handles itself.
*/
// STORES
const siteStore = useSiteStore()
// DATA
/**
* The parser, built on first use and kept.
*
* Imported dynamically so that markdown-it stays out of the bundle a reader downloads for a site with
* no banner, which is nearly all of them -- and so that it is fetched alongside the banner rather than
* ahead of the page.
*/
let md = null
/** Which render is the current one, so that a slower earlier import cannot land on top of a later. */
let generation = 0
const html = ref('')
// COMPUTED
const banner = computed(() => siteStore.banner)
// WATCHERS
watch(banner, render, { deep: true, immediate: true })
// METHODS
async function render() {
const gen = ++generation
const { isEnabled, title, content } = banner.value
if (!isEnabled) {
html.value = ''
return
}
// -> Text, not markdown: a title is one line, and a heading inside a heading is not what an
// administrator typing a sentence there means
const parts = title ? [`<p class="site-banner-title">${escape(title)}</p>`] : []
if (content) {
md ??= new (await import('markdown-it')).default({
html: false,
linkify: true,
breaks: true
})
parts.push(md.render(content))
}
/*
Built as a string rather than written in the template around a `v-html` element, because the
title has to be a DIRECT child of the notice: an element in the template could only hold the
render in a wrapper of its own, and every `>` selector below would then miss.
*/
if (gen === generation) {
html.value = parts.length > 0 ? `<div class="site-banner-alert">${parts.join('')}</div>` : ''
}
}
</script>
<!--
Not scoped: the notice and its title are written by `v-html`, and scoped styles reach only the
elements Vue itself renders. Every selector is under `.site-banner`, which is what keeps it to this
component -- and what carries the specificity, since these rules sit inside `.page-contents` and
have to out-weigh its own (`.page-contents p` and friends are two components, so one class is not
enough).
-->
<style lang="scss">
.site-banner {
/*
Flush to the top of the column, a hairline of it left showing either side, and the article's own
distance below.
All three are measured off the padding `page-container-body` puts around the article -- 1rem, or
0.5rem on a phone, which is why the breakpoint here is the 600px `Index.vue` switches that padding
at. The band takes the whole of it back at the top and all but a pixel of it at the sides, so the
property below is that padding and the margins are what is left of it.
*/
--site-banner-pad: 1rem;
margin: calc(-1 * var(--site-banner-pad)) calc(1px - var(--site-banner-pad)) 1.5rem;
@media (max-width: $breakpoint-xs-max) {
--site-banner-pad: 0.5rem;
}
/*
Descended from the caution admonition in `css/_page-contents.scss` and now drawn on its own terms:
a wash, a heavier rule and an icon -- colour alone would leave a reader who cannot separate the
hues with nothing to go on, and the icon is a masked SVG rather than a glyph because the app ships
no icon webfont.
The rule runs along the BOTTOM and the corners are square, which is where it parts company with an
admonition: this is a band across the top of the page rather than a block within it, so it reads
as a strip the article begins under -- squared off to the column's own edges, and closed by a line
that says where the page's own content starts.
Its colours are declared here as the banner's OWN two properties rather than read from the content
palette, so that re-tinting the banner is these two lines (plus the two in the dark block) and
reaches nothing else.
*/
.site-banner-alert {
--site-banner-hue: #c02636;
--site-banner-wash: rgba(192, 38, 54, 0.08);
position: relative;
padding: 0.9em 1.1em 0.9em 3.1em;
border-bottom: 4px solid var(--site-banner-hue);
background-color: var(--site-banner-wash);
/* -> Lighter, because the notice sits on a dark page rather than in the flow of one */
@at-root .body--dark & {
--site-banner-hue: #ff8b8b;
--site-banner-wash: rgba(255, 139, 139, 0.12);
}
&::before {
content: '';
position: absolute;
/*
Centred on the CAP BAND of the first line rather than on the line box holding it: a line box
is ascent plus descent, and a title inks only what is between the cap line and the baseline,
so an icon centred on the box reads as sitting a pixel high. `0.9em` is the padding above the
first line, `0.79em` the middle of the cap band within it, and `0.625em` half the icon.
*/
top: calc(0.9em + 0.79em - 0.625em);
left: 1.1em;
width: 1.25em;
height: 1.25em;
/*
The warning triangle, drawn here as a mask rather than referenced as an icon: a name would be
resolved at runtime through `/_icons`, and a banner raised because something is wrong is the
last thing that should depend on a fetch.
*/
background-color: var(--site-banner-hue);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z'/%3E%3C/svg%3E");
mask-repeat: no-repeat;
mask-size: contain;
}
/* -> The notice ends where its box does; the last block's own margin would show as a gap */
> :last-child {
margin-bottom: 0;
}
/*
The banner's heading, in the notice's colour since that is what it names, and close above the
text it introduces rather than a paragraph's distance from it.
*/
> .site-banner-title {
margin-bottom: 0.3em;
color: var(--site-banner-hue);
font-weight: 600;
}
}
}
</style>
+7 -1
View File
@@ -49,6 +49,7 @@ import hljs from 'highlight.js/lib/core'
import css from 'highlight.js/lib/languages/css'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import markdown from 'highlight.js/lib/languages/markdown'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
@@ -59,7 +60,10 @@ const props = defineProps({
type: String,
default: ''
},
/** `css` | `html` | `javascript` | `json` | `yaml`; anything else renders unhighlighted. */
/**
* `css` | `html` | `javascript` | `json` | `markdown` | `yaml`; anything else renders
* unhighlighted.
*/
language: {
type: String,
default: 'plaintext'
@@ -102,6 +106,7 @@ const inputEl = ref(null)
hljs.registerLanguage('css', css)
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('yaml', yaml)
@@ -115,6 +120,7 @@ const HLJS_LANGUAGES = {
html: 'xml',
javascript: 'javascript',
json: 'json',
markdown: 'markdown',
yaml: 'yaml'
}
+10 -2
View File
@@ -66,7 +66,14 @@ const props = defineProps({
type: Boolean,
default: false
},
/** Underlying element when this is not a link. */
/**
* Underlying element when this is not a link.
*
* Nullable, and null means the default rather than nothing. Callers write
* `:tag="condition ? 'label' : null"` to say "a label only when the row is operable", and a prop
* default does not cover that: Vue applies one for `undefined` alone, so the null arrived intact
* and `<component :is="null">` rendered the row away to nothing. See `tagName`.
*/
tag: {
type: String,
default: 'div'
@@ -105,7 +112,8 @@ const showsAffordance = computed(
// -> A disabled link must stop being a link, or the browser will still navigate on click
const tagName = computed(() => {
if (!isAnchor.value) {
return props.tag
// -> `?? 'div'` rather than the prop default, which Vue only applies to `undefined`; see `tag`
return props.tag ?? 'div'
}
return props.to ? 'router-link' : 'a'
})
+17 -5
View File
@@ -162,18 +162,19 @@
class="w-select-option flex w-full cursor-pointer flex-nowrap items-center gap-2 px-4 text-left hover:bg-black/5 dark:hover:bg-white/8"
:class="[
optionsDense ? 'min-h-8 py-1 text-body2' : 'min-h-10 py-2',
isSelected(opt.value) ? 'text-primary' : '',
isSelected(opt.value) ? selectedOptionClass : '',
idx === activeIndex ? 'bg-black/8 dark:bg-white/12' : ''
]"
@click.stop="select(opt.value)"
@mousemove="activeIndex = idx">
<!--
A check, not a checkbox. The icon takes the row's own font size unless told otherwise,
which made a 14px square that read as a rendering fault rather than a control -- and the
row already announces its state by colouring itself. The column is held open when
nothing is drawn, so labels line up whatever is selected.
which made a 14px square that read as a rendering fault rather than a control. Drawn for
a single selection too: colour alone is a weak marker, and the one the row used to rely
on could not be strong enough on a dark panel to carry the state by itself. The column is
held open when nothing is drawn, so labels line up whatever is selected.
-->
<span v-if="multiple" class="flex w-5 shrink-0 justify-center">
<span class="flex w-5 shrink-0 justify-center">
<w-icon v-if="isSelected(opt.value)" name="mdi:check" size="20px" />
</span>
<span class="min-w-0 flex-1">
@@ -747,6 +748,17 @@ const floatColorClass = computed(() => {
return isOpen.value ? 'text-primary dark:text-primary-light' : 'text-black/60 dark:text-white/70'
})
/*
Brand blue is a *dark* colour, so on the dark panel it sat at roughly 3:1 against `dark-3` and the
selected row read as the least legible one in the list. The lightened blue is the same colour the
floating label and `.w-section-header` switch to on dark, and it carries the emphasis without the
contrast loss. `dark` is checked separately from the `dark:` variant because a menu opened from a
dark surface renders dark whatever the app theme -- which is exactly the admin sidebar's case.
*/
const selectedOptionClass = computed(() =>
props.dark ? 'text-primary-light' : 'text-primary dark:text-primary-light'
)
const controlClasses = computed(() => [
props.dense ? 'w-input-control--dense min-h-9 px-2 py-1' : 'min-h-11 px-3 py-2',
// -> Its own surface, white or a dark well, matching WInput; see the note there
+1 -1
View File
@@ -19,7 +19,7 @@
flat
color="grey"
:aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin`"
:href="siteStore.docsBase + `/admin/dashboard`"
target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn>
+61
View File
@@ -415,6 +415,55 @@
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- Site-wide Banner -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
<w-card-header>{{ t('admin.general.banner') }}</w-card-header>
<w-item tag="label">
<blueprint-icon icon="flag-filled" />
<w-item-section>
<w-item-label>{{ t(`admin.general.bannerEnabled`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.bannerEnabledHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.banner.isEnabled"
:aria-label="t(`admin.general.bannerEnabled`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="typography" />
<w-item-section>
<w-item-label>{{ t(`admin.general.bannerTitle`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.bannerTitleHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
v-model="state.config.banner.title"
dense
:aria-label="t(`admin.general.bannerTitle`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="markdown" class="self-start" />
<w-item-section>
<w-item-label>{{ t(`admin.general.bannerContent`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.bannerContentHint`) }}</w-item-label>
</w-item-section>
</w-item>
<w-item>
<w-item-section>
<util-code-editor
v-model="state.config.banner.content"
language="markdown"
:aria-label="t(`admin.general.bannerContent`)" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- Discovery -->
<!-- ----------------------- -->
<w-card class="pb-2 mt-4">
@@ -538,6 +587,8 @@ import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import UtilCodeEditor from '@/components/UtilCodeEditor.vue'
import {
clearSiteImage,
isAcceptedSiteImage,
@@ -576,6 +627,11 @@ function defaultConfig() {
company: '',
contentLicense: '',
footerExtra: '',
banner: {
isEnabled: false,
title: '',
content: ''
},
pageExtensions: '',
logoText: false,
ratings: {
@@ -695,6 +751,11 @@ async function save() {
company: state.config.company ?? '',
contentLicense: state.config.contentLicense ?? '',
footerExtra: state.config.footerExtra ?? '',
banner: {
isEnabled: state.config.banner?.isEnabled ?? false,
title: state.config.banner?.title ?? '',
content: state.config.banner?.content ?? ''
},
pageExtensions: parsePageExtensions(state.config.pageExtensions),
logoText: state.config.logoText ?? false,
sitemap: state.config.sitemap ?? false,
+18 -3
View File
@@ -115,6 +115,11 @@
<!-- -> Half the padding on a phone, where 16px a side is 8% of the window spent on margin;
the stylesheet has `--content-bleed` to match -->
<div class="page-container-body p-2 sm:p-4">
<!--
Above the article rather than above the toolbars: what an administrator raises a banner
about is the content, and this is where a reader is already looking.
-->
<site-banner />
<!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render.
@@ -363,6 +368,7 @@ import PageTags from '@/components/PageTags.vue'
import PageToc from '@/components/PageToc.vue'
import PageUnlockDialog from '@/components/PageUnlockDialog.vue'
import SideDialog from '@/components/SideDialog.vue'
import SiteBanner from '@/components/SiteBanner.vue'
const editorComponents = {
markdown: defineAsyncComponent({
@@ -1058,13 +1064,22 @@ $toc-overlay-max: 749.98px;
}
}
/*
A hairline of the page's OWN background between the header and whatever the column starts with, in
each theme's colour -- so it is invisible against the article, which is that colour, and reads as one
pixel of daylight under anything that starts flush to the top of the column. A site banner does
exactly that, and against the header's bottom border it needs the gap.
Both themes: with the dark one left out the banner butted straight into the header there and not in
the light theme, which is the sort of difference that reads as a bug in whichever one you see second.
*/
.page-container {
@at-root .body--light & {
border-top: 1px solid #fff;
}
// @at-root .body--dark & {
// border-top: 1px solid $dark-6;
// }
@at-root .body--dark & {
border-top: 1px solid $dark-6;
}
}
/*
The Tags heading's edit toggle. `visibility` is transitioned alongside the opacity so it still fades
+14
View File
@@ -47,6 +47,16 @@ export const useSiteStore = defineStore('site', {
company: '',
contentLicense: '',
footerExtra: '',
/**
* The notice an administrator can raise above the contents of every page — an outage, a freeze,
* a wiki being moved. `content` is markdown, rendered by `SiteBanner.vue` at display time rather
* than stored as HTML: it is site configuration, and never goes through the page renderer.
*/
banner: {
isEnabled: false,
title: '',
content: ''
},
dark: false,
title: '',
description: '',
@@ -191,6 +201,10 @@ export const useSiteStore = defineStore('site', {
company: siteInfo.company,
contentLicense: siteInfo.contentLicense,
footerExtra: siteInfo.footerExtra,
banner: {
...this.banner,
...siteInfo.banner
},
features: {
...this.features,
...siteInfo.features