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

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)}`);