snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

27
docs/site/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
sidebars/documentation.generated.json
sidebars/developers.generated.json
data/plugin-godocs.json
data/plugin-godocs.json.tmp
data/plugin-jsdocs.json
data/plugin-manifest-docs.json
data/plugin-manifest-docs.json.tmp
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

1
docs/site/.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

234
docs/site/README.md Normal file
View File

@@ -0,0 +1,234 @@
# Mattermost Documentation Site
Docusaurus workspace for [docs.mattermost.com](https://docs.mattermost.com),
living in the [mattermost/mattermost](https://github.com/mattermost/mattermost)
monorepo at `docs/site/`.
## Content layout
| Directory | Route | Description |
|---|---|---|
| `docs/main/` | `/` | User and admin documentation |
| `docs/develop/` | `/developers` | Developer documentation |
| `docs/api/` | `/api` | API reference intro + generated OpenAPI pages |
Paths are relative to the repository root. The Docusaurus site reads them
via the relative paths `../main`, `../develop`, `../api` (from `docs/site/`).
## Prerequisites
- Node.js ≥ 20 — use `nvm use` inside `docs/site/` to pick up `.nvmrc`
- Go and `make` (required for the OpenAPI prebuild step, and for two of the three plugin SDK
reference generators — see below)
- Vale ≥ 3 (for content linting)
## Local development
```shell
cd docs/site
npm ci
npm start # dev server at http://localhost:3000
```
### Sidebar generation
The `documentation` and `developers` sidebars are generated from the content
directories (`sidebars/documentation.generated.json` /
`developers.generated.json`, both gitignored) by `npm run build:sidebars`.
Docusaurus imports these files directly, so **they must exist before
`docusaurus start` or `docusaurus build` runs** — on a fresh checkout there's
no other source for them. This is wired automatically via the `prestart` and
`prebuild` npm lifecycle hooks, so plain `npm start` / `npm run build` just
work.
(`sidebars/active-redirects.json`, by contrast, *is* committed — it's
regenerated and checked in manually via `node scripts/gen-active-redirects.mjs`
when the legacy redirect map changes, not on every build.)
#### Manual grouping overrides
Most top-level sections build their sidebar straight from the filesystem:
each subdirectory becomes a category, each file a doc, sorted by
`sidebar_position` frontmatter then filename. But a few sections are flat
piles of 15-40 files (or split across an inconsistent filesystem nesting
that doesn't reflect any real grouping) that read badly as-is, so
`gen-documentation-sidebar.mjs` layers a **manual grouping override** on
top of the auto-generated tree for those sections only: Overview
(`OVERVIEW_GROUPS`/`OVERVIEW_ROOT_ORDER`), Deployment Guide
(`DEPLOYMENT_GROUPS`/`DEPLOYMENT_ROOT_ORDER`), Administration Guide →
Configure (`ADMIN_CONFIGURE_GROUPS`/`ADMIN_CONFIGURE_ORDER`),
Administration Guide → Manage (`ADMIN_MANAGE_GROUPS`/`ADMIN_MANAGE_ORDER`),
Administration Guide → Onboard (`ADMIN_ONBOARD_GROUPS`/`ADMIN_ONBOARD_ORDER`),
Administration Guide → Scale (`ADMIN_SCALE_GROUPS`/`ADMIN_SCALE_ORDER`),
End User Guide → Collaborate (`COLLABORATE_GROUPS`/`COLLABORATE_ORDER`), and
Integrations Guide (`INTEGRATIONS_GROUPS`/`INTEGRATIONS_ROOT_ORDER`).
Usually the override only changes how the sidebar renders — files stay
flat on disk at their existing paths, so URLs don't move. There are two
exceptions, both cases where the nav home and the URL would otherwise
disagree:
- Administration Guide → Scale: 21 of its 28 files (capacity planning,
HA/architecture, search infrastructure, caching) were physically moved to
`deployment-guide/scale/` and are grouped via `DEPLOYMENT_GROUPS.scaling`,
matching where Sphinx's live nav lists that content. Only the 7
monitoring/observability pages stayed under `ADMIN_SCALE_GROUPS`.
- The PostgreSQL and FIPS migration pages moved the other way, from
`deployment-guide/` to `administration-guide/manage/admin/`, where
Sphinx's only toctree for them lives. They're grouped under
`ADMIN_MANAGE_GROUPS.dataMigration`.
Since those moves change URLs, the Sphinx→Docusaurus redirect targets in
`sidebars/active-redirects.json` were updated to point at the new paths,
and internal `.mdx` links across the docs were rewritten accordingly.
Each override is a pair of constants near the top of the script:
- A `*_GROUPS` map of group key → `{label, landing?, items}`, where `items`
are doc basenames (relative to that section's directory) or nested
inline group objects. A group's `items` can itself contain a nested
`{label, items}` sub-group, which adds a third category level — a
four-level path of Guide → Group → Sub-group → page — for a section
large enough to need it — see `OVERVIEW_GROUPS.subscription`'s "Cloud"
sub-group, or `ADMIN_MANAGE_GROUPS.userAccess`'s "Attribute-Based Access
Control" sub-group, for existing examples.
- A `*_ROOT_ORDER`/`*_ORDER` array listing the top-level order: plain
strings for standalone docs, `{group: 'key'}` for a group from the map
above.
There's no single shared "grouping engine" — each overridden section gets
its own small `buildXItem`/`regroupX` (or `buildXSidebar`) pair that mirrors
the shape of the others (see `buildCollaborateItem`/`regroupCollaborate` for
the End User Guide → Collaborate one). Adding an override for a new section
means copying that shape for the new section, not extending a shared
function.
**Adding a new file to one of these sections:** the script fails loudly if
you forget it — it logs a `WARN: N file(s) missing from *_ORDER` and falls
back to appending the orphaned file(s) at the root of that section, so a
forgotten file surfaces as a warning during `npm run build:sidebars`
rather than silently disappearing. Add the new file's basename to the
relevant group's `items` (or to the root order array, if it's a
standalone/uncategorized page) to place it deliberately instead of
leaving it at the root.
**Adding a whole new manually-grouped section:** copy the pattern of an
existing one (Integrations Guide is the simplest example), then wire it
into `main()` alongside the existing `dir === '...'` checks.
**Nesting a doc from one section under a page in another section:** most
group `items` are plain basenames relative to that section's own
directory, but `buildAdminConfigureItem` also accepts `{doc: '<full id>'}`
for cross-directory references (their label is read directly from the
target file's frontmatter via `docLabelById`, since it won't be in that
section's `leafLabels` map). This is how `ADMIN_CONFIGURE_GROUPS.agents`
nests the vendored Agents plugin pages (`main/agents/docs/`, staged by
`stage-agents-docs.mjs` — not one of the `TOP_LEVEL` sections, so it has
no top-level nav entry of its own) under
`administration-guide/configure/agents-admin-guide`. The same page is
also nested for End User Guide's `end-user-guide/agents` doc, but since
that section has no manual grouping override at all, it uses the smaller
standalone `promoteDocToCategory` helper instead of a full `*_GROUPS`
override — copy that pattern for other one-off single-doc nestings rather
than building a whole grouping override for a section that's otherwise
fine auto-generated.
**Inlining another doc's content onto a page** (rather than just linking
or nesting it): Docusaurus's built-in [Markdown
partials](https://docusaurus.io/docs/next/create-doc#markdown-partials)
feature — any `.md`/`.mdx` file with a leading underscore in its name is
excluded from the docs plugin's routing/sidebars and can be `import`ed
into another MDX file and rendered as `<Component />`. `stage-agents-docs.mjs`
uses this to reproduce Sphinx's `.. include:: /agents/docs/admin_guide.md`
behavior: it stages `admin_guide.md`/`user_guide.md` as normal (but
`unlisted: true`) docs for direct-link parity, *and* as
`_admin_guide_partial.mdx`/`_user_guide_partial.mdx` partials that
`administration-guide/configure/agents-admin-guide.mdx` and
`end-user-guide/agents.mdx` import and render inline — so those two pages
show the full vendored guide content directly, with zero extra clicks,
instead of just linking out to a separate page.
### Plugin SDK reference generators
Three developer pages render content generated at build time from the plugin SDK's own source
rather than hand-written prose, each backed by a gitignored JSON file under `data/`:
| Page | Generator | Reads |
|---|---|---|
| [Server plugin SDK reference](/developers/integrate/reference/server) | `scripts/gen-plugin-godocs` (Go) | `server/public/plugin` |
| [Web app plugin SDK reference](/developers/integrate/reference/webapp) | `scripts/gen-plugin-jsdocs.mjs` (Node) | `webapp/channels/src/plugins/registry.ts` |
| [Manifest reference](/developers/integrate/plugins/manifest-reference) | `scripts/gen-plugin-manifest-docs` (Go) | `server/public/model`'s `Manifest` struct |
They're consumed by the `<PluginGoDocs />`, `<PluginGoExample />`, `<PluginJsDocs />`, and
`<PluginManifestDocs />` components (registered globally in `src/theme/MDXComponents.tsx`), and run
via `npm run build:plugin-docs`, wired into `prestart`/`prebuild` like everything else in this
section. The two Go generators parse their target packages with `go/parser` + `go/doc` rather than
type-checking them via `golang.org/x/tools/go/packages`, so they have no dependency on the Go
toolchain version declared in `server/public/go.mod` — only stdlib, no `go.sum`.
The API reference section (`docs/api/reference/`, also gitignored) has the
same requirement: `docusaurus-plugin-openapi-docs` needs `docusaurus
gen-api-docs mattermost` run before it has any pages to render. `prestart`
handles this too — using the existing OpenAPI spec if present, only falling
back to the slow `make -C api build` spec rebuild if it's missing.
### Full production build
The production build also includes an OpenAPI prebuild step (`npm run
build:openapi`, wired to run automatically before `npm run build` via the
same `prebuild` hook) that invokes `make -C api build`. This requires Go and
takes ~2 minutes.
```shell
cd docs/site
npm ci
npm run build # runs build:sidebars + build:openapi (via prebuild), then Docusaurus build
```
To skip the OpenAPI rebuild during iterative content work:
```shell
cd docs/site
npm run build -- --no-minify # still runs build:sidebars + build:openapi first
```
If you need to bypass the prebuild step entirely (e.g., all generated
artifacts already exist and are current), run:
```shell
cd docs/site
npm run docusaurus build # calls docusaurus directly, skips prebuild
```
### Algolia search
There's a single Algolia DocSearch app for `docs.mattermost.com` — credentials
aren't distributed to individual developers. They're set as repository
variables (`vars.ALGOLIA_APP_ID`, `vars.ALGOLIA_SEARCH_API_KEY`) and injected
only in CI/CD. Local builds simply run without them: the site builds cleanly
and the search bar is omitted (see the conditional in
`docusaurus.config.ts`).
If you need to test search locally, export the same two variables in your
shell before running `npm start`/`npm run build`.
## Scripts
| Command | Description |
|---|---|
| `npm start` | Dev server with hot reload (runs `build:sidebars` + `build:openapi:docs` first via `prestart`) |
| `npm run build` | Production build to `build/` (runs `build:sidebars` + `build:openapi` first via `prebuild`) |
| `npm run build:sidebars` | Regenerate the documentation + developer sidebar JSON |
| `npm run build:openapi:spec` | Regenerate the OpenAPI spec only (slow — invokes `make -C api build`) |
| `npm run build:openapi:docs` | Regenerate the API reference MDX pages from the existing spec (fast) |
| `npm run build:openapi` | Full OpenAPI pipeline: spec then docs |
| `npm run build:plugin-docs` | Regenerate all three plugin SDK reference data files (see above) |
| `npm run serve` | Serve the `build/` output locally |
| `npm run typecheck` | TypeScript type check |
| `node scripts/gen-active-redirects.mjs` | Regenerate legacy redirect map (committed to git; run manually when it changes) |
## Content linting
```shell
vale main develop api
```

View File

@@ -0,0 +1,299 @@
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
// Active redirects (legacy Sphinx URLs → migrated MDX paths). Bulk-extracted
// during the docs migration, maintained by hand since — add entries directly
// to the JSON when a page moves or is removed.
import activeRedirects from './sidebars/active-redirects.json';
// Multi-instance docs setup with three top-level navigations:
// / → Documentation (admin / end-user) sources: ../docs
// /developers → Developers (contribute / integrate) sources: ../develop
// /api → API Reference (OpenAPI-generated) sources: ../api
// See PLAN.md §3.1 for the IA, §3.2 for design tokens.
// Docusaurus's Algolia schema rejects an empty `appId`/`apiKey`, so the
// block is only included when both are set. Without credentials the search
// bar is simply omitted (rather than rendered as an inert element), which
// keeps local and CI builds green.
const algoliaAppId = process.env.ALGOLIA_APP_ID;
const algoliaApiKey = process.env.ALGOLIA_SEARCH_API_KEY;
const algoliaThemeConfig =
algoliaAppId && algoliaApiKey
? {
algolia: {
appId: algoliaAppId,
apiKey: algoliaApiKey,
indexName: 'mattermost-docs',
contextualSearch: true,
searchPagePath: 'search',
searchParameters: {
optionalFilters: [
'docusaurus_tag:docs-documentation-current<score=3>',
'docusaurus_tag:docs-developers-current<score=2>',
'docusaurus_tag:docs-api-current<score=1>',
],
},
},
}
: {};
const config: Config = {
title: 'Mattermost Documentation',
tagline: 'Mission in Motion',
favicon: 'img/brand/icon-denim.svg',
future: {
v4: true,
},
url: 'https://docs.mattermost.com',
baseUrl: process.env.BASE_URL ?? '/',
trailingSlash: false,
organizationName: 'mattermost',
projectName: 'mattermost',
// Broken-link / broken-image policy. During the Phase 4 (developer docs)
// and Phase 5 (main docs) migrations, content references images and
// anchors that haven't been migrated yet — `warn` keeps the build green
// while the unconverted set shows up as warnings we can grep for.
// Tighten to `throw` after each migration phase completes.
onBrokenLinks: 'warn',
markdown: {
mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'warn',
onBrokenMarkdownImages: 'warn',
},
},
i18n: {
defaultLocale: 'en',
locales: ['en'],
},
// Google Fonts: Inter (body) + Archivo Black (headings, free Trade Gothic Heavy fallback).
// Replace with Adobe Fonts kit once Trade Gothic Next licensing is confirmed
// — see PLAN.md §12 open items.
stylesheets: [
{
href: 'https://fonts.googleapis.com/css2?family=Archivo+Black&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap',
type: 'text/css',
},
],
headTags: [
{tagName: 'link', attributes: {rel: 'preconnect', href: 'https://fonts.googleapis.com'}},
{tagName: 'link', attributes: {rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous'}},
],
// Use the classic preset for the default Documentation instance.
// Developers and API are added as separate plugin instances below
// to give each its own routeBasePath and sidebar.
presets: [
[
'classic',
{
docs: {
id: 'documentation',
path: '../main',
routeBasePath: '/',
sidebarPath: './sidebars/documentation.ts',
editUrl: 'https://github.com/mattermost/mattermost/tree/master/docs/main/',
},
blog: false,
theme: {
customCss: ['./src/css/tokens.css', './src/css/custom.css'],
},
} satisfies Preset.Options,
],
],
plugins: [
[
'@docusaurus/plugin-content-docs',
{
id: 'developers',
path: '../develop',
routeBasePath: '/developers',
sidebarPath: './sidebars/developers.ts',
editUrl: 'https://github.com/mattermost/mattermost/tree/master/docs/develop/',
},
],
[
'@docusaurus/plugin-content-docs',
{
id: 'api',
path: '../api',
routeBasePath: '/api',
sidebarPath: './sidebars/api.ts',
editUrl: 'https://github.com/mattermost/mattermost/tree/master/docs/api/',
// Required for endpoint pages generated by docusaurus-plugin-openapi-docs.
docItemComponent: '@theme/ApiItem',
},
],
[
'@docusaurus/plugin-client-redirects',
{
// Legacy URL → migrated MDX path. Pre-filtered to entries whose
// target exists, so the build never breaks on a missing target.
redirects: activeRedirects.redirects,
},
],
// Generates API reference pages from the OpenAPI bundle produced by
// build-openapi.mjs
[
'docusaurus-plugin-openapi-docs',
{
id: 'api-generator',
docsPluginId: 'api',
config: {
mattermost: {
specPath: 'openapi/mattermost-openapi-v4.yaml',
outputDir: '../api/reference',
sidebarOptions: {
groupPathsBy: 'tag',
categoryLinkSource: 'tag',
},
},
},
},
],
],
// Theme for the API endpoint pages (parameter tables, request/response
// schemas, code-sample picker). Layered on the classic preset theme.
themes: ['docusaurus-theme-openapi-docs', '@docusaurus/theme-mermaid'],
themeConfig: {
image: 'img/brand/logo-horizontal-denim.svg',
colorMode: {
defaultMode: 'light',
respectPrefersColorScheme: true,
},
navbar: {
title: '',
logo: {
alt: 'Mattermost',
// Navbar is denim in both light and dark mode, so white logo always.
src: 'img/brand/logo-horizontal-white.svg',
srcDark: 'img/brand/logo-horizontal-white.svg',
},
items: [
{
type: 'docSidebar',
docsPluginId: 'documentation',
sidebarId: 'documentation',
position: 'left',
label: 'Documentation',
},
{
type: 'docSidebar',
docsPluginId: 'developers',
sidebarId: 'developers',
position: 'left',
label: 'Developers',
},
{
type: 'docSidebar',
docsPluginId: 'api',
sidebarId: 'api',
position: 'left',
label: 'API',
},
{
href: 'https://github.com/mattermost/mattermost',
label: 'GitHub',
position: 'right',
},
],
},
footer: {
style: 'dark',
logo: {
alt: 'Mattermost — Mission in Motion',
src: 'img/brand/logo-horizontal-white.svg',
width: 180,
},
links: [
{
title: 'Documentation',
items: [
{label: 'Product Overview', to: '/'},
{label: 'Deployment Guide', to: '/'},
{label: 'Administration Guide', to: '/'},
],
},
{
title: 'Developers',
items: [
{label: 'Contribute', to: '/developers'},
{label: 'Integrate & Extend', to: '/developers'},
{label: 'API Reference', to: '/api'},
],
},
{
title: 'Community',
items: [
{label: 'GitHub', href: 'https://github.com/mattermost/mattermost'},
{label: 'Forum', href: 'https://forum.mattermost.com/'},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} Mattermost, Inc. All rights reserved.`,
},
...algoliaThemeConfig,
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ['bash', 'powershell', 'json', 'yaml', 'go', 'python'],
},
mermaid: {
theme: {light: 'base', dark: 'dark'},
options: {
themeVariables: {
fontSize: '14px',
primaryColor: '#0984e3',
primaryTextColor: '#ffffff',
primaryBorderColor: '#2d3436',
lineColor: '#0745a1',
secondaryColor: '#0984e3',
tertiaryColor: '#d63031',
mainBkg: '#ffffff',
textColor: '#2d3436',
taskBkgColor: '#0984e3',
taskTextColor: '#ffffff',
taskTextLightColor: '#ffffff',
taskTextDarkColor: '#ffffff',
activeTaskBkgColor: '#0984e3',
activeTaskBorderColor: '#2d3436',
doneTaskBkgColor: '#0984e3',
doneTaskBorderColor: '#2d3436',
critBkgColor: '#d63031',
critBorderColor: '#2d3436',
},
gantt: {
fontSize: 14,
sectionFontSize: 14,
barHeight: 28,
barGap: 6,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
},
},
},
// Code-sample tabs shown on every endpoint page, in display order.
// First entry is the default selected tab (curl).
languageTabs: [
{highlight: 'bash', language: 'curl', logoClass: 'curl'},
{highlight: 'powershell', language: 'powershell', logoClass: 'powershell'},
{highlight: 'python', language: 'python', logoClass: 'python'},
{highlight: 'javascript', language: 'nodejs', logoClass: 'nodejs', label: 'Node'},
{highlight: 'go', language: 'go', logoClass: 'go'},
],
} satisfies Preset.ThemeConfig,
};
export default config;

67
docs/site/package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "@mattermost/docs-site",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"postbuild": "node scripts/duplicate-redirect-stubs.mjs",
"stage:agents-docs": "node scripts/stage-agents-docs.mjs",
"build:sidebars": "node scripts/gen-documentation-sidebar.mjs && node scripts/gen-developer-sidebar.mjs",
"build:openapi:spec": "node scripts/build-openapi.mjs",
"build:openapi:docs": "docusaurus gen-api-docs mattermost",
"build:openapi": "npm run build:openapi:spec && npm run build:openapi:docs",
"build:plugin-godocs": "mkdir -p data && go run -C scripts/gen-plugin-godocs . > data/plugin-godocs.json.tmp && mv data/plugin-godocs.json.tmp data/plugin-godocs.json",
"build:plugin-jsdocs": "node scripts/gen-plugin-jsdocs.mjs",
"build:plugin-manifest-docs": "mkdir -p data && go run -C scripts/gen-plugin-manifest-docs . > data/plugin-manifest-docs.json.tmp && mv data/plugin-manifest-docs.json.tmp data/plugin-manifest-docs.json",
"build:plugin-docs": "npm run build:plugin-godocs && npm run build:plugin-jsdocs && npm run build:plugin-manifest-docs",
"prestart": "npm run stage:agents-docs && npm run build:sidebars && ( [ -f openapi/mattermost-openapi-v4.yaml ] || npm run build:openapi:spec ) && npm run build:openapi:docs && npm run build:plugin-docs",
"prebuild": "npm run stage:agents-docs && npm run build:sidebars && npm run build:openapi && npm run build:plugin-docs",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "3.10.1",
"@docusaurus/plugin-client-redirects": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-mermaid": "^3.10.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"yaml": "^2.8.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@types/react": "^19.0.0",
"@typescript-eslint/typescript-estree": "^8.67.0",
"typescript": "~6.0.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}

View File

@@ -0,0 +1 @@
(none — all `<div class="tab">` blocks converted to Docusaurus <Tabs>/<TabItem> in this session)

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Builds the merged OpenAPI spec by delegating to the canonical api/Makefile,
* then sanitizes the output for MDX compatibility.
*
* Sanitization (description / summary / title strings):
* 1) Embedded "..." breaks docusaurus-plugin-openapi-docs frontmatter quoting.
* Replace with curly quotes (“ / ”).
* 2) Markdown autolinks like </api/v4/foo> are parsed as JSX closing tags by MDX.
* Escape bare < not followed by a letter or ! to &lt;.
*
* Usage: node scripts/build-openapi.mjs (from docs/site/)
* Output: docs/site/openapi/mattermost-openapi-v4.yaml
*/
import {execSync} from 'node:child_process';
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parse, stringify} from 'yaml';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); // docs/site/scripts
const DOCS_SITE_DIR = resolve(SCRIPT_DIR, '..'); // docs/site
const MONOREPO_ROOT = resolve(SCRIPT_DIR, '../../..'); // mattermost/
const API_ROOT = resolve(MONOREPO_ROOT, 'api'); // mattermost/api
const RAW_SPEC_PATH = resolve(API_ROOT, 'v4/html/static/mattermost-openapi-v4.yaml'); // make's own output
const SANITIZED_SPEC_PATH = resolve(DOCS_SITE_DIR, 'openapi/mattermost-openapi-v4.yaml'); // what Docusaurus reads
function walkStrings(node, fn, key) {
if (typeof node === 'string') return fn(node, key);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) node[i] = walkStrings(node[i], fn, key);
return node;
}
if (node && typeof node === 'object') {
for (const [k, v] of Object.entries(node)) node[k] = walkStrings(v, fn, k);
return node;
}
return node;
}
function main() {
if (!existsSync(API_ROOT)) {
console.error(`[openapi] api/ directory not found at ${API_ROOT}`);
console.error('[openapi] Ensure you are running from within the mattermost monorepo.');
process.exit(1);
}
console.log(`[openapi] Building OpenAPI spec via make -C ${API_ROOT} build`);
execSync(`make -C ${API_ROOT} build`, {stdio: 'inherit'});
if (!existsSync(RAW_SPEC_PATH)) {
console.error(`[openapi] Expected make output not found: ${RAW_SPEC_PATH}`);
process.exit(1);
}
console.log('[openapi] Sanitizing for MDX compatibility ...');
const doc = parse(readFileSync(RAW_SPEC_PATH, 'utf8'));
let quoteFixes = 0;
let autolinkFixes = 0;
walkStrings(doc, (value, key) => {
if (key !== 'description' && key !== 'summary' && key !== 'title') return value;
let v = value;
if (v.includes('"')) {
const fixed = v.replace(/"([^"]*)"/g, '“$1”');
if (fixed !== v) { quoteFixes++; v = fixed; }
}
if (/<[^a-zA-Z!]/.test(v)) {
const fixed = v.replace(/<(?=[^a-zA-Z!])/g, '&lt;');
if (fixed !== v) { autolinkFixes++; v = fixed; }
}
return v;
});
console.log(`[openapi] Sanitized: quotes=${quoteFixes}, autolinks=${autolinkFixes}`);
mkdirSync(dirname(SANITIZED_SPEC_PATH), {recursive: true});
writeFileSync(SANITIZED_SPEC_PATH, stringify(doc, {lineWidth: 0}));
const sizeMb = (readFileSync(SANITIZED_SPEC_PATH).length / 1024 / 1024).toFixed(2);
console.log(`[openapi] Wrote ${sizeMb} MB → ${SANITIZED_SPEC_PATH}`);
}
main();

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Copy every plugin-client-redirects stub from `build/<from>/index.html` to
// `build/<from>.html` so the docs CloudFront function's `<uri>.html` fallback
// branch finds it (see mattermost-iac-docs-prod → 05-cdn →
// clean-url-rewrite.js).
import {readFileSync, writeFileSync, existsSync, statSync} from 'node:fs';
import {join, resolve, dirname, sep} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..');
const BUILD_DIR = join(SITE_ROOT, 'build');
const REDIRECTS_JSON = join(SITE_ROOT, 'sidebars', 'active-redirects.json');
if (!existsSync(BUILD_DIR)) {
console.error(`[duplicate-redirect-stubs] ${BUILD_DIR} not found; run \`docusaurus build\` first.`);
process.exit(1);
}
const {redirects} = JSON.parse(readFileSync(REDIRECTS_JSON, 'utf8'));
const BUILD_DIR_PREFIX = BUILD_DIR + sep;
let duplicated = 0;
let skippedExisting = 0;
let skippedMissingStub = 0;
let skippedHtmlSuffix = 0;
let skippedOutsideBuild = 0;
for (const {from} of redirects) {
// CFF Phase A already 301s `.html` to the extensionless form.
if (from.endsWith('.html')) {
skippedHtmlSuffix++;
continue;
}
const rel = from.replace(/^\/+/, '');
const stubPath = resolve(BUILD_DIR, rel, 'index.html');
const flatPath = resolve(BUILD_DIR, `${rel}.html`);
if (!stubPath.startsWith(BUILD_DIR_PREFIX) || !flatPath.startsWith(BUILD_DIR_PREFIX)) {
skippedOutsideBuild++;
continue;
}
if (!existsSync(stubPath) || !statSync(stubPath).isFile()) {
skippedMissingStub++;
continue;
}
// Never shadow a real content page at the flat key.
if (existsSync(flatPath)) {
skippedExisting++;
continue;
}
writeFileSync(flatPath, readFileSync(stubPath));
duplicated++;
}
console.log(
`[duplicate-redirect-stubs] duplicated=${duplicated} ` +
`skipped-existing=${skippedExisting} ` +
`skipped-missing-stub=${skippedMissingStub} ` +
`skipped-html-suffix=${skippedHtmlSuffix} ` +
`skipped-outside-build=${skippedOutsideBuild} ` +
`total=${redirects.length}`,
);

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
// Filter the raw redirects.json (extracted from the legacy Sphinx
// redirects.py) to the subset whose target MDX page actually exists in
// the migrated `docs/` tree. Output:
// docs-site/sidebars/active-redirects.json
// (kept in the sidebars/ folder since that's already gitignore-aware and
// regenerated). Consumed by docusaurus.config.ts via
// @docusaurus/plugin-client-redirects.
//
// Internal redirects whose destination is still missing (target page not
// yet migrated, or marked draft) are dropped silently — they'll start
// working as soon as the destination lands. External redirects (to
// github.com, other-host) need server-side rules and aren't emitted here.
//
// Usage: node docs-site/scripts/gen-active-redirects.mjs
import {readFileSync, writeFileSync, readdirSync, statSync, existsSync} from 'node:fs';
import {join, resolve, basename, dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..');
const REPO_ROOT = resolve(SITE_ROOT, '..');
const DOCS = join(REPO_ROOT, 'main');
const SRC = join(SITE_ROOT, 'scripts', 'migrate-main-docs', 'redirects.json');
const OUT = join(SITE_ROOT, 'sidebars', 'active-redirects.json');
function isDraft(filePath) {
try {
const head = readFileSync(filePath, 'utf8').slice(0, 800);
return /^draft:\s*true/m.test(head);
} catch { return false; }
}
function urlFromMdx(absPath) {
// docs/foo/bar.mdx → /foo/bar (index.mdx → /foo)
let rel = absPath.slice(DOCS.length).replace(/\\/g, '/');
rel = rel.replace(/\.mdx$/, '');
if (rel.endsWith('/index')) rel = rel.slice(0, -'/index'.length);
return rel || '/';
}
function collectExistingUrls() {
const urls = new Set();
(function walk(dir) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p);
else if (p.endsWith('.mdx') && !isDraft(p)) urls.add(urlFromMdx(p));
}
})(DOCS);
return urls;
}
function main() {
const raw = JSON.parse(readFileSync(SRC, 'utf8'));
const existing = collectExistingUrls();
const active = [];
const missing = [];
const droppedAnchoredFrom = [];
for (const r of raw.internal) {
// @docusaurus/plugin-client-redirects requires `from` paths without
// anchors. Sphinx allowed anchored sources for mid-page redirects —
// those need server-side rules, not client-side. Drop them.
if (r.from.includes('#')) { droppedAnchoredFrom.push(r); continue; }
// Strip anchor when checking existence; preserve it in the emitted to.
const target = r.to.split('#')[0];
if (existing.has(target)) active.push({from: r.from, to: r.to});
else missing.push(r);
}
// De-duplicate: the plugin refuses duplicate FROM values.
const seenFrom = new Map();
const final = [];
for (const r of active) {
if (seenFrom.has(r.from)) continue;
seenFrom.set(r.from, true);
final.push(r);
}
writeFileSync(
OUT,
JSON.stringify(
{
_meta: {
source: 'docs-site/scripts/migrate-main-docs/redirects.json',
active: final.length,
missing_target: missing.length,
dropped_anchored_from: droppedAnchoredFrom.length,
total_internal: raw.internal.length,
},
redirects: final,
},
null,
2,
),
);
console.log(`[redirects] wrote ${OUT}`);
console.log(` active: ${final.length}`);
console.log(` missing target: ${missing.length}`);
console.log(` dropped (anchored): ${droppedAnchoredFrom.length}`);
console.log(` total internal: ${raw.internal.length}`);
}
main();

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
// Generate the Developers sidebar from the migrated content tree under
// develop/. Output: docs-site/sidebars/developers.generated.json
//
// Usage: node docs-site/scripts/gen-developer-sidebar.mjs
import {readFileSync, writeFileSync, readdirSync, statSync, existsSync} from 'node:fs';
import {join, resolve, basename, dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..');
const REPO_ROOT = resolve(SITE_ROOT, '..');
const SRC = join(REPO_ROOT, 'develop');
const OUT = join(SITE_ROOT, 'sidebars', 'developers.generated.json');
const TOP_LEVEL = [
{dir: 'contribute', label: 'Contribute'},
{dir: 'integrate', label: 'Integrate & Extend'},
{dir: 'internal', label: 'Internal'},
];
function humanize(name) {
return name.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function readFm(filePath, key) {
try {
const text = readFileSync(filePath, 'utf8').slice(0, 2000);
const m = text.match(/^---\n([\s\S]*?)\n---/);
if (!m) return null;
const r = new RegExp(`^${key}:\\s*"?([^"\\n]+)"?`, 'm');
const x = m[1].match(r);
return x ? x[1].trim().replace(/^"|"$/g, '') : null;
} catch { return null; }
}
function pathToDocId(relPath) { return relPath.replace(/\.(md|mdx)$/, ''); }
function buildCategory(absDir, devRelDir) {
const items = [];
const entries = readdirSync(absDir);
const indexFile = entries.find((e) => /^index\.(md|mdx)$/.test(e));
let categoryLink = null;
if (indexFile) {
categoryLink = {type: 'doc', id: pathToDocId(join(devRelDir, indexFile))};
}
const subDirs = [];
const leafDocs = [];
for (const name of entries) {
if (/^index\.(md|mdx)$/.test(name)) continue;
const abs = join(absDir, name);
const st = statSync(abs);
if (st.isDirectory()) subDirs.push(name);
else if (st.isFile() && /\.(md|mdx)$/.test(name)) leafDocs.push(name);
}
function key(name, abs) {
const p = readFm(abs, 'sidebar_position');
return [p ? Number(p) : 9999, name.toLowerCase()];
}
leafDocs.sort((a, b) => {
const ka = key(a, join(absDir, a));
const kb = key(b, join(absDir, b));
return ka[0] - kb[0] || ka[1].localeCompare(kb[1]);
});
subDirs.sort((a, b) => {
const ka = key(a, join(absDir, a, 'index.md'));
const kb = key(b, join(absDir, b, 'index.md'));
return ka[0] - kb[0] || ka[1].localeCompare(kb[1]);
});
for (const name of leafDocs) {
const id = pathToDocId(join(devRelDir, name));
const label = readFm(join(absDir, name), 'title') || humanize(basename(name, /\.(md|mdx)$/.exec(name)[0]));
items.push({type: 'doc', id, label});
}
for (const name of subDirs) {
const sub = buildCategory(join(absDir, name), join(devRelDir, name));
// Skip empty subcategories — Docusaurus refuses sidebar categories that
// have neither a link nor any items.
if (sub) items.push(sub);
}
const label =
readFm(join(absDir, 'index.md'), 'title') ||
readFm(join(absDir, 'index.mdx'), 'title') ||
humanize(basename(absDir));
if (!categoryLink && items.length === 0) return null;
return {type: 'category', label, collapsed: true, ...(categoryLink ? {link: categoryLink} : {}), items};
}
function main() {
if (!existsSync(SRC)) { console.error(`develop/ not found at ${SRC}`); process.exit(1); }
const sidebar = [{type: 'doc', id: 'index', label: 'Welcome'}];
for (const {dir, label} of TOP_LEVEL) {
const abs = join(SRC, dir);
if (!existsSync(abs)) { console.warn(`[sidebar] skip ${dir}: missing`); continue; }
const cat = buildCategory(abs, dir);
cat.label = label;
cat.collapsed = true;
sidebar.push(cat);
}
writeFileSync(OUT, JSON.stringify(sidebar, null, 2));
let cats = 0, docs = 0;
(function walk(n) {
if (Array.isArray(n)) n.forEach(walk);
else if (n && typeof n === 'object') {
if (n.type === 'category') cats++;
if (n.type === 'doc') docs++;
if (n.items) walk(n.items);
}
})(sidebar);
console.log(`[sidebar] wrote ${OUT}: ${cats} categories, ${docs} docs`);
}
main();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/*
* Generates a tileable hexagonal camouflage SVG, matching the Mattermost
* brand book "Hexagonal Camouflage" pattern (denim variant, primary
* brand background).
*
* Output: docs-site/static/img/patterns/hex-camo-denim.svg
*
* Re-run if you want to tweak the shade mix / density / cell size.
*/
import {writeFileSync, mkdirSync} from 'node:fs';
import {dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(HERE, '../static/img/patterns/hex-camo-denim.svg');
// Tile dimensions. Side=9 → ~3x smaller cells than v1 (was side=24).
// Reads as fine grain / texture rather than chunky mosaic.
const SIDE = 9;
const HEX_W = 2 * SIDE;
const HEX_H = Math.sqrt(3) * SIDE;
const COL_STEP = 1.5 * SIDE;
const ROW_STEP = HEX_H;
const COLS = 8; // 8 cols × 8 rows ≈ 108×125 tile
const ROWS = 8;
const TILE_W = COL_STEP * COLS;
const TILE_H = ROW_STEP * ROWS;
// Tight shade band — every cell is within a narrow window of denim-500.
// The base color matches the hero's solid bg, so most cells visually
// disappear; only the slightly-lighter cells provide the texture beat.
// No marigold / no high-contrast outliers.
const SHADES = [
{color: '#1E325C', weight: 14}, // denim-500 (matches hero bg — invisible cells)
{color: '#22386A', weight: 5}, // +4 lightness — faintly visible
{color: '#172A4F', weight: 3}, // -2 lightness — adds depth shadow
];
// Stable PRNG so the SVG is deterministic and reproducible across runs.
function mulberry32(seed) {
return () => {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(0xCAFE);
function pickShade() {
const total = SHADES.reduce((s, x) => s + x.weight, 0);
let r = rand() * total;
for (const s of SHADES) {
if (r < s.weight) return s.color;
r -= s.weight;
}
return SHADES[0].color;
}
function hexPath(cx, cy) {
const pts = [];
for (let i = 0; i < 6; i++) {
const a = Math.PI / 3 * i;
pts.push(`${(cx + SIDE * Math.cos(a)).toFixed(2)},${(cy + SIDE * Math.sin(a)).toFixed(2)}`);
}
return `M${pts.join(' L')}Z`;
}
const polygons = [];
// Render extra cells beyond the tile edge so the seam is hidden when tiled.
for (let col = -1; col <= COLS + 1; col++) {
for (let row = -1; row <= ROWS + 1; row++) {
const cx = col * COL_STEP;
const cy = row * ROW_STEP + (col % 2 ? ROW_STEP / 2 : 0);
polygons.push(`<path d="${hexPath(cx, cy)}" fill="${pickShade()}"/>`);
}
}
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${TILE_W}" height="${TILE_H}"
viewBox="0 0 ${TILE_W} ${TILE_H}"
preserveAspectRatio="xMidYMid slice">
<defs>
<clipPath id="tile"><rect width="${TILE_W}" height="${TILE_H}"/></clipPath>
</defs>
<g clip-path="url(#tile)">
<rect width="${TILE_W}" height="${TILE_H}" fill="#1E325C"/>
${polygons.join('\n ')}
</g>
</svg>
`;
mkdirSync(dirname(OUT), {recursive: true});
writeFileSync(OUT, svg);
console.log(`wrote ${OUT} (${TILE_W}×${TILE_H.toFixed(1)})`);

View File

@@ -0,0 +1,3 @@
module github.com/mattermost/mattermost/docs/site/scripts/gen-plugin-godocs
go 1.26

View File

@@ -0,0 +1,324 @@
// Command gen-plugin-godocs generates docs/site/data/plugin-godocs.json, the data source consumed
// by the <PluginGoDocs /> and <PluginGoExample /> React components that render the server plugin
// SDK reference (docs/develop/integrate/reference/server/index.md).
//
// It reads the server/public/plugin package directly from this monorepo. It deliberately parses
// the package with go/parser + go/doc rather than type-checking it via golang.org/x/tools/go/packages,
// so it has no dependency on the Go toolchain version required by server/public/go.mod (the docs
// site's build environment may lag behind it).
package main
import (
"bytes"
"encoding/json"
"fmt"
"go/ast"
"go/doc"
"go/parser"
"go/printer"
"go/token"
"log"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
)
const pluginImportPath = "github.com/mattermost/mattermost/server/public/plugin"
type Field struct {
Names []string `json:"Names,omitempty"`
Type string
}
type MethodDocs struct {
Name string
Tags []string `json:"Tags,omitempty"`
HTML string
Parameters []*Field `json:"Parameters,omitempty"`
Results []*Field `json:"Results,omitempty"`
}
type InterfaceDocs struct {
HTML string
Tags []string `json:"Tags,omitempty"`
Methods []*MethodDocs
}
type ExampleDocs struct {
HTML string
Code string
}
type Docs struct {
HTML string
API InterfaceDocs
Hooks InterfaceDocs
Helpers InterfaceDocs
Examples map[string]*ExampleDocs
}
func pluginPackageDir() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
log.Fatal("unable to determine source file location")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", ".."))
return filepath.Join(repoRoot, "server", "public", "plugin")
}
func docHTML(text string) string {
buf := &bytes.Buffer{}
doc.ToHTML(buf, text, nil)
return buf.String()
}
func removeDuplicates(items []string) []string {
seen := make(map[string]bool, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
if !seen[item] {
seen[item] = true
out = append(out, item)
}
}
return out
}
var tagRegexp = regexp.MustCompile(`@tag\s+(\w+)\s*`)
func tags(text string) []string {
submatches := tagRegexp.FindAllStringSubmatch(text, -1)
out := make([]string, len(submatches))
for i, submatch := range submatches {
out[i] = submatch[1]
}
return removeDuplicates(out)
}
// builtinTypes is the set of predeclared Go types documented at https://pkg.go.dev/builtin.
var builtinTypes = map[string]bool{
"bool": true, "byte": true, "complex128": true, "complex64": true, "error": true,
"float32": true, "float64": true, "int": true, "int16": true, "int32": true, "int64": true,
"int8": true, "rune": true, "string": true, "uint": true, "uint16": true, "uint32": true,
"uint64": true, "uint8": true, "uintptr": true, "any": true,
}
// importAliases maps the local identifier a file uses for an imported package (its alias, or the
// last path segment when unaliased) to that package's full import path.
func importAliases(file *ast.File) map[string]string {
aliases := make(map[string]string)
for _, imp := range file.Imports {
path := strings.Trim(imp.Path.Value, `"`)
alias := path[strings.LastIndex(path, "/")+1:]
if imp.Name != nil {
alias = imp.Name.Name
}
aliases[alias] = path
}
return aliases
}
// typeString renders a field's type expression as a dotted, fully-qualified string, e.g.
// "[]*github.com/mattermost/mattermost/server/public/model.Manifest", the same shape the old
// go/types-based generator produced. Types local to the plugin package are qualified with
// pluginImportPath so the renderer can link to them the same way it links to imported types.
func typeString(expr ast.Expr, aliases map[string]string) string {
switch x := expr.(type) {
case *ast.StarExpr:
return "*" + typeString(x.X, aliases)
case *ast.ArrayType:
return "[]" + typeString(x.Elt, aliases)
case *ast.Ellipsis:
return "[]" + typeString(x.Elt, aliases)
case *ast.MapType:
return "map[" + typeString(x.Key, aliases) + "]" + typeString(x.Value, aliases)
case *ast.ChanType:
return "chan " + typeString(x.Value, aliases)
case *ast.InterfaceType:
if x.Methods == nil || len(x.Methods.List) == 0 {
return "interface{}"
}
return "interface{ ... }"
case *ast.SelectorExpr:
pkgIdent, ok := x.X.(*ast.Ident)
if !ok {
return x.Sel.Name
}
if path, ok := aliases[pkgIdent.Name]; ok {
return path + "." + x.Sel.Name
}
return pkgIdent.Name + "." + x.Sel.Name
case *ast.Ident:
if builtinTypes[x.Name] || x.Name == "byte" || x.Name == "rune" {
return x.Name
}
// A bare identifier that isn't a builtin must refer to a type declared in this same
// package (plugin), since any imported type is always qualified with a selector.
return pluginImportPath + "." + x.Name
default:
buf := &bytes.Buffer{}
_ = printer.Fprint(buf, token.NewFileSet(), expr)
return buf.String()
}
}
func fields(list *ast.FieldList, aliases map[string]string) (out []*Field) {
if list == nil {
return nil
}
for _, x := range list.List {
field := &Field{}
for _, name := range x.Names {
field.Names = append(field.Names, name.Name)
}
t := typeString(x.Type, aliases)
if _, ok := x.Type.(*ast.Ellipsis); ok {
t = "..." + strings.TrimPrefix(t, "[]")
}
field.Type = t
out = append(out, field)
}
return out
}
func fileForPos(fset *token.FileSet, files map[string]*ast.File, pos token.Pos) *ast.File {
name := fset.Position(pos).Filename
return files[name]
}
func generateDocs() (*Docs, error) {
pluginDir := pluginPackageDir()
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, pluginDir, nil, parser.ParseComments)
if err != nil {
return nil, err
}
docs := &Docs{
Examples: make(map[string]*ExampleDocs),
}
// Files keyed by absolute path, across both the "plugin" and "plugin_test" (external test)
// packages, so we can recover which file a given interface method came from and resolve its
// imports, and so examples defined in either package are picked up.
filesByPath := make(map[string]*ast.File)
var allFiles []*ast.File
for _, pkg := range pkgs {
for path, file := range pkg.Files {
filesByPath[path] = file
allFiles = append(allFiles, file)
}
}
for _, example := range doc.Examples(allFiles...) {
// Play is a synthesized, standalone runnable program and is preferred when available;
// it's nil when go/doc can't build one (e.g. the example can't be wrapped as a whole
// program), in which case Code — the example function's body, always non-nil — is used
// instead.
var node ast.Node = example.Play
if example.Play == nil {
node = example.Code
}
buf := &bytes.Buffer{}
if err := printer.Fprint(buf, fset, node); err != nil {
return nil, fmt.Errorf("failed to print example %q: %w", example.Name, err)
}
docs.Examples[example.Name] = &ExampleDocs{
HTML: docHTML(example.Doc),
Code: buf.String(),
}
}
pluginPkg, ok := pkgs["plugin"]
if !ok {
return nil, os.ErrNotExist
}
godocs := doc.New(pluginPkg, pluginImportPath, doc.Mode(0))
if godocs.Name == "plugin" && godocs.Doc != "" {
docs.HTML = docHTML(godocs.Doc)
}
for _, t := range godocs.Types {
var interfaceDocs *InterfaceDocs
switch t.Name {
case "API":
interfaceDocs = &docs.API
case "Hooks":
interfaceDocs = &docs.Hooks
case "Helpers":
interfaceDocs = &docs.Helpers
default:
continue
}
if t.Doc != "" {
interfaceDocs.HTML = docHTML(t.Doc)
}
for _, spec := range t.Decl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
iface, ok := typeSpec.Type.(*ast.InterfaceType)
if !ok {
continue
}
file := fileForPos(fset, filesByPath, typeSpec.Pos())
var aliases map[string]string
if file != nil {
aliases = importAliases(file)
}
allTags := make([]string, 0)
for _, method := range iface.Methods.List {
funcType, ok := method.Type.(*ast.FuncType)
if !ok || len(method.Names) == 0 {
continue
}
methodDocs := &MethodDocs{
Name: method.Names[0].Name,
Tags: tags(method.Doc.Text()),
HTML: docHTML(method.Doc.Text()),
Parameters: fields(funcType.Params, aliases),
Results: fields(funcType.Results, aliases),
}
interfaceDocs.Methods = append(interfaceDocs.Methods, methodDocs)
allTags = append(allTags, methodDocs.Tags...)
}
allTags = removeDuplicates(allTags)
sort.Strings(allTags)
interfaceDocs.Tags = allTags
}
}
return docs, nil
}
func main() {
docs, err := generateDocs()
if err != nil {
log.Fatal(err)
}
b, err := json.MarshalIndent(docs, "", " ")
if err != nil {
log.Fatal(err)
}
if _, err := os.Stdout.Write(b); err != nil {
log.Fatal(err)
}
if _, err := os.Stdout.Write([]byte("\n")); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
// Generates docs/site/data/plugin-jsdocs.json, the data source consumed by the <PluginJsDocs />
// component that renders the web app plugin SDK reference
// (docs/develop/integrate/reference/webapp/index.md).
//
// Reads webapp/channels/src/plugins/registry.ts directly from this monorepo instead of fetching it
// from GitHub over HTTP.
//
// Usage: node scripts/gen-plugin-jsdocs.mjs (from docs/site/)
import {parse} from '@typescript-eslint/typescript-estree';
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
import {resolve, dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); // docs/site/scripts
const SITE_ROOT = resolve(HERE, '..'); // docs/site
const REPO_ROOT = resolve(SITE_ROOT, '../..'); // mattermost/
const REGISTRY_PATH = resolve(REPO_ROOT, 'webapp/channels/src/plugins/registry.ts');
const OUT_PATH = resolve(SITE_ROOT, 'data/plugin-jsdocs.json');
function paramNamesFromPattern(pattern) {
if (pattern.type === 'Identifier') return [pattern.name];
if (pattern.type === 'ObjectPattern') {
return pattern.properties.flatMap((prop) => (prop.type === 'Property' ? paramNamesFromPattern(prop.value) : paramNamesFromPattern(prop)));
}
if (pattern.type === 'ArrayPattern') {
return pattern.elements.flatMap((el) => (el ? paramNamesFromPattern(el) : []));
}
if (pattern.type === 'AssignmentPattern') return paramNamesFromPattern(pattern.left);
if (pattern.type === 'RestElement') return paramNamesFromPattern(pattern.argument);
return [];
}
// Comments attached to `node`: walk backward from its start, collecting comments as long as only
// whitespace separates them from each other and from the node. Node-scoped, so unlike a global
// "which comments sit on consecutive lines" pass, it can't attribute a comment to the wrong member.
function leadingComments(sourceText, comments, node) {
const attached = [];
let cursor = node.range[0];
for (let i = comments.length - 1; i >= 0; i--) {
const comment = comments[i];
if (comment.range[1] > cursor) continue;
if (!/^\s*$/.test(sourceText.slice(comment.range[1], cursor))) break;
attached.unshift(comment);
cursor = comment.range[0];
}
return attached.flatMap((comment) =>
comment.value
.split('\n')
.map((line) => line.replace(/^\s*\*\s?/, '').trimEnd())
.filter((line) => line.length > 0),
);
}
// reArg(['name', ...], handler) documents its public parameter names explicitly in that array —
// that's the contract callers see, so prefer it over inferring names from the handler's own
// (possibly renamed or destructured) parameters.
function reArgParameterNames(callExpr) {
const [firstArg, ...rest] = callExpr.arguments;
if (callExpr.callee.type === 'Identifier' && callExpr.callee.name === 'reArg' && firstArg?.type === 'ArrayExpression') {
return firstArg.elements.filter((el) => el?.type === 'Literal' && typeof el.value === 'string').map((el) => el.value);
}
const handler = rest.find((arg) => arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression');
return handler ? handler.params.flatMap(paramNamesFromPattern) : [];
}
function findPluginRegistryClass(program) {
return program.body.find(
(statement) =>
statement.type === 'ExportDefaultDeclaration' &&
statement.declaration.type === 'ClassDeclaration' &&
statement.declaration.id?.name === 'PluginRegistry',
)?.declaration;
}
function main() {
const sourceText = readFileSync(REGISTRY_PATH, 'utf8');
const ast = parse(sourceText, {comment: true, range: true});
const classDecl = findPluginRegistryClass(ast);
if (!classDecl) {
throw new Error(`Could not find "export default class PluginRegistry" in ${REGISTRY_PATH}`);
}
const methods = [];
for (const member of classDecl.body.body) {
if (member.key?.type !== 'Identifier') continue;
let params;
if (member.type === 'MethodDefinition' && member.kind !== 'constructor') {
params = member.value.params.flatMap(paramNamesFromPattern);
} else if (member.type === 'PropertyDefinition' && member.value?.type === 'CallExpression') {
params = reArgParameterNames(member.value);
} else {
continue;
}
methods.push({
Name: member.key.name,
Parameters: params,
Comments: leadingComments(sourceText, ast.comments, member),
});
}
const output = {Interface: {Methods: methods}};
mkdirSync(dirname(OUT_PATH), {recursive: true});
writeFileSync(OUT_PATH, JSON.stringify(output, null, 2));
console.log(`[plugin-jsdocs] wrote ${methods.length} methods to ${OUT_PATH}`);
}
main();

View File

@@ -0,0 +1,3 @@
module github.com/mattermost/mattermost/docs/site/scripts/gen-plugin-manifest-docs
go 1.26

View File

@@ -0,0 +1,278 @@
// Command gen-plugin-manifest-docs generates docs/site/data/plugin-manifest-docs.json, the data
// source consumed by the <PluginManifestDocs /> component that renders the plugin manifest
// reference (docs/develop/integrate/plugins/manifest-reference.md).
//
// It reads server/public/model directly from this monorepo, walking the AST with go/parser +
// go/doc instead of type-checking with golang.org/x/tools/go/packages (see gen-plugin-godocs for
// why: it avoids requiring a Go toolchain matching server/public/go.mod's declared version).
// Because of that, type resolution only follows types declared inside the model package's own
// source (which is all model.Manifest ever references) — it doesn't resolve identifiers to types
// from other packages.
package main
import (
"bytes"
"encoding/json"
"go/ast"
"go/doc"
"go/parser"
"go/token"
"log"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
)
type SchemaType string
const (
Array SchemaType = "array"
Bool SchemaType = "bool"
Dict SchemaType = "dict"
Number SchemaType = "number"
Object SchemaType = "object"
String SchemaType = "string"
Interface SchemaType = "interface"
)
const modelImportPath = "github.com/mattermost/mattermost/server/public/model"
type ObjectProperty struct {
Name string
DocHTML string `json:"DocHTML,omitempty"`
Schema *TypeDocs `json:"Schema,omitempty"`
}
type TypeDocs struct {
Type SchemaType
DocHTML string `json:"DocHTML,omitempty"`
ObjectProperties []*ObjectProperty `json:"ObjectProperties,omitempty"`
ValueSchema *TypeDocs `json:"ValueSchema,omitempty"`
}
type Docs struct {
Schema *TypeDocs
}
var basicTypeKinds = map[string]SchemaType{
"string": String,
"bool": Bool,
"byte": Number,
"rune": Number,
"int": Number, "int8": Number, "int16": Number, "int32": Number, "int64": Number,
"uint": Number, "uint8": Number, "uint16": Number, "uint32": Number, "uint64": Number,
"float32": Number, "float64": Number,
}
// fset and filesByPath let namedTypeDocs recover which file (and therefore which imports) a given
// model package type was declared in, so SelectorExpr fields can be resolved against the model
// package's imports rather than by identifier name alone. Populated once in generateDocs.
var (
fset *token.FileSet
filesByPath map[string]*ast.File
)
// importAliases maps the local identifier a file uses for an imported package (its alias, or the
// last path segment when unaliased) to that package's full import path.
func importAliases(file *ast.File) map[string]string {
aliases := make(map[string]string)
for _, imp := range file.Imports {
path := strings.Trim(imp.Path.Value, `"`)
alias := path[strings.LastIndex(path, "/")+1:]
if imp.Name != nil {
alias = imp.Name.Name
}
aliases[alias] = path
}
return aliases
}
func importsForPos(pos token.Pos) map[string]string {
file := filesByPath[fset.Position(pos).Filename]
if file == nil {
return nil
}
return importAliases(file)
}
func modelPackageDir() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
log.Fatal("unable to determine source file location")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", ".."))
return filepath.Join(repoRoot, "server", "public", "model")
}
func docHTML(text string) string {
buf := &bytes.Buffer{}
doc.ToHTML(buf, text, nil)
return buf.String()
}
func typeSpecOf(t *doc.Type) *ast.TypeSpec {
for _, spec := range t.Decl.Specs {
if typeSpec, ok := spec.(*ast.TypeSpec); ok {
return typeSpec
}
}
return nil
}
func namedTypeDocs(t *doc.Type, typesByName map[string]*doc.Type) *TypeDocs {
spec := typeSpecOf(t)
if spec == nil {
return nil
}
ret := exprTypeDocs(spec.Type, typesByName, importsForPos(spec.Pos()))
if ret != nil {
ret.DocHTML = docHTML(t.Doc)
}
return ret
}
// jsonFieldName returns the field's JSON key, mirroring encoding/json's own rules: unexported
// fields and those tagged `json:"-"` are never included; otherwise the tag's name component wins
// if present (including when a comma-only tag like `json:",omitempty"` leaves it empty, in which
// case the Go field name is used, same as an absent tag).
func jsonFieldName(field *ast.Field) string {
if len(field.Names) == 0 {
// Anonymous (embedded) fields aren't handled by this generator — none exist in the
// reachable model.Manifest schema.
return ""
}
fieldName := field.Names[0].Name
if !ast.IsExported(fieldName) {
return ""
}
if field.Tag == nil {
return fieldName
}
tagValue := strings.Trim(field.Tag.Value, "`")
jsonTag := reflect.StructTag(tagValue).Get("json")
if jsonTag == "-" {
return ""
}
name := strings.SplitN(jsonTag, ",", 2)[0]
if name == "" {
return fieldName
}
return name
}
func exprTypeDocs(expr ast.Expr, typesByName map[string]*doc.Type, imports map[string]string) *TypeDocs {
switch x := expr.(type) {
case *ast.StarExpr:
return exprTypeDocs(x.X, typesByName, imports)
case *ast.ArrayType:
return &TypeDocs{
Type: Array,
ValueSchema: exprTypeDocs(x.Elt, typesByName, imports),
}
case *ast.MapType:
return &TypeDocs{
Type: Dict,
ValueSchema: exprTypeDocs(x.Value, typesByName, imports),
}
case *ast.StructType:
ret := &TypeDocs{Type: Object}
for _, field := range x.Fields.List {
name := jsonFieldName(field)
if name == "" {
continue
}
ret.ObjectProperties = append(ret.ObjectProperties, &ObjectProperty{
Name: name,
DocHTML: docHTML(field.Doc.Text()),
Schema: exprTypeDocs(field.Type, typesByName, imports),
})
}
return ret
case *ast.SelectorExpr:
pkgIdent, ok := x.X.(*ast.Ident)
if !ok || imports[pkgIdent.Name] != modelImportPath {
log.Printf("unrecognized qualified type %v (not a reference to the model package)", x)
return nil
}
// A qualified reference to the model package's own import path (i.e. a self-import
// alias) still names a type declared in this same package, so resolve it the same way
// an unqualified identifier would be.
return exprTypeDocs(x.Sel, typesByName, imports)
case *ast.InterfaceType:
return &TypeDocs{Type: Interface}
case *ast.Ident:
if x.Name == "any" {
return &TypeDocs{Type: Interface}
}
if kind, ok := basicTypeKinds[x.Name]; ok {
return &TypeDocs{Type: kind}
}
if t, ok := typesByName[x.Name]; ok {
return namedTypeDocs(t, typesByName)
}
log.Printf("unrecognized identifier %q (not a builtin or a model package type)", x.Name)
return nil
}
log.Printf("unrecognized ast.Expr: %T", expr)
return nil
}
func generateDocs() (*Docs, error) {
modelDir := modelPackageDir()
fset = token.NewFileSet()
notTest := func(info os.FileInfo) bool { return !strings.HasSuffix(info.Name(), "_test.go") }
pkgs, err := parser.ParseDir(fset, modelDir, notTest, parser.ParseComments)
if err != nil {
return nil, err
}
modelPkg, ok := pkgs["model"]
if !ok {
return nil, os.ErrNotExist
}
filesByPath = make(map[string]*ast.File, len(modelPkg.Files))
for path, file := range modelPkg.Files {
filesByPath[path] = file
}
godocs := doc.New(modelPkg, "github.com/mattermost/mattermost/server/public/model", doc.Mode(0))
typesByName := make(map[string]*doc.Type, len(godocs.Types))
for _, t := range godocs.Types {
typesByName[t.Name] = t
}
manifestType, ok := typesByName["Manifest"]
if !ok {
return nil, os.ErrNotExist
}
return &Docs{
Schema: namedTypeDocs(manifestType, typesByName),
}, nil
}
func main() {
docs, err := generateDocs()
if err != nil {
log.Fatal(err)
}
b, err := json.MarshalIndent(docs, "", " ")
if err != nil {
log.Fatal(err)
}
if _, err := os.Stdout.Write(b); err != nil {
log.Fatal(err)
}
if _, err := os.Stdout.Write([]byte("\n")); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
// Stage the mattermost-plugin-agents submodule's docs/ subfolder into
// main/agents/docs/ so the two curated Agents pages (administration-guide/
// configure/agents-admin-guide.mdx and end-user-guide/agents.mdx) and a
// handful of nested reference pages have real content to link to.
//
// Why staging instead of checking out the submodule directly under docs/:
// submodules can't do a sparse "just this subfolder" checkout, and the repo
// also has README/LICENSE/Go source/etc. we don't want as pages. So it's
// vendored out-of-tree at vendor/mattermost-plugin-agents, and this script
// copies+transforms only its docs/** into main/agents/docs/ before the
// sidebar is generated — same effect as Sphinx's conf.py exclude/redirect
// rules on its own full-repo submodule checkout.
//
// Navigation mirrors Sphinx exactly (source/end-user-guide/agents.rst,
// source/administration-guide/configure/agents-admin-guide.rst): no
// top-level Agents nav section. admin_guide.md/user_guide.md are `..
// include::`d into the two curated pages; providers/aws_bedrock_setup/
// sovereign_ai/usage_tips get their own nested pages via a small hidden
// toctree; everything else (load-testing, upgrading_to_2.0, features/*) is
// direct-URL-only, never in any toctree. Reproduced as a three-way split:
// - INLINE_PARTIALS: staged as an unlisted doc (direct-link parity) AND
// as a Markdown partial (leading underscore) the curated pages
// `import` and render inline, reproducing `.. include::`.
// - NAV_CHILDREN: normal listed docs, nested under the curated pages by
// gen-documentation-sidebar.mjs via cross-directory {doc: ...} items.
// - Everything else: unlisted docs — built and linkable, absent from
// the sidebar.
//
// Re-run safely at any time (e.g. after `git submodule update --remote`) —
// it fully regenerates its output directories.
//
// Usage: node docs/site/scripts/stage-agents-docs.mjs
import {
readFileSync, writeFileSync, mkdirSync, readdirSync, statSync,
existsSync, rmSync, copyFileSync,
} from 'node:fs';
import {join, resolve, relative, dirname, extname, posix} from 'node:path';
import {fileURLToPath} from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..');
const REPO_ROOT = resolve(SITE_ROOT, '..');
const VENDOR_DOCS = join(REPO_ROOT, 'vendor', 'mattermost-plugin-agents', 'docs');
const DEST_DOCS = join(REPO_ROOT, 'main', 'agents', 'docs');
const DEST_IMAGES = join(SITE_ROOT, 'static', 'images', 'agents');
// Files whose body is inlined into a curated page via a Markdown partial
// import, in addition to being staged as their own unlisted page.
const INLINE_PARTIALS = new Set(['admin_guide', 'user_guide']);
// Files that get their own listed page, nested under a curated page by
// gen-documentation-sidebar.mjs. Everything else staged ends up unlisted.
const NAV_CHILDREN = new Set(['providers', 'aws_bedrock_setup', 'sovereign_ai', 'usage_tips']);
function rmrf(p) {
if (existsSync(p)) rmSync(p, {recursive: true, force: true});
}
function walk(dir, exclude = []) {
const out = [];
for (const name of readdirSync(dir)) {
if (exclude.includes(name)) continue;
const abs = join(dir, name);
if (statSync(abs).isDirectory()) out.push(...walk(abs, exclude));
else out.push(abs);
}
return out;
}
// Several vendored files lead with a raw `<!-- Copyright ... -->` HTML
// comment. Plain Markdown tolerates that, but MDX doesn't parse bare HTML
// comments the same way (it wants `{/* ... */}`), so strip it before any
// further processing rather than trying to convert it — license headers
// aren't meaningful on rendered docs pages anyway.
function stripLeadingLicenseComment(body) {
return body.replace(/^<!--[\s\S]*?-->\r?\n\r?\n?/, '');
}
// Extract a leading `# Title` line as Docusaurus frontmatter `title`, since
// migrated pages elsewhere in main/ carry the title in frontmatter rather
// than as an in-body H1 (Docusaurus renders the frontmatter title as the
// page's H1 automatically).
function extractTitle(body) {
const m = body.match(/^# (.+)\r?\n\r?\n?/);
if (!m) return {title: null, body};
return {title: m[1].trim(), body: body.slice(m[0].length)};
}
// Vendored markdown references sibling images as `img/foo.png` or
// `../img/foo.png` (relative to its own location under docs/). Those PNGs
// are copied to static/images/agents/, so rewrite refs to the site's
// standard absolute `/images/<dir>/<file>` convention.
function rewriteImagePaths(body) {
return body.replace(/(!\[[^\]]*]\()(?:\.\.\/)?img\/([^)\s]+)(\))/g, '$1/images/agents/$2$3');
}
// Vendored markdown cross-links sibling docs with relative paths, e.g.
// `[...](../admin_guide.md#license-requirements)` or
// `[...](features/channel_summaries.md)`. Resolve those relative to the
// linking file's own directory under docs/ and rewrite to the site's
// absolute `/agents/docs/<id>` doc convention, so links keep working
// regardless of where the linking content ends up rendered (including
// when inlined into a curated page in a completely different directory).
function rewriteRelativeMdLinks(body, fileRelDir) {
return body.replace(/(\[[^\]]*]\()(?!https?:\/\/|\/|#)([^)\s#]+)\.md(#[^)\s]+)?(\))/g, (_m, pre, target, anchor, post) => {
const resolved = posix.normalize(posix.join(fileRelDir, target));
return `${pre}/agents/docs/${resolved}${anchor || ''}${post}`;
});
}
function stageDocs() {
// Clear previous output unconditionally, even on failure below, so a
// reused workspace (stale checkout, missing submodule init) never ships
// docs left over from a prior run instead of failing loudly.
rmrf(DEST_DOCS);
rmrf(DEST_IMAGES);
if (!existsSync(VENDOR_DOCS)) {
throw new Error(
`[stage-agents-docs] submodule content not found at ${VENDOR_DOCS}. ` +
'Run `git submodule update --init --remote docs/vendor/mattermost-plugin-agents` first.',
);
}
const mdFiles = walk(VENDOR_DOCS, ['img']).filter((f) => extname(f) === '.md');
let partialCount = 0;
for (const src of mdFiles) {
const rel = relative(VENDOR_DOCS, src); // e.g. "admin_guide.md" or "features/channel_summaries.md"
const relDir = posix.dirname(rel.replace(/\\/g, '/'));
const baseName = basenameNoExt(rel);
const raw = stripLeadingLicenseComment(readFileSync(src, 'utf8'));
const {title, body} = extractTitle(raw);
const transformed = rewriteRelativeMdLinks(rewriteImagePaths(body), relDir === '.' ? '' : relDir);
const dest = join(DEST_DOCS, rel);
mkdirSync(dirname(dest), {recursive: true});
const listed = NAV_CHILDREN.has(baseName);
const inlined = INLINE_PARTIALS.has(baseName);
const frontmatterLines = [];
if (title) frontmatterLines.push(`title: "${title.replace(/"/g, '\\"')}"`);
if (!listed) frontmatterLines.push('unlisted: true');
const frontmatter = frontmatterLines.length ? `---\n${frontmatterLines.join('\n')}\n---\n\n` : '';
writeFileSync(dest, frontmatter + transformed);
if (inlined) {
// Markdown partials (leading underscore) are auto-excluded from
// Docusaurus routing/sidebars and can be `import`ed + rendered
// inline elsewhere — this is what reproduces Sphinx's
// `.. include:: /agents/docs/*.md` behavior.
const partialDest = join(dirname(dest), `_${baseName}_partial.mdx`);
writeFileSync(partialDest, transformed);
partialCount++;
}
}
const imgDir = join(VENDOR_DOCS, 'img');
let imageCount = 0;
if (existsSync(imgDir)) {
mkdirSync(DEST_IMAGES, {recursive: true});
for (const name of readdirSync(imgDir)) {
copyFileSync(join(imgDir, name), join(DEST_IMAGES, name));
imageCount++;
}
}
return {files: mdFiles.length, partials: partialCount, images: imageCount};
}
function basenameNoExt(relPath) {
const base = posix.basename(relPath.replace(/\\/g, '/'));
return base.replace(/\.md$/, '');
}
const {files, partials, images} = stageDocs();
console.log(`[stage-agents-docs] staged ${files} doc(s) (${partials} with inline partials), ${images} image(s) into ${relative(REPO_ROOT, DEST_DOCS)}`);

29
docs/site/sidebars/api.ts Normal file
View File

@@ -0,0 +1,29 @@
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — generated by `docusaurus gen-api-docs`. The default export
// is the apisidebar items array (not a full SidebarsConfig object).
import apiReference from '../../api/reference/sidebar';
// API tab sidebar.
// "Overview" → hand-authored intro page (api/index.mdx)
// "Examples" → curl quick-start (api/examples.mdx)
// "Reference" → 38 OpenAPI tag groups (~550 endpoints) generated by
// docusaurus-plugin-openapi-docs from
// openapi/mattermost-openapi-v4.yaml.
//
// See docs/site/README.md for local dev setup (includes regenerating this).
const sidebars: SidebarsConfig = {
api: [
{type: 'doc', id: 'index', label: 'Overview'},
{type: 'doc', id: 'examples', label: 'Examples'},
{
type: 'category',
label: 'Reference',
collapsed: false,
items: apiReference as any[],
},
],
};
export default sidebars;

View File

@@ -0,0 +1,15 @@
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — generated by `node docs-site/scripts/gen-developer-sidebar.mjs`
// from the migrated content tree under develop/.
import generated from './developers.generated.json';
// Developers tab sidebar. Generated from the develop/ directory tree
// (Contribute, Integrate & Extend, Internal, with all sub-categories
// derived from sub-directories).
const sidebars: SidebarsConfig = {
developers: generated as any[],
};
export default sidebars;

View File

@@ -0,0 +1,16 @@
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — generated by `node docs-site/scripts/gen-documentation-sidebar.mjs`
// from the migrated content tree under docs/.
import generated from './documentation.generated.json';
// Documentation tab sidebar. Generated from the docs/ directory tree
// (Product Overview, Use Case Guide, Deployment Guide, Administration
// Guide, Security Guide, End User Guide, Integrations Guide, Get Help,
// Agents, Recipes, Samples).
const sidebars: SidebarsConfig = {
documentation: generated as any[],
};
export default sidebars;

View File

@@ -0,0 +1,86 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
// Compliance / authorization status badge for framework pages
// (FedRAMP, DoD IL, DISA STIG, HIPAA, FINRA, CMMC, Common Criteria…).
//
// Status semantics:
// authorized — formally authorized / certified; ATO or equivalent in place.
// in-process — actively pursuing authorization; documented gap exists.
// roadmap — committed for future authorization; no work in flight yet.
// not-pursued — not on the roadmap; framework documented for reference only.
//
// Usage:
// <AttestationStatus framework="FedRAMP Moderate" status="in-process" />
// <AttestationStatus framework="FIPS 140-3" status="authorized" certificate="CMVP #4735" />
// <AttestationStatus framework="Common Criteria" status="not-pursued" />
type Status = 'authorized' | 'in-process' | 'roadmap' | 'not-pursued';
const STATUS_LABEL: Record<Status, string> = {
'authorized': 'Authorized',
'in-process': 'In Process',
'roadmap': 'Roadmap',
'not-pursued': 'Not Pursued',
};
const STATUS_CLASS: Record<Status, string> = {
'authorized': 'authorized',
'in-process': 'inProcess',
'roadmap': 'roadmap',
'not-pursued': 'notPursued',
};
const KNOWN_STATUSES = Object.keys(STATUS_LABEL) as Status[];
export default function AttestationStatus({
framework,
status,
certificate,
asOf,
detailsHref,
}: {
framework: string;
status: string;
certificate?: string;
asOf?: string;
detailsHref?: string;
}) {
if (!framework) {
return (
<aside className={`${styles.box} ${styles.unknown}`} role="note">
<code>&lt;AttestationStatus/&gt;</code> requires a <code>framework</code> prop.
</aside>
);
}
if (!(KNOWN_STATUSES as string[]).includes(status)) {
return (
<aside className={`${styles.box} ${styles.unknown}`} role="note">
Unknown attestation status: <code>{status}</code>. Valid:{' '}
<code>{KNOWN_STATUSES.join(', ')}</code>.
</aside>
);
}
const s = status as Status;
return (
<aside
className={`${styles.box} ${styles[STATUS_CLASS[s]]}`}
role="note"
aria-label={`${framework} attestation status`}
>
<div className={styles.header}>
<span className={styles.framework}>{framework}</span>
<span className={styles.status} data-status={s}>{STATUS_LABEL[s]}</span>
</div>
{(certificate || asOf || detailsHref) && (
<div className={styles.meta}>
{certificate && <span><strong>Certificate:</strong> {certificate}</span>}
{asOf && <span><strong>As of:</strong> {asOf}</span>}
{detailsHref && <Link href={detailsHref}>Attestation details</Link>}
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,77 @@
/* AttestationStatus — framework header + status pill + optional metadata.
* Distinct from edition/deployment badges: this is a full-width block at
* the top of compliance-framework pages, not an inline chip. */
.box {
display: block;
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 4px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .box {
background: rgba(255, 255, 255, 0.04);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.framework {
font-weight: 700;
font-size: 1rem;
letter-spacing: 0.01em;
}
.status {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.75rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid currentColor;
white-space: nowrap;
}
.status[data-status='authorized'] { color: #1d6f42; background: rgba(29, 111, 66, 0.08); }
.status[data-status='in-process'] { color: #8a5d00; background: rgba(255, 188, 31, 0.18); }
.status[data-status='roadmap'] { color: #1E325C; background: rgba(30, 50, 92, 0.08); }
.status[data-status='not-pursued'] { color: #6c757d; background: rgba(108, 117, 125, 0.10); }
[data-theme='dark'] .status[data-status='authorized'] { color: #6dd49b; }
[data-theme='dark'] .status[data-status='in-process'] { color: #FFBC1F; }
[data-theme='dark'] .status[data-status='roadmap'] { color: #9eb1da; }
[data-theme='dark'] .status[data-status='not-pursued'] { color: #b0b6bd; }
/* Variant left-borders per status, for at-a-glance scanning. */
.authorized { border-left-color: #1d6f42; }
.inProcess { border-left-color: #FFBC1F; }
.roadmap { border-left-color: #1E325C; }
.notPursued { border-left-color: #6c757d; }
.meta {
margin-top: 0.55rem;
font-size: 0.88rem;
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
color: var(--mm-text-secondary, var(--mm-text-primary));
}
.meta strong {
font-weight: 700;
}
.unknown {
background: #fff3cd;
color: #664d03;
border-left-color: #ffc107;
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import styles from './styles.module.css';
type Kind = 'note' | 'tip' | 'important' | 'attention' | 'warning' | 'security';
const COPY: Record<Kind, {label: string; icon: string}> = {
note: {label: 'Note', icon: 'i'},
tip: {label: 'Tip', icon: '+'},
important: {label: 'Important', icon: '!'},
attention: {label: 'Attention', icon: '!'},
warning: {label: 'Warning', icon: '!'},
security: {label: 'Security', icon: 'S'},
};
export default function Callout({
kind = 'note',
title,
children,
}: {
kind?: Kind;
title?: string;
children: React.ReactNode;
}) {
const meta = COPY[kind];
return (
<aside className={`${styles.callout} ${styles[kind]}`} role="note">
<div className={styles.header}>
<span className={styles.icon} aria-hidden>{meta.icon}</span>
<span className={styles.label}>{title ?? meta.label}</span>
</div>
<div className={styles.content}>{children}</div>
</aside>
);
}
// Convenience wrappers used like <Note>...</Note> in MDX.
export const Note = (p: {title?: string; children: React.ReactNode}) => <Callout kind="note" {...p} />;
export const Tip = (p: {title?: string; children: React.ReactNode}) => <Callout kind="tip" {...p} />;
export const Important = (p: {title?: string; children: React.ReactNode}) => <Callout kind="important" {...p} />;
export const Attention = (p: {title?: string; children: React.ReactNode}) => <Callout kind="attention" {...p} />;
export const Warning = (p: {title?: string; children: React.ReactNode}) => <Callout kind="warning" {...p} />;
export const Security = (p: {title?: string; children: React.ReactNode}) => <Callout kind="security" {...p} />;

View File

@@ -0,0 +1,121 @@
/* Branded callouts, styled to match the compact Docusaurus admonitions used
* elsewhere in the docs: a tinted panel with a left accent bar and a small
* icon + label header. */
.callout {
margin: 1.5rem 0;
padding: 0.9rem 1.1rem 1rem;
border: 1px solid var(--callout-border);
border-left: 4px solid var(--callout-accent);
border-radius: 4px;
background: var(--callout-bg);
color: var(--mm-text-primary);
}
.header {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.5rem;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.05rem;
height: 1.05rem;
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.7rem;
line-height: 1;
color: var(--callout-accent);
border: 1.5px solid currentColor;
border-radius: 50%;
}
.label {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.8125rem;
letter-spacing: 0.05em;
text-transform: uppercase;
line-height: 1.2;
color: var(--callout-accent);
}
.content > :first-child { margin-top: 0; }
.content > :last-child { margin-bottom: 0; }
.content p { margin: 0.5rem 0; line-height: 1.6; }
/* Kind variants: each sets an accent color plus a tint of the same hue for
* the panel background. */
.note {
--callout-accent: #2E4B87;
--callout-bg: var(--mm-denim-50);
--callout-border: var(--mm-denim-100);
}
[data-theme='dark'] .note {
--callout-accent: var(--mm-denim-200);
--callout-bg: var(--mm-denim-700);
--callout-border: var(--mm-denim-400);
}
.tip {
--callout-accent: #1F5A39;
--callout-bg: #EAF5EE;
--callout-border: #C3E0CE;
}
[data-theme='dark'] .tip {
--callout-accent: #7FCC9E;
--callout-bg: #14442C;
--callout-border: #2C7A4F;
}
.important {
--callout-accent: #8A6200;
--callout-bg: var(--mm-marigold-100);
--callout-border: var(--mm-marigold-300);
}
[data-theme='dark'] .important {
--callout-accent: var(--mm-marigold-300);
--callout-bg: #4A3A10;
--callout-border: #7A6220;
}
/* Attention outranks Important: the reader loses something by skipping it.
* Brighter than Security's maroon so the two stay tellable apart. */
.attention {
--callout-accent: #C8102E;
--callout-bg: #FDECEF;
--callout-border: #F5C6CE;
}
[data-theme='dark'] .attention {
--callout-accent: #F4A0AB;
--callout-bg: #4E0F1B;
--callout-border: #85202F;
}
.warning {
--callout-accent: #6B4F2E;
--callout-bg: #F6F1E9;
--callout-border: #E0D5C1;
}
[data-theme='dark'] .warning {
--callout-accent: var(--mm-color-sand);
--callout-bg: #3A2F22;
--callout-border: var(--mm-color-bark);
}
.security {
--callout-accent: #8B1F1F;
--callout-bg: #FBEDED;
--callout-border: #EFCFCF;
}
[data-theme='dark'] .security {
--callout-accent: #E9A0A0;
--callout-bg: #4A1F1F;
--callout-border: #7A3535;
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type Card = {
title: string;
description?: string;
to: string;
icon?: string; // CompassIcon name
meta?: string; // small annotation (e.g., "549 endpoints")
};
/**
* Grid of navigation cards for landing pages. Replaces bulleted lists
* of links — each card is a discrete navigation surface with title,
* description, and optional Compass icon + meta.
*
* The `compact` variant renders the same cards as a wrapping row of
* low-weight chips, for secondary destinations that need to stay
* scannable without competing with a primary card grid above them.
*
* Usage:
* <CardGrid cards={[
* {title: 'Examples', icon: 'channels', to: '/api/examples',
* description: 'Quick-start with curl.', meta: '6 examples'},
* {title: 'Reference', icon: 'boards', to: '/api/reference/login',
* description: 'Every endpoint, grouped by resource.', meta: '549 endpoints'},
* ]} />
*
* <CardGrid variant="compact" cards={[
* {title: 'Compliance Officers', to: '/for/compliance-officer',
* description: 'Audit evidence, exports.'},
* ]} />
*/
export default function CardGrid({
cards,
columns = 3,
variant = 'default',
}: {
cards: Card[];
columns?: 2 | 3 | 4;
variant?: 'default' | 'compact';
}) {
if (variant === 'compact') {
return (
<div className={styles.chipRow}>
{cards.map((c, i) => (
<Link key={`${c.to}-${i}`} to={c.to} className={styles.chip}>
<span className={styles.chipBody}>
<span className={styles.chipTitle}>{c.title}</span>
{c.description && <span className={styles.chipDescription}>{c.description}</span>}
</span>
<span className={styles.chipArrow} aria-hidden>→</span>
</Link>
))}
</div>
);
}
return (
// Wrapper establishes a size query container for the column-count
// breakpoints in styles.module.css.
<div className={styles.gridContainer}>
<div className={`${styles.grid} ${styles[`cols${columns}`]}`}>
{cards.map((c, i) => (
<Link key={`${c.to}-${i}`} to={c.to} className={styles.card}>
{c.icon && <span className={`${styles.icon} mm-compass-${c.icon}`} aria-hidden />}
<div className={styles.body}>
<div className={styles.title}>{c.title}</div>
<p className={styles.description}>{c.description}</p>
{c.meta && <span className={styles.meta}>{c.meta}</span>}
</div>
<span className={styles.arrow} aria-hidden>→</span>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,261 @@
.gridContainer {
container-type: inline-size;
}
.grid {
display: grid;
gap: 1rem;
margin: 1.5rem 0 2.5rem;
}
/* `repeat(auto-fit, minmax(200px, 1fr))` packed 4 cards into one tall, narrow
* row at typical article widths. Step the column count down explicitly
* instead, halving 4 to 2x2 and 3 to 2 before dropping to a single column. */
.cols2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@container (max-width: 1080px) {
.cols4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (max-width: 820px) {
.cols3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (max-width: 540px) {
.cols2,
.cols3,
.cols4 { grid-template-columns: minmax(0, 1fr); }
}
/* Fallback for browsers without container query support — viewport width is
* a reasonable stand-in since the doc sidebar collapses below 997px. */
@supports not (container-type: inline-size) {
@media (max-width: 996px) {
.cols3,
.cols4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.cols2,
.cols3,
.cols4 { grid-template-columns: minmax(0, 1fr); }
}
}
.card {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.9rem;
padding: 1.4rem 1.6rem;
background: var(--mm-bg-surface);
border: 1px solid var(--mm-border-subtle);
border-radius: 8px;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 2px rgba(30, 50, 92, 0.05);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease,
background 0.15s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 32px -18px rgba(30, 50, 92, 0.3);
border-color: var(--mm-border-strong);
background: var(--mm-bg-subtle);
text-decoration: none;
color: inherit;
}
/* --mm-focus-ring (marigold) is ~1.7:1 against the white card surface,
* well under the 3:1 WCAG 1.4.11 minimum for non-text UI indicators.
* Use denim instead, which passes on both the light and dark surface. */
.card:focus-visible {
outline: 2px solid var(--mm-color-denim);
outline-offset: 2px;
}
[data-theme='dark'] .card:focus-visible {
outline-color: var(--mm-denim-300, #8499C5);
}
[data-theme='dark'] .card {
background: rgba(255, 255, 255, 0.035);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: none;
}
[data-theme='dark'] .card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--mm-denim-300);
}
.icon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
background: var(--mm-bg-subtle);
color: var(--mm-color-denim);
border-radius: 50%;
transition: background 0.15s ease, color 0.15s ease;
}
.card:hover .icon {
background: var(--mm-color-denim);
color: var(--mm-color-white);
}
[data-theme='dark'] .icon {
background: rgba(255, 255, 255, 0.06);
color: var(--mm-denim-200);
}
[data-theme='dark'] .card:hover .icon {
background: var(--mm-color-white);
color: var(--mm-color-denim);
}
.body {
flex: 1 1 auto;
min-width: 0;
padding-right: 1.5rem;
}
.title {
/* Mixed-case, multi-word labels (e.g. "For End Users") read as too
* heavy/shouty in Archivo Black at 900 — same issue already fixed for
* h1-h4 — so use the sans face at a lighter bold weight instead. */
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.01em;
margin-bottom: 0.35rem;
color: var(--mm-text-primary);
}
.description {
font-size: 0.88rem;
line-height: 1.5;
margin: 0 0 0.4rem;
color: var(--mm-text-secondary);
}
.meta {
display: inline-block;
font-family: var(--mm-font-mono);
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--mm-marigold-700);
background: var(--mm-marigold-100);
padding: 0.1rem 0.45rem;
border-radius: 2px;
}
[data-theme='dark'] .meta {
color: var(--mm-color-marigold);
background: rgba(255, 188, 31, 0.12);
}
.arrow {
position: absolute;
top: 1.4rem;
right: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
color: var(--mm-text-secondary);
font-size: 0.95rem;
transition: transform 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.card:hover .arrow {
background: var(--mm-bg-page);
color: var(--mm-color-denim);
transform: translateX(3px);
}
[data-theme='dark'] .card:hover .arrow {
background: rgba(255, 255, 255, 0.08);
color: var(--mm-color-white);
}
/* Compact variant — secondary destinations that sit under a primary
* card grid. Same affordances (surface, hover, arrow) at roughly half
* the visual weight, so they read as a second tier rather than a
* competing one. */
.chipRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 0.6rem;
margin: 0.75rem 0 2.5rem;
}
.chip {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.85rem;
background: var(--mm-bg-surface);
border: 1px solid var(--mm-border-subtle);
border-radius: 6px;
text-decoration: none;
color: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
}
.chip:hover {
background: var(--mm-bg-subtle);
border-color: var(--mm-border-strong);
text-decoration: none;
color: inherit;
}
.chip:focus-visible {
outline: 2px solid var(--mm-color-denim);
outline-offset: 2px;
}
[data-theme='dark'] .chip {
background: rgba(255, 255, 255, 0.035);
border-color: rgba(255, 255, 255, 0.14);
}
[data-theme='dark'] .chip:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--mm-denim-300);
}
[data-theme='dark'] .chip:focus-visible {
outline-color: var(--mm-denim-300, #8499C5);
}
.chipBody {
flex: 1 1 auto;
min-width: 0;
}
.chipTitle {
display: block;
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.92rem;
letter-spacing: -0.01em;
color: var(--mm-text-primary);
}
.chipDescription {
display: block;
font-size: 0.8rem;
line-height: 1.4;
color: var(--mm-text-secondary);
}
.chipArrow {
flex: 0 0 auto;
color: var(--mm-text-secondary);
font-size: 0.85rem;
transition: transform 0.15s ease, color 0.15s ease;
}
.chip:hover .chipArrow {
color: var(--mm-color-denim);
transform: translateX(3px);
}
[data-theme='dark'] .chip:hover .chipArrow {
color: var(--mm-color-white);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import useBaseUrl from '@docusaurus/useBaseUrl';
import styles from './styles.module.css';
/**
* Brand-aligned icon. Ports the Hugo `compass-icon` shortcode used in
* mattermost-developer-documentation. Backed by SVGs in
* docs-site/static/img/icons/. During phase 4 the full Compass icon
* library is bulk-imported (see PLAN.md §11.2 Week 1).
*
* Usage:
* <CompassIcon name="channels" size="md" tone="denim" />
*/
export default function CompassIcon({
name,
size = 'sm',
tone = 'denim',
alt = '',
}: {
name: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
tone?: 'denim' | 'marigold' | 'inherit';
alt?: string;
}) {
const src = useBaseUrl(`/img/icons/${name}.svg`);
return (
<span
className={`${styles.icon} ${styles[size]} ${styles[tone]}`}
role={alt ? 'img' : 'presentation'}
aria-label={alt || undefined}
style={{maskImage: `url(${src})`, WebkitMaskImage: `url(${src})`}}
/>
);
}

View File

@@ -0,0 +1,20 @@
.icon {
display: inline-block;
vertical-align: -0.15em;
background-color: currentColor;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-size: contain;
-webkit-mask-size: contain;
mask-position: center;
-webkit-mask-position: center;
}
.xs { width: 0.85em; height: 0.85em; }
.sm { width: 1em; height: 1em; }
.md { width: 1.4em; height: 1.4em; }
.lg { width: 2em; height: 2em; }
.denim { color: var(--mm-color-denim); }
.marigold { color: var(--mm-color-marigold); }
.inherit { color: inherit; }

View File

@@ -0,0 +1,369 @@
// Data tables for the Deployment Architecture builder.
//
// Tier sizing follows the Mattermost scaling guide. Provider SKUs are
// illustrative starting points — verify with current pricing and any
// regional / compliance constraints before committing to a procurement
// decision.
export type Tier = {
id: string;
label: string;
seats: string;
description: string;
appNodes: number;
appCpu: number;
appRam: number;
dbReplicas: number;
redis: boolean;
search: 'none' | 'optional' | 'recommended' | 'required';
searchNodes: number;
loadBalancer: 'none' | 'single' | 'cluster';
storage: 'local' | 'object';
ha: boolean;
};
export const TIERS: Tier[] = [
{
id: 'trial',
label: 'Trial / Pilot',
seats: '≤ 200 users',
description: 'Single host for evaluation. Mattermost and PostgreSQL co-located; no separate proxy or cache.',
appNodes: 1,
appCpu: 2,
appRam: 4,
dbReplicas: 0,
redis: false,
search: 'none',
searchNodes: 0,
loadBalancer: 'none',
storage: 'local',
ha: false,
},
{
id: 'small',
label: 'Small',
seats: '≤ 2,000 users',
description: 'Separate app and database hosts. NGINX reverse proxy. Single Mattermost node — restart causes a brief outage.',
appNodes: 1,
appCpu: 4,
appRam: 16,
dbReplicas: 0,
redis: false,
search: 'optional',
searchNodes: 1,
loadBalancer: 'single',
storage: 'object',
ha: false,
},
{
id: 'medium',
label: 'Medium',
seats: '≤ 10,000 users',
description: 'High availability: 2-node Mattermost cluster behind a load balancer. PostgreSQL with read replica.',
appNodes: 2,
appCpu: 8,
appRam: 32,
dbReplicas: 1,
redis: false,
search: 'recommended',
searchNodes: 1,
loadBalancer: 'single',
storage: 'object',
ha: true,
},
{
id: 'large',
label: 'Large',
seats: '≤ 25,000 users',
description: '4-node Mattermost cluster. PostgreSQL primary + 2 read replicas. Dedicated Elasticsearch / OpenSearch cluster for message search.',
appNodes: 4,
appCpu: 16,
appRam: 64,
dbReplicas: 2,
redis: false,
search: 'required',
searchNodes: 3,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
{
id: 'xlarge',
label: 'Extra Large',
seats: '≤ 50,000 users',
description: '8-node Mattermost cluster. PostgreSQL primary + 2 replicas with connection pooler. OpenSearch cluster (5 nodes).',
appNodes: 8,
appCpu: 16,
appRam: 64,
dbReplicas: 2,
redis: false,
search: 'required',
searchNodes: 5,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
{
id: 'mega',
label: 'Mega',
seats: '≤ 100,000 users',
description: '16-node Mattermost cluster. PostgreSQL with connection pooler + multiple read replicas. OpenSearch cluster (5+ data nodes). CDN in front of static assets.',
appNodes: 16,
appCpu: 32,
appRam: 128,
dbReplicas: 3,
redis: false,
search: 'required',
searchNodes: 5,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
{
id: 'hyperscale',
label: 'Hyperscale',
seats: '≤ 200,000 users',
description: '32-node Mattermost cluster. Sharded PostgreSQL via connection pooler. Redis cluster (5+ nodes). OpenSearch cluster (10+ data nodes). Multi-region CDN.',
appNodes: 32,
appCpu: 32,
appRam: 128,
dbReplicas: 4,
redis: true,
search: 'required',
searchNodes: 10,
loadBalancer: 'cluster',
storage: 'object',
ha: true,
},
];
export type DeploymentType = 'vm' | 'kubernetes';
export type Provider = 'aws' | 'azure' | 'gcp' | 'oci' | 'on-prem';
export const PROVIDERS: {id: Provider; label: string}[] = [
{id: 'aws', label: 'AWS'},
{id: 'azure', label: 'Azure'},
{id: 'gcp', label: 'Google Cloud'},
{id: 'oci', label: 'Oracle Cloud (OCI)'},
{id: 'on-prem', label: 'On-prem / Other'},
];
/* ============================ Brand short names ============================
* Used in the diagram boxes — proper service product name per provider.
* Longer descriptive labels live in the BOM under "Recommended size / SKU". */
export const DB_BRAND: Record<Provider, string> = {
aws: 'Amazon RDS (PostgreSQL)',
azure: 'Azure Database (PostgreSQL)',
gcp: 'Cloud SQL (PostgreSQL)',
oci: 'OCI Database (PostgreSQL)',
'on-prem': 'PostgreSQL (self-managed)',
};
export const REDIS_BRAND: Record<Provider, string> = {
aws: 'Amazon ElastiCache (Redis)',
azure: 'Azure Cache for Redis',
gcp: 'Memorystore (Redis)',
oci: 'OCI Cache (Redis)',
'on-prem': 'Redis (self-managed)',
};
export const SEARCH_BRAND: Record<Provider, string> = {
aws: 'Amazon OpenSearch',
azure: 'Self-hosted OpenSearch',
gcp: 'Self-hosted OpenSearch',
oci: 'OCI Search (OpenSearch)',
'on-prem': 'OpenSearch (self-managed)',
};
export const STORAGE_BRAND: Record<Provider, string> = {
aws: 'Amazon S3',
azure: 'Azure Blob Storage',
gcp: 'Cloud Storage',
oci: 'OCI Object Storage',
'on-prem': 'MinIO (S3-compatible)',
};
export const LB_BRAND_VM: Record<Provider, string> = {
aws: 'AWS Application Load Balancer',
azure: 'Azure Application Gateway',
gcp: 'Google Cloud HTTPS LB',
oci: 'OCI Flexible Load Balancer',
'on-prem': 'NGINX / HAProxy',
};
export const LB_BRAND_K8S: Record<Provider, string> = {
aws: 'NGINX Ingress + AWS NLB',
azure: 'NGINX Ingress + Azure LB',
gcp: 'NGINX Ingress + GCP LB',
oci: 'NGINX Ingress + OCI LB',
'on-prem': 'NGINX Ingress Controller',
};
/* ============================ DB instance sizing ============================ */
const DB_INSTANCE = {
aws: {trial: 'co-located', small: 'db.m5.large', medium: 'db.m5.xlarge', large: 'db.m5.2xlarge', xlarge: 'db.m5.4xlarge', mega: 'db.r5.4xlarge', hyperscale: 'db.r5.8xlarge'},
azure: {trial: 'co-located', small: 'D4ds_v4', medium: 'D8ds_v4', large: 'D16ds_v4', xlarge: 'D32ds_v4', mega: 'E32ds_v4', hyperscale: 'E48ds_v4'},
gcp: {trial: 'co-located', small: 'db-n1-standard-4', medium: 'db-n1-standard-8', large: 'db-n1-standard-16', xlarge: 'db-n1-standard-32', mega: 'db-n1-highmem-32', hyperscale: 'db-n1-highmem-64'},
oci: {trial: 'co-located', small: 'PostgreSQL.E4.4', medium: 'PostgreSQL.E4.8', large: 'PostgreSQL.E4.16', xlarge: 'PostgreSQL.E4.32', mega: 'PostgreSQL.E4.64', hyperscale: 'PostgreSQL.E4.96'},
'on-prem': {trial: 'co-located', small: '4 vCPU / 16 GB', medium: '8 vCPU / 32 GB', large: '16 vCPU / 64 GB', xlarge: '32 vCPU / 128 GB', mega: '32 vCPU / 256 GB', hyperscale: '64 vCPU / 384 GB'},
} as const;
const APP_SKU = {
aws: {'2-4': 't3.medium', '4-16': 'm5.xlarge', '8-32': 'm5.2xlarge', '16-64': 'm5.4xlarge', '32-128': 'm6i.8xlarge'},
azure: {'2-4': 'B2ms', '4-16': 'D4s_v3', '8-32': 'D8s_v3', '16-64': 'D16s_v3', '32-128': 'D32s_v3'},
gcp: {'2-4': 'e2-medium', '4-16': 'e2-standard-4', '8-32': 'e2-standard-8', '16-64': 'n2-standard-16', '32-128': 'n2-standard-32'},
oci: {'2-4': 'VM.Standard.E4.Flex (2 OCPU)', '4-16': 'VM.Standard.E4.Flex (4 OCPU)', '8-32': 'VM.Standard.E4.Flex (8 OCPU)', '16-64': 'VM.Standard.E4.Flex (16 OCPU)', '32-128': 'VM.Standard.E4.Flex (32 OCPU)'},
'on-prem': {'2-4': '2 vCPU / 4 GB VM', '4-16': '4 vCPU / 16 GB VM', '8-32': '8 vCPU / 32 GB VM', '16-64': '16 vCPU / 64 GB VM', '32-128': '32 vCPU / 128 GB VM'},
} as const;
function bucketFor(cpu: number): keyof typeof APP_SKU['aws'] {
if (cpu <= 2) return '2-4';
if (cpu <= 4) return '4-16';
if (cpu <= 8) return '8-32';
if (cpu <= 16) return '16-64';
return '32-128';
}
/* ============================ Bill of materials ============================ */
export type BomRow = {
component: string;
quantity: string;
spec: string;
notes?: string;
};
export type BomOptions = {
calls: boolean;
push: boolean;
};
export function bomFor(
tier: Tier,
deploymentType: DeploymentType,
provider: Provider,
opts: BomOptions,
): BomRow[] {
const rows: BomRow[] = [];
const onPrem = provider === 'on-prem';
const k8s = deploymentType === 'kubernetes';
const bucket = bucketFor(tier.appCpu);
const tierId = tier.id as keyof typeof DB_INSTANCE['aws'];
// Load balancer / ingress
if (tier.loadBalancer !== 'none') {
rows.push({
component: k8s ? 'Ingress controller' : 'Load balancer',
quantity: tier.loadBalancer === 'cluster' ? 'HA pair' : '1',
spec: k8s ? LB_BRAND_K8S[provider] : LB_BRAND_VM[provider],
notes: 'TLS terminated at the proxy. Mattermost behind, listening on HTTP/8065.',
});
}
// App / Mattermost
rows.push({
component: k8s ? 'Mattermost (Operator)' : 'Mattermost app server',
quantity: `${tier.appNodes} node${tier.appNodes > 1 ? 's' : ''}`,
spec: k8s
? `${tier.appCpu} vCPU / ${tier.appRam} GB RAM per pod — worker nodes: ${APP_SKU[provider][bucket]}`
: `${APP_SKU[provider][bucket]} (${tier.appCpu} vCPU / ${tier.appRam} GB RAM)`,
notes: tier.ha
? 'High availability — restarts and upgrades are non-disruptive.'
: 'Single node — restarts cause a brief outage.',
});
// Database
rows.push({
component: 'PostgreSQL (primary)',
quantity: '1',
spec: `${DB_BRAND[provider]} — ${DB_INSTANCE[provider][tierId]}`,
notes: tier.id === 'trial' ? 'Co-located with the app for evaluation only.' : 'Separate host. PostgreSQL 14 minimum. App connects on TCP/5432.',
});
if (tier.dbReplicas > 0) {
rows.push({
component: 'PostgreSQL read replicas',
quantity: `${tier.dbReplicas}`,
spec: onPrem
? 'Streaming replication, same size as primary'
: `${DB_BRAND[provider]} read replica`,
notes: 'Read queries (search, dashboards) offloaded from the primary.',
});
}
// Redis
if (tier.redis) {
rows.push({
component: 'Redis cache',
quantity: tier.appNodes >= 16 ? 'Cluster (5+ nodes)' : tier.appNodes >= 8 ? 'Cluster (3 nodes)' : '1 node',
spec: REDIS_BRAND[provider],
notes: 'Required for HA — coordinates sessions and cluster state across app nodes. App connects on TCP/6379.',
});
}
// Search
if (tier.search !== 'none') {
rows.push({
component: 'Search index',
quantity: `${tier.searchNodes} node${tier.searchNodes > 1 ? 's' : ''}`,
spec: SEARCH_BRAND[provider],
notes:
tier.search === 'optional'
? 'Optional at this size — message search will work via PostgreSQL but is slower above ~5,000 users. App connects on HTTPS/9200.'
: tier.search === 'recommended'
? 'Recommended — message search performance degrades on the DB alone at this scale. App connects on HTTPS/9200.'
: 'Required — message search at this scale cannot run on PostgreSQL alone. App connects on HTTPS/9200.',
});
}
// File storage
rows.push({
component: 'File storage',
quantity: '—',
spec: tier.storage === 'local' ? 'Local disk on app host (trial only)' : STORAGE_BRAND[provider],
notes: tier.storage === 'local'
? 'Acceptable for trial only. Move to object storage before any HA or multi-node config.'
: 'S3-compatible bucket. App connects on HTTPS/443. Backup + versioning recommended.',
});
// Calls (RTCD) — optional
if (opts.calls) {
rows.push({
component: 'Calls / RTCD service',
quantity: tier.appNodes >= 8 ? '2-3 nodes (HA)' : '1 node',
spec: 'Mattermost RTCD (real-time communications daemon) — small container or VM (2 vCPU / 4 GB)',
notes:
'Standalone service for audio + screen-share. Clients connect on UDP/8443 (and TCP/8443 fallback). Mattermost talks to RTCD on TCP/8045 for the API.',
});
rows.push({
component: 'Calls plugin',
quantity: '—',
spec: 'Bundled Calls plugin enabled in System Console',
notes: 'Required to drive the RTCD service. Configure provider URL pointing at RTCD endpoint.',
});
}
// Push notification proxy — optional
if (opts.push) {
rows.push({
component: 'Push notification proxy',
quantity: '1',
spec: 'Mattermost Push Proxy (open source) — small single host (1 vCPU / 2 GB) or container',
notes:
'Mediates APNs (api.push.apple.com) and FCM (fcm.googleapis.com) on HTTPS/443. Use HPNS instead if you can reach Mattermost-managed; self-host for air-gapped / sovereign deployments.',
});
}
// Kubernetes-specific: cluster sizing note
if (k8s) {
rows.push({
component: 'Kubernetes cluster',
quantity: tier.appNodes <= 2 ? '3 worker nodes (min)' : `${tier.appNodes + 2}+ worker nodes`,
spec: provider === 'aws' ? 'Amazon EKS' : provider === 'azure' ? 'Azure AKS' : provider === 'gcp' ? 'Google GKE' : provider === 'oci' ? 'Oracle OKE' : 'self-managed K8s',
notes: 'Sized for app pods + system overhead. Mattermost Operator manages the app lifecycle.',
});
}
return rows;
}

View File

@@ -0,0 +1,600 @@
import React, {useState, useMemo} from 'react';
import styles from './styles.module.css';
import {
TIERS,
PROVIDERS,
bomFor,
DB_BRAND,
REDIS_BRAND,
SEARCH_BRAND,
STORAGE_BRAND,
LB_BRAND_VM,
LB_BRAND_K8S,
type Tier,
type DeploymentType,
type Provider,
} from './data';
/**
* Interactive deployment-architecture builder.
*
* Pick a scale tier, deployment type, hosting provider, and optional
* services (Calls / Push). Renders an architecture diagram with explicit
* per-connection port labels, plus a bill of materials and a network
* flows table sized to those choices.
*/
/* ============================ Diagram primitives ============================ */
function FlowDown({port}: {port: string}) {
return (
<div className={styles.flow} aria-hidden>
<span className={styles.flowArrow}>↓</span>
<span className={styles.flowPorts}>{port}</span>
</div>
);
}
function MiniFlow({port}: {port: string}) {
return (
<div className={styles.miniFlow} aria-hidden>
<span className={styles.miniArrow}>↓</span> {port}
</div>
);
}
function HFlow({direction, port}: {direction: 'left' | 'right'; port: string}) {
const arrow = direction === 'left' ? '←' : '→';
return (
<div className={styles.hFlow} aria-hidden>
<span className={styles.hArrow}>{arrow}</span>
<span className={styles.hFlowPorts}>{port}</span>
</div>
);
}
function DataItem({
port,
accent,
label,
subtitle,
}: {
port: string;
accent: string;
label: string;
subtitle: string;
}) {
return (
<div className={styles.dataItem}>
<MiniFlow port={port} />
<div className={`${styles.box} ${accent}`}>
<div className={styles.boxLabel}>{label}</div>
<div className={styles.boxSub}>{subtitle}</div>
</div>
</div>
);
}
/* ============================ Architecture diagram ============================
*
* 3-column CSS grid: left side / center / right side.
* - Center column holds: clients, ALB, Mattermost, data tier (so they all
* align vertically — ALB is locked directly above Mattermost).
* - Left column holds RTCD when Calls is on.
* - Right column holds Push Proxy / HPNS.
*
* Connection labels are minimal (just ports). The Network Flows table
* below the BOM documents what each port is for.
*/
function ArchDiagram({
tier,
k8s,
provider,
showCalls,
pushMode,
}: {
tier: Tier;
k8s: boolean;
provider: Provider;
showCalls: boolean;
pushMode: 'self-hosted' | 'hpns';
}) {
const showLb = tier.loadBalancer !== 'none';
const showRedis = tier.redis;
const showSearch = tier.search !== 'none';
const showReplica = tier.dbReplicas > 0;
const lbName = k8s ? LB_BRAND_K8S[provider] : LB_BRAND_VM[provider];
return (
<div className={styles.diagram} aria-label="Architecture diagram">
{/* Client tier — labeled container, two cards (Web/Desktop + Mobile) */}
<div className={styles.gridCenter}>
<div className={styles.clientsBox}>
<div className={styles.clientsLabel}>Clients</div>
<div className={styles.clientsRow}>
<div className={styles.client}>
<div className={styles.clientLabel}>Web &amp; Desktop</div>
<div className={styles.clientSub}>Channels web app · Windows · macOS · Linux</div>
</div>
<div className={styles.client}>
<div className={styles.clientLabel}>Mobile</div>
<div className={styles.clientSub}>iOS · Android</div>
</div>
</div>
</div>
</div>
{/* Flow row: main HTTPS arrow from clients into LB / Mattermost */}
<div className={styles.gridCenter}>
<FlowDown port="TCP/443" />
</div>
{/* Edge tier (ALB) — center column, locked above Mattermost */}
{showLb && (
<>
<div className={styles.gridCenter}>
<div className={`${styles.box} ${styles.boxLb}`}>
<div className={styles.boxLabel}>{lbName}</div>
<div className={styles.boxSub}>
{tier.loadBalancer === 'cluster' ? 'HA pair · TLS termination' : 'TLS termination'}
</div>
</div>
</div>
<div className={styles.gridCenter}>
<FlowDown port="TCP/8065" />
</div>
</>
)}
{/* App row: RTCD (left) — Mattermost (center) — Push (right), all in one row.
* The DIRECT pill floats absolutely above the RTCD box so it doesn't claim
* its own grid row (which would create an empty band across the diagram). */}
{showCalls && (
<div className={styles.gridLeft}>
<div className={styles.sideGroup}>
<div className={styles.rtcdWrap}>
<div className={styles.flowDirectAbove} aria-label="Direct client media path to RTCD">
<span className={styles.directBadge}>direct</span>
<span className={styles.flowArrowDiag}>↓</span>
<span className={styles.flowPorts}>UDP+TCP/8443</span>
</div>
<div className={`${styles.box} ${styles.boxCalls}`}>
<div className={styles.boxLabel}>RTCD (Calls)</div>
<div className={styles.boxSub}>
{tier.appNodes >= 8 ? 'HA (2–3 nodes)' : '1 node'} · audio + screen-share
</div>
</div>
</div>
<HFlow direction="left" port="TCP/8045" />
</div>
</div>
)}
<div className={styles.gridCenter}>
<div className={`${styles.box} ${styles.boxApp}`}>
<div className={styles.boxLabel}>
Mattermost{k8s ? ' (Operator)' : ''}
<span className={styles.qty}>×{tier.appNodes}</span>
</div>
<div className={styles.boxSub}>
{tier.appCpu} vCPU · {tier.appRam} GB RAM{tier.ha ? ' · HA cluster' : ''}
</div>
</div>
</div>
<div className={styles.gridRight}>
<div className={styles.sideGroup}>
<HFlow direction="right" port="HTTPS/443" />
{pushMode === 'self-hosted' ? (
<div className={`${styles.box} ${styles.boxPush}`}>
<div className={styles.boxLabel}>Push Proxy</div>
<div className={styles.boxSub}>self-hosted</div>
</div>
) : (
<div className={`${styles.box} ${styles.boxPushManaged}`}>
<div className={styles.boxLabel}>Mattermost HPNS</div>
<div className={styles.boxSub}>
<span className={styles.managedBadge}>managed by Mattermost</span>
<br />
Hosted Push Notifications Service
</div>
</div>
)}
</div>
</div>
{/* Data tier — spans full width below center */}
<div className={`${styles.gridFull} ${styles.rowData}`}>
<DataItem
port="TCP/5432"
accent={styles.boxDb}
label={DB_BRAND[provider]}
subtitle={`primary${showReplica ? ` + ${tier.dbReplicas} replica${tier.dbReplicas > 1 ? 's' : ''}` : ''}`}
/>
{showRedis && (
<DataItem
port="TCP/6379"
accent={styles.boxRedis}
label={REDIS_BRAND[provider]}
subtitle={`${tier.appNodes >= 8 ? 'Cluster' : 'Single'} · sessions + cluster state`}
/>
)}
{showSearch && (
<DataItem
port="HTTPS/9200"
accent={styles.boxSearch}
label={SEARCH_BRAND[provider]}
subtitle={`${tier.searchNodes} node${tier.searchNodes > 1 ? 's' : ''} · message search`}
/>
)}
<DataItem
port="HTTPS/443"
accent={styles.boxStorage}
label={tier.storage === 'object' ? STORAGE_BRAND[provider] : 'Local disk'}
subtitle={tier.storage === 'object' ? 'S3-compatible · uploads + plugins' : 'host disk (trial)'}
/>
</div>
</div>
);
}
/* ============================ Network Flows table ============================
*
* Documents every connection in the architecture: source → destination,
* protocol, port, and purpose. Useful for firewall configuration.
* Rows are conditional on the user's choices. */
type NetworkFlow = {
from: string;
to: string;
protocol: string;
port: string;
direction: 'inbound' | 'internal' | 'egress';
purpose: string;
};
function networkFlowsFor(
tier: Tier,
provider: Provider,
showCalls: boolean,
pushMode: 'self-hosted' | 'hpns',
): NetworkFlow[] {
const flows: NetworkFlow[] = [];
flows.push({
from: 'Web / Desktop / Mobile clients',
to: tier.loadBalancer !== 'none' ? 'Load balancer' : 'Mattermost',
protocol: 'HTTPS + WSS',
port: 'TCP/443',
direction: 'inbound',
purpose: 'Channels API, WebSocket events, file uploads',
});
if (showCalls) {
flows.push({
from: 'Web / Desktop / Mobile clients',
to: 'RTCD',
protocol: 'UDP, TCP (fallback)',
port: '8443',
direction: 'inbound',
purpose: 'Calls media (audio, video, screen-share) — direct, bypasses LB',
});
}
if (tier.loadBalancer !== 'none') {
flows.push({
from: 'Load balancer',
to: 'Mattermost',
protocol: 'HTTP',
port: 'TCP/8065',
direction: 'internal',
purpose: 'TLS-terminated app traffic',
});
}
flows.push({
from: 'Mattermost',
to: `PostgreSQL (${DB_BRAND[provider]})`,
protocol: 'TCP',
port: '5432',
direction: 'internal',
purpose: 'Database queries',
});
if (tier.redis) {
flows.push({
from: 'Mattermost',
to: `Redis (${REDIS_BRAND[provider]})`,
protocol: 'TCP',
port: '6379',
direction: 'internal',
purpose: 'Sessions, cluster coordination, cache',
});
}
if (tier.search !== 'none') {
flows.push({
from: 'Mattermost',
to: `OpenSearch (${SEARCH_BRAND[provider]})`,
protocol: 'HTTPS',
port: '9200',
direction: 'internal',
purpose: 'Message search index reads + writes',
});
}
flows.push({
from: 'Mattermost',
to: tier.storage === 'object' ? `Object storage (${STORAGE_BRAND[provider]})` : 'Local disk',
protocol: 'HTTPS',
port: 'TCP/443',
direction: tier.storage === 'object' ? 'internal' : 'internal',
purpose: 'File uploads, plugin assets, compliance exports',
});
if (showCalls) {
flows.push({
from: 'Mattermost',
to: 'RTCD',
protocol: 'TCP',
port: '8045',
direction: 'internal',
purpose: 'Calls signaling and orchestration API',
});
}
flows.push({
from: 'Mattermost',
to: pushMode === 'self-hosted' ? 'Mattermost Push Proxy (self-hosted)' : 'Mattermost HPNS (managed)',
protocol: 'HTTPS',
port: 'TCP/443',
direction: pushMode === 'self-hosted' ? 'internal' : 'egress',
purpose: 'Push notification submission',
});
flows.push({
from: pushMode === 'self-hosted' ? 'Mattermost Push Proxy' : 'Mattermost HPNS',
to: 'Apple APNs (api.push.apple.com)',
protocol: 'HTTPS',
port: 'TCP/443',
direction: 'egress',
purpose: 'iOS push fan-out',
});
flows.push({
from: pushMode === 'self-hosted' ? 'Mattermost Push Proxy' : 'Mattermost HPNS',
to: 'Google FCM (fcm.googleapis.com)',
protocol: 'HTTPS',
port: 'TCP/443',
direction: 'egress',
purpose: 'Android push fan-out',
});
return flows;
}
function NetworkFlowsTable({flows}: {flows: NetworkFlow[]}) {
return (
<>
<h3 className={styles.bomHeading}>Network flows</h3>
<p className={styles.flowsIntro}>
Every connection in the architecture above — useful when configuring firewall
rules, security groups, or a CAP. <strong>Inbound</strong> = from clients;{' '}
<strong>internal</strong> = within the deployment boundary;{' '}
<strong>egress</strong> = out to external services.
</p>
<div className={styles.bomTableWrap}>
<table className={styles.bomTable}>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Direction</th>
<th>Protocol</th>
<th>Port</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
{flows.map((f, i) => (
<tr key={`${f.from}-${f.to}-${i}`}>
<td className={styles.cellComponent}>{f.from}</td>
<td className={styles.cellComponent}>{f.to}</td>
<td className={styles.cellQty}>
<span className={`${styles.directionPill} ${styles[`dir_${f.direction}`]}`}>
{f.direction}
</span>
</td>
<td className={styles.cellSpec}>{f.protocol}</td>
<td className={styles.cellSpec}>{f.port}</td>
<td className={styles.cellNotes}>{f.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/* ============================ Main component ============================ */
export default function DeploymentArchitectureBuilder() {
const [tierId, setTierId] = useState<string>('medium');
const [deploymentType, setDeploymentType] = useState<DeploymentType>('vm');
const [provider, setProvider] = useState<Provider>('aws');
const [includeCalls, setIncludeCalls] = useState<boolean>(false);
const [selfHostPush, setSelfHostPush] = useState<boolean>(true);
const tier = useMemo(
() => TIERS.find((t) => t.id === tierId) ?? TIERS[0],
[tierId],
);
const bom = useMemo(
() => bomFor(tier, deploymentType, provider, {calls: includeCalls, push: selfHostPush}),
[tier, deploymentType, provider, includeCalls, selfHostPush],
);
const flows = useMemo(
() => networkFlowsFor(tier, provider, includeCalls, selfHostPush ? 'self-hosted' : 'hpns'),
[tier, provider, includeCalls, selfHostPush],
);
return (
<section className={styles.builder} aria-label="Deployment architecture builder">
<div className={styles.controls}>
<ControlGroup label="Scale tier">
<select
className={styles.select}
value={tierId}
onChange={(e) => setTierId(e.target.value)}
aria-label="Scale tier"
>
{TIERS.map((t) => (
<option key={t.id} value={t.id}>
{t.seats}
</option>
))}
</select>
</ControlGroup>
<ControlGroup label="Deployment type">
<SegmentedControl
name="deployment-type"
value={deploymentType}
onChange={(v) => setDeploymentType(v as DeploymentType)}
options={[
{value: 'vm', label: 'Virtual machines'},
{value: 'kubernetes', label: 'Kubernetes'},
]}
/>
</ControlGroup>
<ControlGroup label="Hosting">
<select
className={styles.select}
value={provider}
onChange={(e) => setProvider(e.target.value as Provider)}
aria-label="Hosting"
>
{PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
</ControlGroup>
<ControlGroup label="Optional services">
<div className={styles.checkboxes}>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={includeCalls}
onChange={(e) => setIncludeCalls(e.target.checked)}
/>
<span>Calls (RTCD)</span>
</label>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={selfHostPush}
onChange={(e) => setSelfHostPush(e.target.checked)}
/>
<span>Self-host Push Proxy</span>
</label>
</div>
</ControlGroup>
</div>
<aside className={styles.summary} role="note">
<strong>{tier.seats}</strong> — {tier.description}
</aside>
<ArchDiagram
tier={tier}
k8s={deploymentType === 'kubernetes'}
provider={provider}
showCalls={includeCalls}
pushMode={selfHostPush ? 'self-hosted' : 'hpns'}
/>
<h3 className={styles.bomHeading}>Bill of materials</h3>
<div className={styles.bomTableWrap}>
<table className={styles.bomTable}>
<thead>
<tr>
<th>Component</th>
<th>Quantity</th>
<th>Recommended size / SKU</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{bom.map((row, i) => (
<tr key={`${row.component}-${i}`}>
<td className={styles.cellComponent}>{row.component}</td>
<td className={styles.cellQty}>{row.quantity}</td>
<td className={styles.cellSpec}>{row.spec}</td>
<td className={styles.cellNotes}>{row.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
<NetworkFlowsTable flows={flows} />
<p className={styles.caveat}>
Sizing follows the Mattermost scaling guide; provider SKUs are illustrative
starting points. Verify against current pricing, regional availability, and any
compliance constraints before procurement.
</p>
</section>
);
}
/* ============================ Small UI bits ============================ */
function ControlGroup({label, children}: {label: string; children: React.ReactNode}) {
return (
<div className={styles.controlGroup}>
<div className={styles.controlLabel}>{label}</div>
{children}
</div>
);
}
function SegmentedControl<T extends string>({
name,
value,
onChange,
options,
}: {
name: string;
value: T;
onChange: (v: T) => void;
options: {value: T; label: string}[];
}) {
return (
<div className={styles.segmented} role="radiogroup" aria-label={name}>
{options.map((opt) => (
<label
key={opt.value}
className={`${styles.segment} ${value === opt.value ? styles.segmentActive : ''}`}
>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
className={styles.segmentInput}
/>
{opt.label}
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,862 @@
/* Deployment Architecture builder.
* Brand alignment: denim headers, marigold accents on boxes, white cards.
* Layout: controls → summary → diagram (stacked rows) → bill of materials. */
.builder {
margin: 1.5rem 0 2rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 8px;
overflow: hidden;
background: var(--mm-color-white);
box-shadow: 0 4px 18px -14px rgba(30, 50, 92, 0.4);
}
[data-theme='dark'] .builder {
background: var(--mm-denim-700, #121E37);
border-color: rgba(255, 255, 255, 0.1);
}
/* ============================ Controls ============================ */
.controls {
display: grid;
grid-template-columns: minmax(200px, 0.9fr) minmax(220px, 1fr) minmax(280px, 1.3fr) minmax(180px, 0.9fr);
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--mm-color-denim, #1E325C);
color: var(--mm-color-white);
align-items: end;
}
@media (max-width: 1100px) {
.controls { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.controls { grid-template-columns: 1fr; }
}
.controlGroup {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.controlLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.78);
}
.select {
appearance: none;
-webkit-appearance: none;
background-color: var(--mm-color-white);
color: var(--mm-text-primary);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
padding: 0.55rem 2.25rem 0.55rem 0.7rem;
font-size: 0.9rem;
font-family: var(--mm-font-sans);
cursor: pointer;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231E325C' stroke-width='2'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.55rem center;
background-size: 1rem 1rem;
}
.segmented {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.07);
padding: 0.2rem;
border-radius: 4px;
}
.segment {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.7rem;
font-size: 0.82rem;
font-weight: 600;
font-family: var(--mm-font-sans);
color: rgba(255, 255, 255, 0.78);
cursor: pointer;
border-radius: 3px;
transition: background 120ms ease, color 120ms ease;
white-space: nowrap;
}
.segment:hover {
color: var(--mm-color-white);
}
.segmentActive {
background: var(--mm-color-marigold, #FFBC1F);
color: var(--mm-color-black, #1B1D22) !important;
}
.segmentInput {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Optional services checkboxes */
.checkboxes {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
user-select: none;
}
.checkbox input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border: 1.5px solid rgba(255, 255, 255, 0.55);
border-radius: 3px;
background: transparent;
cursor: pointer;
position: relative;
transition: border-color 120ms ease, background 120ms ease;
}
.checkbox input[type="checkbox"]:hover {
border-color: var(--mm-color-marigold, #FFBC1F);
}
.checkbox input[type="checkbox"]:checked {
background: var(--mm-color-marigold, #FFBC1F);
border-color: var(--mm-color-marigold, #FFBC1F);
}
.checkbox input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 3px;
top: 0;
width: 4px;
height: 8px;
border: solid var(--mm-color-black, #1B1D22);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* ============================ Summary ============================ */
.summary {
padding: 0.85rem 1.25rem;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-bottom: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.12));
font-size: 0.88rem;
line-height: 1.55;
color: var(--mm-text-primary);
}
[data-theme='dark'] .summary {
background: rgba(255, 255, 255, 0.03);
color: var(--mm-color-white);
}
.summary strong {
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .summary strong {
color: var(--mm-color-marigold, #FFBC1F);
}
/* ============================ Diagram ============================ */
/* Diagram laid out as a 3-column grid:
* col 1 (1fr, justify-end): left-side components — RTCD when Calls is on
* col 2 (auto): center spine — clients, ALB, Mattermost, data tier
* col 3 (1fr, justify-start): right-side components — Push Proxy / HPNS
*
* The center column auto-sizes to Mattermost's box width, so the ALB sits
* directly above Mattermost regardless of whether RTCD/Push are present.
* Side columns fill remaining space and align toward the spine. */
.diagram {
display: grid;
/* 1fr | auto | 1fr — equal side columns guarantee Mattermost is dead-centred
* regardless of what's in the side columns (RTCD on/off, Push narrow/wide). */
grid-template-columns: 1fr auto 1fr;
align-items: center;
row-gap: 0.85rem; /* generous vertical rhythm */
column-gap: 1rem; /* visible breathing room around Mattermost-adjacent arrows */
padding: 1.5rem 1.25rem 1rem;
}
/* Center column — every spine element (clients, ALB, flows, Mattermost) lives here */
.gridCenter {
grid-column: 2;
justify-self: center;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Left column — RTCD lives here when Calls is enabled */
.gridLeft {
grid-column: 1;
justify-self: end;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Right column — Push Proxy / HPNS lives here */
.gridRight {
grid-column: 3;
justify-self: start;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Full-width rows — data tier spans the whole diagram */
.gridFull {
grid-column: 1 / -1;
justify-self: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
width: 100%;
}
/* Side group: box + horizontal connector together (used in left/right columns).
* Generous gap so the arrow has equal breathing room from its anchor box
* and from Mattermost (matched to .diagram column-gap). */
.sideGroup {
display: inline-flex;
align-items: center;
gap: 1rem;
}
/* Direct-flow pill sits in col 1, aligned to the right edge */
.directFlowCell {
align-self: center;
}
/* Legacy class kept for the few places using it (none active) */
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
width: 100%;
position: relative;
}
.rowData {
margin-top: 0.2rem;
align-items: flex-start;
}
/* App cluster — 3 columns aligned horizontally with arrow connectors:
* [Push column] ← [Mattermost] → [RTCD column]
* Stretches center-aligned; columns stack vertically inside (e.g., Push has
* APNs/FCM below it). Collapses to vertical at narrow viewports. */
.appCluster {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin: 0.3rem 0 0.6rem;
width: 100%;
}
.appCol {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
/* Horizontal flow connector between columns — bordered pill with arrow + port */
.hFlow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-bottom: 2px solid var(--mm-color-marigold, #FFBC1F);
border-radius: 2px;
white-space: nowrap;
align-self: center;
}
[data-theme='dark'] .hFlow {
background: rgba(255, 255, 255, 0.04);
}
.hArrow {
color: var(--mm-color-marigold, #FFBC1F);
font-weight: 700;
font-size: 0.9rem;
line-height: 1;
}
.hFlowText {
color: var(--mm-color-denim, #1E325C);
font-weight: 600;
}
[data-theme='dark'] .hFlowText {
color: var(--mm-color-white);
}
.hFlowPorts {
margin-left: 0.45rem;
font-weight: 500;
color: var(--mm-text-secondary);
opacity: 0.85;
}
[data-theme='dark'] .hFlowPorts {
color: rgba(255, 255, 255, 0.65);
}
/* Each data tier component gets its own arrow + port indicator above it */
.dataItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.miniFlow {
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 2px solid var(--mm-color-denim, #1E325C);
padding: 0.15rem 0.45rem;
border-radius: 2px;
white-space: nowrap;
}
[data-theme='dark'] .miniFlow {
color: var(--mm-color-white);
background: rgba(255, 255, 255, 0.05);
border-left-color: var(--mm-denim-300, #8499C5);
}
.miniArrow {
color: var(--mm-color-marigold, #FFBC1F);
font-weight: 700;
margin-right: 0.15rem;
}
/* Flow label between rows — shows protocol + port */
.flow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0.15rem 0;
font-size: 0.7rem;
font-family: var(--mm-font-mono, monospace);
color: var(--mm-text-secondary, var(--mm-text-primary));
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
padding: 0.18rem 0.55rem;
border-radius: 2px;
border-left: 2px solid var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .flow {
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.78);
border-left-color: var(--mm-denim-300, #8499C5);
}
.flowArrow {
color: var(--mm-color-marigold, #FFBC1F);
font-weight: 700;
font-size: 0.85rem;
line-height: 1;
}
.flowText {
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .flowText {
color: var(--mm-color-white);
}
.flowPorts {
margin-left: 0.5rem;
font-weight: 500;
color: var(--mm-text-secondary);
opacity: 0.85;
}
[data-theme='dark'] .flowPorts {
color: rgba(255, 255, 255, 0.65);
}
/* Client tier — wrapped in a labeled container "Clients" */
.clientsBox {
position: relative;
display: inline-block;
padding: 1.1rem 1rem 0.7rem;
border: 1.5px dashed var(--mm-border-subtle, rgba(30, 50, 92, 0.35));
border-radius: 6px;
background: transparent;
}
[data-theme='dark'] .clientsBox {
border-color: rgba(255, 255, 255, 0.25);
}
.clientsLabel {
position: absolute;
top: -0.55rem;
left: 0.85rem;
padding: 0.05rem 0.45rem;
font-family: var(--mm-font-sans);
font-weight: 800;
font-size: 0.62rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mm-color-denim, #1E325C);
background: var(--mm-color-white);
}
[data-theme='dark'] .clientsLabel {
color: var(--mm-color-white);
background: var(--mm-denim-700, #121E37);
}
.clientsRow {
display: flex;
gap: 0.6rem;
justify-content: center;
flex-wrap: nowrap;
}
.client {
flex: 0 0 auto;
min-width: 12rem;
max-width: 16rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.2));
border-radius: 4px;
background: var(--mm-color-white);
color: var(--mm-text-primary);
text-align: center;
}
[data-theme='dark'] .client {
color: var(--mm-color-white);
background: var(--mm-denim-800, #0C1424);
border-color: rgba(255, 255, 255, 0.1);
}
.clientLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.82rem;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .clientLabel {
color: var(--mm-color-white);
}
.clientSub {
font-size: 0.7rem;
margin-top: 0.15rem;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.85;
}
/* Legacy class kept for backward compat — used by old .rowClients references */
.rowClients {
gap: 0.6rem;
}
.box {
flex: 0 0 auto;
min-width: 9rem;
max-width: 14rem;
padding: 0.55rem 0.85rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 4px;
background: var(--mm-color-white);
color: var(--mm-text-primary);
text-align: center;
position: relative;
}
[data-theme='dark'] .box {
background: var(--mm-denim-800, #0C1424);
color: var(--mm-color-white);
border-color: rgba(255, 255, 255, 0.12);
}
.boxLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.85rem;
line-height: 1.2;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .boxLabel {
color: var(--mm-color-white);
}
.qty {
display: inline-block;
margin-left: 0.3rem;
padding: 0.05rem 0.35rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--mm-color-black);
background: var(--mm-color-marigold, #FFBC1F);
border-radius: 2px;
}
.boxSub {
margin-top: 0.2rem;
font-size: 0.72rem;
line-height: 1.35;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.85;
}
/* Per-role left-edge accents.
* App-row boxes (App / Calls / Push) all share min-height so they align
* horizontally in the grid row even when their subtitles wrap differently. */
.boxLb { border-left: 3px solid var(--mm-color-marigold, #FFBC1F); min-width: 16rem; }
.boxApp { border-left: 3px solid var(--mm-color-denim, #1E325C); min-width: 16rem; min-height: 4rem; }
.boxDb { border-left: 3px solid #1d6f42; min-width: 11rem; } /* postgres green */
.boxRedis { border-left: 3px solid #d4322c; min-width: 11rem; } /* redis red */
.boxSearch { border-left: 3px solid #5a3aa6; min-width: 11rem; } /* opensearch purple */
.boxStorage { border-left: 3px solid #4a5568; min-width: 11rem; } /* neutral */
.boxCalls { border-left: 3px solid #007a5a; min-width: 12rem; min-height: 4rem; } /* calls teal-green */
.boxPush { border-left: 3px solid #3a6ea5; min-width: 12rem; min-height: 4rem; } /* push blue (self-hosted) */
/* HPNS managed-by-Mattermost variant — dotted border + secondary background */
.boxPushManaged {
border: 1.5px dashed var(--mm-color-marigold, #FFBC1F);
background: rgba(255, 188, 31, 0.06);
min-width: 18rem;
min-height: 4rem;
}
[data-theme='dark'] .boxPushManaged {
background: rgba(255, 188, 31, 0.1);
}
.managedBadge {
display: inline-block;
margin-top: 0.2rem;
padding: 0.08rem 0.45rem;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--mm-color-marigold, #FFBC1F);
color: var(--mm-color-black, #1B1D22);
border-radius: 2px;
}
/* Flows-row container — sits between the client tier and the LB tier.
* When Calls is enabled, the direct client→RTCD flow renders on the LEFT
* (matching RTCD's column position below), and the main HTTPS+WSS path
* renders to its right. */
.flowsRow {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
margin: 0.15rem 0;
}
.flowDirect {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
background: rgba(0, 122, 90, 0.08);
border-left: 2px solid #007a5a;
border-radius: 2px;
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
white-space: nowrap;
}
[data-theme='dark'] .flowDirect {
background: rgba(0, 122, 90, 0.18);
color: var(--mm-color-white);
}
/* RTCD column wrapper — relative anchor for the absolutely-positioned DIRECT pill */
.rtcdWrap {
position: relative;
}
/* Floating DIRECT pill: sits above the RTCD box without claiming a grid row */
.flowDirectAbove {
position: absolute;
bottom: calc(100% + 0.35rem);
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
background: rgba(0, 122, 90, 0.08);
border-left: 2px solid #007a5a;
border-radius: 2px;
font-family: var(--mm-font-mono, monospace);
font-size: 0.68rem;
font-weight: 600;
color: var(--mm-color-denim, #1E325C);
white-space: nowrap;
pointer-events: none;
}
[data-theme='dark'] .flowDirectAbove {
background: rgba(0, 122, 90, 0.22);
color: var(--mm-color-white);
}
.directBadge {
display: inline-block;
padding: 0.05rem 0.35rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #007a5a;
color: var(--mm-color-white);
border-radius: 2px;
}
.flowArrowDiag {
color: #007a5a;
font-weight: 700;
font-size: 0.95rem;
line-height: 1;
}
.directText {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* Legacy inline-in-RTCD callout (no longer used after layout swap) */
.calloutDirect {
margin-top: 0.5rem;
padding: 0.4rem 0.55rem;
background: rgba(0, 122, 90, 0.08);
border-left: 2px solid #007a5a;
border-radius: 2px;
font-size: 0.7rem;
line-height: 1.45;
text-align: left;
color: var(--mm-text-primary);
}
[data-theme='dark'] .calloutDirect {
background: rgba(0, 122, 90, 0.18);
color: var(--mm-color-white);
}
.calloutDirect code {
font-family: var(--mm-font-mono, monospace);
font-size: 0.66rem;
background: rgba(255, 255, 255, 0.6);
padding: 0.05rem 0.3rem;
border-radius: 2px;
}
[data-theme='dark'] .calloutDirect code {
background: rgba(255, 255, 255, 0.1);
color: var(--mm-color-white);
}
.calloutBadge {
display: inline-block;
margin-right: 0.35rem;
padding: 0.05rem 0.35rem;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #007a5a;
color: var(--mm-color-white);
border-radius: 2px;
}
/* External services (APNs / FCM) — small, secondary visual treatment */
.external {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 0.45rem 0.95rem;
border: 1px dotted var(--mm-border-subtle, rgba(30, 50, 92, 0.3));
border-radius: 4px;
background: transparent;
text-align: center;
min-width: 12rem;
}
[data-theme='dark'] .external {
border-color: rgba(255, 255, 255, 0.25);
}
.externalLabel {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.78rem;
color: var(--mm-text-secondary, var(--mm-text-primary));
letter-spacing: 0.04em;
}
.externalSub {
font-size: 0.66rem;
margin-top: 0.1rem;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.75;
}
/* ============================ Bill of materials ============================ */
.bomHeading {
margin: 1.25rem 1.25rem 0.6rem;
font-family: var(--mm-font-sans);
font-size: 1rem;
font-weight: 700;
color: var(--mm-color-denim, #1E325C);
display: flex;
align-items: center;
gap: 0.45rem;
}
.bomHeading::before {
content: '';
display: inline-block;
width: 0.85rem;
height: 0.85rem;
background: var(--mm-color-marigold, #FFBC1F);
}
[data-theme='dark'] .bomHeading {
color: var(--mm-color-white);
}
.bomTableWrap {
overflow-x: auto;
padding: 0 1.25rem;
}
.bomTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
min-width: 720px;
}
.bomTable th,
.bomTable td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.1));
vertical-align: top;
}
.bomTable th {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mm-text-secondary, var(--mm-text-primary));
border-bottom: 2px solid var(--mm-color-denim, #1E325C);
background: var(--mm-denim-50, rgba(30, 50, 92, 0.04));
}
[data-theme='dark'] .bomTable th {
background: rgba(255, 255, 255, 0.03);
color: var(--mm-color-white);
}
.cellComponent { font-weight: 600; width: 18%; min-width: 9rem; }
.cellQty { width: 12%; min-width: 5rem; color: var(--mm-text-secondary); }
.cellSpec { width: 30%; font-family: var(--mm-font-mono, monospace); font-size: 0.8rem; }
.cellNotes { width: 40%; color: var(--mm-text-secondary, var(--mm-text-primary)); opacity: 0.95; }
.caveat {
margin: 0.9rem 1.25rem 1.25rem;
font-size: 0.78rem;
line-height: 1.5;
color: var(--mm-text-secondary, var(--mm-text-primary));
opacity: 0.85;
}
/* Network flows table — intro paragraph + direction pills */
.flowsIntro {
margin: 0.4rem 1.25rem 0.75rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--mm-text-secondary, var(--mm-text-primary));
}
.directionPill {
display: inline-block;
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 2px;
color: var(--mm-color-white);
white-space: nowrap;
}
.dir_inbound { background: #3a6ea5; }
.dir_internal { background: #4a5568; }
.dir_egress { background: #b45309; }
@media (max-width: 720px) {
.bomTable { min-width: 0; }
.cellSpec, .cellNotes { font-size: 0.78rem; }
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
// Per-page deployment-mode badge. Paired with <EditionAvailability/>.
//
// Canonical mode slugs:
// cloud-shared — Mattermost Cloud (multi-tenant)
// cloud-dedicated — Mattermost Cloud Dedicated (single-tenant managed)
// cloud-government — Mattermost Cloud Government (FedRAMP Moderate)
// self-hosted — customer-managed, internet-connected
// air-gapped — customer-managed, network-isolated
// tactical-edge — DDIL forward-deployed
//
// Usage:
// <DeploymentAvailability modes="self-hosted,air-gapped,cloud-government" />
type Mode =
| 'cloud-shared'
| 'cloud-dedicated'
| 'cloud-government'
| 'self-hosted'
| 'air-gapped'
| 'tactical-edge';
const MODE_LABEL: Record<Mode, string> = {
'cloud-shared': 'Cloud',
'cloud-dedicated': 'Cloud Dedicated',
'cloud-government': 'Cloud Government',
'self-hosted': 'Self-Hosted',
'air-gapped': 'Air-Gapped',
'tactical-edge': 'Tactical Edge',
};
const KNOWN_MODES = Object.keys(MODE_LABEL) as Mode[];
function parseModes(value: string): {valid: Mode[]; invalid: string[]} {
const valid: Mode[] = [];
const invalid: string[] = [];
for (const raw of value.split(',').map((s) => s.trim()).filter(Boolean)) {
if ((KNOWN_MODES as string[]).includes(raw)) valid.push(raw as Mode);
else invalid.push(raw);
}
return {valid, invalid};
}
export default function DeploymentAvailability({modes}: {modes: string}) {
const {valid, invalid} = parseModes(modes || '');
if (invalid.length > 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
Unknown deployment mode(s): <code>{invalid.join(', ')}</code>. Valid:{' '}
<code>{KNOWN_MODES.join(', ')}</code>.
</aside>
);
}
if (valid.length === 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
<code>&lt;DeploymentAvailability/&gt;</code> requires a non-empty <code>modes</code> prop.
</aside>
);
}
const labels = valid.map((m) => MODE_LABEL[m]).join(', ');
return (
<aside className={`${styles.badge} ${styles.deployment}`} role="note" aria-label="Deployment availability">
<span className={styles.key}>Deployment</span>
<span className={styles.value}>
<Link href="/product-overview/editions-and-offerings">{labels}</Link>
</span>
</aside>
);
}

View File

@@ -0,0 +1,52 @@
/* Shares the visual contract with EditionAvailability. */
.badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0.35rem 0.6rem 0.35rem 0;
padding: 0.35rem 0.7rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.2;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 3px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .badge {
background: rgba(255, 255, 255, 0.04);
}
.deployment {
border-left-color: var(--mm-color-denim, #1E325C);
}
.key {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
opacity: 0.75;
}
.value {
font-weight: 600;
}
.value a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted currentColor;
}
.value a:hover {
text-decoration: none;
border-bottom-style: solid;
}
.unknown {
background: #fff3cd;
color: #664d03;
border-left-color: #ffc107;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
// Inline conditional rendering keyed off the `?scope=` URL parameter.
// Pattern adopted from Teleport (`?scope=enterprise`). Same canonical page,
// multiple rendered variants by deployment mode.
//
// Usage:
// <DeploymentOnly modes="self-hosted,air-gapped">
// This paragraph only renders when ?scope=self-hosted or ?scope=air-gapped.
// </DeploymentOnly>
//
// <DeploymentOnly modes="cloud-shared,cloud-dedicated,cloud-government" hideWhen="other">
// Cloud-specific content. Hidden when ?scope= is set to a non-Cloud mode.
// </DeploymentOnly>
//
// Behaviour:
// - No `?scope=` in URL: render the content (default = show everything).
// - `?scope=<mode>` present and `<mode>` is in `modes`: render content.
// - `?scope=<mode>` present and NOT in `modes`: hide content.
// - SSR: render content (matches "no scope" default — safe for SEO).
type Mode =
| 'cloud-shared'
| 'cloud-dedicated'
| 'cloud-government'
| 'self-hosted'
| 'air-gapped'
| 'tactical-edge';
const KNOWN_MODES: Mode[] = [
'cloud-shared',
'cloud-dedicated',
'cloud-government',
'self-hosted',
'air-gapped',
'tactical-edge',
];
function getScope(): string | null {
if (typeof window === 'undefined') return null;
try {
return new URLSearchParams(window.location.search).get('scope');
} catch {
return null;
}
}
function Inner({
modes,
children,
}: {
modes: string;
children: React.ReactNode;
}) {
const allowed = (modes || '')
.split(',')
.map((s) => s.trim())
.filter((s) => (KNOWN_MODES as string[]).includes(s));
const scope = getScope();
if (!scope) return <>{children}</>; // No filter set — show everything.
if (allowed.includes(scope)) return <>{children}</>;
return null;
}
export default function DeploymentOnly(props: {
modes: string;
children: React.ReactNode;
}) {
return (
<BrowserOnly fallback={<>{props.children}</>}>
{() => <Inner {...props} />}
</BrowserOnly>
);
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
// Per-page edition badge. Authors mark which Mattermost editions a feature
// or configuration applies to. Renders a single-row inline badge at the
// top of the page (paired with <DeploymentAvailability/>).
//
// Canonical tier slugs:
// free — Mattermost Team Edition (free, OSS)
// professional — Mattermost Professional
// enterprise — Mattermost Enterprise
// enterprise-advanced — Mattermost Enterprise Advanced
//
// Usage:
// <EditionAvailability tiers="enterprise,enterprise-advanced" />
// <EditionAvailability tiers="free,professional,enterprise,enterprise-advanced" />
type Tier = 'free' | 'professional' | 'enterprise' | 'enterprise-advanced';
const TIER_LABEL: Record<Tier, string> = {
'free': 'Team Edition',
'professional': 'Professional',
'enterprise': 'Enterprise',
'enterprise-advanced': 'Enterprise Advanced',
};
const KNOWN_TIERS = Object.keys(TIER_LABEL) as Tier[];
function parseTiers(value: string): {valid: Tier[]; invalid: string[]} {
const valid: Tier[] = [];
const invalid: string[] = [];
for (const raw of value.split(',').map((s) => s.trim()).filter(Boolean)) {
if ((KNOWN_TIERS as string[]).includes(raw)) {
valid.push(raw as Tier);
} else {
invalid.push(raw);
}
}
return {valid, invalid};
}
export default function EditionAvailability({tiers}: {tiers: string}) {
const {valid, invalid} = parseTiers(tiers || '');
if (invalid.length > 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
Unknown edition tier(s): <code>{invalid.join(', ')}</code>. Valid:{' '}
<code>{KNOWN_TIERS.join(', ')}</code>.
</aside>
);
}
if (valid.length === 0) {
return (
<aside className={`${styles.badge} ${styles.unknown}`} role="note">
<code>&lt;EditionAvailability/&gt;</code> requires a non-empty <code>tiers</code> prop.
</aside>
);
}
const labels = valid.map((t) => TIER_LABEL[t]).join(', ');
return (
<aside className={`${styles.badge} ${styles.edition}`} role="note" aria-label="Edition availability">
<span className={styles.key}>Edition</span>
<span className={styles.value}>
<Link href="/product-overview/editions-and-offerings">{labels}</Link>
</span>
</aside>
);
}

View File

@@ -0,0 +1,60 @@
/* Per-page Edition / Deployment / Attestation badges.
* Shared visual contract; the three badge components compose into a
* single inline row at the top of the page. */
.badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin: 0.35rem 0.6rem 0.35rem 0;
padding: 0.35rem 0.7rem;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.2;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 3px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .badge {
background: rgba(255, 255, 255, 0.04);
}
/* Edition and Deployment badges share the same denim left-edge — no color
* differentiation between them; the label text already distinguishes them. */
.edition {
border-left-color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .edition {
border-left-color: var(--mm-denim-300, #8499C5);
}
.key {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
opacity: 0.75;
}
.value {
font-weight: 600;
}
.value a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted currentColor;
}
.value a:hover {
text-decoration: none;
border-bottom-style: solid;
}
.unknown {
background: #fff3cd;
color: #664d03;
border-left-color: #ffc107;
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import styles from './styles.module.css';
/**
* Small section-name marker that sits above the H1 on inner-page
* landings. Replaces the Hero block on /api, /api/examples, and
* /developers — content-first, no marketing flair, brand presence
* via the marigold uppercase eyebrow only.
*
* Usage:
* <Eyebrow>API Reference</Eyebrow>
* # REST API v4
* Subtitle paragraph...
*/
export default function Eyebrow({children}: {children: React.ReactNode}) {
return <div className={styles.eyebrow}>{children}</div>;
}

View File

@@ -0,0 +1,16 @@
.eyebrow {
display: inline-block;
font-family: var(--mm-font-heading);
font-size: 0.74rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--mm-marigold-700);
border-bottom: 2px solid var(--mm-color-marigold);
padding-bottom: 0.25rem;
margin-bottom: 1rem;
}
[data-theme='dark'] .eyebrow {
color: var(--mm-color-marigold);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import styles from './styles.module.css';
type Cta = {
label: string;
to: string;
variant?: 'primary' | 'ghost';
};
type Props = {
eyebrow?: string;
title?: string;
subtitle?: string;
ctas?: Cta[];
/** Pillars under the hero — defaults to brand pillars on the home hero. */
pillars?: {label: string; description?: string}[];
/** Restraint level: 'full' for /, 'compact' for /developers /api. */
intensity?: 'full' | 'compact';
};
export default function Hero({
eyebrow,
title,
subtitle,
ctas = [],
pillars,
intensity = 'compact',
}: Props): React.ReactElement {
const camoUrl = useBaseUrl('/img/patterns/hex-camo-denim.svg');
return (
<section
className={`mm-hero ${styles.hero} ${intensity === 'full' ? styles.full : styles.compact}`}
style={{['--mm-hero-camo' as string]: `url(${camoUrl})`}}
>
<div className={styles.inner}>
{eyebrow && <div className={styles.eyebrow}>{eyebrow}</div>}
{title && <h1 className={styles.title}>{title}</h1>}
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
{ctas.length > 0 && (
<div className={styles.ctas}>
{ctas.map((cta, i) => (
<Link
key={`${cta.label}-${i}`}
to={cta.to}
className={cta.variant === 'ghost' ? styles.ctaGhost : styles.ctaPrimary}
>
{cta.label}
<span aria-hidden>→</span>
</Link>
))}
</div>
)}
{pillars && (
<ul className={styles.pillars}>
{pillars.map((p) => (
<li key={p.label}>
<strong>{p.label}</strong>
{p.description && <span>{p.description}</span>}
</li>
))}
</ul>
)}
</div>
<div className={styles.curve} aria-hidden />
</section>
);
}

View File

@@ -0,0 +1,175 @@
/* Branded landing-page hero. Mid-intensity per design call:
* full-bleed denim with hex camo, but capped height so it doesn't
* dominate every visit. The curved bottom edge (`.curve`) matches
* the brand book's photo/color transition treatment.
*/
/* The hero fills the article column it lives in (no full-viewport escape).
* Why: 100vw includes scrollbar width on Windows/Chrome and produces a
* horizontal-scroll bleed. Also when a sidebar is present, escaping to
* 100vw misaligns the hero's centered inner content vs. the body content
* below (which sits in the article column). Containing the hero keeps
* its left/right edges consistent with the rest of the page. */
/* The hero is a flat banded section, no rounded corners (the card-like
* frame felt clunky and disconnected from the rest of the page). The
* pattern sits under a heavier dimming overlay so it reads as
* atmospheric texture, not foreground motif. */
.hero {
position: relative;
width: 100%;
margin-top: -1.25rem;
margin-bottom: 2.5rem;
background-color: var(--mm-color-denim);
background-image:
linear-gradient(180deg, rgba(12, 20, 36, 0.55) 0%, rgba(12, 20, 36, 0.25) 100%),
radial-gradient(ellipse 50% 100% at 100% 50%, rgba(91, 119, 177, 0.15) 0%, rgba(30, 50, 92, 0) 60%),
var(--mm-hero-camo);
background-size: cover, cover, 108px 125px;
background-repeat: no-repeat, no-repeat, repeat;
color: var(--mm-color-white);
overflow: hidden;
isolation: isolate;
border-bottom: 3px solid var(--mm-color-marigold);
}
.full {
padding: clamp(2rem, 3.5vw, 3rem) 0 clamp(2rem, 3.5vw, 3rem);
}
.compact {
padding: clamp(1.5rem, 2.5vw, 2.25rem) 0 clamp(1.75rem, 3vw, 2.5rem);
}
.inner {
/* Fill the hero band; the band itself is sized by its parent
* (the article column). Padding keeps content off the band edges. */
margin: 0 auto;
padding: 0 clamp(1.25rem, 3vw, 2rem);
position: relative;
z-index: 2;
}
.eyebrow {
display: inline-block;
font-family: var(--mm-font-heading);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--mm-color-marigold);
border: 1px solid rgba(255, 188, 31, 0.55);
padding: 0.32rem 0.75rem;
border-radius: 2px;
margin-bottom: 1rem;
}
.title {
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: clamp(1.6rem, 3.2vw, 2.4rem);
line-height: 1.1;
letter-spacing: -0.01em;
color: var(--mm-color-white);
margin: 0 0 0.75rem;
max-width: 28ch;
}
.full .title {
font-size: clamp(1.85rem, 3.8vw, 3rem);
max-width: 24ch;
}
.subtitle {
font-size: clamp(0.95rem, 1.2vw, 1.1rem);
line-height: 1.55;
color: rgba(255, 255, 255, 0.82);
max-width: 64ch;
margin: 0 0 1.5rem;
}
.ctas {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin-top: 0.5rem;
}
.ctaPrimary,
.ctaGhost {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.1rem;
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
text-decoration: none;
border-radius: 3px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.ctaPrimary {
background: var(--mm-color-marigold);
color: var(--mm-color-black);
border: 2px solid var(--mm-color-marigold);
}
.ctaPrimary:hover {
transform: translateY(-1px);
box-shadow: 0 10px 18px -10px rgba(0, 0, 0, 0.5);
color: var(--mm-color-black);
text-decoration: none;
}
.ctaGhost {
background: transparent;
color: var(--mm-color-white);
border: 2px solid rgba(255, 255, 255, 0.5);
}
.ctaGhost:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--mm-color-white);
color: var(--mm-color-white);
text-decoration: none;
}
.pillars {
list-style: none;
padding: 1.75rem 0 0;
margin: 1.75rem 0 0;
border-top: 1px solid rgba(255, 255, 255, 0.15);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem 1.5rem;
}
@media (max-width: 720px) {
.pillars { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.pillars li {
position: relative;
padding-top: 0.65rem;
border-top: 2px solid var(--mm-color-marigold);
}
.pillars strong {
display: block;
font-family: var(--mm-font-heading);
font-size: 0.9rem;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 0.25rem;
color: var(--mm-color-white);
}
.pillars span {
display: block;
font-size: 0.82rem;
line-height: 1.45;
color: rgba(255, 255, 255, 0.75);
}
.curve { display: none; } /* dropping the curve — too theatrical at this scale */

View File

@@ -0,0 +1,254 @@
import React from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
/**
* Interactive Intelligent Mission Environment (IME) diagram.
*
* Recreates the four-layer IME infographic (Use Cases → Application →
* Integration → Deployment) with every region linked to its canonical
* documentation page. Replaces the static infographic image on the
* landing page so visitors can drill into the docs from the diagram
* itself.
*/
type CellProps = {
to: string;
title: string;
children: React.ReactNode;
icon?: React.ReactNode;
};
function Cell({to, title, children, icon}: CellProps) {
return (
<Link to={to} className={styles.cell}>
{icon && <span className={styles.icon} aria-hidden>{icon}</span>}
<div className={styles.cellBody}>
<strong className={styles.cellTitle}>{title}</strong>
<div className={styles.cellText}>{children}</div>
</div>
</Link>
);
}
/* === Inline brand-aligned SVG icons (marigold tone via currentColor) === */
const IconChat = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
);
const IconChecklist = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
);
const IconCall = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.33 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
);
const IconTarget = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
</svg>
);
const IconSparkles = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/>
</svg>
);
const IconShield = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const IconCode = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
);
const IconServer = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="6" rx="1"/>
<rect x="2" y="15" width="20" height="6" rx="1"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
);
const IconCompass = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>
</svg>
);
const IconLayers = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
);
const IconPlug = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
);
const IconGlobe = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
const IconDevices = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="14" height="11" rx="1"/>
<rect x="14" y="9" width="8" height="11" rx="1"/>
<line x1="2" y1="17" x2="11" y2="17"/>
</svg>
);
const IconVideo = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
);
export default function IMEDiagram() {
return (
<section className={styles.diagram} aria-label="Intelligent Mission Environment overview">
{/* USE CASES */}
<div className={`${styles.layer} ${styles.layerUseCases}`}>
<div className={styles.layerLabel}>Use Cases</div>
<div className={styles.layerBody}>
<div className={styles.useCaseRow}>
<Cell to="/use-case-guide/integrated-security-operations" title="Cyber Defense" icon={<IconShield />}>
SOC/CERT ops, out-of-band incident response, Red Team
</Cell>
<Cell to="/use-case-guide/devops-collaboration" title="DevSecOps" icon={<IconCode />}>
Dev productivity, CI/CD, platform engineering, emergency comms
</Cell>
<Cell to="/use-case-guide/secure-command-and-control" title="Mission Operations" icon={<IconCompass />}>
Critical workflow, Zero Trust, C2 to tactical edge, joint ops
</Cell>
</div>
</div>
</div>
{/* APPLICATION */}
<div className={`${styles.layer} ${styles.layerApplication}`}>
<div className={styles.layerLabel}>Application</div>
<div className={styles.layerBody}>
<div className={styles.appGrid}>
<div className={styles.appIntro}>
<h3><span className={styles.introIcon}><IconLayers /></span>Secure Collaborative Workflow</h3>
<p>
Messaging collaboration across web, desktop, and mobile with file sharing,
audio/screenshare, workflow automation, issue tracking, bots, agents, and open
API access — integrating into modern toolchains and legacy systems with
advanced customization and security controls.
</p>
</div>
<div className={styles.productGrid}>
<Cell to="/end-user-guide/collaborate/collaborate-index" title="Messaging Collaboration" icon={<IconChat />}>
Mattermost Channels for ChatOps and automation
</Cell>
<Cell to="/end-user-guide/workflow-automation/workflow-automation-index" title="Workflow Automation" icon={<IconChecklist />}>
Mattermost Playbooks for automating SOPs
</Cell>
<Cell to="/end-user-guide/collaborate/audio-and-screensharing" title="Audio & Screenshare" icon={<IconCall />}>
Mattermost Calls for real‑time calling and screenshare
</Cell>
<Cell to="/end-user-guide/project-management/project-management-index" title="Project Tracking" icon={<IconTarget />}>
Mattermost Boards for Kanban and work management
</Cell>
<Cell to="/end-user-guide/agents" title="AI Agents & Open APIs" icon={<IconSparkles />}>
Mattermost Agents — AI assistance and integration
</Cell>
</div>
</div>
<div className={styles.appFooter}>
<Link to="/end-user-guide/access/access-your-workspace" className={styles.footerCell}>
<span className={styles.footerIcon}><IconDevices /></span>
Desktop, web, mobile &amp; Microsoft&nbsp;Teams clients
</Link>
<Link to="/developers" className={styles.footerCell}>
<span className={styles.footerIcon}><IconCode /></span>
Extensible open-source architecture
</Link>
</div>
</div>
</div>
{/* INTEGRATION */}
<div className={`${styles.layer} ${styles.layerIntegration}`}>
<div className={styles.layerLabel}>Integration</div>
<div className={styles.layerBody}>
<div className={styles.intGrid}>
<div className={styles.intIntro}>
<h3><span className={styles.introIcon}><IconPlug /></span>Integration & AI Platform</h3>
<p>
Operational extensibility with pre-packaged, source-available connectors,
automations, and templates for rapid and effective systems integration.
</p>
</div>
<Cell to="/integrations-guide/integrations-guide-index" title="Layered Extensibility" icon={<IconCode />}>
<ul>
<li>Pre-packaged and custom integrations</li>
<li>Webhooks and slash commands</li>
<li>Plugin architecture</li>
</ul>
</Cell>
<Cell to="/end-user-guide/agents" title="Multi-Agent / Multi-LLM Integration" icon={<IconSparkles />}>
<ul>
<li>Sovereign AI model support via OpenAI APIs</li>
<li>Custom instructions, RAG, semantic search</li>
<li>Responsible AI control plane</li>
<li>MCP and agent-to-agent architecture</li>
</ul>
</Cell>
</div>
<div className={styles.appFooter}>
<Link to="/integrations-guide/integrations-guide-index" className={styles.footerCell}>
<span className={styles.footerIcon}><IconVideo /></span>
Video meetings: Pexip · Webex · Cisco
</Link>
<Link to="/integrations-guide/integrations-guide-index" className={styles.footerCell}>
<span className={styles.footerIcon}><IconPlug /></span>
Pre-built: GitHub · GitLab · Jira · ServiceNow · M365
</Link>
</div>
</div>
</div>
{/* DEPLOYMENT */}
<div className={`${styles.layer} ${styles.layerDeployment}`}>
<div className={styles.layerLabel}>Deployment</div>
<div className={styles.layerBody}>
<div className={styles.depGrid}>
<div className={styles.depIntro}>
<h3><span className={styles.introIcon}><IconGlobe /></span>Sovereign, Cyber‑Resilient Deployment</h3>
<p>
Kubernetes-based orchestration on private, government, and air-gapped clouds.
Scales from tactical edge to strategic core with geo-distributed ultra-high
availability.
</p>
</div>
<Cell to="/deployment-guide/deployment-scenarios/deployment-scenarios-index" title="Tactical Edge to Strategic Core" icon={<IconServer />}>
Runs at the edge, in your data center, in sovereign clouds, and on global hyperscalers: Azure, AWS, Google Cloud, Oracle Cloud.
</Cell>
<Cell to="/for/air-gapped-operator" title="Mission-Ready Security & Resilience" icon={<IconShield />}>
<ul>
<li>Classified, air-gapped, and DDIL operations</li>
<li>Defense-grade controls, monitoring, and mobile security</li>
<li>Scales to 200K+ users</li>
</ul>
</Cell>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,354 @@
/* Intelligent Mission Environment diagram — v2.
*
* Tighter visual match to the brand infographic:
* - Application zone visibly contained in a denim wash + border
* - Intro panels are NOT clickable cards; they're plain text columns
* (no left-edge accent bar — the surrounding denim wash/borders already
* signal the section boundary)
* - Use Case cells use a soft denim-tinted background, not white-on-white
* - All clickable cells share the same white-with-marigold-on-hover treatment
* - Section titles get a thin marigold underline for visual rhythm
*/
.diagram {
margin: 2rem 0 2.5rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 8px;
overflow: hidden;
background: var(--mm-color-white);
box-shadow: 0 4px 18px -14px rgba(30, 50, 92, 0.4);
}
[data-theme='dark'] .diagram {
background: var(--mm-denim-700, #121E37);
border-color: rgba(255, 255, 255, 0.1);
}
/* === Layer scaffold (vertical denim label on left + content area on right) === */
.layer {
display: grid;
grid-template-columns: 2rem 1fr;
border-top: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.15));
}
.layer:first-child {
border-top: none;
}
.layerLabel {
display: flex;
align-items: center;
justify-content: center;
background: var(--mm-color-denim, #1E325C);
color: var(--mm-color-white, #fff);
font-family: var(--mm-font-sans);
font-weight: 800;
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
writing-mode: vertical-rl;
transform: rotate(180deg);
padding: 0.85rem 0;
}
.layerBody {
padding: 0.9rem 1rem;
background: var(--mm-bg-page, #fff);
}
[data-theme='dark'] .layerBody {
background: var(--mm-denim-800, #0C1424);
}
/* Application zone gets a stronger denim wash to visibly contain it,
matching the original infographic where the whole Application block
reads as one tinted panel. */
.layerApplication .layerBody {
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
}
[data-theme='dark'] .layerApplication .layerBody {
background: rgba(255, 255, 255, 0.03);
}
/* === Use Cases row — 3 horizontal cells, soft grey-tinted === */
.useCaseRow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
}
.useCaseRow .cell {
background: var(--mm-denim-50, rgba(30, 50, 92, 0.045));
border-color: transparent;
}
.useCaseRow .cell:hover {
background: var(--mm-color-white, #fff);
border-color: var(--mm-color-marigold, #FFBC1F);
}
[data-theme='dark'] .useCaseRow .cell {
background: rgba(255, 255, 255, 0.04);
border-color: transparent;
}
/* === Intro panels (Application / Integration / Deployment left column) ===
Not clickable — plain text columns, no left-edge accent bar. */
.appIntro,
.intIntro,
.depIntro {
padding: 0.25rem 0.6rem 0.25rem 0;
}
.appIntro h3,
.intIntro h3,
.depIntro h3 {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--mm-font-sans);
font-size: 0.95rem;
font-weight: 700;
color: var(--mm-color-denim, #1E325C);
margin: 0 0 0.4rem;
letter-spacing: -0.005em;
line-height: 1.25;
}
.introIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.2rem;
height: 1.2rem;
color: var(--mm-color-denim, #1E325C);
}
.introIcon svg {
width: 100%;
height: 100%;
}
[data-theme='dark'] .appIntro .introIcon,
[data-theme='dark'] .intIntro .introIcon,
[data-theme='dark'] .depIntro .introIcon {
color: var(--mm-denim-300, #8499C5);
}
[data-theme='dark'] .appIntro h3,
[data-theme='dark'] .intIntro h3,
[data-theme='dark'] .depIntro h3 {
color: var(--mm-color-white);
}
.appIntro p,
.intIntro p,
.depIntro p {
font-size: 0.78rem;
line-height: 1.5;
color: var(--mm-text-secondary, var(--mm-text-primary));
margin: 0;
}
/* === Application grid: intro + 5 product cards === */
.appGrid {
display: grid;
grid-template-columns: minmax(190px, 1.1fr) 3.4fr;
gap: 1rem;
align-items: stretch;
}
.productGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
.appFooter {
display: grid;
/* Footer strips read as standalone bands — balanced columns so neither
* label wraps mid-phrase. (The grid above intentionally mirrors the
* intro/products asymmetry; the footer doesn't need to.) */
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.65rem;
}
.footerCell {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
font-size: 0.78rem;
font-weight: 600;
text-align: center;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.15));
border-radius: 4px;
background: var(--mm-bg-page, #fff);
color: var(--mm-text-primary);
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease;
}
.footerIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 1.1rem;
height: 1.1rem;
color: var(--mm-color-marigold, #FFBC1F);
}
.footerIcon svg {
width: 100%;
height: 100%;
}
.footerCell:hover {
border-color: var(--mm-color-marigold, #FFBC1F);
background: var(--mm-marigold-100, rgba(255, 188, 31, 0.08));
color: var(--mm-text-primary);
text-decoration: none;
}
[data-theme='dark'] .footerCell {
background: var(--mm-denim-700, #121E37);
color: var(--mm-color-white);
}
/* === Integration & Deployment grids === */
.intGrid,
.depGrid {
display: grid;
grid-template-columns: minmax(190px, 1.1fr) 1.7fr 1.7fr;
gap: 1rem;
align-items: stretch;
}
/* === Cell — the reusable clickable unit === */
.cell {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--mm-border-subtle, rgba(30, 50, 92, 0.18));
border-radius: 4px;
background: var(--mm-bg-page, #fff);
color: var(--mm-text-primary);
text-decoration: none;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.cell:hover {
border-color: var(--mm-color-marigold, #FFBC1F);
box-shadow: 0 4px 14px -10px rgba(30, 50, 92, 0.35);
text-decoration: none;
color: var(--mm-text-primary);
transform: translateY(-1px);
}
[data-theme='dark'] .cell {
background: var(--mm-denim-700, #121E37);
color: var(--mm-color-white);
border-color: rgba(255, 255, 255, 0.1);
}
[data-theme='dark'] .cell:hover {
color: var(--mm-color-white);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.45rem;
height: 1.45rem;
color: var(--mm-color-marigold, #FFBC1F);
margin-bottom: 0.15rem;
}
.icon svg {
width: 100%;
height: 100%;
}
.cellBody {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.cellTitle {
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.82rem;
line-height: 1.25;
color: var(--mm-color-denim, #1E325C);
}
[data-theme='dark'] .cellTitle {
color: var(--mm-color-white);
}
.cellText {
font-size: 0.74rem;
line-height: 1.4;
color: var(--mm-text-secondary, var(--mm-text-primary));
}
.cellText ul {
margin: 0.2rem 0 0;
padding-left: 0.95rem;
list-style: disc;
}
.cellText li {
margin: 0.08rem 0;
}
/* === Use Case cells get larger title + tighter sub-text — readable at row scale === */
.useCaseRow .cellTitle {
font-size: 0.95rem;
}
.useCaseRow .cellText {
font-size: 0.78rem;
}
/* === Responsive collapse === */
@media (max-width: 980px) {
.appGrid,
.intGrid,
.depGrid {
grid-template-columns: 1fr;
}
.productGrid {
grid-template-columns: repeat(2, 1fr);
}
.appFooter {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.useCaseRow {
grid-template-columns: 1fr;
}
.productGrid {
grid-template-columns: 1fr;
}
.layer {
grid-template-columns: 1fr;
}
.layerLabel {
writing-mode: horizontal-tb;
transform: none;
padding: 0.4rem 0.85rem;
justify-content: flex-start;
font-size: 0.65rem;
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import styles from './styles.module.css';
const METHODS: Array<{verb: string; tone: string; meaning: string}> = [
{verb: 'GET', tone: 'get', meaning: 'Read'},
{verb: 'POST', tone: 'post', meaning: 'Create / action'},
{verb: 'PUT', tone: 'put', meaning: 'Replace'},
{verb: 'PATCH', tone: 'patch', meaning: 'Update fields'},
{verb: 'DELETE', tone: 'delete', meaning: 'Remove'},
];
/**
* Inline legend that decodes the HTTP method colors used throughout
* the API sidebar and endpoint pages. Most readers internalize these
* fast — but the first 30 seconds on the API landing matter, and the
* legend collapses that ramp.
*/
export default function MethodLegend() {
return (
<ul className={styles.legend}>
{METHODS.map((m) => (
<li key={m.verb} className={styles.item}>
<span className={`${styles.badge} ${styles[m.tone]}`}>{m.verb}</span>
<span className={styles.meaning}>{m.meaning}</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,41 @@
.legend {
list-style: none;
margin: 1rem 0 2rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem;
}
.item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0;
margin: 0;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 3.6rem;
padding: 0.2rem 0.55rem;
font-family: var(--mm-font-mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 3px;
color: var(--mm-color-white);
}
.get { background: #2D7BD2; } /* operational blue */
.post { background: #2C7A4F; } /* operational green */
.put { background: #A86A1F; } /* operational amber */
.patch { background: #6B4FA0; } /* operational violet */
.delete { background: #8B1F1F; } /* operational red */
.meaning {
color: var(--mm-text-secondary);
}

View File

@@ -0,0 +1,124 @@
import React from 'react';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import styles from './styles.module.css';
// Plan-availability and Academy badges. Replaces Sphinx's
// .. include:: /_static/badges/<slug>.rst
// pattern. Slug values mirror the original badge filenames so the
// mechanical RST→MDX conversion is a 1:1 rewrite.
type PlanData = {
kind: 'plan';
text: string;
linkText: string;
href: string;
};
type AcademyData = {
kind: 'academy';
title: string;
href: string;
};
const BADGES: Record<string, PlanData | AcademyData> = {
// Plan-availability banners
'all-commercial': {
kind: 'plan',
text: 'Available on',
linkText: 'Entry, Professional, Enterprise, and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'ent-plus': {
kind: 'plan',
text: 'Available on',
linkText: 'Enterprise and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'ent-adv': {
kind: 'plan',
text: 'Available on',
linkText: 'Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'ent-cloud-dedicated': {
kind: 'plan',
text: 'Available only on',
linkText: 'Enterprise and Enterprise Advanced plans',
href: 'https://mattermost.com/contact-sales/',
},
'entry-adv': {
kind: 'plan',
text: 'Available on',
linkText: 'Entry and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
'entry-ent': {
kind: 'plan',
text: 'Available on',
linkText: 'Entry, Enterprise, and Enterprise Advanced plans (not available on Professional)',
href: 'https://mattermost.com/pricing/',
},
'pro-plus': {
kind: 'plan',
text: 'Available on',
linkText: 'Professional, Enterprise, and Enterprise Advanced plans',
href: 'https://mattermost.com/pricing/',
},
// Academy callout cards
'academy': {kind: 'academy', title: 'Free online training available', href: 'https://academy.mattermost.com'},
'academy-calls': {kind: 'academy', title: 'Learn about Mattermost Calls', href: 'https://academy.mattermost.com/p/new-mattermost-copilot-multi-llm-setup-usage'},
'academy-channels': {kind: 'academy', title: 'Learn about Mattermost channels', href: 'https://mattermost.com/pl/mattermost-academy-channels-training'},
'academy-copilot-calls': {kind: 'academy', title: 'Learn about Mattermost Copilot and Calls', href: 'https://academy.mattermost.com/p/streamline-communication-with-mattermost-copilot-calls'},
'academy-copilot-setup': {kind: 'academy', title: 'Learn about setting up and configuring Mattermost Copilot with multiple LLMs', href: 'https://academy.mattermost.com/p/new-mattermost-copilot-multi-llm-setup-usage'},
'academy-customize-ui': {kind: 'academy', title: 'Learn what you can customize', href: 'https://mattermost.com/pl/mattermost-academy-customization-training'},
'academy-file-storage': {kind: 'academy', title: 'Learn about file storage', href: 'https://mattermost.com/pl/mattermost-academy-configure-file-storage-training'},
'academy-mattermost-database': {kind: 'academy', title: 'Learn about setting up the Mattermost database', href: 'https://mattermost.com/pl/mattermost-academy-database-configuration-training'},
'academy-message-formatting': {kind: 'academy', title: 'Learn about message formatting', href: 'https://mattermost.com/pl/mattermost-academy-format-messages-training'},
'academy-msteams': {kind: 'academy', title: 'Learn about integrating with Microsoft Teams', href: 'https://academy.mattermost.com/p/new-mattermost-for-microsoft-teams-integration'},
'academy-notifications': {kind: 'academy', title: 'Learn about notifications', href: 'https://mattermost.com/pl/mattermost-academy-notifications-training'},
'academy-platform-overview':{kind: 'academy', title: 'Learn about Mattermost', href: 'https://mattermost.com/pl/mattermost-academy-intro-training'},
'academy-playbooks': {kind: 'academy', title: 'Learn about Mattermost Playbooks', href: 'https://academy.mattermost.com/p/mattermost-playbooks-onboarding-training'},
'academy-search': {kind: 'academy', title: 'Learn about search', href: 'https://mattermost.com/pl/mattermost-academy-search-training'},
'academy-tarball-deployment':{kind: 'academy', title: 'Learn about deploying Mattermost using a tarball', href: 'https://mattermost.com/pl/mattermost-academy-deploy-tarball-training'},
'academy-tarball-upgrade': {kind: 'academy', title: 'Learn about upgrading Mattermost using a tarball', href: 'https://mattermost.com/pl/mattermost-academy-upgrade-tarball-training'},
'academy-teams': {kind: 'academy', title: 'Learn about teams', href: 'https://academy.mattermost.com/p/new-mattermost-for-microsoft-teams-integration'},
};
export default function PlanAvailability({slug}: {slug: string}) {
const flagSrc = useBaseUrl('/img/badges/flag-yellow.svg');
const academySrc = useBaseUrl('/img/badges/academy-callout.jpg');
const data = BADGES[slug];
if (!data) {
return (
<div className={`${styles.badge} ${styles.unknown}`} role="note">
Unknown plan-availability slug: <code>{slug}</code>
</div>
);
}
if (data.kind === 'plan') {
return (
<aside className={`${styles.badge} ${styles.plan}`} role="note">
<img src={flagSrc} alt="" className={styles.flag} aria-hidden />
<span>
{data.text}{' '}
<Link href={data.href}>{data.linkText}</Link>
{data.text.endsWith('on') && !data.linkText.includes('plans') ? ' plans' : ''}
</span>
</aside>
);
}
return (
<Link href={data.href} className={`${styles.badge} ${styles.academy}`} target="_blank" rel="noopener">
<img src={academySrc} alt="" className={styles.academyImg} aria-hidden />
<div className={styles.academyCopy}>
<span className={styles.academyAccent}>Mattermost Academy</span>
<span className={styles.academyTitle}>{data.title}</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,92 @@
/* Plan-availability banner + Academy callout card.
* Ports the visual contract of the Sphinx _static/badges/*.rst includes. */
.badge {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 1rem 0;
border-radius: 6px;
font-size: 0.95rem;
}
/* Plan-availability — flat denim-tinted strip, denim flag on the left
* (matches the Edition/Deployment/Attestation badge family). */
.plan {
padding: 0.55rem 0.85rem;
background: var(--mm-denim-50, rgba(30, 50, 92, 0.05));
border-left: 3px solid var(--mm-color-denim, #1E325C);
color: var(--mm-text-primary);
}
[data-theme='dark'] .plan {
background: rgba(255, 255, 255, 0.04);
border-left-color: var(--mm-denim-300, #8499C5);
color: var(--mm-text-primary);
}
.plan a {
font-weight: 600;
}
.flag {
flex: 0 0 1.05rem;
width: 1.05rem;
height: auto;
}
/* Academy callout — full-bleed card with image on the left, two stacked
* lines of copy on the right. Whole card is the clickable link. */
.academy {
display: flex;
align-items: stretch;
text-decoration: none;
color: inherit;
overflow: hidden;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-surface);
transition: box-shadow 120ms ease, transform 120ms ease;
}
.academy:hover {
text-decoration: none;
box-shadow: 0 4px 12px rgba(30, 50, 92, 0.12);
transform: translateY(-1px);
}
.academyImg {
flex: 0 0 auto;
width: 6rem;
height: 100%;
object-fit: cover;
display: block;
}
.academyCopy {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0.7rem 1rem;
gap: 0.15rem;
}
.academyAccent {
font-family: var(--mm-font-heading);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mm-color-marigold);
}
.academyTitle {
font-weight: 600;
color: var(--mm-text-primary);
}
.unknown {
padding: 0.55rem 0.85rem;
background: #fff3cd;
color: #664d03;
border-left: 3px solid #ffc107;
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import styles from './styles.module.css';
type Plan = 'free' | 'professional' | 'enterprise' | 'cloud' | 'self-hosted';
const META: Record<Plan, {label: string; tone: 'neutral' | 'accent' | 'inverse'}> = {
'free': {label: 'Free', tone: 'neutral'},
'professional': {label: 'Professional', tone: 'accent'},
'enterprise': {label: 'Enterprise', tone: 'inverse'},
'cloud': {label: 'Cloud', tone: 'neutral'},
'self-hosted': {label: 'Self-hosted', tone: 'neutral'},
};
export default function PlanBadge({plan}: {plan: Plan}) {
const m = META[plan];
return <span className={`${styles.badge} ${styles[m.tone]}`}>{m.label}</span>;
}

View File

@@ -0,0 +1,33 @@
.badge {
display: inline-block;
padding: 0.18rem 0.55rem;
font-family: var(--mm-font-heading);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 2px;
vertical-align: middle;
margin: 0 0.25rem;
}
.neutral {
background: var(--mm-denim-50);
color: var(--mm-denim-700);
border: 1px solid var(--mm-denim-100);
}
[data-theme='dark'] .neutral {
background: var(--mm-denim-800);
color: var(--mm-denim-100);
border-color: var(--mm-denim-700);
}
.accent {
background: var(--mm-color-marigold);
color: var(--mm-color-black);
}
.inverse {
background: var(--mm-color-denim);
color: var(--mm-color-white);
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
// @ts-ignore — generated by `npm run build:plugin-godocs` (from docs/site) from
// server/public/plugin; gitignored build output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-godocs.json';
import type {GoDocs, GoInterfaceDocs} from './types';
import {signatureText} from './types';
import styles from './styles.module.css';
const docs: GoDocs | undefined = rawDocs && Object.keys(rawDocs).length > 0 ? (rawDocs as GoDocs) : undefined;
function MissingDataNotice() {
return (
<p>
Run <code>npm run build:plugin-godocs</code> (from <code>docs/site</code>) to generate this
documentation.
</p>
);
}
function InterfaceTOC({name, iface}: {name: string; iface: GoInterfaceDocs}) {
const methods = iface.Methods ?? [];
const untagged = methods.filter((m) => !m.Tags || m.Tags.length === 0);
const tags = iface.Tags ?? [];
return (
<ul className={styles.toc}>
{untagged.map((m) => (
<li key={m.Name}>
<a href={`#${name}.${m.Name}`}>{signatureText(m)}</a>
</li>
))}
{tags.map((tag) => (
<li key={tag}>
{tag}
<ul>
{methods
.filter((m) => m.Tags?.includes(tag))
.map((m) => (
<li key={m.Name}>
<a href={`#${name}.${m.Name}`}>{signatureText(m)}</a>
</li>
))}
</ul>
</li>
))}
</ul>
);
}
function InterfaceOverview({name, iface}: {name: string; iface: GoInterfaceDocs}) {
if (!iface.HTML && (!iface.Methods || iface.Methods.length === 0)) return null;
return (
<div>
<h2 id={name}>{name}</h2>
{iface.HTML && <div dangerouslySetInnerHTML={{__html: iface.HTML}} />}
<InterfaceTOC name={name} iface={iface} />
</div>
);
}
function InterfaceMethodDocs({name, iface}: {name: string; iface: GoInterfaceDocs}) {
const methods = iface.Methods ?? [];
if (methods.length === 0) return null;
return (
<div>
<h2>{name}</h2>
{methods.map((m) => (
<div key={m.Name} className={styles.method}>
<h3 id={`${name}.${m.Name}`}>
({name}) {m.Name}
</h3>
<CodeBlock language="go">{signatureText(m)}</CodeBlock>
{m.HTML && <div dangerouslySetInnerHTML={{__html: m.HTML}} />}
</div>
))}
</div>
);
}
function exampleEntries(examples: Record<string, {HTML: string; Code: string}>) {
return Object.entries(examples ?? {}).filter(([name]) => name.startsWith('_'));
}
function ExamplesTOC({examples}: {examples: Record<string, {HTML: string; Code: string}>}) {
const entries = exampleEntries(examples);
if (entries.length === 0) return null;
return (
<div>
<h2 id="Examples">Examples</h2>
<ul className={styles.toc}>
{entries.map(([name]) => (
<li key={name}>
<a href={`#Examples.${name}`}>{displayName(name)}</a>
</li>
))}
</ul>
</div>
);
}
function ExamplesDocs({examples}: {examples: Record<string, {HTML: string; Code: string}>}) {
const entries = exampleEntries(examples);
if (entries.length === 0) return null;
return (
<div>
<h2>Examples</h2>
{entries.map(([name, example]) => (
<div key={name} className={styles.method}>
<h3 id={`Examples.${name}`}>(Example) {displayName(name)}</h3>
{example.HTML && <div dangerouslySetInnerHTML={{__html: example.HTML}} />}
<CodeBlock language="go">{example.Code}</CodeBlock>
</div>
))}
</div>
);
}
function displayName(name: string): string {
const stripped = name.replace(/^_/, '');
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
}
/**
* Renders the full server plugin SDK reference (API / Hooks interfaces, plus runnable examples)
* generated from `server/public/plugin`'s Go doc comments by `gen-plugin-godocs`.
*
* Method signatures are rendered as plain, syntax-highlighted Go code (via the site's standard
* `CodeBlock`) rather than HTML with inline pkg.go.dev links baked into the signature, for
* consistent theming (including dark mode) and real Prism highlighting.
*/
export default function PluginGoDocs() {
if (!docs) {
return <MissingDataNotice />;
}
const hasHelpers = (docs.Helpers?.Methods?.length ?? 0) > 0;
return (
<div className={styles.pluginGoDocs}>
{docs.HTML && <div dangerouslySetInnerHTML={{__html: docs.HTML}} />}
<InterfaceOverview name="API" iface={docs.API} />
<InterfaceOverview name="Hooks" iface={docs.Hooks} />
{hasHelpers && <InterfaceOverview name="Helpers" iface={docs.Helpers} />}
<ExamplesTOC examples={docs.Examples} />
<hr />
<InterfaceMethodDocs name="API" iface={docs.API} />
<hr />
<InterfaceMethodDocs name="Hooks" iface={docs.Hooks} />
{hasHelpers && (
<>
<hr />
<InterfaceMethodDocs name="Helpers" iface={docs.Helpers} />
</>
)}
<hr />
<ExamplesDocs examples={docs.Examples} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
.pluginGoDocs h2 {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--mm-border-subtle);
}
.toc {
columns: 2;
column-gap: 2rem;
}
.toc li {
break-inside: avoid;
}
.method {
margin: 1.75rem 0;
}
.method h3 {
font-family: var(--mm-font-mono);
font-size: 1rem;
}
@media (max-width: 768px) {
.toc {
columns: 1;
}
}

View File

@@ -0,0 +1,82 @@
// Shape produced by docs/site/scripts/gen-plugin-godocs — kept in sync manually since the
// generator is Go and can't share TS types directly.
export type GoField = {
Names?: string[];
Type: string;
};
export type GoMethodDocs = {
Name: string;
Tags?: string[];
HTML: string;
Parameters?: GoField[];
Results?: GoField[];
};
export type GoInterfaceDocs = {
HTML: string;
Tags?: string[] | null;
Methods?: GoMethodDocs[] | null;
};
export type GoExampleDocs = {
HTML: string;
Code: string;
};
export type GoDocs = {
HTML: string;
API: GoInterfaceDocs;
Hooks: GoInterfaceDocs;
Helpers: GoInterfaceDocs;
Examples: Record<string, GoExampleDocs>;
};
// "[]" / "*" / "..." prefixes, and package-qualified names (e.g.
// "github.com/mattermost/mattermost/server/public/model.Manifest"), mirroring the old Hugo
// `TypeString` shortcode define — take the last "/"-delimited segment, keeping the "pkg.Type"
// suffix intact.
export function typeText(type: string): string {
if (type.startsWith('[]')) return '[]' + typeText(type.slice(2));
if (type.startsWith('*')) return '*' + typeText(type.slice(1));
if (type.startsWith('...')) return '...' + typeText(type.slice(3));
if (type.startsWith('map[')) {
let depth = 0;
let i = 4;
for (; i < type.length; i++) {
if (type[i] === '[') depth++;
else if (type[i] === ']') {
if (depth === 0) break;
depth--;
}
}
return `map[${typeText(type.slice(4, i))}]${typeText(type.slice(i + 1))}`;
}
const parts = type.split('/');
return parts[parts.length - 1];
}
export function fieldsText(fields?: GoField[]): string {
if (!fields || fields.length === 0) return '';
return fields
.map((f) => (f.Names?.length ? `${f.Names.join(', ')} ` : '') + typeText(f.Type))
.join(', ');
}
export function resultsText(results?: GoField[]): string {
if (!results || results.length === 0) return '';
const needsParens = results.length > 1 || Boolean(results[0].Names?.length);
const inner = fieldsText(results);
return needsParens ? `(${inner})` : inner;
}
export function signatureText(method: GoMethodDocs): string {
const params = fieldsText(method.Parameters);
const results = resultsText(method.Results);
return `${method.Name}(${params})${results ? ' ' + results : ''}`;
}
export function displayExampleName(name: string): string {
const stripped = name.replace(/^_/, '');
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
// @ts-ignore — generated by `npm run build:plugin-godocs` (from docs/site); gitignored build
// output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-godocs.json';
import type {GoDocs} from '../PluginGoDocs/types';
const docs: GoDocs | undefined = rawDocs && Object.keys(rawDocs).length > 0 ? (rawDocs as GoDocs) : undefined;
/**
* Renders a single Go example's source code, sourced from the same generated data as
* `<PluginGoDocs />`, e.g. `<PluginGoExample name="_helloWorld" />` renders the body of
* `Example_helloWorld`.
*/
export default function PluginGoExample({name}: {name: string}) {
const example = docs?.Examples?.[name];
if (!example) {
return (
<p>
Run <code>npm run build:plugin-godocs</code> (from <code>docs/site</code>) to generate this
example code.
</p>
);
}
return <CodeBlock language="go">{example.Code}</CodeBlock>;
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import CodeBlock from '@theme/CodeBlock';
// @ts-ignore — generated by `npm run build:plugin-jsdocs` (from docs/site) from
// webapp/channels/src/plugins/registry.ts; gitignored build output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-jsdocs.json';
import styles from './styles.module.css';
type JsMethodDocs = {
Name: string;
Parameters: string[];
Comments: string[];
};
type JsDocs = {
Interface: {
Methods: JsMethodDocs[];
};
};
const docs: JsDocs | undefined = rawDocs?.Interface?.Methods?.length > 0 ? (rawDocs as JsDocs) : undefined;
function signature(method: JsMethodDocs): string {
return `${method.Name}(${method.Parameters.join(', ')})`;
}
/**
* Renders the web app plugin registry method reference, generated from the JSDoc comments above
* each `PluginRegistry` method/property in `webapp/channels/src/plugins/registry.ts` by
* `gen-plugin-jsdocs`.
*/
export default function PluginJsDocs() {
if (!docs) {
return (
<p>
Run <code>npm run build:plugin-jsdocs</code> (from <code>docs/site</code>) to generate this
documentation.
</p>
);
}
const methods = docs.Interface.Methods;
return (
<div className={styles.pluginJsDocs}>
<ul className={styles.toc}>
{methods.map((m) => (
<li key={m.Name}>
<a href={`#${m.Name}`}>{signature(m)}</a>
</li>
))}
</ul>
<hr />
{methods.map((m) => (
<div key={m.Name} className={styles.method}>
<h3 id={m.Name}>{m.Name}</h3>
<CodeBlock language="js">{`/**\n${m.Comments.map((c) => ` * ${c}`).join('\n')}\n */\n${signature(m)}`}</CodeBlock>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,23 @@
.toc {
columns: 2;
column-gap: 2rem;
}
.toc li {
break-inside: avoid;
}
.method {
margin: 1.75rem 0;
}
.method h3 {
font-family: var(--mm-font-mono);
font-size: 1rem;
}
@media (max-width: 768px) {
.toc {
columns: 1;
}
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
// @ts-ignore — generated by `npm run build:plugin-manifest-docs` (from docs/site) from
// server/public/model's Manifest struct; gitignored build output, see docs/site/.gitignore.
import rawDocs from '@site/data/plugin-manifest-docs.json';
import styles from './styles.module.css';
type SchemaType = 'array' | 'bool' | 'dict' | 'number' | 'object' | 'string' | 'interface';
type ObjectProperty = {
Name: string;
DocHTML?: string;
Schema?: TypeDocs;
};
type TypeDocs = {
Type: SchemaType;
DocHTML?: string;
ObjectProperties?: ObjectProperty[];
ValueSchema?: TypeDocs;
};
type ManifestDocs = {
Schema: TypeDocs;
};
const docs: ManifestDocs | undefined = rawDocs?.Schema ? (rawDocs as ManifestDocs) : undefined;
function titleCase(type: SchemaType): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
// Arrays/dicts have no properties of their own — the ToC/Docs walk passes through to their
// element schema, same as the old Hugo `pluginmanifestdocs` shortcode.
function TOC({schema, prefix}: {schema?: TypeDocs; prefix: string}) {
if (!schema) return null;
if (schema.Type === 'object') {
return (
<ul>
{(schema.ObjectProperties ?? []).map((prop) => (
<li key={prop.Name}>
<a href={`#${prefix}${prop.Name}`}>
<code>{prop.Name}</code>
</a>
{prop.Schema && ` - ${titleCase(prop.Schema.Type)}`}
{prop.Schema && <TOC schema={prop.Schema} prefix={`${prefix}${prop.Name}.`} />}
</li>
))}
</ul>
);
}
if (schema.Type === 'array' || schema.Type === 'dict') {
return <TOC schema={schema.ValueSchema} prefix={prefix} />;
}
return null;
}
function Docs({schema, prefix}: {schema?: TypeDocs; prefix: string}) {
if (!schema) return null;
if (schema.Type === 'object') {
return (
<ul className={styles.docsList}>
{(schema.ObjectProperties ?? []).map((prop) => (
<li key={prop.Name} id={`${prefix}${prop.Name}`}>
<p>
<code>{prop.Name}</code>
{prop.Schema && ` - ${titleCase(prop.Schema.Type)}`}
</p>
{prop.DocHTML && <div dangerouslySetInnerHTML={{__html: prop.DocHTML}} />}
{prop.Schema?.DocHTML && <div dangerouslySetInnerHTML={{__html: prop.Schema.DocHTML}} />}
{prop.Schema && <Docs schema={prop.Schema} prefix={`${prefix}${prop.Name}.`} />}
</li>
))}
</ul>
);
}
if (schema.Type === 'array' || schema.Type === 'dict') {
return (
<>
{schema.ValueSchema?.DocHTML && <div dangerouslySetInnerHTML={{__html: schema.ValueSchema.DocHTML}} />}
<Docs schema={schema.ValueSchema} prefix={prefix} />
</>
);
}
return null;
}
/**
* Renders the plugin.json/plugin.yaml manifest field reference, generated from
* `model.Manifest`'s Go doc comments by `gen-plugin-manifest-docs`.
*/
export default function PluginManifestDocs() {
if (!docs) {
return (
<p>
Run <code>npm run build:plugin-manifest-docs</code> (from <code>docs/site</code>) to
generate this documentation.
</p>
);
}
return (
<div className={styles.pluginManifestDocs}>
{docs.Schema.DocHTML && <div dangerouslySetInnerHTML={{__html: docs.Schema.DocHTML}} />}
<h2>Table of contents</h2>
<TOC schema={docs.Schema} prefix="" />
<h2>Documentation</h2>
<Docs schema={docs.Schema} prefix="" />
</div>
);
}

View File

@@ -0,0 +1,14 @@
.docsList {
list-style: none;
padding-left: 0;
}
.docsList li {
margin: 1.25rem 0;
padding-left: 1rem;
border-left: 2px solid var(--mm-border-subtle);
}
.docsList code {
font-size: 0.95em;
}

View File

@@ -0,0 +1,158 @@
import {parseAffectedLines, type VersionRange} from './versions';
export const FEED_URL = 'https://securityupdates.mattermost.com/security_updates.json';
export type TabId = 'server' | 'desktop' | 'mobile' | 'plugins';
export type SecurityUpdateRow = {
issueId: string;
cveId?: string;
severity: string;
affectedVersions: string[];
affectedRanges: VersionRange[];
releaseDate: string;
fixVersions: string[];
details: string;
};
export type SecurityUpdateTab = {
id: TabId;
label: string;
rows: SecurityUpdateRow[];
};
export const TAB_ORDER: {id: TabId; label: string}[] = [
{id: 'server', label: 'Server'},
{id: 'desktop', label: 'Desktop'},
{id: 'mobile', label: 'Mobile'},
{id: 'plugins', label: 'Plugins'},
];
const PLATFORM_MAP: Record<string, TabId> = {
'Mattermost Server': 'server',
'Mattermost Desktop App': 'desktop',
'Mattermost Mobile Apps': 'mobile',
'Mattermost Plugins': 'plugins',
};
type FeedItem = {
issue_id?: string;
cve_id?: string;
severity?: string;
affected_versions?: unknown;
fix_release_date?: string;
fix_versions?: unknown;
details?: string;
platform?: string;
};
function normalizeVersions(value: unknown): string[] {
if (value == null || value === '') {
return [];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => normalizeVersions(entry)).map((entry) => entry.trim()).filter(Boolean);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
if (trimmed.startsWith('[')) {
try {
return normalizeVersions(JSON.parse(trimmed));
} catch {
/* keep as plain string */
}
}
return [trimmed];
}
return [String(value)];
}
function normalizeSeverity(value: string): string {
const trimmed = value.trim();
if (!trimmed || /^n\/?a$/i.test(trimmed)) {
return 'N/A';
}
return trimmed.replace(/^\w/, (c) => c.toUpperCase());
}
function parseIssueParts(issueId: string) {
const parts = issueId.split('-');
return {
year: Number.parseInt(parts[1] || '', 10) || 0,
num: Number.parseInt(parts[2] || '', 10) || 0,
extra: Number.parseInt(parts[3] || '', 10) || 0,
};
}
function toRow(item: FeedItem): SecurityUpdateRow {
const cveId = String(item.cve_id || '').trim();
const affectedVersions = normalizeVersions(item.affected_versions);
const fixVersions = normalizeVersions(item.fix_versions);
return {
issueId: String(item.issue_id || '').trim(),
...(cveId ? {cveId} : {}),
severity: normalizeSeverity(String(item.severity || '')),
affectedVersions,
affectedRanges: parseAffectedLines(affectedVersions, fixVersions),
releaseDate: String(item.fix_release_date || '').slice(0, 10),
fixVersions,
details: String(item.details || '').replace(/\s+/g, ' ').trim(),
};
}
function compareFeedItems(a: FeedItem, b: FeedItem): number {
const dateA = Date.parse(String(a.fix_release_date || '').slice(0, 10)) || 0;
const dateB = Date.parse(String(b.fix_release_date || '').slice(0, 10)) || 0;
if (dateA !== dateB) {
return dateB - dateA;
}
const pa = parseIssueParts(String(a.issue_id || ''));
const pb = parseIssueParts(String(b.issue_id || ''));
if (pa.year !== pb.year) {
return pb.year - pa.year;
}
if (pa.num !== pb.num) {
return pb.num - pa.num;
}
return pb.extra - pa.extra;
}
export function normalizeFeed(feed: unknown): SecurityUpdateTab[] {
if (!Array.isArray(feed)) {
throw new Error('Unexpected feed shape: expected an array of advisories.');
}
const tabs = Object.fromEntries(
TAB_ORDER.map((tab) => [tab.id, {...tab, rows: [] as SecurityUpdateRow[]}]),
) as Record<TabId, SecurityUpdateTab>;
[...(feed as FeedItem[])]
.sort(compareFeedItems)
.forEach((item) => {
const tabId = PLATFORM_MAP[String(item.platform || '').trim()];
if (!tabId) {
return;
}
tabs[tabId].rows.push(toRow(item));
});
return TAB_ORDER.map((tab) => tabs[tab.id]);
}
export async function fetchSecurityUpdates(): Promise<{
tabs: SecurityUpdateTab[];
lastModified: string | null;
}> {
const response = await fetch(FEED_URL, {cache: 'no-store'});
if (!response.ok) {
throw new Error(`Security updates feed returned ${response.status}.`);
}
const payload: unknown = await response.json();
return {
tabs: normalizeFeed(payload),
lastModified: response.headers.get('Last-Modified'),
};
}

View File

@@ -0,0 +1,608 @@
import React, {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
import {
fetchSecurityUpdates,
TAB_ORDER,
type SecurityUpdateRow,
type SecurityUpdateTab,
type TabId,
} from './feed';
import styles from './styles.module.css';
import {labelToVersion, rowMatchesVersionRange, type Version} from './versions';
type SortKey = 'issueId' | 'severity' | 'affectedVersions' | 'releaseDate' | 'fixVersions' | 'details';
type SortDir = 'asc' | 'desc';
const PAGE_SIZES = [10, 25, 50, 100];
const SEVERITY_OPTIONS = ['Critical', 'High', 'Medium', 'Low', 'N/A'] as const;
const SEVERITY_RANK: Record<string, number> = {
Critical: 4,
High: 3,
Medium: 2,
Low: 1,
'N/A': 0,
};
const VERSION_FILTER_DEBOUNCE_MS = 250;
const EMPTY_FILTERS = {
issueId: '',
severity: '',
affectedVersion: '',
dateFrom: '',
dateTo: '',
fixVersions: '',
search: '',
};
function parseIssueSort(issueId: string): number[] {
const parts = issueId.split('-');
return [
Number.parseInt(parts[1] || '', 10) || 0,
Number.parseInt(parts[2] || '', 10) || 0,
Number.parseInt(parts[3] || '', 10) || 0,
];
}
function firstVersionLabel(lines: string[]): string {
return lines[0] || '';
}
function sortRows(rows: SecurityUpdateRow[], key: SortKey, dir: SortDir): SecurityUpdateRow[] {
const copy = [...rows];
copy.sort((a, b) => {
let result = 0;
if (key === 'issueId') {
const pa = parseIssueSort(a.issueId);
const pb = parseIssueSort(b.issueId);
result = pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2] || a.issueId.localeCompare(b.issueId);
} else if (key === 'severity') {
result = (SEVERITY_RANK[a.severity] ?? -1) - (SEVERITY_RANK[b.severity] ?? -1);
} else if (key === 'releaseDate') {
result = (a.releaseDate || '').localeCompare(b.releaseDate || '');
} else if (key === 'affectedVersions') {
result = firstVersionLabel(a.affectedVersions).localeCompare(firstVersionLabel(b.affectedVersions));
} else if (key === 'fixVersions') {
result = firstVersionLabel(a.fixVersions).localeCompare(firstVersionLabel(b.fixVersions));
} else {
result = a.details.localeCompare(b.details);
}
return dir === 'asc' ? result : -result;
});
return copy;
}
function rowMatchesFilters(
row: SecurityUpdateRow,
filters: typeof EMPTY_FILTERS,
minV: Version | null,
maxV: Version | null,
): boolean {
const search = filters.search.trim().toLowerCase();
if (search) {
const haystack = [
row.issueId,
row.cveId,
row.severity,
row.releaseDate,
row.details,
...row.affectedVersions,
...row.fixVersions,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (!haystack.includes(search)) {
return false;
}
}
if (filters.issueId.trim()) {
const q = filters.issueId.trim().toLowerCase();
const issueHay = `${row.issueId} ${row.cveId || ''}`.toLowerCase();
if (!issueHay.includes(q)) {
return false;
}
}
if (filters.severity && row.severity !== filters.severity) {
return false;
}
if (filters.fixVersions.trim()) {
const q = filters.fixVersions.trim().toLowerCase();
if (!row.fixVersions.some((line) => line.toLowerCase().includes(q))) {
return false;
}
}
if (filters.dateFrom && row.releaseDate && row.releaseDate < filters.dateFrom) {
return false;
}
if (filters.dateTo && row.releaseDate && row.releaseDate > filters.dateTo) {
return false;
}
if (!rowMatchesVersionRange(row.affectedRanges, minV, maxV)) {
return false;
}
return true;
}
function buildPagination(current: number, total: number): (number | '…')[] {
if (total <= 7) {
return Array.from({length: total}, (_, i) => i + 1);
}
const pages: (number | '…')[] = [1];
const start = Math.max(2, current - 2);
const end = Math.min(total - 1, current + 2);
if (start > 2) {
pages.push('…');
}
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
if (end < total - 1) {
pages.push('…');
}
pages.push(total);
return pages;
}
function VersionList({lines}: {lines: string[]}) {
if (lines.length === 0) {
return <span className={styles.muted}>—</span>;
}
return (
<ul className={styles.versionList}>
{lines.map((line, index) => (
<li key={`${line}-${index}`}>{line}</li>
))}
</ul>
);
}
function rowKey(row: SecurityUpdateRow): string {
return `${row.issueId}|${row.releaseDate}|${row.affectedVersions.join(';')}|${row.fixVersions.join(';')}`;
}
export default function SecurityUpdates() {
const formId = useId();
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
const [error, setError] = useState<string | null>(null);
const [tabs, setTabs] = useState<SecurityUpdateTab[]>([]);
const [activeTab, setActiveTab] = useState<TabId>('server');
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [versionDraft, setVersionDraft] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('releaseDate');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const loadGeneration = useRef(0);
const load = useCallback(async () => {
const generation = (loadGeneration.current += 1);
setStatus('loading');
setError(null);
try {
const result = await fetchSecurityUpdates();
if (generation !== loadGeneration.current) {
return;
}
setTabs(result.tabs);
setStatus('ready');
} catch (err) {
if (generation !== loadGeneration.current) {
return;
}
setError(err instanceof Error ? err.message : 'Unable to load security updates.');
setStatus('error');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const activeRows = useMemo(
() => tabs.find((tab) => tab.id === activeTab)?.rows ?? [],
[tabs, activeTab],
);
useEffect(() => {
const timer = window.setTimeout(() => {
setFilters((prev) => {
if (prev.affectedVersion === versionDraft) {
return prev;
}
return {...prev, affectedVersion: versionDraft};
});
}, VERSION_FILTER_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [versionDraft]);
const dateOrderError = useMemo(() => {
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
return 'Fix released to must be on or after fix released from.';
}
return null;
}, [filters.dateFrom, filters.dateTo]);
const affectedVersionError = useMemo(() => {
const trimmed = filters.affectedVersion.trim();
if (!trimmed) {
return null;
}
if (!labelToVersion(trimmed, 'min')) {
return 'Enter a version such as 10.11 or 10.11.23.';
}
return null;
}, [filters.affectedVersion]);
const filterError = affectedVersionError || dateOrderError;
const filterErrorId = `${formId}-filter-error`;
const filteredRows = useMemo(() => {
if (filterError) {
return [];
}
const minV = labelToVersion(filters.affectedVersion, 'min');
const maxV = labelToVersion(filters.affectedVersion, 'max');
return sortRows(
activeRows.filter((row) => rowMatchesFilters(row, filters, minV, maxV)),
sortKey,
sortDir,
);
}, [activeRows, filters, sortKey, sortDir, filterError]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [activeTab, filters, pageSize, sortKey, sortDir]);
const hasActiveFilters = useMemo(
() =>
Object.values(filters).some((value) => value.trim() !== '') || versionDraft.trim() !== '',
[filters, versionDraft],
);
const setFilter = (key: keyof typeof EMPTY_FILTERS, value: string) => {
setFilters((prev) => ({...prev, [key]: value}));
};
const clearFilters = () => {
setFilters(EMPTY_FILTERS);
setVersionDraft('');
};
const toggleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDir(key === 'releaseDate' || key === 'severity' || key === 'issueId' ? 'desc' : 'asc');
};
const sortProps = (key: SortKey) => ({
'aria-sort': sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none',
}) as const;
const renderSortButton = (key: SortKey, label: string) => {
const direction =
sortKey === key ? (sortDir === 'asc' ? 'sorted ascending' : 'sorted descending') : 'not sorted';
return (
<button
type="button"
className={styles.sortButton}
onClick={() => toggleSort(key)}
aria-label={`${label}, ${direction}`}
>
{label}
<span className={styles.sortIcons} aria-hidden>
<span className={sortKey === key && sortDir === 'asc' ? styles.sortActive : undefined}>▲</span>
<span className={sortKey === key && sortDir === 'desc' ? styles.sortActive : undefined}>▼</span>
</span>
</button>
);
};
return (
<div className={styles.root}>
<div className={styles.tabs} role="tablist" aria-label="Product">
{TAB_ORDER.map((tab) => {
const selected = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
role="tab"
id={`${formId}-tab-${tab.id}`}
aria-selected={selected}
aria-controls={`${formId}-panel`}
tabIndex={selected ? 0 : -1}
className={selected ? `${styles.tab} ${styles.tabActive}` : styles.tab}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => {
const order = TAB_ORDER.map((item) => item.id);
const index = order.indexOf(tab.id);
let next = -1;
if (event.key === 'ArrowRight') {
next = (index + 1) % order.length;
} else if (event.key === 'ArrowLeft') {
next = (index - 1 + order.length) % order.length;
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = order.length - 1;
}
if (next < 0) {
return;
}
event.preventDefault();
setActiveTab(order[next]);
document.getElementById(`${formId}-tab-${order[next]}`)?.focus();
}}
>
{tab.label}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`${formId}-panel`}
aria-labelledby={`${formId}-tab-${activeTab}`}
>
{status === 'loading' && (
<div className={styles.status} role="status">
Loading security updates…
</div>
)}
{status === 'error' && (
<div className={styles.error} role="alert">
<p>{error}</p>
<button type="button" className={styles.primaryButton} onClick={() => void load()}>
Retry
</button>
</div>
)}
{status === 'ready' && (
<>
<form
className={styles.filters}
onSubmit={(event) => event.preventDefault()}
aria-label="Filter security updates"
>
<label className={styles.searchField}>
Search all columns
<input
type="search"
value={filters.search}
onChange={(event) => setFilter('search', event.target.value)}
placeholder="Issue ID, CVE, version, or details"
/>
</label>
<label>
Issue ID / CVE
<input
type="search"
value={filters.issueId}
onChange={(event) => setFilter('issueId', event.target.value)}
placeholder="MMSA or CVE"
/>
</label>
<label>
Severity
<select
value={filters.severity}
onChange={(event) => setFilter('severity', event.target.value)}
>
<option value="">All severities</option>
{SEVERITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label>
Affected version
<input
value={versionDraft}
onChange={(event) => setVersionDraft(event.target.value)}
placeholder="e.g. 10.11.23"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(affectedVersionError)}
aria-describedby={
affectedVersionError
? `${filterErrorId} ${formId}-affected-hint`
: `${formId}-affected-hint`
}
/>
<small id={`${formId}-affected-hint`} className={styles.fieldHint}>
Enter the version you&rsquo;re running to see affected advisories.
</small>
</label>
<label>
Fix released from
<input
type="date"
value={filters.dateFrom}
onChange={(event) => setFilter('dateFrom', event.target.value)}
aria-invalid={Boolean(dateOrderError)}
aria-describedby={dateOrderError ? filterErrorId : undefined}
/>
</label>
<label>
Fix released to
<input
type="date"
value={filters.dateTo}
onChange={(event) => setFilter('dateTo', event.target.value)}
aria-invalid={Boolean(dateOrderError)}
aria-describedby={dateOrderError ? filterErrorId : undefined}
/>
</label>
<label>
Fix versions
<input
type="search"
value={filters.fixVersions}
onChange={(event) => setFilter('fixVersions', event.target.value)}
placeholder="e.g. 10.11.23"
/>
</label>
<div className={styles.filterActions}>
<button
type="button"
className={styles.resetButton}
onClick={clearFilters}
disabled={!hasActiveFilters}
>
Clear filters
</button>
</div>
{filterError ? (
<p className={styles.filterError} id={filterErrorId} role="alert">
{filterError}
</p>
) : null}
</form>
<div className={styles.toolbar}>
<label className={styles.pageSize}>
Rows
<select
value={pageSize}
onChange={(event) => setPageSize(Number.parseInt(event.target.value, 10) || 25)}
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<p className={styles.summary} role="status">
{filteredRows.length === 0
? 'No matching advisories'
: `Showing ${(currentPage - 1) * pageSize + 1}–${(currentPage - 1) * pageSize + pageRows.length} of ${filteredRows.length}`}
</p>
</div>
<div className={styles.tableWrap}>
<table className={styles.table} aria-label={`${TAB_ORDER.find((tab) => tab.id === activeTab)?.label} security updates`}>
<thead>
<tr>
<th scope="col" {...sortProps('issueId')}>
{renderSortButton('issueId', 'Issue ID')}
</th>
<th scope="col" {...sortProps('severity')}>
{renderSortButton('severity', 'Severity')}
</th>
<th scope="col" {...sortProps('affectedVersions')}>
{renderSortButton('affectedVersions', 'Affected versions')}
</th>
<th scope="col" {...sortProps('releaseDate')}>
{renderSortButton('releaseDate', 'Release date')}
</th>
<th scope="col" {...sortProps('fixVersions')}>
{renderSortButton('fixVersions', 'Fix versions')}
</th>
<th scope="col" {...sortProps('details')}>
{renderSortButton('details', 'Details')}
</th>
</tr>
</thead>
<tbody>
{pageRows.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>
No security updates match these filters.
</td>
</tr>
) : (
pageRows.map((row) => (
<tr key={rowKey(row)}>
<td data-label="Issue ID">
<span className={styles.issueId}>{row.issueId}</span>
{row.cveId ? (
<a
className={styles.cve}
href={`https://nvd.nist.gov/vuln/detail/${row.cveId}`}
rel="noopener noreferrer"
>
{row.cveId}
</a>
) : null}
</td>
<td data-label="Severity">
<span
className={`${styles.badge} ${
styles[`badge${row.severity.replace(/[^A-Za-z]/g, '')}` as keyof typeof styles] ||
styles.badgeNA
}`}
>
{row.severity}
</span>
</td>
<td data-label="Affected versions">
<VersionList lines={row.affectedVersions} />
</td>
<td data-label="Release date">{row.releaseDate || '—'}</td>
<td data-label="Fix versions">
<VersionList lines={row.fixVersions} />
</td>
<td data-label="Details">{row.details || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<nav className={styles.pagination} aria-label="Security updates pages">
<ol>
{buildPagination(currentPage, totalPages).map((item, index) => (
<li key={`${item}-${index}`}>
{item === '…' ? (
<span className={styles.ellipsis}>…</span>
) : (
<button
type="button"
className={item === currentPage ? styles.pageActive : undefined}
aria-label={`Page ${item}`}
aria-current={item === currentPage ? 'page' : undefined}
onClick={() => setPage(item)}
>
{item}
</button>
)}
</li>
))}
</ol>
</nav>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,458 @@
.root {
margin: 1.5rem 0 2.5rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0;
margin-bottom: -1px;
}
.tab {
display: inline-flex;
align-items: center;
padding: 0.55rem 0.95rem;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-surface);
color: var(--mm-text-primary);
font: inherit;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 0.78rem;
cursor: pointer;
}
.tab:focus-visible {
outline: 2px solid var(--mm-focus-ring);
outline-offset: 2px;
z-index: 1;
}
.tabActive {
background: var(--mm-color-denim);
border-color: var(--mm-color-denim);
color: var(--mm-color-white);
}
[data-theme='dark'] .tabActive {
background: var(--mm-denim-500);
border-color: var(--mm-denim-500);
color: var(--mm-color-white);
}
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
gap: 0.75rem 1rem;
padding: 1rem 1.15rem;
margin-bottom: 1rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-subtle);
}
.filters label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--mm-text-secondary);
}
.filters input,
.filters select,
.pageSize select {
width: 100%;
min-height: 2.4rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-surface);
color: var(--mm-text-primary);
font: inherit;
font-weight: 400;
color-scheme: light;
}
[data-theme='dark'] .filters input,
[data-theme='dark'] .filters select,
[data-theme='dark'] .pageSize select {
color-scheme: dark;
}
.filters input:focus-visible,
.filters select:focus-visible,
.pageSize select:focus-visible,
.sortButton:focus-visible,
.primaryButton:focus-visible,
.resetButton:focus-visible,
.pagination button:focus-visible {
outline: 2px solid var(--mm-focus-ring);
outline-offset: 2px;
}
.searchField {
grid-column: 1 / -1;
}
.filterActions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.hint {
margin: 0;
flex: 1 1 16rem;
font-size: 0.82rem;
font-weight: 400;
color: var(--mm-text-secondary);
}
.fieldHint {
margin: 0.15rem 0 0;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.4;
color: var(--mm-text-secondary);
}
.fieldHint code {
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--mm-bg-surface);
font-size: 0.72rem;
}
.filterError {
grid-column: 1 / -1;
margin: 0;
color: #8b1f1f;
font-size: 0.9rem;
}
[data-theme='dark'] .filterError {
color: #f4a0ab;
}
.primaryButton,
.resetButton {
padding: 0.5rem 0.95rem;
border-radius: 4px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.primaryButton {
border: none;
background: var(--mm-color-denim);
color: var(--mm-color-white);
}
.primaryButton:hover {
background: var(--mm-denim-600);
}
.resetButton {
border: 1px solid var(--mm-border-subtle);
background: transparent;
color: var(--mm-text-primary);
}
.resetButton:hover:not(:disabled) {
background: var(--mm-bg-surface);
}
.resetButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.pageSize {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.pageSize select {
width: auto;
min-width: 4.5rem;
}
.summary {
margin: 0;
color: var(--mm-text-secondary);
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-surface);
}
.tableWrap .table {
width: 100%;
min-width: 0;
display: table;
border-collapse: collapse;
table-layout: auto;
}
.table th,
.table td {
padding: 0.7rem 0.75rem;
text-align: left;
vertical-align: top;
font-size: 0.86rem;
line-height: 1.45;
border-bottom: 1px solid var(--mm-border-subtle);
overflow-wrap: anywhere;
word-break: break-word;
}
.table th {
background: var(--mm-denim-50);
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mm-text-secondary);
}
[data-theme='dark'] .table th {
background: var(--mm-denim-800);
}
.table tbody tr:nth-child(even) {
background: var(--mm-bg-subtle);
}
.table th:nth-child(1),
.table td:nth-child(1) {
width: 14%;
}
.table th:nth-child(2),
.table td:nth-child(2) {
width: 9%;
}
.table th:nth-child(3),
.table td:nth-child(3),
.table th:nth-child(5),
.table td:nth-child(5) {
width: 15%;
}
.table th:nth-child(4),
.table td:nth-child(4) {
width: 11%;
}
.sortButton {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2rem;
padding: 0.35rem 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.sortIcons {
display: inline-flex;
flex-direction: column;
font-size: 0.55rem;
line-height: 0.7;
}
.sortIcons span {
opacity: 0.4;
}
.sortIcons .sortActive {
opacity: 1;
color: var(--mm-color-denim);
}
[data-theme='dark'] .sortIcons .sortActive {
color: var(--mm-denim-200);
}
.issueId {
display: block;
font-family: var(--mm-font-mono);
font-size: 0.8rem;
font-weight: 600;
}
.cve {
display: inline-block;
margin-top: 0.2rem;
font-size: 0.78rem;
}
.versionList {
margin: 0;
padding: 0;
list-style: none;
}
.versionList li + li {
margin-top: 0.2rem;
}
.muted,
.empty {
color: var(--mm-text-secondary);
text-align: center;
}
.empty {
padding: 1.5rem 0.75rem;
}
.badge {
display: inline-flex;
padding: 0.15rem 0.5rem;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.badgeCritical {
background: #fbeded;
color: #8b1f1f;
}
.badgeHigh {
background: #fdecef;
color: #c8102e;
}
.badgeMedium {
background: var(--mm-marigold-300);
color: var(--mm-color-black);
border-color: var(--mm-marigold-500);
}
.badgeLow {
background: #eaf5ee;
color: #1f5a39;
}
.badgeNA {
background: var(--mm-denim-100);
color: var(--mm-denim-700);
}
[data-theme='dark'] .badgeCritical {
background: #4a1f1f;
color: #e9a0a0;
}
[data-theme='dark'] .badgeHigh {
background: #4e0f1b;
color: #f4a0ab;
}
[data-theme='dark'] .badgeMedium {
background: #5c4a14;
color: var(--mm-marigold-100);
border-color: var(--mm-marigold-700);
}
[data-theme='dark'] .badgeLow {
background: #14442c;
color: #7fcc9e;
}
[data-theme='dark'] .badgeNA {
background: var(--mm-denim-700);
color: var(--mm-denim-200);
}
.pagination {
margin-top: 1rem;
}
.pagination ol {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.pagination button,
.ellipsis {
min-width: 2rem;
height: 2rem;
padding: 0 0.4rem;
border: 1px solid transparent;
background: transparent;
color: var(--mm-text-primary);
font: inherit;
cursor: pointer;
}
.pageActive {
border-color: var(--mm-border-strong) !important;
color: var(--mm-color-denim);
font-weight: 700;
}
[data-theme='dark'] .pageActive {
color: var(--mm-marigold-300);
border-color: var(--mm-denim-300) !important;
}
.status,
.error {
padding: 1.25rem;
border: 1px solid var(--mm-border-subtle);
background: var(--mm-bg-subtle);
}
.error p {
margin: 0 0 0.75rem;
}
@media (max-width: 996px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.filters {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,231 @@
export type Version = [number, number, number];
export type VersionRange = {min: Version; max: Version};
const INF = 999_999;
const ZERO: Version = [0, 0, 0];
const MAX: Version = [INF, INF, INF];
export function parseVersionToken(raw: string): Version | null {
const cleaned = raw.trim().replace(/^v/i, '');
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+|x))?/i);
if (!match) {
return null;
}
const major = Number(match[1]);
const minor = match[2] === undefined ? 0 : Number(match[2]);
const patchToken = match[3];
const patch =
patchToken === undefined ? 0 : patchToken.toLowerCase() === 'x' ? 0 : Number(patchToken);
return [major, minor, patch];
}
export function compareVersions(a: Version, b: Version): number {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) {
return a[i] - b[i];
}
}
return 0;
}
function exclusiveUpperBound(v: Version): Version {
if (v[2] > 0) {
return [v[0], v[1], v[2] - 1];
}
if (v[1] > 0) {
return [v[0], v[1] - 1, INF];
}
if (v[0] > 0) {
return [v[0] - 1, INF, INF];
}
return ZERO;
}
function versionFromMatch(token: string, asUpperBound: boolean): Version | null {
const parsed = parseVersionToken(token);
if (!parsed) {
return null;
}
const cleaned = token.trim().replace(/^v/i, '');
if (asUpperBound && /\.x$/i.test(cleaned)) {
return [parsed[0], parsed[1], INF];
}
// "11" as a max means the whole 11 major line.
if (asUpperBound && /^\d+$/.test(cleaned)) {
return [parsed[0], INF, INF];
}
// "11.9" as a max means the whole 11.9 line.
if (asUpperBound && /^\d+\.\d+$/.test(cleaned)) {
return [parsed[0], parsed[1], INF];
}
return parsed;
}
function parseOneLine(line: string): VersionRange[] {
const text = line.trim();
if (!text || /^(n\/a|na)$/i.test(text) || /^exclud/i.test(text)) {
return [];
}
if (/^all$/i.test(text)) {
return [{min: ZERO, max: MAX}];
}
const toMatch = text.match(
/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+to\s+v?(\d+(?:\.\d+){0,2}(?:\.x)?)/i,
);
if (toMatch) {
const min = versionFromMatch(toMatch[1], false);
const max = versionFromMatch(toMatch[2], true);
if (min && max) {
return [{min, max}];
}
}
const earlier = text.match(/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+and earlier/i);
if (earlier) {
const max = versionFromMatch(earlier[1], true);
if (max) {
return [{min: ZERO, max}];
}
}
if (text.includes('<=')) {
const segments = text.split('<=');
const left = (segments[0] || '').trim();
const rightSegments = segments.slice(1).map((part) => part.trim()).filter(Boolean);
if (rightSegments.length === 0) {
return [];
}
// Multiple "<=" operators: union of upper bounds (e.g. "<=7.1.9 <=7.8.4").
if (rightSegments.length > 1) {
return rightSegments
.map((part) => versionFromMatch(part.split(/\s+/)[0] || '', true))
.filter((max): max is Version => max !== null)
.map((max) => ({min: ZERO, max}));
}
const tokens = rightSegments[0].split(/\s+/).filter(Boolean);
// Pre-2024 mashed line: "<=11.9 11.0.9 11.4.8 …" is a version list, not one open range.
if (tokens.length > 1 && !left) {
return tokens.flatMap((token) => {
const min = versionFromMatch(token, false);
const max = versionFromMatch(token, true);
return min && max ? [{min, max}] : [];
});
}
const max = versionFromMatch(tokens[0] || '', true);
if (!max) {
return [];
}
const branch = left.match(/(\d+)\.(\d+)\.x/i);
if (branch) {
return [{min: [Number(branch[1]), Number(branch[2]), 0], max}];
}
const leftVer = parseVersionToken(left.replace(/^.*\s/, ''));
if (leftVer) {
return [{min: leftVer, max}];
}
return [{min: ZERO, max}];
}
if (text.includes('>=')) {
const min = versionFromMatch(text.split('>=')[1] || '', false);
if (min) {
return [{min, max: MAX}];
}
}
if (text.includes('<')) {
const ranges: VersionRange[] = [];
for (const match of text.matchAll(/<\s*v?(\d+(?:\.\d+){0,2}(?:\.x)?)/gi)) {
// Exclusive bound: do not expand "7.9" to the whole 7.9 line first.
const bound = versionFromMatch(match[1], false);
if (bound) {
ranges.push({min: ZERO, max: exclusiveUpperBound(bound)});
}
}
if (ranges.length > 0) {
return ranges;
}
}
const ranges: VersionRange[] = [];
for (const match of text.matchAll(/v?(\d+\.\d+(?:\.\d+|\.x)?)/gi)) {
const token = match[1];
const version = parseVersionToken(token);
if (version) {
ranges.push({min: version, max: version});
}
}
return ranges;
}
function highestFixVersion(fixVersions: string[]): Version | null {
let highest: Version | null = null;
for (const line of fixVersions) {
for (const match of line.matchAll(/v?(\d+\.\d+(?:\.\d+)?)/gi)) {
const parsed = parseVersionToken(match[1]);
if (!parsed) {
continue;
}
if (!highest || compareVersions(parsed, highest) > 0) {
highest = parsed;
}
}
}
return highest;
}
function isUnbounded(range: VersionRange): boolean {
return compareVersions(range.max, MAX) === 0;
}
export function parseAffectedLines(lines: string[], fixVersions: string[] = []): VersionRange[] {
const ranges = lines.flatMap(parseOneLine);
const cap = highestFixVersion(fixVersions);
if (!cap) {
return ranges;
}
return ranges.map((range) => {
if (!isUnbounded(range)) {
return range;
}
return {
min: range.min,
max: compareVersions(cap, range.min) < 0 ? range.min : cap,
};
});
}
export function rangesOverlap(a: VersionRange, b: VersionRange): boolean {
return compareVersions(a.min, b.max) <= 0 && compareVersions(b.min, a.max) <= 0;
}
export function rowMatchesVersionRange(
ranges: VersionRange[],
minFilter: Version | null,
maxFilter: Version | null,
): boolean {
if (!minFilter && !maxFilter) {
return true;
}
if (ranges.length === 0) {
return false;
}
const filter: VersionRange = {
min: minFilter ?? ZERO,
max: maxFilter ?? MAX,
};
return ranges.some((range) => rangesOverlap(range, filter));
}
export function labelToVersion(label: string, bound: 'min' | 'max'): Version | null {
const trimmed = label.trim();
if (!trimmed || !/^v?\d+(?:\.\d+){0,2}(?:\.x)?$/i.test(trimmed)) {
return null;
}
return versionFromMatch(trimmed, bound === 'max');
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import styles from './styles.module.css';
type Stat = {
value: string | number;
label: string;
hint?: string;
};
/**
* Horizontal strip of headline numbers — used at the top of landing
* pages to surface the size/scope of the section in a single glance.
*
* Usage:
* <StatStrip stats={[
* {value: '549', label: 'Endpoints'},
* {value: '38', label: 'Resource groups'},
* {value: '5', label: 'Languages', hint: 'curl, PowerShell, Python, Node, Go'},
* ]} />
*/
export default function StatStrip({stats}: {stats: Stat[]}) {
return (
<ul className={styles.strip}>
{stats.map((s) => (
<li key={s.label} className={styles.stat}>
<span className={styles.value}>{s.value}</span>
<span className={styles.label}>{s.label}</span>
{s.hint && <span className={styles.hint}>{s.hint}</span>}
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,48 @@
.strip {
list-style: none;
margin: 1.5rem 0 2.5rem;
padding: 1.25rem 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem 2rem;
border-top: 2px solid var(--mm-color-marigold);
border-bottom: 1px solid var(--mm-border-subtle);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0;
margin: 0;
}
.value {
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: clamp(1.75rem, 2.5vw, 2.4rem);
line-height: 1;
letter-spacing: -0.02em;
color: var(--mm-color-denim);
}
[data-theme='dark'] .value {
color: var(--mm-color-white);
}
.label {
font-family: var(--mm-font-heading);
font-weight: 900;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--mm-text-secondary);
margin-top: 0.4rem;
}
.hint {
font-size: 0.78rem;
line-height: 1.4;
color: var(--mm-text-secondary);
margin-top: 0.25rem;
}

View File

@@ -0,0 +1,224 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import styles from './styles.module.css';
type VersionParts = {major: number; minor: number};
type RowRef = {row: HTMLTableRowElement; version: string};
function sortVersions(versions: string[]): string[] {
return [...versions].sort((a, b) => {
const [majorA, minorA] = a.split('.').map(Number);
const [majorB, minorB] = b.split('.').map(Number);
if (majorA !== majorB) {
return majorB - majorA;
}
return minorB - minorA;
});
}
function parseVersion(version: string): VersionParts {
if (version === 'all') {
return {major: 0, minor: 0};
}
const [major, minor] = version.split('.').map(Number);
return {major, minor};
}
function isVersionRelevant(
version: string,
sourceVersion: string,
targetVersion: string,
): boolean {
const rowV = parseVersion(version);
const sourceV = parseVersion(sourceVersion);
const targetV = parseVersion(targetVersion);
return (
(sourceVersion === 'all' ||
rowV.major > sourceV.major ||
(rowV.major === sourceV.major && rowV.minor >= sourceV.minor)) &&
(targetVersion === 'all' ||
rowV.major < targetV.major ||
(rowV.major === targetV.major && rowV.minor <= targetV.minor))
);
}
function collectRows(table: HTMLTableElement): {rows: RowRef[]; versions: string[]} {
const rows: RowRef[] = [];
const versions: string[] = [];
let lastVersion: string | null = null;
table.querySelectorAll<HTMLTableRowElement>('tbody tr').forEach((tr) => {
const cells = tr.querySelectorAll('td');
if (cells.length > 1) {
const versionText = cells[0].textContent?.trim() ?? '';
const versionMatch = versionText.match(/^v(\d+\.\d+)$/);
if (versionMatch) {
const version = versionMatch[1];
if (!versions.includes(version)) {
versions.push(version);
}
lastVersion = version;
rows.push({row: tr, version});
return;
}
}
if (lastVersion && cells.length >= 1) {
rows.push({row: tr, version: lastVersion});
}
});
rows.forEach(({row, version}) => {
row.dataset.version = version;
});
return {rows, versions: sortVersions(versions)};
}
function findUpgradeNotesTable(): HTMLTableElement | null {
return document.querySelector<HTMLTableElement>('.upgrade-notes-table');
}
export default function UpgradeNotesFilter() {
const rowsRef = useRef<RowRef[]>([]);
const [versions, setVersions] = useState<string[]>([]);
const [sourceVersion, setSourceVersion] = useState('all');
const [targetVersion, setTargetVersion] = useState('all');
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const initializeRows = useCallback(() => {
const table = findUpgradeNotesTable();
if (!table || table.querySelectorAll('tbody tr').length === 0) {
return false;
}
const {rows, versions: foundVersions} = collectRows(table);
if (rows.length === 0 || foundVersions.length === 0) {
return false;
}
rowsRef.current = rows;
setVersions(foundVersions);
setReady(true);
return true;
}, []);
useEffect(() => {
if (initializeRows()) {
return undefined;
}
const timer = window.setInterval(() => {
if (initializeRows()) {
window.clearInterval(timer);
}
}, 100);
return () => window.clearInterval(timer);
}, [initializeRows]);
const showAllRows = () => {
rowsRef.current.forEach(({row}) => row.classList.remove('filtered'));
};
const applyFilter = () => {
setError(null);
if (!ready || rowsRef.current.length === 0) {
if (!initializeRows()) {
setError('Error: Upgrade notes table is still loading. Try again in a moment.');
return;
}
}
if (sourceVersion === 'all' && targetVersion === 'all') {
showAllRows();
return;
}
const sourceV = parseVersion(sourceVersion);
const targetV = parseVersion(targetVersion);
if (
targetV.major < sourceV.major ||
(targetV.major === sourceV.major && targetV.minor < sourceV.minor)
) {
setError('Error: Target version must be greater than or equal to source version.');
return;
}
const versionVisibility = new Map<string, boolean>();
versions.forEach((version) => {
versionVisibility.set(
version,
isVersionRelevant(version, sourceVersion, targetVersion),
);
});
rowsRef.current.forEach(({row, version}) => {
row.classList.toggle('filtered', !versionVisibility.get(version));
});
};
const resetFilter = () => {
setSourceVersion('all');
setTargetVersion('all');
setError(null);
showAllRows();
};
return (
<div className={styles.filters}>
<h3 className={styles.title}>Filter Upgrade Notes</h3>
<p className={styles.description}>
Select source and target versions to see only relevant upgrade notes
</p>
<div className={styles.controls}>
<label className={styles.field} htmlFor="source-version">
From version:
<select
id="source-version"
value={sourceVersion}
onChange={(event) => setSourceVersion(event.target.value)}
disabled={!ready}>
<option value="all">All versions</option>
{versions.map((version) => (
<option key={`source-${version}`} value={version}>
v{version}
</option>
))}
</select>
</label>
<label className={styles.field} htmlFor="target-version">
To version:
<select
id="target-version"
value={targetVersion}
onChange={(event) => setTargetVersion(event.target.value)}
disabled={!ready}>
<option value="all">All versions</option>
{versions.map((version) => (
<option key={`target-${version}`} value={version}>
v{version}
</option>
))}
</select>
</label>
<button
type="button"
className={styles.applyButton}
onClick={applyFilter}
disabled={!ready}>
Apply Filter
</button>
<button type="button" className={styles.resetButton} onClick={resetFilter}>
Reset
</button>
</div>
{error ? <div className={styles.error}>{error}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,82 @@
.filters {
margin: 1.5rem 0;
padding: 1rem 1.25rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-subtle);
}
.title {
margin: 0 0 0.35rem;
font-family: var(--mm-font-heading);
font-size: 1.1rem;
}
.description {
margin: 0 0 1rem;
color: var(--mm-text-secondary);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
align-items: end;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
}
.field select {
min-width: 10rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
background: var(--mm-bg-surface);
color: var(--mm-text-primary);
}
.applyButton,
.resetButton {
padding: 0.55rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.applyButton {
background: var(--mm-denim-400);
color: var(--mm-color-white);
}
.applyButton:hover {
background: var(--mm-color-denim);
}
.resetButton {
background: transparent;
color: var(--mm-text-primary);
border: 1px solid var(--mm-border-subtle);
}
.resetButton:hover {
background: var(--mm-bg-surface);
}
.error {
margin-top: 0.75rem;
padding: 0.65rem 0.75rem;
border-left: 4px solid #e74c3c;
border-radius: 4px;
background: #fadbd8;
color: #922b21;
}
:global(.upgrade-notes-table tr.filtered) {
display: none;
}

View File

@@ -0,0 +1,989 @@
/*
* Mattermost docs theme overrides on top of Infima.
* Tokens live in tokens.css. This file maps tokens to Infima vars and
* adds the brand surface treatments (navbar density, code blocks,
* sidebar, footer) so the site reads as part of the Mattermost product
* family, not a default Docusaurus install.
*/
/* === Map brand tokens onto Infima === */
:root {
--ifm-color-primary: var(--mm-denim-500);
--ifm-color-primary-dark: var(--mm-denim-600);
--ifm-color-primary-darker: var(--mm-denim-700);
--ifm-color-primary-darkest: var(--mm-denim-800);
--ifm-color-primary-light: var(--mm-denim-400);
--ifm-color-primary-lighter: var(--mm-denim-300);
--ifm-color-primary-lightest: var(--mm-denim-200);
--ifm-font-family-base: var(--mm-font-sans);
--ifm-font-family-monospace: var(--mm-font-mono);
--ifm-heading-font-family: var(--mm-font-sans);
--ifm-heading-font-weight: 700;
--ifm-h1-font-size: 2.6rem;
--ifm-h2-font-size: 1.85rem;
--ifm-h3-font-size: 1.35rem;
--ifm-line-height-base: 1.65;
--ifm-spacing-vertical: 1.2rem;
--ifm-code-font-size: 90%;
--docusaurus-highlighted-code-line-bg: rgba(30, 50, 92, 0.1);
--ifm-background-color: var(--mm-bg-page);
--ifm-background-surface-color: var(--mm-bg-surface);
--ifm-navbar-background-color: var(--mm-color-denim);
--ifm-navbar-link-color: rgba(255, 255, 255, 0.85);
--ifm-navbar-link-hover-color: var(--mm-color-white);
--ifm-navbar-height: 4rem;
--ifm-footer-background-color: var(--mm-color-denim);
--ifm-footer-color: var(--mm-color-white);
--ifm-footer-link-color: rgba(255,255,255,0.78);
--ifm-footer-link-hover-color: var(--mm-color-marigold);
--ifm-footer-title-color: var(--mm-color-white);
--ifm-link-color: var(--mm-link);
--ifm-link-hover-color: var(--mm-link-hover);
--ifm-link-hover-decoration: underline;
--ifm-toc-border-color: var(--mm-border-subtle);
--ifm-hr-border-color: var(--mm-border-subtle);
/* Infima's own default body color isn't derived from our brand palette
* at all — pin it to --mm-text-primary for a guaranteed, auditable
* near-black. */
--ifm-font-color-base: var(--mm-text-primary);
--ifm-color-content: var(--mm-text-primary);
--ifm-font-color-secondary: var(--mm-text-secondary);
--ifm-color-content-secondary: var(--mm-text-secondary);
/* Sidebar */
--ifm-menu-color: var(--mm-denim-700);
--ifm-menu-color-active: var(--mm-color-denim);
/* denim-100, not -50: the sidebar container itself now sits on
* --mm-bg-subtle (= denim-50, see .theme-doc-sidebar-container), so the
* active/hover highlight needs a visibly darker step or it'd disappear
* into its own background. */
--ifm-menu-color-background-active: var(--mm-denim-100);
--ifm-menu-color-background-hover: var(--mm-denim-100);
--ifm-menu-link-padding-vertical: 0.45rem;
--ifm-menu-link-padding-horizontal: 0.85rem;
}
[data-theme='dark'] {
--ifm-color-primary: var(--mm-color-sky);
--ifm-color-primary-dark: var(--mm-denim-300);
--ifm-color-primary-darker: var(--mm-denim-400);
--ifm-color-primary-darkest: var(--mm-denim-500);
--ifm-color-primary-light: var(--mm-denim-100);
--ifm-color-primary-lighter: var(--mm-denim-50);
--ifm-color-primary-lightest: var(--mm-color-white);
--ifm-background-color: var(--mm-bg-page);
--ifm-background-surface-color: var(--mm-bg-surface);
--docusaurus-highlighted-code-line-bg: rgba(255, 188, 31, 0.18);
--ifm-navbar-background-color: var(--mm-denim-800);
--ifm-navbar-link-color: rgba(255,255,255,0.85);
--ifm-navbar-link-hover-color: var(--mm-color-white);
--ifm-menu-color: rgba(255,255,255,0.78);
--ifm-menu-color-active: var(--mm-color-white);
--ifm-menu-color-background-active: var(--mm-denim-700);
--ifm-menu-color-background-hover: var(--mm-denim-700);
/* See light-mode block above — same rationale, dark-mode near-white. */
--ifm-font-color-base: var(--mm-text-primary);
--ifm-color-content: var(--mm-text-primary);
--ifm-font-color-secondary: var(--mm-text-secondary);
--ifm-color-content-secondary: var(--mm-text-secondary);
}
/* The mobile nav slide-out (.navbar-sidebar) always has a dark denim
* background, but its menu inherits the light-mode --ifm-menu-color*
* vars tuned for the light desktop sidebar — force the dark-mode values
* unconditionally so mobile text stays readable in light mode too. */
.navbar-sidebar {
--ifm-menu-color: rgba(255,255,255,0.78);
--ifm-menu-color-active: var(--mm-color-white);
--ifm-menu-color-background-active: var(--mm-denim-700);
--ifm-menu-color-background-hover: var(--mm-denim-700);
}
/* "Back to main menu" text color isn't covered by the vars above. */
.navbar-sidebar__back {
color: var(--ifm-menu-color-active);
}
/* === Type rhythm === */
html {
font-feature-settings: "ss01", "cv11"; /* Inter alt forms */
}
body {
font-family: var(--mm-font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Headlines target Trade Gothic Next Heavy proportions: tighter set
* than Archivo Black's default. We tune letter-spacing per size and
* apply a mild horizontal squeeze via font-stretch where the platform
* supports it (no-op on Archivo Black, which is fixed-width).
*
* h1 used to be 900-weight Archivo Black, but that read as too
* heavy/shouty for page titles at body-copy reading distance — same
* complaint that already moved h2-h4 to this sans-at-700 treatment, so
* h1 now shares it too (just at its own larger --ifm-h1-font-size). */
h1,
.markdown h2, .markdown h3, .markdown h4,
h2, h3, h4 {
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.25;
}
/* A touch of extra tracking on bold prose keeps adjacent heavy glyphs
* from visually merging — subtle enough not to loosen word shapes. */
.markdown strong,
.markdown b {
letter-spacing: 0.01em;
}
/* Color alone isn't a reliable "this is a link" signal (WCAG 1.4.1), but a
* default underline on every inline link reads as visual noise in body
* copy. Medium weight is the non-color cue instead — heavier than body
* text, without competing with `.markdown strong`'s bold weight or adding
* an underline (reserved for hover/focus). Scoped to prose so nav/footer/
* sidebar links keep their own treatments. */
.markdown a {
font-weight: 600;
}
/* Search results page ("See all results") — each hit's title is a plain h2
* outside `.markdown`, so it inherits the full --ifm-h2-font-size (1.85rem),
* as large as an in-article section header. Scoped to the wrapper class
* Docusaurus's SearchPage puts on <html> rather than the plugin's own
* (hashed) CSS-module class, so this survives dependency upgrades. */
.search-page-wrapper h2 {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.005em;
}
/* Section heading rhythm — a small marigold rule sits above each h2.
* Scoped via the `mm-section` class (auto-applied below) so it doesn't
* collide with OpenAPI plugin internal h2s (endpoint URL header,
* Request/Response panels, tab headings, etc.) which use their own
* layout and get squashed by our padding-top.
*
* The auto-apply trick: any h2 inside a doc page that ISN'T a
* descendant of an `.openapi-*` container gets the rule. */
.markdown h2:not([class*="openapi"]):not([class*="openapi"] *) {
position: relative;
margin-top: 3rem;
padding-top: 1.4rem;
}
.markdown h2:not([class*="openapi"]):not([class*="openapi"] *)::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3rem;
height: 2px;
background: var(--mm-color-marigold);
}
/* Belt-and-braces: explicitly null the rule on every OpenAPI plugin
* heading we know the plugin emits. */
.markdown .openapi__method-endpoint-path,
.markdown .openapi-tabs__heading,
.markdown .openapi-tabs__response-header,
.markdown [class*="openapi"] h2 {
position: static !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
.markdown .openapi__method-endpoint-path::before,
.markdown .openapi-tabs__heading::before,
.markdown .openapi-tabs__response-header::before,
.markdown [class*="openapi"] h2::before {
display: none !important;
}
/* === Navbar — solid denim with white text === */
.navbar {
background-color: var(--mm-color-denim);
border-bottom: 2px solid var(--mm-color-marigold);
box-shadow: 0 2px 12px -8px rgba(0, 0, 0, 0.35);
}
.navbar__inner {
max-width: 1280px;
margin: 0 auto;
padding: 0 clamp(1rem, 2.5vw, 1.75rem);
}
.navbar__brand {
margin-right: 1.5rem;
}
.navbar__logo {
height: 1.85rem;
margin-right: 0;
}
.navbar__item {
font-size: 0.9rem;
}
.navbar__link {
/* Body font (Inter) for legibility at small sizes — Archivo Black 900 is
* designed for display headlines, not 12-14px nav. Brand identity is
* preserved via uppercase + letter-spacing. */
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 0.875rem;
padding: 0 0.85rem;
position: relative;
}
.navbar__link {
color: rgba(255, 255, 255, 0.92);
}
.navbar__link:hover {
color: var(--mm-color-white);
text-decoration: none;
}
.navbar__link--active,
.navbar__link[aria-current="page"] {
color: var(--mm-color-white);
}
.navbar__link--active::after,
.navbar__link[aria-current="page"]::after {
content: '';
position: absolute;
left: 0.85rem;
right: 0.85rem;
bottom: -1.2rem;
height: 3px;
background: var(--mm-color-marigold);
}
/* GitHub link → render as outlined ghost on a denim navbar. */
.navbar__items--right .navbar__link[href*="github.com"] {
border: 1px solid rgba(255, 255, 255, 0.3);
color: var(--mm-color-white);
border-radius: 3px;
padding: 0.4rem 0.9rem;
letter-spacing: 0.06em;
}
.navbar__items--right .navbar__link[href*="github.com"]:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--mm-color-white);
}
/* Color-mode toggle on a denim navbar — white icon, subtle hover. */
.navbar .clean-btn {
color: rgba(255, 255, 255, 0.78);
}
.navbar .clean-btn:hover {
color: var(--mm-color-white);
background: rgba(255, 255, 255, 0.08);
}
/* === Sidebar === */
/* Left nav, right TOC, and the main reading column previously all shared
* the same page background, so nothing set the article apart as "the
* content". Tinting just the side chrome with --mm-bg-subtle — leaving
* the main column on --mm-bg-page/-surface — makes the reading pane read
* as the brighter, foregrounded surface. */
.theme-doc-sidebar-container {
background-color: var(--mm-bg-subtle);
border-right: 1px solid var(--mm-border-subtle) !important;
}
[data-theme='dark'] .theme-doc-sidebar-container {
border-right-color: var(--mm-denim-700) !important;
}
.menu {
padding: 1.25rem 0.75rem !important;
font-size: 0.9rem;
}
.menu__list-item-collapsible,
.menu__link {
border-radius: 3px;
}
.menu__link--sublist-caret::after {
opacity: 0.6;
}
.menu__caret {
background: transparent !important;
}
.menu__list-item--collapsed .menu__list-item-collapsible {
border-radius: 3px;
}
/* Categories at any depth — including OpenAPI tag groups in the API
* sidebar (users → Users, audit logs → Audit Logs) — render in title
* case. (`::first-letter` doesn't apply to the menu link's flex layout,
* so we use text-transform: capitalize which capitalizes the first
* letter of every word.)
*
* Font: Archivo Black's 900-weight display face reads fine for a short,
* single word/acronym at large size (e.g. an "IME" page title), but at
* this small size (0.82rem) its heavy strokes and tight kerning crowd
* multi-word labels together — use the body sans face at bold instead. */
.menu .menu__list-item-collapsible > .menu__link,
.menu > .menu__list > .menu__list-item > .menu__link {
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: 0.02em;
text-transform: capitalize;
font-size: 0.82rem;
color: var(--mm-text-secondary);
padding-top: 0.55rem;
padding-bottom: 0.4rem;
}
/* Category headers hardcode --mm-text-secondary above, so they don't
* pick up the .navbar-sidebar menu-color override — fix separately. */
.navbar-sidebar .menu .menu__list-item-collapsible > .menu__link,
.navbar-sidebar .menu > .menu__list > .menu__list-item > .menu__link {
color: var(--ifm-menu-color);
}
/* Endpoint leaves keep their natural case (e.g. "Login to Mattermost server"). */
.menu .menu__link.api-method {
font-family: var(--mm-font-sans);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
font-size: 0.85rem;
color: var(--mm-text-primary);
}
/* === Code === */
/* `:not(pre) > code`, not bare `code`: a code block's own `<code>` (inside
* `<pre>`) is also just a `code` element, and this rule's specificity
* ([data-theme='dark'] + tag) beats Infima's `pre code { background:
* transparent }` (two tags only). Left unscoped, this inline-code
* background painted over almost the entire visible area of every
* dark-mode code block. */
:not(pre) > code {
background-color: var(--mm-denim-50);
color: var(--mm-text-primary);
border: 1px solid var(--mm-border-subtle);
border-radius: 3px;
padding: 0.1em 0.35em;
font-size: 0.88em;
/* Infima's `code` selector has no line-height of its own, so an inline
* code span inherits the paragraph's 1.65 --ifm-line-height-base,
* inflating the whole line's height wherever one shows up mid-sentence.
* Multi-line fences are unaffected — `pre code` has higher specificity
* and `.theme-code-block .prism-code` sets its own line-height. */
line-height: 1.2;
}
/* Same dark-on-dark collision as blockquote/callout/code-block: denim-800
* (#0C1424) and denim-700 (#121E37) are both within ~1:1 contrast of the
* page background (#0E1525), so inline code like `ClusterEncryptionKey`
* had no visible chip around it in dark mode. --mm-bg-surface + denim-400
* match the fill/border already used for code blocks elsewhere in this
* file, for a consistent "raised surface" look across all code UI. */
[data-theme='dark'] :not(pre) > code {
background-color: var(--mm-bg-surface);
border-color: var(--mm-denim-400);
}
/* === Admonitions / callouts ===
* Dark-mode callout text/label/icon were nearly the same color as the
* background across all five admonition types (note/tip/info/warning/
* danger) — Infima's default alert coloring was never wired to our brand
* tokens. Pin explicit, contrast-checked colors instead. Left border/
* accent-bar colors (--ifm-alert-border-color) are intentionally left
* untouched — a parallel "remove yellow left border" workstream reworks
* those on the same selectors.
*
* Heading label and icon are set explicitly (not just via the shared
* --ifm-alert-foreground-color below) since the label was the most
* visibly low-contrast part, in case a theme upgrade changes the
* inheritance. */
[data-theme='dark'] .alert {
--ifm-alert-foreground-color: var(--mm-callout-text);
}
[data-theme='dark'] .alert__heading,
[data-theme='dark'] .alert__icon svg {
color: var(--mm-callout-text);
fill: var(--mm-callout-text);
}
/* denim-700 (the previous note/info background) is ~1.1:1 from the page
* — same navy hue family, no visible surface. success/warning/danger
* don't have this problem since green/amber/red are a different hue
* family from the page. Fix: denim-500 (brand primary, ~4x page
* luminance) as the fill, plus a border (separate from the reserved
* --ifm-alert-border-color/left accent bar) for a crisp edge. */
[data-theme='dark'] .alert--secondary,
[data-theme='dark'] .alert--info {
--ifm-alert-background-color: var(--mm-denim-500);
border-top: 1px solid var(--mm-denim-300);
border-right: 1px solid var(--mm-denim-300);
border-bottom: 1px solid var(--mm-denim-300);
}
[data-theme='dark'] .alert--success {
--ifm-alert-background-color: #14442c;
}
[data-theme='dark'] .alert--warning {
--ifm-alert-background-color: #4a3a10;
}
[data-theme='dark'] .alert--danger {
--ifm-alert-background-color: #4a1f1f;
}
.theme-code-block {
border: 1px solid var(--mm-border-subtle);
border-radius: 4px;
box-shadow: none !important;
}
/* --mm-denim-700 (#121E37) is only ~1.1:1 contrast from the page
* background (#0E1525) — same dark-navy hue family, no visible edge.
* denim-400 is a real jump in lightness, giving the block a border that
* actually reads as a boundary. */
[data-theme='dark'] .theme-code-block {
border-color: var(--mm-denim-400);
}
/* Light mode uses the "github" Prism theme (near-white bg) — --mm-denim-50
* keeps it in the brand palette without breaking github's token contrast.
*
* Dark mode uses "dracula" (docusaurus.config.ts), whose token colors are
* tuned against its own #282a36 background — don't hardcode a different
* background here, or token colors go muddy/low-contrast. But dracula's own
* #282a36 is only ~1.3:1 from our page bg (#0E1525), i.e. visually the same,
* so it still needs lightening. #52607A (~2.9:1) is that lighter fill;
* denim-400 below gives it a real border. Needed to go further than the
* contrast ratio alone suggests — low-luminance colors require a bigger
* jump than the formula implies to look distinct to the eye. */
.theme-code-block .prism-code {
font-size: 0.85rem;
background: var(--mm-denim-50) !important;
}
[data-theme='dark'] .theme-code-block .prism-code {
background: #52607A !important;
}
/* Lightening the dark-mode code block fill above (#52607A) leaves
* dracula's comment token — already its dimmest/de-emphasized color
* (#6272a4) — at only ~1.35:1 contrast against it, i.e. nearly invisible.
* #D3DBF2 restores ~4.6:1 (WCAG AA) while staying a muted blue-lavender,
* distinguishable from the brighter default token colors but still legible.
* !important is required: prism-react-renderer sets each token's color via
* an inline `style` attribute, which otherwise beats this class selector. */
[data-theme='dark'] .theme-code-block .prism-code .token.comment {
color: #D3DBF2 !important;
}
/* Copy button — denim border, marigold on hover */
.clean-btn[class*="copyButton"] {
border: 1px solid var(--mm-border-subtle) !important;
border-radius: 3px !important;
}
.clean-btn[class*="copyButton"]:hover {
background: var(--mm-color-marigold) !important;
color: var(--mm-color-black) !important;
border-color: var(--mm-color-marigold) !important;
}
/* === Tables === */
table {
display: table;
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
table thead {
background: var(--mm-color-denim);
}
/* Same problem as the TOC category labels above — multi-word headers
* like "Feature category" at 0.78rem crowd together in Archivo Black.
* Same fix: base sans face at bold weight. */
table thead th {
color: var(--mm-color-white);
font-family: var(--mm-font-sans);
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 0.78rem;
padding: 0.85rem 1rem;
text-align: left;
border: none;
}
table tbody td {
border-color: var(--mm-border-subtle);
padding: 0.7rem 1rem;
}
table tbody tr:nth-child(even) {
background: var(--mm-denim-50);
}
/* Same collision as blockquote/callout/code — denim-800 zebra striping
* was indistinguishable from the page background in dark mode. */
[data-theme='dark'] table tbody tr:nth-child(even) {
background: var(--mm-bg-surface);
}
/* === Blockquote ===
* Neutral denim border + subtle tint — marigold is reserved for CTAs and
* interactive accents (see tokens.css), not an unconditional bar on every
* quoted paragraph. */
blockquote {
border-left: 3px solid var(--mm-border-strong);
background: var(--mm-bg-subtle);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
border-radius: 0 4px 4px 0;
color: var(--mm-text-primary);
}
/* --mm-bg-subtle in dark mode is denim-800 (#0C1424), effectively the same
* as the page background — same collision already fixed for callouts and
* code blocks. The marigold left border already reads fine; only the
* fill needed the surface-level bump. */
[data-theme='dark'] blockquote {
background: var(--mm-bg-surface);
}
/* === Content images ===
* Screenshots/diagrams are committed at capture resolution (up to 1024px,
* some portrait) with no cap beyond Infima's 100%-of-column default, so
* they rendered at full source size. Cap both dimensions and let the
* browser scale proportionally against whichever binds first.
*
* `:not([class])` scopes this to authored MDX images, leaving
* component-rendered images (always CSS-module-classed) alone. An author
* can opt out with an explicit `width` or `height` attribute. */
.markdown img:not([class]):not([width]):not([height]) {
display: block;
width: auto;
height: auto;
max-width: min(100%, 720px);
max-height: 640px;
margin-inline: auto;
}
/* === Inline UI icons (product menu, checkmarks, keyboard glyphs, etc.) ===
* `<img className="theme-icon">` is used throughout the docs to inline small
* UI icons (e.g. the product-switcher waffle icon) next to text. Nothing
* previously styled this class, so icons rendered at their raw SVG size
* (baseline-aligned, often 24px) and — since they're flat single-color
* PNGs/SVGs baked black with no `fill` attribute an <img> could recolor —
* were nearly invisible against a dark background in dark mode. */
.theme-icon {
width: 1.1em;
height: 1.1em;
vertical-align: text-bottom;
margin: 0 0.05em;
}
[data-theme='dark'] .theme-icon {
filter: invert(1);
}
/* A few icons carry their own brand color (green checkmark, colored
* presence dots/badges) rather than being flat black glyphs — inverting
* those would shift their hue instead of fixing contrast, so exempt them. */
[data-theme='dark'] .theme-icon[src*="checkmark.svg"],
[data-theme='dark'] .theme-icon[src*="Ack-Button"],
[data-theme='dark'] .theme-icon[src$=".png"] {
filter: none;
}
.footer {
background-color: var(--ifm-footer-background-color);
background-image:
linear-gradient(0deg, rgba(12, 20, 36, 0.55), rgba(12, 20, 36, 0.55)),
url('/img/patterns/hex-camo-denim.svg');
background-size: cover, 108px 125px;
background-repeat: no-repeat, repeat;
color: var(--ifm-footer-color);
padding: 3.5rem 1rem 2.5rem;
}
/* Footer column titles ("Docs", "Community", "More") stay on the heading
* face: short, single words are the case Archivo Black handles fine —
* it's small multi-word labels (TOC categories, table headers above)
* that crowd together at this weight. */
.footer__title {
color: var(--ifm-footer-title-color);
font-family: var(--mm-font-heading);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.78rem;
margin-bottom: 1rem;
}
.footer__copyright {
margin-top: 2rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255,255,255,0.18);
font-size: 0.82rem;
color: rgba(255,255,255,0.7);
}
/* === Doc page chrome === */
/* Docusaurus's default `.theme-doc-markdown` is fluid; we don't constrain
* it. The OpenAPI plugin's 2-column layout (request panel on the right)
* needs the full width of the article column. */
.theme-doc-markdown {
font-size: 1rem;
}
.theme-doc-markdown h1:first-child {
margin-top: 0.5rem;
font-size: 2.4rem;
letter-spacing: -0.025em;
}
/* Widen the docs main container so the sidebar isn't pushed to the edge
* with a huge gap before the content. Default Infima container is too
* conservative for API pages with right panels. */
.docMainContainer_TBSr,
[class*='docMainContainer'] {
max-width: none !important;
/* Sits in an outer flex row alongside the left sidebar; without
* min-width: 0 its default `min-width: auto` lets wide content (long
* code block lines, etc.) force it — and the whole row — wider than
* the viewport instead of scrolling within its own content. */
min-width: 0;
}
.docMainContainer_TBSr > .container,
[class*='docMainContainer'] > .container {
max-width: 1600px;
padding-left: clamp(1rem, 2vw, 2rem);
padding-right: clamp(1rem, 2vw, 2rem);
}
/* theme-classic's DocItem/Layout only caps the article column at 75% via
* its own `docItemCol` class when a TOC renders alongside it — pages with
* `hide_table_of_contents: true` (e.g. the homepage) get a lone full-width
* `.col` instead, so prose reads much wider than on every other doc page.
* Recreate the same 75% cap here, keyed off the `<article>` that's always
* a descendant of the doc content column, regardless of TOC presence. */
@media (min-width: 997px) {
.docMainContainer_TBSr .col:has(> div > article),
[class*='docMainContainer'] .col:has(> div > article) {
max-width: 75%;
}
}
/* Without max-width, the doc content flex column no longer has an upper
* bound to shrink against, so a wide code block (long lines, e.g. inline
* YAML comments) inflates its own intrinsic min-content size and drags the
* whole row — including the TOC column — wider than the viewport, instead
* of just scrolling horizontally within the code block itself. `min-width:
* 0` restores the standard flexbox shrink behavior so `overflow-x: auto` on
* code blocks can do its job. */
.docMainContainer_TBSr > .container > .row > [class*='col'],
[class*='docMainContainer'] > .container > .row > [class*='col'] {
min-width: 0;
}
/* TOC right column (per-page) — same side-panel treatment as the left
* sidebar, see comment above `.theme-doc-sidebar-container`. */
.table-of-contents {
background-color: var(--mm-bg-subtle);
border-left: 1px solid var(--mm-border-subtle);
padding: 0.4rem 0.75rem 0.4rem 1rem;
font-size: 0.82rem;
}
/* --mm-color-denim is a fixed brand navy, not a semantic/theme-aware
* token — it never adapts for dark mode. Against the dark page background
* that's only ~1.45:1 contrast, i.e. the active TOC item nearly vanishes.
* --ifm-color-primary is already the theme-aware equivalent (denim in
* light mode, --mm-color-sky in dark, ~12:1 against the dark page). */
.table-of-contents__link--active,
.table-of-contents__link:hover {
color: var(--ifm-color-primary);
font-weight: 600;
}
/* === Marigold CTA pill (used in MDX content) === */
.mm-cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--mm-color-marigold);
color: var(--mm-color-black);
font-family: var(--mm-font-heading);
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 3px;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.mm-cta:hover {
transform: translateY(-1px);
box-shadow: 0 8px 16px -8px rgba(0, 0, 0, 0.25);
color: var(--mm-color-black);
text-decoration: none;
}
/* Tables: keep native table layout so header and body columns stay aligned.
* Wide tables (e.g. upgrade notes) use a dedicated wrapper class instead. */
.markdown table {
display: table;
width: 100%;
table-layout: auto;
border-collapse: collapse;
}
.upgrade-notes-table-wrapper {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.markdown table.upgrade-notes-table {
display: table;
width: 100%;
min-width: 0;
table-layout: fixed;
}
.markdown table.upgrade-notes-table thead {
display: table-header-group;
}
.markdown table.upgrade-notes-table tbody {
display: table-row-group;
}
.markdown table.upgrade-notes-table thead th {
text-transform: none;
letter-spacing: normal;
font-family: var(--mm-font-sans);
font-weight: 700;
font-size: 0.85rem;
line-height: 1.35;
padding: 0.5rem 0.65rem;
}
.markdown table.upgrade-notes-table th,
.markdown table.upgrade-notes-table td {
vertical-align: top;
padding: 0.5rem 0.65rem;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
.markdown table.upgrade-notes-table th:first-child,
.markdown table.upgrade-notes-table td:first-child {
width: 25%;
}
.markdown table.upgrade-notes-table th:last-child,
.markdown table.upgrade-notes-table td:last-child {
width: 75%;
max-width: 0;
}
.markdown table.upgrade-notes-table td:first-child:not(:last-child) {
white-space: nowrap;
}
.markdown table.upgrade-notes-table tbody tr:nth-child(even) {
background: transparent;
}
.markdown table.upgrade-notes-table tbody td {
border-bottom: 1px solid var(--mm-border-subtle);
}
.markdown table.upgrade-notes-table pre,
.markdown table.upgrade-notes-table .sourceCode {
max-width: 100%;
margin: 0.5rem 0 0;
overflow-x: auto;
}
.markdown table.upgrade-notes-table pre,
.markdown table.upgrade-notes-table pre code,
.markdown table.upgrade-notes-table code {
white-space: pre-wrap;
word-break: break-word;
}
/* === Mobile / responsive === */
@media (max-width: 996px) {
/* Tighter heading scale on small screens. */
.markdown h1 { font-size: 1.85rem; line-height: 1.15; }
.markdown h2 { font-size: 1.4rem; }
.markdown h3 { font-size: 1.15rem; }
}
@media (max-width: 768px) {
/* Navbar: tighter logo + items. */
.navbar__inner { padding: 0 0.75rem; }
.navbar__logo { height: 1.5rem; }
.navbar__link { padding: 0 0.5rem; font-size: 0.72rem; }
/* OpenAPI right-panel (Authorization, Request, Response) — stacks
* below the content on phones instead of squeezing in a 2nd column. */
.openapi-right-panel__container {
width: 100% !important;
margin-top: 1.5rem;
}
.openapi-left-panel__container {
width: 100% !important;
}
/* Footer: looser stacking, less padding. */
.footer { padding: 2.5rem 1rem 1.75rem; }
/* Code blocks: smaller font for phones so lines don't break too eagerly. */
.theme-code-block .prism-code { font-size: 0.78rem; }
}
@media (max-width: 480px) {
/* Hide the marigold accent on the navbar at very narrow widths
* where the gradient distracts from the compact nav. */
.navbar::after { display: none; }
/* Hero: even tighter on small phones. */
.markdown h1 { font-size: 1.6rem; }
}
/* === Section divider used between Hero and content === */
.mm-section-rule {
height: 0;
border: none;
border-top: 2px solid var(--mm-color-marigold);
width: 3rem;
margin: 2.5rem 0 1.5rem;
}
/* === Release policy Gantt chart (scoped — no global mermaid overrides) === */
.release-policy-gantt {
margin: 1.25rem 0;
/* On narrow viewports, scroll horizontally instead of shrinking the
* chart below a legible size. */
overflow-x: auto;
}
.release-policy-gantt .docusaurus-mermaid-container {
margin: 0;
background: var(--mm-color-white);
border: 2px solid #0745a1;
border-radius: 4px;
padding: 0.35rem 0.5rem 0.25rem;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container {
background: #1e1e1e;
border-color: #555555;
}
/* Hide mermaid's semi-transparent gantt row backgrounds (pink tint in light mode) */
.release-policy-gantt .docusaurus-mermaid-container svg rect.section,
.release-policy-gantt .docusaurus-mermaid-container svg rect.grid-panel {
opacity: 0 !important;
}
.release-policy-gantt .docusaurus-mermaid-container svg {
background: transparent;
}
.release-policy-gantt .docusaurus-mermaid-container svg line {
stroke: #0745a1;
}
.release-policy-gantt .docusaurus-mermaid-container svg rect.task {
fill: #0984e3 !important;
stroke: #2d3436 !important;
stroke-width: 2px !important;
}
.release-policy-gantt .docusaurus-mermaid-container svg rect[class*='crit'] {
fill: #d63031 !important;
stroke: #2d3436 !important;
stroke-width: 2px !important;
}
.release-policy-gantt .docusaurus-mermaid-container .taskText {
fill: #ffffff !important;
font-weight: 700 !important;
font-size: 22px !important;
}
.release-policy-gantt .docusaurus-mermaid-container .taskTextOutsideLeft,
.release-policy-gantt .docusaurus-mermaid-container .taskTextOutsideRight {
fill: #2d3436 !important;
font-weight: 700 !important;
font-size: 22px !important;
}
.release-policy-gantt .docusaurus-mermaid-container .sectionTitle,
.release-policy-gantt .docusaurus-mermaid-container text[class*='tick'],
.release-policy-gantt .docusaurus-mermaid-container text[class*='label'] {
fill: #2d3436 !important;
font-weight: 600 !important;
font-size: 22px !important;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container svg rect.task {
stroke: #ffffff !important;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container svg rect[class*='crit'] {
stroke: #ffffff !important;
}
[data-theme='dark'] .release-policy-gantt .docusaurus-mermaid-container text {
fill: #ffffff !important;
}
.release-policy-gantt .docusaurus-mermaid-container > svg {
display: block;
width: 100%;
height: auto;
}
/* (Marigold strip is now the navbar's own border-bottom.) */
/* === Pages with a Hero hide breadcrumbs + prev/next pagination ===
* Hero is already a strong page-opener; the breadcrumbs ("Home >
* Product Overview > Overview") read as redundant clutter. */
.theme-doc-markdown:has(.mm-hero) ~ nav.pagination-nav,
nav.pagination-nav:has(+ .theme-doc-markdown .mm-hero) {
display: none;
}
article:has(.mm-hero) > .theme-doc-breadcrumbs,
article:has(.mm-hero) > nav[aria-label*="readcrumb"],
.theme-doc-markdown:has(.mm-hero) > div:first-child:has(nav[aria-label*="readcrumb"]),
nav[aria-label*="readcrumb"]:has(~ .theme-doc-markdown .mm-hero) {
display: none;
}
/* Pages with a Hero: drop the article's top-padding so the hero band
* sits flush under the navbar. Hero and body content both fill the
* article column naturally (no width override needed). */
.theme-doc-markdown:has(.mm-hero) {
padding-top: 0;
}

View File

@@ -0,0 +1,99 @@
/*
* Mattermost brand design tokens.
* Source: Mattermost-Brand-Guidelines-2025.
* See PLAN.md §3.2 for the full token spec and rationale.
*
* Accessibility constraint (per brand guidelines): use only black or white
* text on the four primary colors — these combinations are pre-approved
* for contrast.
*/
:root {
/* === Primary palette === */
--mm-color-denim: #1E325C; /* primary brand, dominant */
--mm-color-marigold: #FFBC1F; /* accent — CTAs only, used sparingly */
--mm-color-white: #FFFFFF;
--mm-color-black: #1B1D22;
/* === Secondary palette (sparingly, never overpower) === */
--mm-color-sand: #CCC4AE;
--mm-color-bark: #604F3D;
--mm-color-sky: #C5D2EC;
/* === Denim shade ramp (derived for surfaces, borders, focus rings) === */
--mm-denim-50: #EEF1F8;
--mm-denim-100: #D6DDEC;
--mm-denim-200: #ADBBD8;
--mm-denim-300: #8499C5;
--mm-denim-400: #5B77B1;
--mm-denim-500: #1E325C; /* = denim, brand primary */
--mm-denim-600: #182849;
--mm-denim-700: #121E37;
--mm-denim-800: #0C1424;
--mm-denim-900: #060A12;
/* === Marigold shade ramp === */
--mm-marigold-100: #FFEFC8;
--mm-marigold-300: #FFD672;
--mm-marigold-500: #FFBC1F; /* = marigold, brand accent */
--mm-marigold-700: #B27F00;
/* === Typography ===
* Trade Gothic Next is licensed via Adobe Fonts and not embedded yet
* (open item — see PLAN.md §12). Until licensing is confirmed, body uses
* Inter and headlines use Archivo Black, which is the closest free match
* for Trade Gothic Heavy's geometric, ultra-bold display feel. */
--mm-font-sans: "Inter", "Trade Gothic Next", -apple-system, BlinkMacSystemFont,
"Segoe UI", "Helvetica Neue", system-ui, sans-serif;
--mm-font-heading: "Archivo Black", "Trade Gothic Next Heavy",
"Helvetica Neue", "Impact", system-ui, sans-serif;
--mm-font-mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas",
"Liberation Mono", monospace;
/* === Semantic surfaces (light) === */
--mm-bg-page: var(--mm-color-white);
--mm-bg-surface: var(--mm-color-white);
--mm-bg-inverse: var(--mm-color-denim);
--mm-bg-subtle: var(--mm-denim-50);
--mm-text-primary: var(--mm-color-black);
--mm-text-secondary: var(--mm-denim-600);
--mm-text-inverse: var(--mm-color-white);
--mm-text-on-accent: var(--mm-color-black); /* black text on marigold */
--mm-border-subtle: var(--mm-denim-100);
--mm-border-strong: var(--mm-denim-300);
/* Not --mm-color-denim (#1E325C): it clears WCAG AA against white
* (12.6:1) but sits only ~1.5:1 from --mm-text-primary, so links and
* body copy read as the same shade of "dark". This blue keeps 6.5:1+
* against the page while staying clearly apart from body text. */
--mm-link: #3D5D96;
--mm-link-hover: var(--mm-denim-700);
--mm-accent: var(--mm-color-marigold);
--mm-focus-ring: var(--mm-marigold-500);
}
/* === Dark mode (data-theme=dark applied by Docusaurus) === */
[data-theme='dark'] {
--mm-bg-page: #0E1525;
--mm-bg-surface: #14213D;
--mm-bg-inverse: var(--mm-color-denim);
--mm-bg-subtle: var(--mm-denim-800);
--mm-text-primary: var(--mm-color-white);
--mm-text-secondary: var(--mm-denim-200);
--mm-text-inverse: var(--mm-color-black);
--mm-text-on-accent: var(--mm-color-black);
--mm-border-subtle: var(--mm-denim-700);
--mm-border-strong: var(--mm-denim-500);
/* Not --mm-color-sky (#C5D2EC): passes AA against the page (~12:1),
* but sits only ~1.5:1 from white body text, so links disappear into
* surrounding copy. This blue keeps ~6.4:1 against the page while
* reading clearly darker than white body text. */
--mm-link: #5B9BF0;
--mm-link-hover: var(--mm-color-white);
--mm-accent: var(--mm-color-marigold);
/* Infima's default secondary-alert text/background pairing isn't wired
* to our palette — pin explicit, contrast-checked brand colors instead.
* Left border/accent-bar colors are deliberately left alone — a
* parallel "remove yellow left border" workstream touches those. */
--mm-callout-text: var(--mm-denim-50);
--mm-callout-note-bg: var(--mm-denim-700);
}

View File

@@ -0,0 +1,61 @@
// Globally-available MDX components. Authors can write <Note>...</Note>
// in any .mdx file without an import.
import MDXComponents from '@theme-original/MDXComponents';
import {Note, Tip, Important, Attention, Warning, Security} from '@site/src/components/Callout';
import PlanBadge from '@site/src/components/PlanBadge';
import PlanAvailability from '@site/src/components/PlanAvailability';
import EditionAvailability from '@site/src/components/EditionAvailability';
import DeploymentAvailability from '@site/src/components/DeploymentAvailability';
import AttestationStatus from '@site/src/components/AttestationStatus';
import DeploymentOnly from '@site/src/components/DeploymentOnly';
import IMEDiagram from '@site/src/components/IMEDiagram';
import DeploymentArchitectureBuilder from '@site/src/components/DeploymentArchitectureBuilder';
import CompassIcon from '@site/src/components/CompassIcon';
import Hero from '@site/src/components/Hero';
import Eyebrow from '@site/src/components/Eyebrow';
import StatStrip from '@site/src/components/StatStrip';
import MethodLegend from '@site/src/components/MethodLegend';
import CardGrid from '@site/src/components/CardGrid';
import UpgradeNotesFilter from '@site/src/components/UpgradeNotesFilter';
import SecurityUpdates from '@site/src/components/SecurityUpdates';
import PluginGoDocs from '@site/src/components/PluginGoDocs';
import PluginGoExample from '@site/src/components/PluginGoExample';
import PluginJsDocs from '@site/src/components/PluginJsDocs';
import PluginManifestDocs from '@site/src/components/PluginManifestDocs';
// Globally available so migrated developer docs (Hugo `tabs` shortcode)
// can use them without imports.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
export default {
...MDXComponents,
Note,
Tip,
Important,
Attention,
Warning,
Security,
PlanBadge,
PlanAvailability,
EditionAvailability,
DeploymentAvailability,
AttestationStatus,
DeploymentOnly,
IMEDiagram,
DeploymentArchitectureBuilder,
CompassIcon,
Hero,
Eyebrow,
StatStrip,
MethodLegend,
CardGrid,
UpgradeNotesFilter,
SecurityUpdates,
PluginGoDocs,
PluginGoExample,
PluginJsDocs,
PluginManifestDocs,
Tabs,
TabItem,
};

View File

@@ -0,0 +1,623 @@
/* eslint-disable jsx-a11y/no-autofocus */
// Ejected from @docusaurus/theme-search-algolia@3.10.1 (`SearchPage`).
//
// Upstream never forwards `themeConfig.algolia.searchParameters` to this
// component — it destructures only {appId, apiKey, indexName,
// contextualSearch} and builds the query helper from a hardcoded parameter
// set. The DocSearch modal does honour `searchParameters`, so the two search
// surfaces returned differently ordered results for the same query. There is
// no config option to fix this, and Algolia Rules aren't available on our
// plan, so swizzling is the only route.
//
// The ONLY delta from upstream is the three edits marked "Mattermost delta"
// below: `searchParameters` added to the `useAlgoliaThemeConfig()`
// destructure, `...searchParameters` spread last into the
// `algoliaSearchHelper` options, and the `PlainSearchParameters` type import
// the spread's cast needs. Everything else is verbatim upstream, including
// the swizzle-stripped MIT license header. Filter values and scores are
// deliberately not duplicated here — docusaurus.config.ts stays the single
// source of truth so the modal and this page cannot drift apart again.
//
// On a Docusaurus upgrade, re-eject from the new version and re-apply those
// three edits rather than diffing this whole file:
// npm run swizzle @docusaurus/theme-search-algolia SearchPage -- \
// --eject --danger --typescript
// (the theme is only registered when ALGOLIA_APP_ID and
// ALGOLIA_SEARCH_API_KEY are set, so export placeholders first). Drop this
// file entirely once upstream passes `searchParameters` through.
import React, {
type ReactNode,
useEffect,
useReducer,
useRef,
useState,
} from 'react';
import clsx from 'clsx';
import algoliaSearchHelper, {
type PlainSearchParameters,
} from 'algoliasearch-helper';
import {liteClient} from 'algoliasearch/lite';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Head from '@docusaurus/Head';
import Link from '@docusaurus/Link';
import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
import {
HtmlClassNameProvider,
PageMetadata,
useEvent,
usePluralForm,
useSearchQueryString,
} from '@docusaurus/theme-common';
import Translate, {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {
useAlgoliaThemeConfig,
useSearchResultUrlProcessor,
} from '@docusaurus/theme-search-algolia/client';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import styles from './styles.module.css';
// Very simple pluralization: probably good enough for now
function useDocumentsFoundPlural() {
const {selectMessage} = usePluralForm();
return (count: number) =>
selectMessage(
count,
translate(
{
id: 'theme.SearchPage.documentsFound.plurals',
description:
'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
message: 'One document found|{count} documents found',
},
{count},
),
);
}
function useDocsSearchVersionsHelpers() {
const allDocsData = useAllDocsData();
// State of the version select menus / algolia facet filters
// docsPluginId -> versionName map
const [searchVersions, setSearchVersions] = useState<{
[pluginId: string]: string;
}>(() =>
Object.entries(allDocsData).reduce(
(acc, [pluginId, pluginData]) => ({
...acc,
[pluginId]: pluginData.versions[0]!.name,
}),
{},
),
);
// Set the value of a single select menu
const setSearchVersion = (pluginId: string, searchVersion: string) =>
setSearchVersions((s) => ({...s, [pluginId]: searchVersion}));
const versioningEnabled = Object.values(allDocsData).some(
(docsData) => docsData.versions.length > 1,
);
return {
allDocsData,
versioningEnabled,
searchVersions,
setSearchVersion,
};
}
// We want to display one select per versioned docs plugin instance
function SearchVersionSelectList({
docsSearchVersionsHelpers,
}: {
docsSearchVersionsHelpers: ReturnType<typeof useDocsSearchVersionsHelpers>;
}) {
const versionedPluginEntries = Object.entries(
docsSearchVersionsHelpers.allDocsData,
)
// Do not show a version select for unversioned docs plugin instances
.filter(([, docsData]) => docsData.versions.length > 1);
return (
<div
className={clsx(
'col',
'col--3',
'padding-left--none',
styles.searchVersionColumn,
)}>
{versionedPluginEntries.map(([pluginId, docsData]) => {
const labelPrefix =
versionedPluginEntries.length > 1 ? `${pluginId}: ` : '';
return (
<select
key={pluginId}
onChange={(e) =>
docsSearchVersionsHelpers.setSearchVersion(
pluginId,
e.target.value,
)
}
defaultValue={docsSearchVersionsHelpers.searchVersions[pluginId]}
className={styles.searchVersionInput}>
{docsData.versions.map((version, i) => (
<option
key={i}
label={`${labelPrefix}${version.label}`}
value={version.name}
/>
))}
</select>
);
})}
</div>
);
}
function AlgoliaLogo(): ReactNode {
return (
<svg
width="80"
height="24"
aria-label="Algolia"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2196.2 500"
style={{maxWidth: '150px'}}>
<defs>
{/* eslint-disable-next-line @docusaurus/no-untranslated-text */}
<style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fill-rule:evenodd}`}</style>
</defs>
<path
className="cls-2"
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<rect
className="cls-1"
x="1845.88"
y="104.73"
width="62.58"
height="277.9"
rx="5.9"
ry="5.9"
/>
<path
className="cls-2"
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
/>
<path
className="cls-2"
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<path
className="cls-2"
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
/>
<path
className="cls-2"
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
/>
<path
className="cls-1"
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
/>
</svg>
);
}
type ResultDispatcherState = {
items: {
title: string;
url: string;
summary: string;
breadcrumbs: string[];
}[];
query: string | null;
totalResults: number | null;
totalPages: number | null;
lastPage: number | null;
hasMore: boolean | null;
loading: boolean | null;
};
type ResultDispatcher =
| {type: 'reset'; value?: undefined}
| {type: 'loading'; value?: undefined}
| {type: 'update'; value: ResultDispatcherState}
| {type: 'advance'; value?: undefined};
function getSearchPageTitle(searchQuery: string | undefined): string {
return searchQuery
? translate(
{
id: 'theme.SearchPage.existingResultsTitle',
message: 'Search results for "{query}"',
description: 'The search page title for non-empty query',
},
{
query: searchQuery,
},
)
: translate({
id: 'theme.SearchPage.emptyResultsTitle',
message: 'Search the documentation',
description: 'The search page title for empty query',
});
}
function SearchPageContent(): ReactNode {
const {
i18n: {currentLocale},
} = useDocusaurusContext();
const {
// Mattermost delta: `searchParameters` added (upstream omits it).
algolia: {appId, apiKey, indexName, contextualSearch, searchParameters},
} = useAlgoliaThemeConfig();
const processSearchResultUrl = useSearchResultUrlProcessor();
const documentsFoundPlural = useDocumentsFoundPlural();
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
const [searchQuery, setSearchQuery] = useSearchQueryString();
const pageTitle = getSearchPageTitle(searchQuery);
const initialSearchResultState: ResultDispatcherState = {
items: [],
query: null,
totalResults: null,
totalPages: null,
lastPage: null,
hasMore: null,
loading: null,
};
const [searchResultState, searchResultStateDispatcher] = useReducer(
(prevState: ResultDispatcherState, data: ResultDispatcher) => {
switch (data.type) {
case 'reset': {
return initialSearchResultState;
}
case 'loading': {
return {...prevState, loading: true};
}
case 'update': {
if (searchQuery !== data.value.query) {
return prevState;
}
return {
...data.value,
items:
data.value.lastPage === 0
? data.value.items
: prevState.items.concat(data.value.items),
};
}
case 'advance': {
const hasMore = prevState.totalPages! > prevState.lastPage! + 1;
return {
...prevState,
lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage,
hasMore,
};
}
default:
return prevState;
}
},
initialSearchResultState,
);
// respect settings from the theme config for facets
const disjunctiveFacets = contextualSearch
? ['language', 'docusaurus_tag']
: [];
const algoliaClient = liteClient(appId, apiKey);
const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: why errors happens after upgrading to TS 5.5 ?
hitsPerPage: 15,
advancedSyntax: true,
disjunctiveFacets,
// Mattermost delta: spread last so theme config wins on conflicts. The
// cast bridges DocSearch's `SearchOptions` (where `optionalFilters` also
// accepts a bare string) and the helper's narrower array-only type.
...(searchParameters as PlainSearchParameters),
});
algoliaHelper.on(
'result',
({results: {query, hits, page, nbHits, nbPages}}) => {
if (query === '' || !Array.isArray(hits)) {
searchResultStateDispatcher({type: 'reset'});
return;
}
const sanitizeValue = (value: string) =>
value.replace(
/algolia-docsearch-suggestion--highlight/g,
'search-result-match',
);
const items = hits.map(
({
url,
_highlightResult: {hierarchy},
_snippetResult: snippet = {},
}: {
url: string;
_highlightResult: {hierarchy: {[key: string]: {value: string}}};
_snippetResult: {content?: {value: string}};
}) => {
const titles = Object.keys(hierarchy).map((key) =>
sanitizeValue(hierarchy[key]!.value),
);
return {
title: titles.pop()!,
url: processSearchResultUrl(url),
summary: snippet.content
? `${sanitizeValue(snippet.content.value)}...`
: '',
breadcrumbs: titles,
};
},
);
searchResultStateDispatcher({
type: 'update',
value: {
items,
query,
totalResults: nbHits,
totalPages: nbPages,
lastPage: page,
hasMore: nbPages > page + 1,
loading: false,
},
});
},
);
const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(null);
const prevY = useRef(0);
const observer = useRef(
ExecutionEnvironment.canUseIntersectionObserver &&
new IntersectionObserver(
// TODO need to fix this React Compiler lint error
// eslint-disable-next-line react-compiler/react-compiler
(entries) => {
const {
isIntersecting,
boundingClientRect: {y: currentY},
} = entries[0]!;
if (isIntersecting && prevY.current > currentY) {
searchResultStateDispatcher({type: 'advance'});
}
prevY.current = currentY;
},
{threshold: 1},
),
);
const makeSearch = useEvent((page: number = 0) => {
if (contextualSearch) {
algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale);
Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(
([pluginId, searchVersion]) => {
algoliaHelper.addDisjunctiveFacetRefinement(
'docusaurus_tag',
`docs-${pluginId}-${searchVersion}`,
);
},
);
}
algoliaHelper.setQuery(searchQuery).setPage(page).search();
});
useEffect(() => {
if (!loaderRef) {
return undefined;
}
const currentObserver = observer.current;
if (currentObserver) {
currentObserver.observe(loaderRef);
return () => currentObserver.unobserve(loaderRef);
}
return () => true;
}, [loaderRef]);
useEffect(() => {
searchResultStateDispatcher({type: 'reset'});
if (searchQuery) {
searchResultStateDispatcher({type: 'loading'});
setTimeout(() => {
makeSearch();
}, 300);
}
}, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]);
useEffect(() => {
if (!searchResultState.lastPage || searchResultState.lastPage === 0) {
return;
}
makeSearch(searchResultState.lastPage);
}, [makeSearch, searchResultState.lastPage]);
return (
<Layout>
<PageMetadata title={pageTitle} />
<Head>
{/*
We should not index search pages
See https://github.com/facebook/docusaurus/pull/3233
*/}
<meta property="robots" content="noindex, follow" />
</Head>
<div className="container margin-vert--lg">
<Heading as="h1">{pageTitle}</Heading>
<form className="row" onSubmit={(e) => e.preventDefault()}>
<div
className={clsx('col', styles.searchQueryColumn, {
'col--9': docsSearchVersionsHelpers.versioningEnabled,
'col--12': !docsSearchVersionsHelpers.versioningEnabled,
})}>
<input
type="search"
name="q"
className={styles.searchQueryInput}
placeholder={translate({
id: 'theme.SearchPage.inputPlaceholder',
message: 'Type your search here',
description: 'The placeholder for search page input',
})}
aria-label={translate({
id: 'theme.SearchPage.inputLabel',
message: 'Search',
description: 'The ARIA label for search page input',
})}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
autoComplete="off"
autoFocus
/>
</div>
{contextualSearch && docsSearchVersionsHelpers.versioningEnabled && (
<SearchVersionSelectList
docsSearchVersionsHelpers={docsSearchVersionsHelpers}
/>
)}
</form>
<div className="row">
<div className={clsx('col', 'col--8', styles.searchResultsColumn)}>
{!!searchResultState.totalResults &&
documentsFoundPlural(searchResultState.totalResults)}
</div>
<div className={clsx('col', 'col--4', styles.searchLogoColumn)}>
<span>
{translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by',
description:
'The text explain that the search powered by Algolia',
})}
</span>
<Link
to="https://www.algolia.com/"
aria-label={translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by Algolia',
description: 'The description label for Algolia mention',
})}>
<AlgoliaLogo />
</Link>
</div>
</div>
{searchResultState.items.length > 0 ? (
<main>
{searchResultState.items.map(
({title, url, summary, breadcrumbs}, i) => (
<article key={i} className={styles.searchResultItem}>
<Heading as="h2" className={styles.searchResultItemHeading}>
<Link to={url} dangerouslySetInnerHTML={{__html: title}} />
</Heading>
{breadcrumbs.length > 0 && (
<nav aria-label="breadcrumbs">
<ul
className={clsx(
'breadcrumbs',
styles.searchResultItemPath,
)}>
{breadcrumbs.map((html, index) => (
<li
key={index}
className="breadcrumbs__item"
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: html}}
/>
))}
</ul>
</nav>
)}
{summary && (
<p
className={styles.searchResultItemSummary}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: summary}}
/>
)}
</article>
),
)}
</main>
) : (
[
searchQuery && !searchResultState.loading && (
<p key="no-results">
<Translate
id="theme.SearchPage.noResultsText"
description="The paragraph for empty search result">
No results were found
</Translate>
</p>
),
!!searchResultState.loading && (
<div key="spinner" className={styles.loadingSpinner} />
),
]
)}
{searchResultState.hasMore && (
<div className={styles.loader} ref={setLoaderRef}>
<Translate
id="theme.SearchPage.fetchingNewResults"
description="The paragraph for fetching new search results">
Fetching new results...
</Translate>
</div>
)}
</div>
</Layout>
);
}
export default function SearchPage(): ReactNode {
return (
<HtmlClassNameProvider className="search-page-wrapper">
<SearchPageContent />
</HtmlClassNameProvider>
);
}

View File

@@ -0,0 +1,120 @@
.searchQueryInput,
.searchVersionInput {
border-radius: var(--ifm-global-radius);
border: 2px solid var(--ifm-toc-border-color);
font: var(--ifm-font-size-base) var(--ifm-font-family-base);
padding: 0.8rem;
width: 100%;
background: var(--docsearch-searchbox-focus-background);
color: var(--docsearch-text-color);
margin-bottom: 0.5rem;
transition: border var(--ifm-transition-fast) ease;
}
.searchQueryInput:focus,
.searchVersionInput:focus {
border-color: var(--docsearch-primary-color);
outline: none;
}
.searchQueryInput::placeholder {
color: var(--docsearch-muted-color);
}
.searchResultsColumn {
font-size: 0.9rem;
font-weight: bold;
}
.searchLogoColumn {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.searchLogoColumn a {
display: flex;
}
.searchLogoColumn span {
color: var(--docsearch-muted-color);
font-weight: normal;
}
.searchResultItem {
padding: 1rem 0;
border-bottom: 1px solid var(--ifm-toc-border-color);
}
.searchResultItemHeading {
font-weight: 400;
margin-bottom: 0;
}
.searchResultItemPath {
font-size: 0.8rem;
color: var(--ifm-color-content-secondary);
--ifm-breadcrumb-separator-size-multiplier: 1;
}
.searchResultItemSummary {
margin: 0.5rem 0 0;
font-style: italic;
}
@media only screen and (max-width: 996px) {
.searchQueryColumn {
max-width: 60% !important;
}
.searchVersionColumn {
max-width: 40% !important;
}
.searchResultsColumn {
max-width: 60% !important;
}
.searchLogoColumn {
max-width: 40% !important;
padding-left: 0 !important;
}
}
@media screen and (max-width: 576px) {
.searchQueryColumn {
max-width: 100% !important;
}
.searchVersionColumn {
max-width: 100% !important;
padding-left: var(--ifm-spacing-horizontal) !important;
}
}
.loadingSpinner {
width: 3rem;
height: 3rem;
border: 0.4em solid #eee;
border-top-color: var(--ifm-color-primary);
border-radius: 50%;
animation: loading-spin 1s linear infinite;
margin: 0 auto;
}
@keyframes loading-spin {
100% {
transform: rotate(360deg);
}
}
.loader {
margin-top: 2rem;
}
:global(.search-result-match) {
color: var(--docsearch-hit-color);
background: rgb(255 215 142 / 25%);
padding: 0.09em 0;
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,718 @@
{
"__inputs": [
{
"name": "DS_LOKI",
"label": "Loki",
"description": "Loki data source",
"type": "datasource",
"pluginId": "loki",
"pluginName": "Loki"
}
],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "10.0.0"
},
{
"type": "datasource",
"id": "loki",
"name": "Loki",
"version": "1.0.0"
}
],
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 10,
"title": "Overview",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "Count",
"drawStyle": "bars",
"fillOpacity": 80,
"gradientMode": "none",
"lineWidth": 1,
"pointSize": 5,
"stacking": {
"group": "A",
"mode": "normal"
}
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "error"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "red",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "warn"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "yellow",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "info"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "green",
"mode": "fixed"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "debug"
},
"properties": [
{
"id": "color",
"value": {
"fixedColor": "blue",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 1
},
"id": 1,
"options": {
"legend": {
"calcs": [
"sum"
],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"title": "Log Volume by Level",
"type": "timeseries",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "sum(count_over_time({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | detected_level=~\"$log_level\" [$__auto])) by (detected_level)",
"legendFormat": "{{detected_level}}",
"refId": "A"
}
]
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 10
},
{
"color": "red",
"value": 50
}
]
},
"mappings": []
}
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 9
},
"id": 2,
"options": {
"colorMode": "background",
"graphMode": "area",
"reduceOptions": {
"calcs": [
"sum"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"title": "Total Errors (selected range)",
"type": "stat",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "sum(count_over_time({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | detected_level=\"error\" [$__auto]))",
"refId": "A"
}
]
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 50
},
{
"color": "orange",
"value": 200
}
]
}
}
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 9
},
"id": 3,
"options": {
"colorMode": "background",
"graphMode": "area",
"reduceOptions": {
"calcs": [
"sum"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"title": "Total Warnings (selected range)",
"type": "stat",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "sum(count_over_time({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | detected_level=\"warn\" [$__auto]))",
"refId": "A"
}
]
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
}
}
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 9
},
"id": 4,
"options": {
"colorMode": "background",
"graphMode": "area",
"reduceOptions": {
"calcs": [
"sum"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"title": "Total Log Lines (selected range)",
"type": "stat",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "sum(count_over_time({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} [$__auto]))",
"refId": "A"
}
]
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
}
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 9
},
"id": 5,
"options": {
"colorMode": "background",
"graphMode": "area",
"reduceOptions": {
"calcs": [
"sum"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"title": "HTTP 4xx/5xx Responses",
"type": "stat",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "sum(count_over_time({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | json | status_code >= 400 [$__auto]))",
"refId": "A"
}
]
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 13
},
"id": 11,
"title": "Error Analysis",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "red",
"mode": "fixed"
},
"custom": {
"axisBorderShow": false,
"axisLabel": "errors / sec",
"drawStyle": "line",
"fillOpacity": 20,
"lineWidth": 2,
"pointSize": 5
}
}
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 14
},
"id": 6,
"options": {
"legend": {
"calcs": [
"mean",
"max"
],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"title": "Error Rate Over Time",
"type": "timeseries",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "sum(rate({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | detected_level=\"error\" [$__auto])) by (service_instance_id)",
"legendFormat": "{{service_instance_id}}",
"refId": "A"
}
]
},
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"filterable": true
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Count"
},
"properties": [
{
"id": "custom.width",
"value": 100
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 22
},
"id": 7,
"options": {
"showHeader": true,
"sortBy": [
{
"desc": true,
"displayName": "Count"
}
]
},
"title": "Top Error Messages",
"type": "table",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "topk(10, sum by (service_name, log_message) (count_over_time({service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | detected_level=\"error\" | json | label_format log_message=\"{{or .msg .message}}\" [$__range])))",
"queryType": "instant",
"refId": "A"
}
],
"transformations": [
{
"id": "labelsToFields",
"options": {
"mode": "columns"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true
},
"indexByName": {},
"renameByName": {
"Value": "Count",
"Value #A": "Count",
"log_message": "Message",
"service_name": "Service"
}
}
}
]
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 32
},
"id": 12,
"title": "Log Browser",
"type": "row"
},
{
"id": 8,
"type": "logs",
"title": "Mattermost Logs",
"gridPos": {
"x": 0,
"y": 33,
"h": 14,
"w": 24
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"pluginVersion": "11.6.1",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"expr": "{service_name=~\"$service_name\", service_instance_id=~\"$service_instance_id\"} | json | detected_level=~\"$log_level\" |~ \"$search\"",
"refId": "A",
"editorMode": "code",
"queryType": "range",
"direction": "backward"
}
],
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"options": {
"showTime": true,
"showLabels": false,
"showCommonLabels": true,
"wrapLogMessage": true,
"prettifyLogMessage": false,
"enableLogDetails": true,
"enableInfiniteScrolling": false,
"dedupStrategy": "none",
"sortOrder": "Descending"
}
}
],
"schemaVersion": 39,
"tags": [
"mattermost",
"loki",
"logs"
],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"definition": "label_values(service_name)",
"includeAll": true,
"multi": true,
"name": "service_name",
"query": "label_values(service_name)",
"refresh": 2,
"sort": 1,
"type": "query"
},
{
"current": {
"selected": true,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "loki",
"uid": "${DS_LOKI}"
},
"definition": "label_values(service_instance_id)",
"includeAll": true,
"multi": true,
"name": "service_instance_id",
"query": "label_values(service_instance_id)",
"refresh": 2,
"sort": 1,
"type": "query"
},
{
"current": {
"selected": true,
"text": "All",
"value": "$__all"
},
"description": "Filter by log severity",
"hide": 0,
"includeAll": true,
"multi": true,
"name": "log_level",
"options": [
{
"selected": true,
"text": "All",
"value": "$__all"
},
{
"selected": false,
"text": "info",
"value": "info"
},
{
"selected": false,
"text": "warn",
"value": "warn"
},
{
"selected": false,
"text": "error",
"value": "error"
}
],
"query": "info,warn,error",
"queryValue": "",
"refresh": 2,
"skipUrlSync": false,
"type": "custom"
},
{
"current": {
"selected": false,
"text": "",
"value": ""
},
"name": "search",
"options": [],
"query": "",
"type": "textbox"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Mattermost Log Aggregation",
"uid": "mattermost-loki-logs",
"version": 1
}

View File

@@ -0,0 +1,73 @@
# Grafana Loki configuration for Mattermost log aggregation
# This is a production-ready config for a single-instance deployment
# colocated with an existing Grafana/Prometheus monitoring server.
#
# Install location: /opt/loki/loki-config.yaml
# Data directory: /opt/loki/data/
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9095
log_level: info
common:
instance_addr: 127.0.0.1
path_prefix: /opt/loki/data
storage:
filesystem:
chunks_directory: /opt/loki/data/chunks
rules_directory: /opt/loki/data/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
# ---------------------------------------------------------------------------
# Retention settings
# ---------------------------------------------------------------------------
# Loki requires retention to be specified in hours (h). The default here
# is 14 days. To change it, update retention_period below.
#
# Common values:
# 336h = 14 days (default)
# 720h = 30 days
# 2160h = 90 days
# 8760h = 365 days
# ---------------------------------------------------------------------------
limits_config:
retention_period: 336h # 14 days (336 hours) — adjust as needed
metric_aggregation_enabled: true
allow_structured_metadata: true
compactor:
working_directory: /opt/loki/data/compactor
compaction_interval: 10m
retention_enabled: true # must be true for retention_period to apply
retention_delete_delay: 2h
retention_delete_worker_count: 150
delete_request_store: filesystem
schema_config:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
ruler:
alertmanager_url: http://localhost:9093
frontend:
encoding: protobuf

View File

@@ -0,0 +1,50 @@
# OpenTelemetry Collector configuration for Mattermost log collection
receivers:
filelog/mattermost:
include: [ /opt/mattermost/logs/mattermost.log ]
start_at: beginning
operators:
- type: json_parser
processors:
batch:
send_batch_size: 10000
timeout: 10s
resource:
attributes:
- key: host.name
value: "<HOSTNAME>"
action: upsert
- key: service.instance.id
value: "<HOSTNAME>"
action: upsert
- key: service.name
value: "<SERVICE_NAME>"
action: upsert
transform/severity:
log_statements:
- context: log
statements:
- set(log.severity_text, log.attributes["level"]) where log.attributes["level"] != nil
- set(log.severity_number, 9) where log.attributes["level"] == "info"
- set(log.severity_number, 13) where log.attributes["level"] == "warn" or log.attributes["level"] == "warning"
- set(log.severity_number, 17) where log.attributes["level"] == "error"
exporters:
otlp_http/loki:
endpoint: "http://<LOKI_HOST>:3100/otlp"
tls:
insecure: true
service:
telemetry:
logs:
level: "info"
pipelines:
logs/mattermost:
receivers: [ filelog/mattermost ]
processors: [ resource, transform/severity, batch ]
exporters: [ otlp_http/loki ]

View File

@@ -0,0 +1,50 @@
# OpenTelemetry Collector configuration for PostgreSQL log collection
receivers:
filelog/postgres:
include: [ /var/log/postgresql/*.json ]
start_at: beginning
operators:
- type: json_parser
processors:
batch:
send_batch_size: 10000
timeout: 10s
resource:
attributes:
- key: host.name
value: "<HOSTNAME>"
action: upsert
- key: service.instance.id
value: "<HOSTNAME>"
action: upsert
- key: service.name
value: "<SERVICE_NAME>"
action: upsert
transform/severity:
log_statements:
- context: log
statements:
- set(log.severity_text, log.attributes["error_severity"]) where log.attributes["error_severity"] != nil
- set(log.severity_number, 9) where log.attributes["error_severity"] == "LOG" or log.attributes["error_severity"] == "DETAIL" or log.attributes["error_severity"] == "STATEMENT"
- set(log.severity_number, 13) where log.attributes["error_severity"] == "WARNING"
- set(log.severity_number, 17) where log.attributes["error_severity"] == "ERROR" or log.attributes["error_severity"] == "FATAL" or log.attributes["error_severity"] == "PANIC"
exporters:
otlp_http/loki:
endpoint: "http://<LOKI_HOST>:3100/otlp"
tls:
insecure: true
service:
telemetry:
logs:
level: "info"
pipelines:
logs/postgres:
receivers: [ filelog/postgres ]
processors: [ resource, transform/severity, batch ]
exporters: [ otlp_http/loki ]

View File

@@ -0,0 +1,178 @@
<managedAppConfiguration>
<version>1.0.0.1</version>
<bundleId>com.mattermost.rn</bundleId>
<dict>
<string keyName="inAppPinCode">
<defaultValue>
<value>true</value>
</defaultValue>
</string>
<string keyName="inAppSessionAuth">
<defaultValue>
<value>false</value>
</defaultValue>
</string>
<string keyName="blurApplicationScreen">
<defaultValue>
<value>true</value>
</defaultValue>
</string>
<string keyName="jailBreakDetection">
<defaultValue>
<value>true</value>
</defaultValue>
</string>
<string keyName="copyAndPasteProtection">
<defaultValue>
<value>false</value>
</defaultValue>
</string>
<string keyName="serverUrl">
</string>
<string keyName="allowOtherServers">
<defaultValue>
<value>true</value>
</defaultValue>
</string>
<string keyName="username">
<defaultValue>
<userVariable value="emailAddress" />
</defaultValue>
</string>
<string keyName="vendor">
<defaultValue>
<value>Mattermost</value>
</defaultValue>
</string>
<string keyName="useVPN">
<defaultValue>
<value>false</value>
</defaultValue>
</string>
<string keyName="timeoutVPN">
<defaultValue>
<value>30</value>
</defaultValue>
</string>
</dict>
<presentation defaultLocale="en-US">
<fieldGroup>
<name>
<!-- Language will be the next thing the Spec Creator Tool will support. Only English supported at the moment -->
<language value="en-US">Mobile Privacy</language>
</name>
<field keyName="inAppPinCode" type="input">
<label>
<language value="en-US">in-App Pincode</language>
</label>
<description>
<language value="en-US">Locks the app so the user needs to validate as the owner of the phone, it will prompt for fingerprint or passcode when the app first opens and when the app has been in the background for more than 5 minutes.</language>
</description>
</field>
<field keyName="inAppSessionAuth" type="input">
<label>
<language value="en-US">in-App Session Authentication</language>
</label>
<description>
<language value="en-US">Use the app’s internal browser for SSO instead of an external browser.</language>
</description>
</field>
<field keyName="blurApplicationScreen" type="input">
<label>
<language value="en-US">Blur Application Screen</language>
</label>
<description>
<language value="en-US">Protects app on screen information while the app is in the background.</language>
</description>
</field>
</fieldGroup>
<fieldGroup>
<name>
<!-- Language will be the next thing the Spec Creator Tool will support. Only English supported at the moment -->
<language value="en-US">OS Integrity</language>
</name>
<field keyName="jailBreakDetection" type="input">
<label>
<language value="en-US">Jailbreak &#x2F; Root Detection</language>
</label>
<description>
<language value="en-US">Disable app launch on Jailbroken or rooted devices.</language>
</description>
</field>
</fieldGroup>
<fieldGroup>
<name>
<!-- Language will be the next thing the Spec Creator Tool will support. Only English supported at the moment -->
<language value="en-US">Mobile Data Loss Prevention</language>
</name>
<field keyName="copyAndPasteProtection" type="input">
<label>
<language value="en-US">Copy&amp;Paste Protection</language>
</label>
<description>
<language value="en-US">Disables the ability to copy or paste from any input in the app.</language>
</description>
</field>
</fieldGroup>
<fieldGroup>
<name>
<!-- Language will be the next thing the Spec Creator Tool will support. Only English supported at the moment -->
<language value="en-US">Networking</language>
</name>
<field keyName="useVPN" type="input">
<label>
<language value="en-US">Use VPN</language>
</label>
<description>
<language value="en-US">Enable connection to the Mattermost Server to use a per-app VPN or VPN on-demand.</language>
</description>
</field>
<field keyName="timeoutVPN" type="input">
<label>
<language value="en-US">VPN Timeout</language>
</label>
<description>
<language value="en-US">Set how long the request waits (in seconds) for an initial VPN connection to establish before timeout.</language>
</description>
</field>
</fieldGroup>
<fieldGroup>
<name>
<!-- Language will be the next thing the Spec Creator Tool will support. Only English supported at the moment -->
<language value="en-US">Mattermost settings</language>
</name>
<field keyName="serverUrl" type="input">
<label>
<language value="en-US">Mattermost Server URL</language>
</label>
<description>
<language value="en-US">Set the Mattermost server URL users will be using as default.</language>
</description>
</field>
<field keyName="allowOtherServers" type="input">
<label>
<language value="en-US">Allow Other Servers</language>
</label>
<description>
<language value="en-US">Allow the user to change the above server URL.</language>
</description>
</field>
<field keyName="username" type="input">
<label>
<language value="en-US">Default Username</language>
</label>
<description>
<language value="en-US">Set the username or email address to use to authenticate against the Mattermost Server.</language>
</description>
</field>
<field keyName="vendor" type="input">
<label>
<language value="en-US">EMM Vendor or Company Name</language>
</label>
<description>
<language value="en-US">Name of the EMM vendor or company deploying the app so the users are aware the app is being protected.</language>
</description>
</field>
</fieldGroup>
</presentation>
</managedAppConfiguration>

View File

@@ -0,0 +1,90 @@
{
"sample-console": {
"type": "console",
"options": {
"out": "stdout"
},
"format": "plain",
"format_options": {
"delim": " | "
},
"levels": [
{"id": 5, "name": "debug"},
{"id": 4, "name": "info", "color": 36},
{"id": 3, "name": "warn"},
{"id": 2, "name": "error", "stacktrace": true, "color": 31},
{"id": 1, "name": "fatal", "stacktrace": true},
{"id": 0, "name": "panic", "stacktrace": true}
],
"maxqueuesize": 1000
},
"sample-file": {
"type": "file",
"options": {
"filename": "test.log",
"max_size": 1000000,
"max_age": 1,
"max_backups": 10,
"compress": true
},
"format": "json",
"format_options": {
},
"levels": [
{"id": 5, "name": "debug"},
{"id": 4, "name": "info"},
{"id": 3, "name": "warn"},
{"id": 2, "name": "error", "stacktrace": true},
{"id": 1, "name": "fatal", "stacktrace": true},
{"id": 0, "name": "panic", "stacktrace": true}
],
"maxqueuesize": 1000
},
"sample-tcp": {
"type": "tcp",
"options": {
"host": "localhost",
"port": 18066,
"tls": false,
"cert": "",
"insecure": false
},
"format": "gelf",
"format_options": {
"hostname": "server01"
},
"levels": [
{"id": 5, "name": "debug"},
{"id": 4, "name": "info"},
{"id": 3, "name": "warn"},
{"id": 2, "name": "error", "stacktrace": true},
{"id": 1, "name": "fatal", "stacktrace": true},
{"id": 0, "name": "panic", "stacktrace": true}
],
"maxqueuesize": 1000
},
"sample-syslog": {
"type": "syslog",
"options": {
"host": "localhost",
"port": 18066,
"tls": false,
"cert": "",
"insecure": false,
"tag": "testapp"
},
"format": "plain",
"format_options": {
"delim": " "
},
"levels": [
{"id": 5, "name": "debug"},
{"id": 4, "name": "info"},
{"id": 3, "name": "warn"},
{"id": 2, "name": "error", "stacktrace": true},
{"id": 1, "name": "fatal", "stacktrace": true},
{"id": 0, "name": "panic", "stacktrace": true}
],
"maxqueuesize": 1000
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More