refactor progress
This commit is contained in:
195
fluxer_docs/scripts/check_broken_links.mjs
Normal file
195
fluxer_docs/scripts/check_broken_links.mjs
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '..');
|
||||
|
||||
/**
|
||||
* Slugify a string for URL use.
|
||||
*/
|
||||
function slugify(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of valid API reference URLs from OpenAPI spec.
|
||||
* Mintlify uses summary (slugified) for URLs, not operationId.
|
||||
*/
|
||||
async function buildValidUrlMap(openapiPath) {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const openapi = JSON.parse(content);
|
||||
|
||||
const validUrls = new Map();
|
||||
const operationIdToUrl = new Map();
|
||||
|
||||
for (const [pathTemplate, methods] of Object.entries(openapi.paths || {})) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
if (method === 'parameters') continue;
|
||||
|
||||
const operationId = operation.operationId;
|
||||
const summary = operation.summary;
|
||||
const tags = operation.tags || ['General'];
|
||||
const primaryTag = tags[0];
|
||||
|
||||
if (summary && primaryTag) {
|
||||
const tagSlug = slugify(primaryTag);
|
||||
const summarySlug = slugify(summary);
|
||||
const url = `/api-reference/${tagSlug}/${summarySlug}`;
|
||||
validUrls.set(url, {operationId, summary, tag: primaryTag, method, path: pathTemplate});
|
||||
|
||||
if (operationId) {
|
||||
const opIdSlug = operationId.replace(/_/g, '-');
|
||||
const opIdUrl = `/api-reference/${tagSlug}/${opIdSlug}`;
|
||||
operationIdToUrl.set(opIdUrl, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {validUrls, operationIdToUrl};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all internal links from MDX content.
|
||||
*/
|
||||
function extractLinks(content) {
|
||||
const links = [];
|
||||
const linkRegex = /\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
while ((match = linkRegex.exec(content)) !== null) {
|
||||
const url = match[1];
|
||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||
links.push({url, index: match.index});
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all MDX files recursively.
|
||||
*/
|
||||
async function findMdxFiles(dir, files = []) {
|
||||
const entries = await fs.readdir(dir, {withFileTypes: true});
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
await findMdxFiles(fullPath, files);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.mdx')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const openapiPath = path.join(repoRoot, 'api-reference/openapi.json');
|
||||
const {validUrls, operationIdToUrl} = await buildValidUrlMap(openapiPath);
|
||||
|
||||
console.log(`Loaded ${validUrls.size} valid API reference URLs\n`);
|
||||
|
||||
const mdxFiles = await findMdxFiles(repoRoot);
|
||||
const brokenLinks = [];
|
||||
const fixableLinks = [];
|
||||
|
||||
for (const filePath of mdxFiles) {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const links = extractLinks(content);
|
||||
const relativePath = path.relative(repoRoot, filePath);
|
||||
|
||||
for (const {url} of links) {
|
||||
if (url.startsWith('/api-reference/')) {
|
||||
const urlWithoutAnchor = url.split('#')[0];
|
||||
if (!validUrls.has(urlWithoutAnchor)) {
|
||||
const correctUrl = operationIdToUrl.get(urlWithoutAnchor);
|
||||
if (correctUrl) {
|
||||
fixableLinks.push({file: relativePath, broken: url, correct: correctUrl});
|
||||
} else {
|
||||
brokenLinks.push({file: relativePath, url});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixableLinks.length > 0) {
|
||||
console.log('=== FIXABLE LINKS (operationId → summary) ===\n');
|
||||
const grouped = {};
|
||||
for (const {file, broken, correct} of fixableLinks) {
|
||||
if (!grouped[file]) grouped[file] = [];
|
||||
grouped[file].push({broken, correct});
|
||||
}
|
||||
for (const [file, links] of Object.entries(grouped)) {
|
||||
console.log(`${file}:`);
|
||||
for (const {broken, correct} of links) {
|
||||
console.log(` ${broken}`);
|
||||
console.log(` → ${correct}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
if (brokenLinks.length > 0) {
|
||||
console.log('=== UNFIXABLE BROKEN LINKS ===\n');
|
||||
const grouped = {};
|
||||
for (const {file, url} of brokenLinks) {
|
||||
if (!grouped[file]) grouped[file] = [];
|
||||
grouped[file].push(url);
|
||||
}
|
||||
for (const [file, urls] of Object.entries(grouped)) {
|
||||
console.log(`${file}:`);
|
||||
for (const url of urls) {
|
||||
console.log(` ${url}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary:`);
|
||||
console.log(` Fixable links: ${fixableLinks.length}`);
|
||||
console.log(` Unfixable broken links: ${brokenLinks.length}`);
|
||||
|
||||
if (process.argv.includes('--fix')) {
|
||||
console.log('\n=== APPLYING FIXES ===\n');
|
||||
const fileUpdates = {};
|
||||
for (const {file, broken, correct} of fixableLinks) {
|
||||
if (!fileUpdates[file]) {
|
||||
fileUpdates[file] = await fs.readFile(path.join(repoRoot, file), 'utf-8');
|
||||
}
|
||||
fileUpdates[file] = fileUpdates[file].split(broken).join(correct);
|
||||
}
|
||||
for (const [file, content] of Object.entries(fileUpdates)) {
|
||||
await fs.writeFile(path.join(repoRoot, file), content);
|
||||
console.log(`Fixed: ${file}`);
|
||||
}
|
||||
console.log(`\nFixed ${Object.keys(fileUpdates).length} files.`);
|
||||
} else if (fixableLinks.length > 0) {
|
||||
console.log(`\nRun with --fix to apply fixes.`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
409
fluxer_docs/scripts/generate_config.mjs
Normal file
409
fluxer_docs/scripts/generate_config.mjs
Normal file
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {escapeTableText, formatDefault, readJsonFile, toAnchor, wrapCode, writeFile} from './shared.mjs';
|
||||
|
||||
function buildDefMap(schema) {
|
||||
return schema.$defs ?? {};
|
||||
}
|
||||
|
||||
function formatType(propSchema, defs) {
|
||||
if (!propSchema || typeof propSchema !== 'object') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (propSchema.$ref) {
|
||||
const refName = propSchema.$ref.split('/').pop();
|
||||
return `[${refName}](#${toAnchor(refName)})`;
|
||||
}
|
||||
|
||||
if (propSchema.enum) {
|
||||
const values = propSchema.enum.map((v) => wrapCode(v)).join(', ');
|
||||
return `enum<${values}>`;
|
||||
}
|
||||
|
||||
if (propSchema.type === 'array') {
|
||||
const items = propSchema.items;
|
||||
if (items) {
|
||||
const itemType = formatType(items, defs);
|
||||
return `array<${itemType}>`;
|
||||
}
|
||||
return 'array';
|
||||
}
|
||||
|
||||
if (propSchema.type === 'object') {
|
||||
if (propSchema.properties && Object.keys(propSchema.properties).length > 0) {
|
||||
return 'object';
|
||||
}
|
||||
if (propSchema.additionalProperties) {
|
||||
return 'object';
|
||||
}
|
||||
return 'object';
|
||||
}
|
||||
|
||||
if (propSchema.type) {
|
||||
return propSchema.type;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field name with optional indicator.
|
||||
* Appends ? suffix when the field is not required.
|
||||
*/
|
||||
function formatFieldName(name, isRequired) {
|
||||
return isRequired ? name : `${name}?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build description with default value if present.
|
||||
*/
|
||||
function buildDescription(propSchema) {
|
||||
let desc = escapeTableText(propSchema.description ?? '');
|
||||
const defaultVal = formatDefault(propSchema.default);
|
||||
if (defaultVal) {
|
||||
if (desc) {
|
||||
desc += ` Default: ${defaultVal}`;
|
||||
} else {
|
||||
desc = `Default: ${defaultVal}`;
|
||||
}
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
|
||||
function renderPropertyTable(properties, requiredSet, defs) {
|
||||
if (!properties || Object.keys(properties).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const propNames = Object.keys(properties).sort((a, b) => a.localeCompare(b));
|
||||
let out = '';
|
||||
out += '| Property | Type | Description |\n';
|
||||
out += '|----------|------|-------------|\n';
|
||||
|
||||
for (const propName of propNames) {
|
||||
const propSchema = properties[propName];
|
||||
const type = formatType(propSchema, defs);
|
||||
const isRequired = requiredSet.has(propName);
|
||||
const fieldName = formatFieldName(propName, isRequired);
|
||||
const description = buildDescription(propSchema);
|
||||
out += `| ${fieldName} | ${type} | ${description} |\n`;
|
||||
}
|
||||
|
||||
out += '\n';
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderConditionalNote(schema) {
|
||||
if (!schema.if || !schema.then) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let out = '';
|
||||
const condition = schema.if;
|
||||
const thenClause = schema.then;
|
||||
|
||||
const conditionParts = new Set();
|
||||
const keysWithExplicitCondition = new Set();
|
||||
if (condition.properties) {
|
||||
for (const [key, val] of Object.entries(condition.properties)) {
|
||||
if (val.const !== undefined) {
|
||||
conditionParts.add(`${wrapCode(key)} = ${wrapCode(val.const)}`);
|
||||
keysWithExplicitCondition.add(key);
|
||||
continue;
|
||||
}
|
||||
if (val.properties) {
|
||||
for (const [subKey, subVal] of Object.entries(val.properties)) {
|
||||
if (subVal.const !== undefined) {
|
||||
conditionParts.add(`${wrapCode(`${key}.${subKey}`)} = ${wrapCode(subVal.const)}`);
|
||||
keysWithExplicitCondition.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (condition.required) {
|
||||
for (const requiredKey of condition.required) {
|
||||
if (!keysWithExplicitCondition.has(requiredKey)) {
|
||||
conditionParts.add(`${wrapCode(requiredKey)} is present`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const conditionText = Array.from(conditionParts).join(' and ');
|
||||
|
||||
let requiredProps = [];
|
||||
if (thenClause.required) {
|
||||
requiredProps = thenClause.required;
|
||||
}
|
||||
if (thenClause.properties) {
|
||||
for (const [key, val] of Object.entries(thenClause.properties)) {
|
||||
if (val.required) {
|
||||
for (const req of val.required) {
|
||||
requiredProps.push(`${key}.${req}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conditionText && requiredProps.length > 0) {
|
||||
const requiredList = requiredProps.map((p) => wrapCode(p)).join(', ');
|
||||
out += `<Note>\nWhen ${conditionText}, the following properties are required: ${requiredList}\n</Note>\n\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderJsonExample(_sectionPath, properties, requiredSet, _defs) {
|
||||
if (!properties || Object.keys(properties).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const exampleObj = {};
|
||||
|
||||
const propNames = Object.keys(properties).sort((a, b) => {
|
||||
const aRequired = requiredSet.has(a) ? 0 : 1;
|
||||
const bRequired = requiredSet.has(b) ? 0 : 1;
|
||||
if (aRequired !== bRequired) return aRequired - bRequired;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const propName of propNames) {
|
||||
const propSchema = properties[propName];
|
||||
|
||||
if (propSchema.$ref) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (propSchema.type === 'object' && propSchema.properties) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isRequired = requiredSet.has(propName);
|
||||
let exampleValue;
|
||||
|
||||
if (propSchema.default !== undefined) {
|
||||
exampleValue = propSchema.default;
|
||||
} else if (propSchema.enum) {
|
||||
exampleValue = propSchema.enum[0];
|
||||
} else if (propSchema.type === 'string') {
|
||||
exampleValue = isRequired ? `your_${propName}` : '';
|
||||
} else if (propSchema.type === 'number') {
|
||||
exampleValue = 0;
|
||||
} else if (propSchema.type === 'boolean') {
|
||||
exampleValue = false;
|
||||
} else if (propSchema.type === 'array') {
|
||||
exampleValue = [];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
exampleObj[propName] = exampleValue;
|
||||
}
|
||||
|
||||
if (Object.keys(exampleObj).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(exampleObj, null, 2);
|
||||
|
||||
let out = '<Expandable title="Example JSON">\n';
|
||||
out += '```json\n';
|
||||
out += jsonStr;
|
||||
out += '\n```\n';
|
||||
out += '</Expandable>\n\n';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderDefinition(defName, defSchema, defs, jsonPath) {
|
||||
let out = '';
|
||||
|
||||
out += `### ${defName}\n\n`;
|
||||
|
||||
if (jsonPath) {
|
||||
out += `JSON path: ${wrapCode(jsonPath)}\n\n`;
|
||||
}
|
||||
|
||||
if (defSchema.description) {
|
||||
out += `${defSchema.description}\n\n`;
|
||||
}
|
||||
|
||||
const requiredSet = new Set(defSchema.required ?? []);
|
||||
|
||||
out += renderConditionalNote(defSchema);
|
||||
|
||||
if (defSchema.properties) {
|
||||
out += renderPropertyTable(defSchema.properties, requiredSet, defs);
|
||||
out += renderJsonExample(jsonPath, defSchema.properties, requiredSet, defs);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderNestedDefinitions(_defName, defSchema, defs, parentPath, rendered) {
|
||||
let out = '';
|
||||
|
||||
if (!defSchema.properties) {
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(defSchema.properties)) {
|
||||
if (propSchema.$ref) {
|
||||
const refName = propSchema.$ref.split('/').pop();
|
||||
if (rendered.has(refName)) {
|
||||
continue;
|
||||
}
|
||||
const refSchema = defs[refName];
|
||||
if (refSchema) {
|
||||
const nestedPath = parentPath ? `${parentPath}.${propName}` : propName;
|
||||
rendered.add(refName);
|
||||
out += renderDefinition(refName, refSchema, defs, nestedPath);
|
||||
out += renderNestedDefinitions(refName, refSchema, defs, nestedPath, rendered);
|
||||
}
|
||||
} else if (propSchema.type === 'object' && propSchema.properties) {
|
||||
const _nestedPath = parentPath ? `${parentPath}.${propName}` : propName;
|
||||
const syntheticName = propName;
|
||||
out += `#### ${syntheticName}\n\n`;
|
||||
if (propSchema.description) {
|
||||
out += `${propSchema.description}\n\n`;
|
||||
}
|
||||
const requiredSet = new Set(propSchema.required ?? []);
|
||||
out += renderPropertyTable(propSchema.properties, requiredSet, defs);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTableOfContents(schema, _defs) {
|
||||
let out = '## Table of contents\n\n';
|
||||
|
||||
out += '**Root configuration**\n\n';
|
||||
out += '- [Root properties](#root-properties)\n';
|
||||
|
||||
out += '\n**Sections**\n\n';
|
||||
|
||||
const rootProps = schema.properties ?? {};
|
||||
const sections = [];
|
||||
|
||||
for (const [_propName, propSchema] of Object.entries(rootProps)) {
|
||||
if (propSchema.$ref) {
|
||||
const refName = propSchema.$ref.split('/').pop();
|
||||
sections.push({name: refName, anchor: toAnchor(refName)});
|
||||
}
|
||||
}
|
||||
|
||||
sections.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const section of sections) {
|
||||
out += `- [${section.name}](#${section.anchor})\n`;
|
||||
}
|
||||
|
||||
out += '\n';
|
||||
|
||||
out += '## Field notation\n\n';
|
||||
out += 'Configuration tables use a compact notation:\n\n';
|
||||
out += '| Notation | Meaning |\n';
|
||||
out += '|----------|----------|\n';
|
||||
out += '| `property` | Required property |\n';
|
||||
out += '| `property?` | Optional property (may be omitted) |\n';
|
||||
out += '\n';
|
||||
out += 'Default values are shown in the Description column when applicable.\n\n';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderRootProperties(schema, defs) {
|
||||
let out = '## Root properties\n\n';
|
||||
|
||||
const properties = schema.properties ?? {};
|
||||
const requiredSet = new Set(schema.required ?? []);
|
||||
|
||||
out += 'These are the top-level configuration options in your `config.json`.\n\n';
|
||||
out += renderPropertyTable(properties, requiredSet, defs);
|
||||
|
||||
out += renderConditionalNote(schema);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderMdx(schema) {
|
||||
const defs = buildDefMap(schema);
|
||||
let out = '';
|
||||
|
||||
out += '---\n';
|
||||
out += "title: 'Configuration'\n";
|
||||
out += "description: 'config.json reference for self-hosted Fluxer.'\n";
|
||||
out += '---\n\n';
|
||||
|
||||
out += renderTableOfContents(schema, defs);
|
||||
out += renderRootProperties(schema, defs);
|
||||
|
||||
const rendered = new Set();
|
||||
const rootProps = schema.properties ?? {};
|
||||
|
||||
const sortedProps = Object.entries(rootProps).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
for (const [propName, propSchema] of sortedProps) {
|
||||
if (propSchema.$ref) {
|
||||
const refName = propSchema.$ref.split('/').pop();
|
||||
if (rendered.has(refName)) {
|
||||
continue;
|
||||
}
|
||||
const refSchema = defs[refName];
|
||||
if (refSchema) {
|
||||
rendered.add(refName);
|
||||
out += `---\n\n`;
|
||||
out += `## ${refName}\n\n`;
|
||||
out += `<a id="${toAnchor(refName)}"></a>\n\n`;
|
||||
out += `JSON path: ${wrapCode(propName)}\n\n`;
|
||||
if (refSchema.description) {
|
||||
out += `${refSchema.description}\n\n`;
|
||||
}
|
||||
const requiredSet = new Set(refSchema.required ?? []);
|
||||
out += renderConditionalNote(refSchema);
|
||||
if (refSchema.properties) {
|
||||
out += renderPropertyTable(refSchema.properties, requiredSet, defs);
|
||||
out += renderJsonExample(propName, refSchema.properties, requiredSet, defs);
|
||||
}
|
||||
out += renderNestedDefinitions(refName, refSchema, defs, propName, rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
const schemaPath = path.join(repoRoot, 'packages/config/src/ConfigSchema.json');
|
||||
const outPath = path.join(repoRoot, 'fluxer_docs/self_hosting/configuration.mdx');
|
||||
|
||||
const schema = await readJsonFile(schemaPath);
|
||||
const mdx = renderMdx(schema);
|
||||
|
||||
await writeFile(outPath, mdx);
|
||||
console.log(`Generated configuration documentation at ${outPath}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
94
fluxer_docs/scripts/generate_error_codes.mjs
Normal file
94
fluxer_docs/scripts/generate_error_codes.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createFrontmatter, escapeTableText, readJsonFile, writeFile} from './shared.mjs';
|
||||
|
||||
/**
|
||||
* Render a table of error codes with descriptions.
|
||||
*/
|
||||
function renderErrorCodeTable(codes, descriptions) {
|
||||
let out = '';
|
||||
out += '| Code | Description |\n';
|
||||
out += '|------|-------------|\n';
|
||||
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
const description = descriptions[i] ?? '-';
|
||||
out += `| \`${escapeTableText(code)}\` | ${escapeTableText(description)} |\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
const openapiPath = path.join(repoRoot, 'fluxer_docs/api-reference/openapi.json');
|
||||
const outPath = path.join(repoRoot, 'fluxer_docs/topics/error_codes.mdx');
|
||||
|
||||
const openapi = await readJsonFile(openapiPath);
|
||||
const schemas = openapi?.components?.schemas ?? {};
|
||||
|
||||
const apiErrorCodeSchema = schemas.APIErrorCode;
|
||||
const validationErrorCodeSchema = schemas.ValidationErrorCodeSchema;
|
||||
|
||||
if (!apiErrorCodeSchema) {
|
||||
throw new Error('APIErrorCode schema not found in OpenAPI spec');
|
||||
}
|
||||
if (!validationErrorCodeSchema) {
|
||||
throw new Error('ValidationErrorCodeSchema schema not found in OpenAPI spec');
|
||||
}
|
||||
|
||||
const apiErrorCodes = apiErrorCodeSchema.enum ?? [];
|
||||
const apiErrorDescriptions = apiErrorCodeSchema['x-enumDescriptions'] ?? [];
|
||||
|
||||
const validationErrorCodes = validationErrorCodeSchema['x-enumNames'] ?? validationErrorCodeSchema.enum ?? [];
|
||||
const validationErrorDescriptions = validationErrorCodeSchema['x-enumDescriptions'] ?? [];
|
||||
|
||||
let out = '';
|
||||
out += createFrontmatter({
|
||||
title: 'Error codes',
|
||||
description: 'Reference for API and validation error codes returned by Fluxer.',
|
||||
});
|
||||
out += '\n\n';
|
||||
|
||||
out +=
|
||||
'When the API returns an error response, it includes a structured error object with a `code` field that identifies the specific error. This page documents all possible error codes.\n\n';
|
||||
|
||||
out += '## API error codes\n\n';
|
||||
out += 'These error codes are returned in the `code` field of the main error response body.\n\n';
|
||||
out += renderErrorCodeTable(apiErrorCodes, apiErrorDescriptions);
|
||||
out += '\n';
|
||||
|
||||
out += '## Validation error codes\n\n';
|
||||
out +=
|
||||
'These error codes are returned in the `code` field of individual validation error items when request validation fails.\n\n';
|
||||
out += renderErrorCodeTable(validationErrorCodes, validationErrorDescriptions);
|
||||
|
||||
await writeFile(outPath, out);
|
||||
|
||||
console.log('Generated error codes documentation:');
|
||||
console.log(` - ${outPath}`);
|
||||
console.log(` - ${apiErrorCodes.length} API error codes`);
|
||||
console.log(` - ${validationErrorCodes.length} validation error codes`);
|
||||
}
|
||||
|
||||
await main();
|
||||
776
fluxer_docs/scripts/generate_gateway.mjs
Normal file
776
fluxer_docs/scripts/generate_gateway.mjs
Normal file
@@ -0,0 +1,776 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createFrontmatter, escapeTableText, readJsonFile, writeFile} from './shared.mjs';
|
||||
|
||||
/**
|
||||
* Schema name to resource page mapping.
|
||||
* Used to link schema references to their documentation pages.
|
||||
*/
|
||||
const SCHEMA_TO_RESOURCE = {
|
||||
UserPartialResponse: 'users',
|
||||
UserPrivateResponse: 'users',
|
||||
UserResponse: 'users',
|
||||
UserSettingsResponse: 'users',
|
||||
UserGuildSettingsResponse: 'users',
|
||||
RelationshipResponse: 'users',
|
||||
GuildResponse: 'guilds',
|
||||
GuildPartialResponse: 'guilds',
|
||||
GuildMemberResponse: 'guilds',
|
||||
GuildRoleResponse: 'guilds',
|
||||
GuildEmojiResponse: 'guilds',
|
||||
GuildStickerResponse: 'packs',
|
||||
ChannelResponse: 'channels',
|
||||
ChannelPartialResponse: 'channels',
|
||||
MessageResponse: 'channels',
|
||||
FavoriteMemeResponse: 'saved_media',
|
||||
InviteResponse: 'invites',
|
||||
WebhookResponse: 'webhooks',
|
||||
};
|
||||
|
||||
/**
|
||||
* Gateway-specific schemas that are documented in the events page.
|
||||
*/
|
||||
const GATEWAY_LOCAL_SCHEMAS = new Set([
|
||||
'VoiceStateResponse',
|
||||
'PresenceResponse',
|
||||
'SessionResponse',
|
||||
'ReadStateResponse',
|
||||
'GuildReadyResponse',
|
||||
'CustomStatusResponse',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Gateway opcodes with descriptions and client action (send/receive).
|
||||
* These are defined in @fluxer/constants/src/GatewayConstants.tsx
|
||||
*/
|
||||
const GatewayOpcodes = [
|
||||
{code: 0, name: 'DISPATCH', description: 'Dispatches an event to the client', action: 'Receive'},
|
||||
{code: 1, name: 'HEARTBEAT', description: 'Fired periodically to keep the connection alive', action: 'Send/Receive'},
|
||||
{code: 2, name: 'IDENTIFY', description: 'Starts a new session during the initial handshake', action: 'Send'},
|
||||
{code: 3, name: 'PRESENCE_UPDATE', description: 'Updates the client presence', action: 'Send'},
|
||||
{
|
||||
code: 4,
|
||||
name: 'VOICE_STATE_UPDATE',
|
||||
description: 'Joins, moves, or disconnects from a voice channel',
|
||||
action: 'Send',
|
||||
},
|
||||
{code: 5, name: 'VOICE_SERVER_PING', description: 'Pings the voice server', action: 'Send'},
|
||||
{code: 6, name: 'RESUME', description: 'Resumes a previous session after a disconnect', action: 'Send'},
|
||||
{code: 7, name: 'RECONNECT', description: 'Indicates the client should reconnect to the gateway', action: 'Receive'},
|
||||
{code: 8, name: 'REQUEST_GUILD_MEMBERS', description: 'Requests members for a guild', action: 'Send'},
|
||||
{
|
||||
code: 9,
|
||||
name: 'INVALID_SESSION',
|
||||
description: 'Session has been invalidated; client should reconnect and identify',
|
||||
action: 'Receive',
|
||||
},
|
||||
{
|
||||
code: 10,
|
||||
name: 'HELLO',
|
||||
description: 'Sent immediately after connecting; contains heartbeat interval',
|
||||
action: 'Receive',
|
||||
},
|
||||
{code: 11, name: 'HEARTBEAT_ACK', description: 'Acknowledgement of a heartbeat', action: 'Receive'},
|
||||
{
|
||||
code: 12,
|
||||
name: 'GATEWAY_ERROR',
|
||||
description: 'Indicates an error occurred while processing a gateway message',
|
||||
action: 'Receive',
|
||||
},
|
||||
{code: 14, name: 'LAZY_REQUEST', description: 'Requests lazy-loaded guild data', action: 'Send'},
|
||||
];
|
||||
|
||||
/**
|
||||
* Gateway close codes with descriptions and whether clients should reconnect.
|
||||
* These are defined in @fluxer/constants/src/GatewayConstants.tsx
|
||||
*/
|
||||
const GatewayCloseCodes = [
|
||||
{code: 4000, name: 'UNKNOWN_ERROR', description: 'Unknown error occurred', reconnect: true},
|
||||
{code: 4001, name: 'UNKNOWN_OPCODE', description: 'Sent an invalid gateway opcode', reconnect: true},
|
||||
{code: 4002, name: 'DECODE_ERROR', description: 'Sent an invalid payload', reconnect: true},
|
||||
{code: 4003, name: 'NOT_AUTHENTICATED', description: 'Sent a payload before identifying', reconnect: true},
|
||||
{code: 4004, name: 'AUTHENTICATION_FAILED', description: 'Account token is invalid', reconnect: false},
|
||||
{code: 4005, name: 'ALREADY_AUTHENTICATED', description: 'Sent more than one identify payload', reconnect: true},
|
||||
{code: 4007, name: 'INVALID_SEQ', description: 'Sent an invalid sequence when resuming', reconnect: true},
|
||||
{code: 4008, name: 'RATE_LIMITED', description: 'Sending payloads too quickly', reconnect: true},
|
||||
{
|
||||
code: 4009,
|
||||
name: 'SESSION_TIMEOUT',
|
||||
description: 'Session timed out; reconnect and start a new one',
|
||||
reconnect: true,
|
||||
},
|
||||
{code: 4010, name: 'INVALID_SHARD', description: 'Sent an invalid shard when identifying', reconnect: false},
|
||||
{
|
||||
code: 4011,
|
||||
name: 'SHARDING_REQUIRED',
|
||||
description: 'Session would have handled too many guilds; sharding is required',
|
||||
reconnect: false,
|
||||
},
|
||||
{code: 4012, name: 'INVALID_API_VERSION', description: 'Sent an invalid gateway version', reconnect: false},
|
||||
];
|
||||
|
||||
/**
|
||||
* Event categories for grouping in documentation.
|
||||
*/
|
||||
const EventCategories = [
|
||||
{
|
||||
name: 'Session',
|
||||
events: ['READY', 'RESUMED', 'SESSIONS_REPLACE'],
|
||||
},
|
||||
{
|
||||
name: 'User',
|
||||
events: [
|
||||
'USER_UPDATE',
|
||||
'USER_PINNED_DMS_UPDATE',
|
||||
'USER_SETTINGS_UPDATE',
|
||||
'USER_GUILD_SETTINGS_UPDATE',
|
||||
'USER_NOTE_UPDATE',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'User content',
|
||||
events: ['RECENT_MENTION_DELETE', 'SAVED_MESSAGE_CREATE', 'SAVED_MESSAGE_DELETE'],
|
||||
},
|
||||
{
|
||||
name: 'Favourite memes',
|
||||
events: ['FAVORITE_MEME_CREATE', 'FAVORITE_MEME_UPDATE', 'FAVORITE_MEME_DELETE'],
|
||||
},
|
||||
{
|
||||
name: 'Authentication',
|
||||
events: ['AUTH_SESSION_CHANGE'],
|
||||
},
|
||||
{
|
||||
name: 'Presence',
|
||||
events: ['PRESENCE_UPDATE'],
|
||||
},
|
||||
{
|
||||
name: 'Guild',
|
||||
events: ['GUILD_CREATE', 'GUILD_UPDATE', 'GUILD_DELETE'],
|
||||
},
|
||||
{
|
||||
name: 'Guild members',
|
||||
events: ['GUILD_MEMBER_ADD', 'GUILD_MEMBER_UPDATE', 'GUILD_MEMBER_REMOVE'],
|
||||
},
|
||||
{
|
||||
name: 'Guild roles',
|
||||
events: ['GUILD_ROLE_CREATE', 'GUILD_ROLE_UPDATE', 'GUILD_ROLE_UPDATE_BULK', 'GUILD_ROLE_DELETE'],
|
||||
},
|
||||
{
|
||||
name: 'Guild content',
|
||||
events: ['GUILD_EMOJIS_UPDATE', 'GUILD_STICKERS_UPDATE'],
|
||||
},
|
||||
{
|
||||
name: 'Guild moderation',
|
||||
events: ['GUILD_BAN_ADD', 'GUILD_BAN_REMOVE'],
|
||||
},
|
||||
{
|
||||
name: 'Channel',
|
||||
events: [
|
||||
'CHANNEL_CREATE',
|
||||
'CHANNEL_UPDATE',
|
||||
'CHANNEL_UPDATE_BULK',
|
||||
'CHANNEL_DELETE',
|
||||
'CHANNEL_PINS_UPDATE',
|
||||
'CHANNEL_PINS_ACK',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Group DM',
|
||||
events: ['CHANNEL_RECIPIENT_ADD', 'CHANNEL_RECIPIENT_REMOVE'],
|
||||
},
|
||||
{
|
||||
name: 'Message',
|
||||
events: ['MESSAGE_CREATE', 'MESSAGE_UPDATE', 'MESSAGE_DELETE', 'MESSAGE_DELETE_BULK'],
|
||||
},
|
||||
{
|
||||
name: 'Message reactions',
|
||||
events: [
|
||||
'MESSAGE_REACTION_ADD',
|
||||
'MESSAGE_REACTION_REMOVE',
|
||||
'MESSAGE_REACTION_REMOVE_ALL',
|
||||
'MESSAGE_REACTION_REMOVE_EMOJI',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Read state',
|
||||
events: ['MESSAGE_ACK'],
|
||||
},
|
||||
{
|
||||
name: 'Typing',
|
||||
events: ['TYPING_START'],
|
||||
},
|
||||
{
|
||||
name: 'Webhooks',
|
||||
events: ['WEBHOOKS_UPDATE'],
|
||||
},
|
||||
{
|
||||
name: 'Invites',
|
||||
events: ['INVITE_CREATE', 'INVITE_DELETE'],
|
||||
},
|
||||
{
|
||||
name: 'Relationships',
|
||||
events: ['RELATIONSHIP_ADD', 'RELATIONSHIP_UPDATE', 'RELATIONSHIP_REMOVE'],
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
events: ['VOICE_STATE_UPDATE', 'VOICE_SERVER_UPDATE'],
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
events: ['CALL_CREATE', 'CALL_UPDATE', 'CALL_DELETE'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalise a path by replacing all parameter placeholders with a generic marker.
|
||||
* This allows matching paths regardless of parameter names.
|
||||
* @param {string} path - The path to normalise (e.g., "/users/@me/notes/:user_id" or "/users/@me/notes/{target_id}")
|
||||
* @returns {string} Normalised path with all params replaced by "{_}"
|
||||
*/
|
||||
function normalisePathPattern(path) {
|
||||
return path.replace(/:\w+/g, '{_}').replace(/\{[^}]+\}/g, '{_}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugify a string for URL use.
|
||||
* @param {string} str - The string to slugify.
|
||||
* @returns {string} URL-safe slug.
|
||||
*/
|
||||
function slugify(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map from endpoint strings to API reference URLs.
|
||||
* Mintlify generates URLs from summary (slugified), not operationId.
|
||||
* @param {object} openapi - The OpenAPI specification object.
|
||||
* @returns {{exact: Map<string, string>, pattern: Map<string, string>}} Maps for exact and pattern-based lookup.
|
||||
*/
|
||||
function buildEndpointMap(openapi) {
|
||||
const exact = new Map();
|
||||
const pattern = new Map();
|
||||
|
||||
for (const [pathTemplate, methods] of Object.entries(openapi.paths || {})) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
if (method === 'parameters') continue;
|
||||
|
||||
const summary = operation.summary;
|
||||
const tags = operation.tags || ['General'];
|
||||
const primaryTag = tags[0];
|
||||
|
||||
if (summary && primaryTag) {
|
||||
const tagSlug = slugify(primaryTag);
|
||||
const summarySlug = slugify(summary);
|
||||
const url = `/api-reference/${tagSlug}/${summarySlug}`;
|
||||
|
||||
const normMethod = method.toUpperCase();
|
||||
|
||||
const key = `${normMethod} ${pathTemplate}`;
|
||||
exact.set(key, url);
|
||||
|
||||
const colonPath = pathTemplate.replace(/\{(\w+)\}/g, ':$1');
|
||||
if (colonPath !== pathTemplate) {
|
||||
exact.set(`${normMethod} ${colonPath}`, url);
|
||||
}
|
||||
|
||||
const patternKey = `${normMethod} ${normalisePathPattern(pathTemplate)}`;
|
||||
if (!pattern.has(patternKey)) {
|
||||
pattern.set(patternKey, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {exact, pattern};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an endpoint string to an API reference link.
|
||||
* @param {string} endpoint - The endpoint string (e.g., "POST /users/@me/memes" or "POST /invites/:code (group DM invites)")
|
||||
* @param {{exact: Map<string, string>, pattern: Map<string, string>}} endpointMap - Maps for exact and pattern-based lookup.
|
||||
* @returns {string} Markdown link or plain code if no match found.
|
||||
*/
|
||||
function endpointToLink(endpoint, endpointMap) {
|
||||
const parenMatch = endpoint.match(/^(.+?)\s*\((.+)\)$/);
|
||||
const cleanEndpoint = parenMatch ? parenMatch[1].trim() : endpoint;
|
||||
const description = parenMatch ? parenMatch[2] : null;
|
||||
|
||||
let url = endpointMap.exact.get(cleanEndpoint);
|
||||
|
||||
if (!url) {
|
||||
const normalised = cleanEndpoint.replace(/:(\w+)/g, '{$1}');
|
||||
url = endpointMap.exact.get(normalised);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
const [method, ...pathParts] = cleanEndpoint.split(' ');
|
||||
const path = pathParts.join(' ');
|
||||
const patternKey = `${method} ${normalisePathPattern(path)}`;
|
||||
url = endpointMap.pattern.get(patternKey);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const link = `[\`${cleanEndpoint}\`](${url})`;
|
||||
return description ? `${link} (${description})` : link;
|
||||
}
|
||||
|
||||
return `\`${endpoint}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all event schemas from the schemas/events directory.
|
||||
*/
|
||||
async function loadEventSchemas(schemasDir) {
|
||||
const schemas = new Map();
|
||||
try {
|
||||
const files = await fs.readdir(schemasDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const filePath = path.join(schemasDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const schema = JSON.parse(content);
|
||||
schemas.set(schema.name, schema);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn(`Warning: Could not load event schemas from ${schemasDir}`);
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resource page URL for a schema name.
|
||||
*/
|
||||
function getSchemaLink(schemaName) {
|
||||
if (GATEWAY_LOCAL_SCHEMAS.has(schemaName)) {
|
||||
return `#${schemaName.toLowerCase()}`;
|
||||
}
|
||||
|
||||
const resource = SCHEMA_TO_RESOURCE[schemaName];
|
||||
if (resource) {
|
||||
return `/resources/${resource}#${schemaName.toLowerCase()}`;
|
||||
}
|
||||
const baseName = schemaName
|
||||
.replace(/Response$/, '')
|
||||
.replace(/Request$/, '')
|
||||
.toLowerCase();
|
||||
return `/resources/${baseName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a type reference for display.
|
||||
*/
|
||||
function formatTypeRef(typeInfo) {
|
||||
if (!typeInfo) return 'unknown';
|
||||
|
||||
if (typeInfo.$ref) {
|
||||
const refName = typeInfo.$ref;
|
||||
const link = getSchemaLink(refName);
|
||||
return `[${refName}](${link})`;
|
||||
}
|
||||
|
||||
if (Array.isArray(typeInfo.type)) {
|
||||
const nonNullTypes = typeInfo.type.filter((t) => t !== 'null');
|
||||
const hasNull = typeInfo.type.includes('null');
|
||||
if (nonNullTypes.length === 1 && hasNull) {
|
||||
return `?${nonNullTypes[0]}`;
|
||||
}
|
||||
return typeInfo.type.map((t) => (t === 'null' ? 'null' : t)).join(' \\| ');
|
||||
}
|
||||
|
||||
if (typeInfo.type === 'array') {
|
||||
if (typeInfo.items?.$ref) {
|
||||
const refName = typeInfo.items.$ref;
|
||||
const link = getSchemaLink(refName);
|
||||
return `[${refName}](${link})[]`;
|
||||
}
|
||||
if (typeInfo.items?.type) {
|
||||
return `${typeInfo.items.type}[]`;
|
||||
}
|
||||
return 'array';
|
||||
}
|
||||
|
||||
if (typeInfo.type === 'object' && typeInfo.properties) {
|
||||
return 'object';
|
||||
}
|
||||
|
||||
return typeInfo.type || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render scope badge.
|
||||
*/
|
||||
function renderScopeBadge(scope) {
|
||||
const badges = {
|
||||
session: '`session`',
|
||||
presence: '`presence`',
|
||||
guild: '`guild`',
|
||||
channel: '`channel`',
|
||||
};
|
||||
return badges[scope] || `\`${scope}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render payload fields table.
|
||||
*/
|
||||
function renderPayloadTable(payload, required = []) {
|
||||
if (!payload || !payload.properties) {
|
||||
if (payload?.$ref) {
|
||||
const link = getSchemaLink(payload.$ref);
|
||||
return `See [${payload.$ref}](${link}) for payload structure.\n`;
|
||||
}
|
||||
if (payload?.type === 'array') {
|
||||
return `Payload is an ${formatTypeRef(payload)}.\n`;
|
||||
}
|
||||
if (payload?.description && Object.keys(payload).length <= 2) {
|
||||
return `${payload.description}\n`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
let out = '';
|
||||
out += '| Field | Type | Description |\n';
|
||||
out += '|-------|------|-------------|\n';
|
||||
|
||||
const requiredSet = new Set(required);
|
||||
|
||||
for (const [fieldName, fieldInfo] of Object.entries(payload.properties)) {
|
||||
const isRequired = requiredSet.has(fieldName);
|
||||
const displayName = isRequired ? fieldName : `${fieldName}?`;
|
||||
const typeStr = formatTypeRef(fieldInfo);
|
||||
const description = escapeTableText(fieldInfo.description || '');
|
||||
|
||||
out += `| ${displayName} | ${typeStr} | ${description} |\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render dispatched by section.
|
||||
* @param {string[]} dispatchedBy - Array of endpoint strings or 'gateway'.
|
||||
* @param {Map<string, string>} endpointMap - Map from endpoints to API reference URLs.
|
||||
*/
|
||||
function renderDispatchedBy(dispatchedBy, endpointMap) {
|
||||
if (!dispatchedBy || dispatchedBy.length === 0) return '';
|
||||
|
||||
const endpoints = dispatchedBy.filter((d) => d !== 'gateway');
|
||||
const isGatewayOnly = dispatchedBy.includes('gateway') && endpoints.length === 0;
|
||||
|
||||
if (isGatewayOnly) {
|
||||
return '**Dispatched by:** Gateway (internal)\n\n';
|
||||
}
|
||||
|
||||
if (endpoints.length === 0) return '';
|
||||
|
||||
let out = '**Dispatched by:**\n';
|
||||
for (const endpoint of endpoints) {
|
||||
const link = endpointToLink(endpoint, endpointMap);
|
||||
out += `- ${link}\n`;
|
||||
}
|
||||
if (dispatchedBy.includes('gateway')) {
|
||||
out += '- Gateway (internal)\n';
|
||||
}
|
||||
out += '\n';
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single event section.
|
||||
* @param {object} schema - The event schema object.
|
||||
* @param {Map<string, string>} endpointMap - Map from endpoints to API reference URLs.
|
||||
*/
|
||||
function renderEventSection(schema, endpointMap) {
|
||||
let out = '';
|
||||
|
||||
out += `### \`${schema.name}\`\n\n`;
|
||||
out += `${schema.description}\n\n`;
|
||||
|
||||
out += `**Scope:** ${renderScopeBadge(schema.scope)}`;
|
||||
if (schema.scopeNote) {
|
||||
out += ` – ${schema.scopeNote}`;
|
||||
}
|
||||
out += '\n\n';
|
||||
|
||||
out += renderDispatchedBy(schema.dispatchedBy, endpointMap);
|
||||
|
||||
if (schema.note) {
|
||||
out += `<Note>${schema.note}</Note>\n\n`;
|
||||
}
|
||||
|
||||
out += '**Payload:**\n\n';
|
||||
const payloadTable = renderPayloadTable(schema.payload, schema.payload?.required || []);
|
||||
if (payloadTable) {
|
||||
out += payloadTable;
|
||||
} else {
|
||||
out += 'Empty payload.\n';
|
||||
}
|
||||
out += '\n';
|
||||
|
||||
if (schema.payload?.additionalProperties) {
|
||||
out += '**Additional fields:**\n\n';
|
||||
out += renderPayloadTable(
|
||||
{properties: schema.payload.additionalProperties},
|
||||
Object.keys(schema.payload.additionalProperties),
|
||||
);
|
||||
out += '\n';
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render opcodes table.
|
||||
*/
|
||||
function renderOpcodesTable(opcodes) {
|
||||
let out = '';
|
||||
out += '| Opcode | Name | Description | Client Action |\n';
|
||||
out += '|--------|------|-------------|---------------|\n';
|
||||
|
||||
for (const {code, name, description, action} of opcodes) {
|
||||
out += `| \`${code}\` | \`${escapeTableText(name)}\` | ${escapeTableText(description)} | ${escapeTableText(action)} |\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render close codes table.
|
||||
*/
|
||||
function renderCloseCodesTable(closeCodes) {
|
||||
let out = '';
|
||||
out += '| Code | Name | Description | Reconnect |\n';
|
||||
out += '|------|------|-------------|----------|\n';
|
||||
|
||||
for (const {code, name, description, reconnect} of closeCodes) {
|
||||
out += `| \`${code}\` | \`${escapeTableText(name)}\` | ${escapeTableText(description)} | ${reconnect ? 'Yes' : 'No'} |\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render events quick reference table.
|
||||
*/
|
||||
function renderEventsQuickReferenceTable(schemas) {
|
||||
let out = '';
|
||||
out += '| Event | Scope | Description |\n';
|
||||
out += '|-------|-------|-------------|\n';
|
||||
|
||||
for (const category of EventCategories) {
|
||||
for (const eventName of category.events) {
|
||||
const schema = schemas.get(eventName);
|
||||
if (schema) {
|
||||
const anchor = eventName.toLowerCase().replace(/_/g, '-');
|
||||
out += `| [\`${eventName}\`](#${anchor}) | ${renderScopeBadge(schema.scope)} | ${escapeTableText(schema.description)} |\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
const gatewayDir = path.join(repoRoot, 'fluxer_docs/gateway');
|
||||
const schemasDir = path.join(repoRoot, 'fluxer_docs/schemas/events');
|
||||
const openapiPath = path.join(repoRoot, 'fluxer_docs/api-reference/openapi.json');
|
||||
|
||||
const openapi = await readJsonFile(openapiPath);
|
||||
const endpointMap = buildEndpointMap(openapi);
|
||||
console.log(
|
||||
`Built endpoint map with ${endpointMap.exact.size} exact and ${endpointMap.pattern.size} pattern entries`,
|
||||
);
|
||||
|
||||
const eventSchemas = await loadEventSchemas(schemasDir);
|
||||
console.log(`Loaded ${eventSchemas.size} event schemas`);
|
||||
|
||||
let opcodesContent = '';
|
||||
opcodesContent += createFrontmatter({
|
||||
title: 'Opcodes',
|
||||
description: 'Gateway opcodes used for communication between client and server.',
|
||||
});
|
||||
opcodesContent += '\n\n';
|
||||
opcodesContent +=
|
||||
'Gateway opcodes indicate the type of payload being sent or received. Clients send and receive different opcodes depending on their role in the connection lifecycle.\n\n';
|
||||
opcodesContent += '## Opcode reference\n\n';
|
||||
opcodesContent += renderOpcodesTable(GatewayOpcodes);
|
||||
|
||||
const opcodesPath = path.join(gatewayDir, 'opcodes.mdx');
|
||||
await writeFile(opcodesPath, opcodesContent);
|
||||
|
||||
let closeCodesContent = '';
|
||||
closeCodesContent += createFrontmatter({
|
||||
title: 'Close codes',
|
||||
description: 'WebSocket close codes used by the Fluxer gateway.',
|
||||
});
|
||||
closeCodesContent += '\n\n';
|
||||
closeCodesContent +=
|
||||
'When the gateway closes a connection, it sends a close code indicating why. Some close codes are recoverable (the client should reconnect), while others are not.\n\n';
|
||||
closeCodesContent += '## Close code reference\n\n';
|
||||
closeCodesContent += renderCloseCodesTable(GatewayCloseCodes);
|
||||
|
||||
const closeCodesPath = path.join(gatewayDir, 'close_codes.mdx');
|
||||
await writeFile(closeCodesPath, closeCodesContent);
|
||||
|
||||
let eventsContent = '';
|
||||
eventsContent += createFrontmatter({
|
||||
title: 'Events',
|
||||
description: 'Gateway dispatch events sent by the Fluxer gateway.',
|
||||
});
|
||||
eventsContent += '\n\n';
|
||||
eventsContent +=
|
||||
'Dispatch events are sent by the gateway to notify the client of state changes. These events are sent with opcode `0` (DISPATCH) and include an event name and associated data.\n\n';
|
||||
|
||||
eventsContent += '## Event scopes\n\n';
|
||||
eventsContent += 'Events are delivered based on their scope:\n\n';
|
||||
eventsContent += '| Scope | Description |\n';
|
||||
eventsContent += '|-------|-------------|\n';
|
||||
eventsContent += '| `session` | Sent only to the current session |\n';
|
||||
eventsContent += '| `presence` | Sent to all sessions of the current user |\n';
|
||||
eventsContent += '| `guild` | Sent to all users in a guild who have permission to receive it |\n';
|
||||
eventsContent +=
|
||||
'| `channel` | Sent based on channel type (guild channels use guild scope, DMs use presence scope) |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '## Event reference\n\n';
|
||||
eventsContent += renderEventsQuickReferenceTable(eventSchemas);
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '## Gateway types\n\n';
|
||||
eventsContent += 'These types are used in gateway event payloads but are not exposed through the HTTP API.\n\n';
|
||||
|
||||
eventsContent += '### VoiceStateResponse\n\n';
|
||||
eventsContent += "Represents a user's voice connection state.\n\n";
|
||||
eventsContent += '| Field | Type | Description |\n';
|
||||
eventsContent += '|-------|------|-------------|\n';
|
||||
eventsContent += '| guild_id | ?snowflake | The guild ID this voice state is for, null if in a DM call |\n';
|
||||
eventsContent += '| channel_id | ?snowflake | The channel ID the user is connected to, null if disconnected |\n';
|
||||
eventsContent += '| user_id | snowflake | The user ID this voice state is for |\n';
|
||||
eventsContent += '| connection_id? | ?string | The unique connection identifier |\n';
|
||||
eventsContent += '| session_id? | string | The session ID for this voice state |\n';
|
||||
eventsContent +=
|
||||
'| member? | [GuildMemberResponse](/resources/guilds#guildmemberresponse) | The guild member data, if in a guild voice channel |\n';
|
||||
eventsContent += '| mute | boolean | Whether the user is server muted |\n';
|
||||
eventsContent += '| deaf | boolean | Whether the user is server deafened |\n';
|
||||
eventsContent += '| self_mute | boolean | Whether the user has muted themselves |\n';
|
||||
eventsContent += '| self_deaf | boolean | Whether the user has deafened themselves |\n';
|
||||
eventsContent += '| self_video? | boolean | Whether the user has their camera enabled |\n';
|
||||
eventsContent += '| self_stream? | boolean | Whether the user is streaming |\n';
|
||||
eventsContent += '| is_mobile? | boolean | Whether the user is connected from a mobile device |\n';
|
||||
eventsContent += '| viewer_stream_keys? | string[] | An array of stream keys the user is currently viewing |\n';
|
||||
eventsContent += '| version? | integer | The voice state version for ordering updates |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '### PresenceResponse\n\n';
|
||||
eventsContent += "Represents a user's presence (online status and activity).\n\n";
|
||||
eventsContent += '| Field | Type | Description |\n';
|
||||
eventsContent += '|-------|------|-------------|\n';
|
||||
eventsContent +=
|
||||
'| user | [UserPartialResponse](/resources/users#userpartialresponse) | The user this presence is for |\n';
|
||||
eventsContent += '| status | string | The current online status (online, idle, dnd, invisible, offline) |\n';
|
||||
eventsContent += '| mobile | boolean | Whether the user is on a mobile device |\n';
|
||||
eventsContent += '| afk | boolean | Whether the user is marked as AFK |\n';
|
||||
eventsContent +=
|
||||
'| custom_status | ?[CustomStatusResponse](#customstatusresponse) | The custom status set by the user |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '### CustomStatusResponse\n\n';
|
||||
eventsContent += "Represents a user's custom status.\n\n";
|
||||
eventsContent += '| Field | Type | Description |\n';
|
||||
eventsContent += '|-------|------|-------------|\n';
|
||||
eventsContent += '| text | string | The custom status text |\n';
|
||||
eventsContent += '| emoji_id | ?snowflake | The ID of the custom emoji used in the status |\n';
|
||||
eventsContent += '| emoji_name | ?string | The name of the emoji used in the status |\n';
|
||||
eventsContent += '| expires_at | ?string | ISO8601 timestamp when the custom status expires |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '### SessionResponse\n\n';
|
||||
eventsContent += "Represents a user's gateway session.\n\n";
|
||||
eventsContent += '| Field | Type | Description |\n';
|
||||
eventsContent += '|-------|------|-------------|\n';
|
||||
eventsContent += '| session_id | string | The session identifier, or "all" for the aggregate session |\n';
|
||||
eventsContent += '| status | string | The status for this session (online, idle, dnd, invisible, offline) |\n';
|
||||
eventsContent += '| mobile | boolean | Whether this session is on a mobile device |\n';
|
||||
eventsContent += '| afk | boolean | Whether this session is marked as AFK |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '### ReadStateResponse\n\n';
|
||||
eventsContent += 'Represents read state for a channel.\n\n';
|
||||
eventsContent += '| Field | Type | Description |\n';
|
||||
eventsContent += '|-------|------|-------------|\n';
|
||||
eventsContent += '| id | snowflake | The channel ID for this read state |\n';
|
||||
eventsContent += '| mention_count | integer | Number of unread mentions in the channel |\n';
|
||||
eventsContent += '| last_message_id | ?snowflake | The ID of the last message read |\n';
|
||||
eventsContent += '| last_pin_timestamp | ?string | ISO8601 timestamp of the last pinned message acknowledged |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '### GuildReadyResponse\n\n';
|
||||
eventsContent += 'Partial guild data sent in the READY event.\n\n';
|
||||
eventsContent += '| Field | Type | Description |\n';
|
||||
eventsContent += '|-------|------|-------------|\n';
|
||||
eventsContent += '| id | snowflake | The unique identifier for this guild |\n';
|
||||
eventsContent += '| unavailable? | boolean | Whether the guild is unavailable due to an outage |\n';
|
||||
eventsContent += '| name? | string | The name of the guild |\n';
|
||||
eventsContent += '| icon? | ?string | The hash of the guild icon |\n';
|
||||
eventsContent += '| owner_id? | snowflake | The ID of the guild owner |\n';
|
||||
eventsContent += '| member_count? | integer | Total number of members in the guild |\n';
|
||||
eventsContent += '| lazy? | boolean | Whether this guild uses lazy loading |\n';
|
||||
eventsContent += '| large? | boolean | Whether this guild is considered large |\n';
|
||||
eventsContent += '| joined_at? | string | ISO8601 timestamp of when the user joined |\n';
|
||||
eventsContent += '\n';
|
||||
|
||||
eventsContent += '## Event details\n\n';
|
||||
|
||||
for (const category of EventCategories) {
|
||||
eventsContent += `### ${category.name} events\n\n`;
|
||||
|
||||
for (const eventName of category.events) {
|
||||
const schema = eventSchemas.get(eventName);
|
||||
if (schema) {
|
||||
eventsContent += renderEventSection(schema, endpointMap);
|
||||
eventsContent += '---\n\n';
|
||||
} else {
|
||||
eventsContent += `#### \`${eventName}\`\n\n`;
|
||||
eventsContent += 'Documentation pending.\n\n';
|
||||
eventsContent += '---\n\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eventsPath = path.join(gatewayDir, 'events.mdx');
|
||||
await writeFile(eventsPath, eventsContent);
|
||||
|
||||
console.log('Generated gateway documentation:');
|
||||
console.log(` - ${opcodesPath} (${GatewayOpcodes.length} opcodes)`);
|
||||
console.log(` - ${closeCodesPath} (${GatewayCloseCodes.length} close codes)`);
|
||||
console.log(` - ${eventsPath} (${eventSchemas.size} events with payload documentation)`);
|
||||
}
|
||||
|
||||
await main();
|
||||
1060
fluxer_docs/scripts/generate_media_proxy.mjs
Normal file
1060
fluxer_docs/scripts/generate_media_proxy.mjs
Normal file
File diff suppressed because it is too large
Load Diff
159
fluxer_docs/scripts/generate_permissions.mjs
Normal file
159
fluxer_docs/scripts/generate_permissions.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createFrontmatter, escapeTableText, writeFile} from './shared.mjs';
|
||||
|
||||
/**
|
||||
* Permission definitions from @fluxer/constants.
|
||||
* These are copied here to avoid import issues with ESM/TypeScript in the generator.
|
||||
*/
|
||||
const Permissions = {
|
||||
CREATE_INSTANT_INVITE: 1n << 0n,
|
||||
KICK_MEMBERS: 1n << 1n,
|
||||
BAN_MEMBERS: 1n << 2n,
|
||||
ADMINISTRATOR: 1n << 3n,
|
||||
MANAGE_CHANNELS: 1n << 4n,
|
||||
MANAGE_GUILD: 1n << 5n,
|
||||
ADD_REACTIONS: 1n << 6n,
|
||||
VIEW_AUDIT_LOG: 1n << 7n,
|
||||
PRIORITY_SPEAKER: 1n << 8n,
|
||||
STREAM: 1n << 9n,
|
||||
VIEW_CHANNEL: 1n << 10n,
|
||||
SEND_MESSAGES: 1n << 11n,
|
||||
SEND_TTS_MESSAGES: 1n << 12n,
|
||||
MANAGE_MESSAGES: 1n << 13n,
|
||||
EMBED_LINKS: 1n << 14n,
|
||||
ATTACH_FILES: 1n << 15n,
|
||||
READ_MESSAGE_HISTORY: 1n << 16n,
|
||||
MENTION_EVERYONE: 1n << 17n,
|
||||
USE_EXTERNAL_EMOJIS: 1n << 18n,
|
||||
CONNECT: 1n << 20n,
|
||||
SPEAK: 1n << 21n,
|
||||
MUTE_MEMBERS: 1n << 22n,
|
||||
DEAFEN_MEMBERS: 1n << 23n,
|
||||
MOVE_MEMBERS: 1n << 24n,
|
||||
USE_VAD: 1n << 25n,
|
||||
CHANGE_NICKNAME: 1n << 26n,
|
||||
MANAGE_NICKNAMES: 1n << 27n,
|
||||
MANAGE_ROLES: 1n << 28n,
|
||||
MANAGE_WEBHOOKS: 1n << 29n,
|
||||
MANAGE_EXPRESSIONS: 1n << 30n,
|
||||
USE_EXTERNAL_STICKERS: 1n << 37n,
|
||||
MODERATE_MEMBERS: 1n << 40n,
|
||||
CREATE_EXPRESSIONS: 1n << 43n,
|
||||
PIN_MESSAGES: 1n << 51n,
|
||||
BYPASS_SLOWMODE: 1n << 52n,
|
||||
UPDATE_RTC_REGION: 1n << 53n,
|
||||
};
|
||||
|
||||
const PermissionsDescriptions = {
|
||||
CREATE_INSTANT_INVITE: 'Allows creation of instant invites',
|
||||
KICK_MEMBERS: 'Allows kicking members from the guild',
|
||||
BAN_MEMBERS: 'Allows banning members from the guild',
|
||||
ADMINISTRATOR: 'Grants all permissions and bypasses channel permission overwrites',
|
||||
MANAGE_CHANNELS: 'Allows management and editing of channels',
|
||||
MANAGE_GUILD: 'Allows management and editing of the guild',
|
||||
ADD_REACTIONS: 'Allows adding reactions to messages',
|
||||
VIEW_AUDIT_LOG: 'Allows viewing of the audit log',
|
||||
PRIORITY_SPEAKER: 'Allows using priority speaker in a voice channel',
|
||||
STREAM: 'Allows the user to go live',
|
||||
VIEW_CHANNEL: 'Allows viewing a channel',
|
||||
SEND_MESSAGES: 'Allows sending messages in a channel',
|
||||
SEND_TTS_MESSAGES: 'Allows sending text-to-speech messages',
|
||||
MANAGE_MESSAGES: 'Allows for deleting and pinning messages',
|
||||
EMBED_LINKS: 'Links sent will have an embed automatically',
|
||||
ATTACH_FILES: 'Allows uploading files',
|
||||
READ_MESSAGE_HISTORY: 'Allows reading message history',
|
||||
MENTION_EVERYONE: 'Allows using @everyone and @here mentions',
|
||||
USE_EXTERNAL_EMOJIS: 'Allows using emojis from other guilds',
|
||||
CONNECT: 'Allows connecting to a voice channel',
|
||||
SPEAK: 'Allows speaking in a voice channel',
|
||||
MUTE_MEMBERS: 'Allows muting members in voice channels',
|
||||
DEAFEN_MEMBERS: 'Allows deafening members in voice channels',
|
||||
MOVE_MEMBERS: 'Allows moving members between voice channels',
|
||||
USE_VAD: 'Allows using voice activity detection',
|
||||
CHANGE_NICKNAME: 'Allows changing own nickname',
|
||||
MANAGE_NICKNAMES: 'Allows changing other members nicknames',
|
||||
MANAGE_ROLES: 'Allows management and editing of roles',
|
||||
MANAGE_WEBHOOKS: 'Allows management and editing of webhooks',
|
||||
MANAGE_EXPRESSIONS: 'Allows management of guild expressions',
|
||||
USE_EXTERNAL_STICKERS: 'Allows using stickers from other guilds',
|
||||
MODERATE_MEMBERS: 'Allows timing out users',
|
||||
CREATE_EXPRESSIONS: 'Allows creating guild expressions',
|
||||
PIN_MESSAGES: 'Allows pinning messages',
|
||||
BYPASS_SLOWMODE: 'Allows bypassing slowmode',
|
||||
UPDATE_RTC_REGION: 'Allows updating the voice region',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a bigint permission value to a hex string.
|
||||
*/
|
||||
function toHex(value) {
|
||||
return `0x${value.toString(16).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a table of permissions with values and descriptions.
|
||||
*/
|
||||
function renderPermissionsTable(permissions, descriptions) {
|
||||
let out = '';
|
||||
out += '| Permission | Value | Description |\n';
|
||||
out += '|------------|-------|-------------|\n';
|
||||
|
||||
for (const [name, value] of Object.entries(permissions)) {
|
||||
const description = descriptions[name] ?? '-';
|
||||
const hexValue = toHex(value);
|
||||
out += `| \`${escapeTableText(name)}\` | \`${escapeTableText(hexValue)}\` | ${escapeTableText(description)} |\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
const outPath = path.join(repoRoot, 'fluxer_docs/topics/permissions.mdx');
|
||||
|
||||
let out = '';
|
||||
out += createFrontmatter({
|
||||
title: 'Permissions',
|
||||
description: 'Reference for permission bitfields used by Fluxer guilds and channels.',
|
||||
});
|
||||
out += '\n\n';
|
||||
|
||||
out += 'Permissions in Fluxer are stored as a bitfield, represented as a string containing the permission integer. ';
|
||||
out += 'Each permission is a bit position that can be set or unset. ';
|
||||
out +=
|
||||
"To check if a user has a permission, perform a bitwise AND operation between the user's permission value and the permission flag.\n\n";
|
||||
|
||||
out += '## Permission flags\n\n';
|
||||
out += 'The following table lists all available permission flags, their values, and descriptions.\n\n';
|
||||
out += renderPermissionsTable(Permissions, PermissionsDescriptions);
|
||||
|
||||
await writeFile(outPath, out);
|
||||
|
||||
const permissionCount = Object.keys(Permissions).length;
|
||||
console.log('Generated permissions documentation:');
|
||||
console.log(` - ${outPath}`);
|
||||
console.log(` - ${permissionCount} permissions`);
|
||||
}
|
||||
|
||||
await main();
|
||||
1103
fluxer_docs/scripts/generate_resources.mjs
Normal file
1103
fluxer_docs/scripts/generate_resources.mjs
Normal file
File diff suppressed because it is too large
Load Diff
76
fluxer_docs/scripts/generate_scopes.mjs
Normal file
76
fluxer_docs/scripts/generate_scopes.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createFrontmatter, escapeTableText, writeFile} from './shared.mjs';
|
||||
|
||||
/**
|
||||
* OAuth2 scopes and their descriptions.
|
||||
* The scope values must match OAuthScopes from @fluxer/schema/src/domains/oauth/OAuthSchemas.tsx
|
||||
*/
|
||||
const OAuth2ScopeDescriptions = {
|
||||
identify: 'Allows access to basic user information including username, discriminator, avatar, and flags.',
|
||||
email: "Allows access to the user's email address. Requires the identify scope.",
|
||||
guilds: 'Allows access to the list of guilds the user is a member of.',
|
||||
bot: 'Used for bot authorization. Adds a bot to a guild on behalf of the authorizing user.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the OAuth2 scopes table.
|
||||
*/
|
||||
function renderScopesTable(scopes) {
|
||||
let out = '';
|
||||
out += '| Scope | Description |\n';
|
||||
out += '|-------|-------------|\n';
|
||||
|
||||
for (const [scope, description] of Object.entries(scopes)) {
|
||||
out += `| \`${escapeTableText(scope)}\` | ${escapeTableText(description)} |\n`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '../..');
|
||||
const outPath = path.join(repoRoot, 'fluxer_docs/topics/oauth2.mdx');
|
||||
|
||||
let out = '';
|
||||
out += createFrontmatter({
|
||||
title: 'OAuth2',
|
||||
description: 'OAuth2 scopes in Fluxer and what each scope enables.',
|
||||
});
|
||||
out += '\n\n';
|
||||
|
||||
out +=
|
||||
'OAuth2 scopes define the level of access that an application can request from a user. When a user authorizes an application, they grant permission for the application to access specific resources on their behalf.\n\n';
|
||||
|
||||
out += '## Available scopes\n\n';
|
||||
out += renderScopesTable(OAuth2ScopeDescriptions);
|
||||
|
||||
await writeFile(outPath, out);
|
||||
|
||||
const scopeCount = Object.keys(OAuth2ScopeDescriptions).length;
|
||||
console.log('Generated OAuth2 documentation:');
|
||||
console.log(` - ${outPath}`);
|
||||
console.log(` - ${scopeCount} scopes`);
|
||||
}
|
||||
|
||||
await main();
|
||||
110
fluxer_docs/scripts/shared.mjs
Normal file
110
fluxer_docs/scripts/shared.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Escape text for use in markdown tables.
|
||||
* Replaces newlines with spaces, escapes pipe characters, and escapes angle brackets for MDX.
|
||||
*/
|
||||
export function escapeTableText(value) {
|
||||
return String(value).replaceAll('\n', ' ').replaceAll('|', '\\|').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a value in backticks for inline code.
|
||||
* Escapes any existing backticks in the value.
|
||||
*/
|
||||
export function wrapCode(value) {
|
||||
const text = String(value).replace(/`/g, '\\`');
|
||||
return `\`${text}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a name to a lowercase anchor ID suitable for markdown links.
|
||||
* Replaces non-alphanumeric characters with hyphens.
|
||||
*/
|
||||
export function toAnchor(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a JSON file.
|
||||
*/
|
||||
export async function readJsonFile(filePath) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file, creating parent directories if needed.
|
||||
*/
|
||||
export async function writeFile(filePath, content) {
|
||||
await fs.mkdir(path.dirname(filePath), {recursive: true});
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MDX frontmatter from options.
|
||||
*/
|
||||
export function createFrontmatter(options) {
|
||||
const lines = ['---'];
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
lines.push(`${key}: '${String(value).replace(/'/g, "''")}'`);
|
||||
}
|
||||
}
|
||||
lines.push('---');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a default value for display in documentation.
|
||||
*/
|
||||
export function formatDefault(value) {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (value === '') {
|
||||
return wrapCode('""');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return wrapCode(String(value));
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return wrapCode(String(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return wrapCode(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return wrapCode('[]');
|
||||
}
|
||||
return wrapCode(JSON.stringify(value));
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (Object.keys(value).length === 0) {
|
||||
return wrapCode('{}');
|
||||
}
|
||||
return wrapCode(JSON.stringify(value));
|
||||
}
|
||||
return wrapCode(String(value));
|
||||
}
|
||||
Reference in New Issue
Block a user