config: add a generator for the configuration file reference

Adds a second bin target to mas-config which renders
docs/reference/configuration.md from the config JSON schema: doc
comments become the YAML comments, schemars examples/defaults become the
example values, and an x-doc schema extension (commented/skip/yaml)
covers the idioms that need hand-authoring. Wired into misc/update.sh so
the existing CI freshness check keeps the reference in sync.
This commit is contained in:
Quentin Gliech
2026-07-02 13:58:13 +02:00
parent a4ece977c5
commit 67fdeecf70
4 changed files with 998 additions and 0 deletions
Generated
+1
View File
@@ -3247,6 +3247,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"serde_yaml",
"tokio",
"tracing",
"ulid",
+6
View File
@@ -5,6 +5,7 @@
[package]
name = "mas-config"
default-run = "schema"
version.workspace = true
authors.workspace = true
edition.workspace = true
@@ -34,6 +35,7 @@ rustls-pki-types.workspace = true
schemars.workspace = true
serde_json.workspace = true
serde_with.workspace = true
serde_yaml.workspace = true
serde.workspace = true
tokio.workspace = true
tracing.workspace = true
@@ -51,3 +53,7 @@ dist = []
[[bin]]
name = "schema"
doc = false
[[bin]]
name = "docs"
doc = false
+988
View File
@@ -0,0 +1,988 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Generates `docs/reference/configuration.md` from the config JSON schema.
//!
//! The renderer walks the JSON schema (as a [`serde_json::Value`], not the
//! typed schemars API) and emits one annotated YAML example block per config
//! section, in the style of the hand-written reference document.
use schemars::generate::SchemaSettings;
use serde_json::{Map, Value};
/// Order in which sections are rendered. Sections present in the schema but
/// missing from this list are appended at the end and produce a warning.
const SECTION_ORDER: &[&str] = &[
"http",
"database",
"matrix",
"templates",
"clients",
"secrets",
"passwords",
"account",
"captcha",
"policy",
"rate_limiting",
"telemetry",
"email",
"upstream_oauth2",
"branding",
"oauth",
"experimental",
];
/// Fields that are pulled out of their parent's YAML block and rendered as
/// their own `### ` subsection. Each entry is `section.field`.
const HOISTED: &[&str] = &["http.listeners", "upstream_oauth2.providers"];
fn main() {
let generator = SchemaSettings::draft07().into_generator();
let schema = generator.into_root_schema_for::<mas_config::RootConfig>();
let schema: Value =
serde_json::to_value(&schema).expect("Failed to convert schema to serde_json::Value");
let mut renderer = Renderer::new(&schema);
let output = renderer.render();
print!("{output}");
for warning in &renderer.warnings {
eprintln!("{warning}");
}
}
struct Renderer {
/// Top-level properties of the schema (the sections).
properties: Map<String, Value>,
/// The `definitions` map, used to resolve `$ref`s.
definitions: Map<String, Value>,
/// Warnings collected while rendering, printed to stderr at the end.
warnings: Vec<String>,
}
/// A key/value union variant that carries a `const` tag, i.e. a string enum.
struct EnumInfo {
/// The list of `(value, description)` pairs.
values: Vec<(String, Option<String>)>,
/// Whether at least one variant carries a description.
has_descriptions: bool,
}
impl Renderer {
fn new(schema: &Value) -> Self {
let properties = schema
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let definitions = schema
.get("definitions")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
Self {
properties,
definitions,
warnings: Vec::new(),
}
}
fn warn(&mut self, message: String) {
self.warnings.push(message);
}
// --- Schema resolution -------------------------------------------------
/// Follow `$ref`s and unwrap single-element `allOf`s until a fixed point
/// is reached. Keys on the referring site override the target's.
fn resolve(&self, node: &Value) -> Value {
let mut node = node.clone();
loop {
let Some(obj) = node.as_object() else {
return node;
};
// Unwrap single-element `allOf`, merging sibling keys (ref-site wins).
if let Some(all_of) = obj.get("allOf").and_then(Value::as_array)
&& all_of.len() == 1
{
let mut merged = all_of[0].as_object().cloned().unwrap_or_default();
for (key, value) in obj {
if key != "allOf" {
merged.insert(key.clone(), value.clone());
}
}
node = Value::Object(merged);
continue;
}
// Resolve `$ref` into `definitions`, letting ref-site keys win.
if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
let name = reference.rsplit('/').next().unwrap_or(reference);
let mut merged = self
.definitions
.get(name)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (key, value) in obj {
if key != "$ref" {
merged.insert(key.clone(), value.clone());
}
}
node = Value::Object(merged);
continue;
}
return node;
}
}
/// The description of the *definition* a node refers to, ignoring any
/// description on the referring site. `None` when the node is not a
/// reference or the target has no description.
fn definition_description(&self, node: &Value) -> Option<String> {
let mut node = node;
// Look through single-element `allOf` wrappers.
while let Some(all_of) = node.get("allOf").and_then(Value::as_array)
&& let [inner] = all_of.as_slice()
{
node = inner;
}
let reference = node.get("$ref")?.as_str()?;
let name = reference.rsplit('/').next().unwrap_or(reference);
let description = self.definitions.get(name)?.get("description")?.as_str()?;
Some(clean_description(description)).filter(|d| !d.is_empty())
}
/// Resolve a node and, if it represents an `Option<T>`, unwrap it to `T`.
///
/// Handles both `type: ["X", "null"]` and `anyOf: [T, {type: "null"}]`.
fn strip_null(&self, node: &Value) -> Value {
let node = self.resolve(node);
let Some(obj) = node.as_object() else {
return node;
};
// `type: ["X", "null"]` → `type: "X"`
if let Some(types) = obj.get("type").and_then(Value::as_array)
&& types.iter().any(|t| t == "null")
{
let rest: Vec<Value> = types.iter().filter(|t| *t != "null").cloned().collect();
let mut merged = obj.clone();
match rest.as_slice() {
[single] => merged.insert("type".to_owned(), single.clone()),
_ => merged.insert("type".to_owned(), Value::Array(rest)),
};
return Value::Object(merged);
}
// `anyOf: [T, {type: "null"}]` → `T` (with ref-site keys merged in)
let null = serde_json::json!({ "type": "null" });
if let Some(variants) = obj.get("anyOf").and_then(Value::as_array)
&& variants.len() == 2
&& variants[1] == null
{
let inner = self.resolve(&variants[0]);
let mut merged = inner.as_object().cloned().unwrap_or_default();
for (key, value) in obj {
if key != "anyOf" {
merged.insert(key.clone(), value.clone());
}
}
return Value::Object(merged);
}
node
}
// --- `x-doc` extension --------------------------------------------------
fn x_doc(node: &Value) -> Option<&Map<String, Value>> {
node.get("x-doc").and_then(Value::as_object)
}
fn x_doc_commented(node: &Value) -> bool {
Self::x_doc(node)
.and_then(|d| d.get("commented"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn x_doc_yaml(node: &Value) -> Option<String> {
Self::x_doc(node)
.and_then(|d| d.get("yaml"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn x_doc_skip(node: &Value) -> bool {
Self::x_doc(node)
.and_then(|d| d.get("skip"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
// --- Rendering ---------------------------------------------------------
fn render(&mut self) -> String {
let mut lines: Vec<String> = vec![
"# Configuration file reference".to_owned(),
String::new(),
"<!-- This file is generated by `cargo run -p mas-config --bin docs`. Do not edit manually. -->"
.to_owned(),
String::new(),
];
// Figure out the section order: known sections first, then any extra.
let mut order: Vec<String> = SECTION_ORDER
.iter()
.filter(|name| self.properties.contains_key(**name))
.map(|name| (*name).to_owned())
.collect();
let mut extra: Vec<String> = self
.properties
.keys()
.filter(|name| !SECTION_ORDER.contains(&name.as_str()))
.cloned()
.collect();
extra.sort();
for name in &extra {
self.warn(format!(
"section not in SECTION_ORDER, appended at the end: {name}"
));
}
order.extend(extra);
for name in order {
self.render_section(&mut lines, &name);
}
finalize(&lines)
}
fn render_section(&mut self, lines: &mut Vec<String>, name: &str) {
let raw_node = self.properties.get(name).cloned().unwrap_or(Value::Null);
let node = self.strip_null(&raw_node);
lines.push(format!("## `{name}`"));
lines.push(String::new());
// Section description, split around an optional `<!-- more -->` marker.
//
// Prefer the section *struct*'s own doc comment over the `RootConfig`
// field's: the resolver lets ref-site keys win (which is right for
// fields), but section structs carry the full reference prose while
// the `RootConfig` field docs are one-line summaries.
let description = self
.definition_description(&raw_node)
.or_else(|| {
node.get("description")
.and_then(Value::as_str)
.map(clean_description)
})
.unwrap_or_default();
let (before, after) = split_more(&description);
if !before.is_empty() {
lines.push(before);
lines.push(String::new());
}
// Which of this section's fields are hoisted into their own subsection.
let hoisted: Vec<String> = HOISTED
.iter()
.filter_map(|h| h.strip_prefix(&format!("{name}.")))
.map(ToOwned::to_owned)
.collect();
let hoisted_refs: Vec<&str> = hoisted.iter().map(String::as_str).collect();
// Emit the section's own YAML block, unless everything is hoisted.
let has_body = section_has_body(&node, &hoisted_refs);
if has_body {
lines.push("```yaml".to_owned());
let body = self.emit_value(name, &node, 0, name, &hoisted_refs, None);
lines.extend(body);
lines.push("```".to_owned());
lines.push(String::new());
}
if !after.is_empty() {
lines.push(after);
lines.push(String::new());
}
// Emit hoisted subsections.
for field in &hoisted {
self.render_hoisted(lines, name, &node, field);
}
}
fn render_hoisted(
&mut self,
lines: &mut Vec<String>,
section: &str,
node: &Value,
field: &str,
) {
let Some(field_node) = node
.get("properties")
.and_then(Value::as_object)
.and_then(|props| props.get(field))
else {
return;
};
let field_node = self.strip_null(field_node);
lines.push(format!("### `{section}.{field}`"));
lines.push(String::new());
// Field description, split around an optional `<!-- more -->` marker.
let (before, after) = split_more(
field_node
.get("description")
.and_then(Value::as_str)
.map(clean_description)
.as_deref()
.unwrap_or_default(),
);
if !before.is_empty() {
lines.push(before);
lines.push(String::new());
}
lines.push("```yaml".to_owned());
lines.push(format!("{section}:"));
let path = format!("{section}.{field}");
let body = self.emit_value(field, &field_node, 2, &path, &[], None);
lines.extend(body);
lines.push("```".to_owned());
lines.push(String::new());
if !after.is_empty() {
lines.push(after);
lines.push(String::new());
}
}
/// Emit a single field: its comment lines followed by its value.
///
/// A field marked with `x-doc.skip` emits nothing (its content is
/// hand-authored in a sibling's `x-doc.yaml` block). A field with
/// `x-doc.yaml` is entirely hand-authored: the raw YAML (comments and key
/// line included) replaces the whole emission.
fn emit_field(
&mut self,
name: &str,
node: &Value,
indent: usize,
path: &str,
inherited: Option<&Value>,
) -> Vec<String> {
let resolved = self.strip_null(node);
if Self::x_doc_skip(&resolved) {
return Vec::new();
}
if let Some(yaml) = Self::x_doc_yaml(&resolved) {
return reindent(&yaml, indent);
}
let commented = Self::x_doc_commented(&resolved);
let mut out = comment_lines(&resolved, indent);
let value = self.emit_value(name, &resolved, indent, path, &[], inherited);
if commented {
out.extend(value.into_iter().map(|line| comment_out(&line)));
} else {
out.extend(value);
}
out
}
/// Emit the value of a field: `name:` and everything below it, unindented
/// by `indent` spaces. `skip` names direct children to omit (used to hoist
/// subsections out of a section's body).
fn emit_value(
&mut self,
name: &str,
resolved: &Value,
indent: usize,
path: &str,
skip: &[&str],
inherited: Option<&Value>,
) -> Vec<String> {
let pad = " ".repeat(indent);
// An explicit example wins over recursing into the value's structure:
// it renders as one YAML block and short-circuits descending into
// (potentially huge) sub-schemas like the JSON Web Key types.
if resolved
.get("examples")
.and_then(Value::as_array)
.is_some_and(|examples| !examples.is_empty())
{
return self.emit_leaf(name, resolved, indent, path, inherited);
}
// Object with properties → recurse into each child, handing each the
// matching part of this object's `default` so that leaves without
// their own example/default can still show a value (e.g. the rate
// limiters, whose defaults are whole-object defaults on the parent).
if let Some(props) = resolved.get("properties").and_then(Value::as_object)
&& !props.is_empty()
{
let defaults = resolved.get("default").or(inherited);
let mut out = vec![format!("{pad}{name}:")];
let mut first = true;
for (key, child) in props {
if skip.contains(&key.as_str()) {
continue;
}
let child_path = format!("{path}.{key}");
let child_inherited = defaults.and_then(|d| d.get(key));
let emitted = self.emit_field(key, child, indent + 2, &child_path, child_inherited);
if emitted.is_empty() {
continue;
}
if !first {
out.push(String::new());
}
first = false;
out.extend(emitted);
}
return out;
}
// Arrays with structured items.
if resolved.get("type").and_then(Value::as_str) == Some("array")
&& let Some(items) = resolved.get("items")
{
let items = self.strip_null(items);
let item_path = format!("{path}[]");
let has_props = items
.get("properties")
.and_then(Value::as_object)
.is_some_and(|p| !p.is_empty());
if has_props {
let mut out = vec![format!("{pad}{name}:")];
out.extend(self.render_list_item(&items, indent + 2, &item_path));
return out;
}
if let Some(variants) = variants_of(&items) {
let mut out = vec![format!("{pad}{name}:")];
out.extend(self.render_variants(&variants, indent + 2, &item_path));
return out;
}
}
// Leaf value: example, then default, then a commented placeholder.
self.emit_leaf(name, resolved, indent, path, inherited)
}
/// Emit a leaf value using the first example, then the default (own or
/// inherited from a parent object's default), then a commented
/// placeholder (which also records a warning).
fn emit_leaf(
&mut self,
name: &str,
resolved: &Value,
indent: usize,
path: &str,
inherited: Option<&Value>,
) -> Vec<String> {
// A `const` is a fixed value: render it directly.
if let Some(constant) = resolved.get("const") {
return render_scalar(name, constant, resolved, indent);
}
if let Some(example) = resolved
.get("examples")
.and_then(Value::as_array)
.and_then(|a| a.first())
{
return render_scalar(name, example, resolved, indent);
}
if let Some(default) = resolved.get("default")
&& !default.is_null()
{
return render_scalar(name, default, resolved, indent);
}
if let Some(inherited) = inherited
&& !inherited.is_null()
{
return render_scalar(name, inherited, resolved, indent);
}
let pad = " ".repeat(indent);
// A field explicitly marked `commented` with no value to show renders
// as a bare commented-out key, without a placeholder or a warning.
if Self::x_doc_commented(resolved) {
return vec![format!("{pad}{name}:")];
}
self.warn(format!("missing example: {path}"));
vec![format!("{pad}#{name}: <{}>", placeholder_type(resolved))]
}
/// Render a single list item (`- …`) whose type is an object with
/// properties, splicing the `- ` marker into the first emitted line.
fn render_list_item(&mut self, item: &Value, dash_indent: usize, path: &str) -> Vec<String> {
let mut lines = Vec::new();
if let Some(props) = item.get("properties").and_then(Value::as_object) {
// Emit `const` properties (an internally-tagged enum's tag, e.g. a
// resource's `name`) before the variant's other fields: the tag is
// what identifies the item, so it reads best first.
let (tags, fields): (Vec<_>, Vec<_>) = props
.iter()
.partition(|(_, child)| child.get("const").is_some());
let mut first = true;
for (key, child) in tags.into_iter().chain(fields) {
let child_path = format!("{path}.{key}");
let emitted = self.emit_field(key, child, dash_indent + 2, &child_path, None);
if emitted.is_empty() {
continue;
}
if !first {
lines.push(String::new());
}
first = false;
lines.extend(emitted);
}
}
splice_dash(&mut lines, dash_indent);
lines
}
/// Render one list item per variant of an `anyOf`/`oneOf`, each preceded by
/// its variant description as a comment.
fn render_variants(
&mut self,
variants: &[Value],
dash_indent: usize,
path: &str,
) -> Vec<String> {
let pad = " ".repeat(dash_indent);
let mut out = Vec::new();
for (index, variant) in variants.iter().enumerate() {
if index > 0 {
out.push(String::new());
}
let resolved = self.strip_null(variant);
if let Some(desc) = resolved.get("description").and_then(Value::as_str) {
out.extend(comment_block(&clean_description(desc), dash_indent));
}
let has_props = resolved
.get("properties")
.and_then(Value::as_object)
.is_some_and(|p| !p.is_empty());
if has_props {
out.extend(self.render_list_item(&resolved, dash_indent, path));
} else if let Some(constant) = resolved.get("const").and_then(Value::as_str) {
out.push(format!("{pad}- {constant}"));
} else {
self.warn(format!("missing example: {path}"));
out.push(format!("{pad}- <{}>", placeholder_type(&resolved)));
}
}
out
}
}
// --- Free functions --------------------------------------------------------
/// Collapse runs of blank lines and ensure a single trailing newline.
fn finalize(lines: &[String]) -> String {
let mut output = String::new();
let mut previous_blank = true; // suppresses a leading blank line
for line in lines {
let blank = line.is_empty();
if blank && previous_blank {
continue;
}
previous_blank = blank;
output.push_str(line);
output.push('\n');
}
while output.ends_with("\n\n") {
output.pop();
}
output
}
/// Strip the single leading space schemars keeps on each doc-comment line.
fn clean_description(desc: &str) -> String {
desc.lines()
.map(|line| line.strip_prefix(' ').unwrap_or(line))
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_owned()
}
/// Split a section description around a `<!-- more -->` marker line.
fn split_more(desc: &str) -> (String, String) {
let mut before = Vec::new();
let mut after = Vec::new();
let mut seen = false;
for line in desc.lines() {
if line.trim() == "<!-- more -->" {
seen = true;
continue;
}
if seen {
after.push(line);
} else {
before.push(line);
}
}
(
before.join("\n").trim_end().to_owned(),
after.join("\n").trim().to_owned(),
)
}
/// Build the comment lines for a field: its description, wrapped, plus a
/// "Possible values" block for described string enums.
fn comment_lines(resolved: &Value, indent: usize) -> Vec<String> {
let mut out = Vec::new();
if let Some(desc) = resolved.get("description").and_then(Value::as_str) {
out.extend(comment_block(&clean_description(desc), indent));
}
if let Some(info) = enum_info(resolved)
&& info.has_descriptions
{
let pad = " ".repeat(indent);
out.push(format!("{pad}# Possible values are:"));
for (value, desc) in &info.values {
let first = format!("{pad}# - `{value}`: ");
let cont = format!("{pad}# ");
let text = desc.as_deref().map(clean_description).unwrap_or_default();
let words: Vec<&str> = text.split_whitespace().collect();
out.extend(wrap_prefixed(&words, &first, &cont, 80));
}
}
out
}
/// Turn a description into `# `-prefixed comment lines, wrapped to ~80 columns.
///
/// Blank lines become a bare `#`. Lines that look like list items or are
/// indented are emitted verbatim (their markers/indentation preserved) rather
/// than being reflowed.
fn comment_block(desc: &str, indent: usize) -> Vec<String> {
let pad = " ".repeat(indent);
let prefix = format!("{pad}# ");
let mut out = Vec::new();
let mut paragraph: Vec<&str> = Vec::new();
let flush = |paragraph: &mut Vec<&str>, out: &mut Vec<String>| {
if !paragraph.is_empty() {
let words: Vec<&str> = paragraph
.iter()
.flat_map(|l| l.split_whitespace())
.collect();
out.extend(wrap_prefixed(&words, &prefix, &prefix, 80));
paragraph.clear();
}
};
for line in desc.lines() {
if line.trim().is_empty() {
flush(&mut paragraph, &mut out);
out.push(format!("{pad}#"));
} else if is_structured(line) {
flush(&mut paragraph, &mut out);
out.push(format!("{pad}# {line}"));
} else {
paragraph.push(line);
}
}
flush(&mut paragraph, &mut out);
out
}
/// Whether a description line should be preserved verbatim (list item or
/// indented) rather than reflowed with its neighbours.
fn is_structured(line: &str) -> bool {
if line.starts_with(char::is_whitespace) {
return true;
}
let trimmed = line.trim_start();
trimmed.starts_with("- ") || trimmed.starts_with("* ")
}
/// Greedy word wrap into lines that start with `first`, continuing with `cont`,
/// aiming to keep each line at most `width` columns wide.
fn wrap_prefixed(words: &[&str], first: &str, cont: &str, width: usize) -> Vec<String> {
if words.is_empty() {
return vec![first.trim_end().to_owned()];
}
let mut lines = Vec::new();
let mut current = first.to_owned();
let mut has_word = false;
for word in words {
let projected = current.len() + usize::from(has_word) + word.len();
if has_word && projected > width {
lines.push(std::mem::take(&mut current));
current.push_str(cont);
current.push_str(word);
} else {
if has_word {
current.push(' ');
}
current.push_str(word);
}
has_word = true;
}
lines.push(current);
lines
}
/// Comment out a rendered value line by inserting `#` at the line's own
/// indentation.
///
/// Lines that are already comments (or already commented) are
/// left untouched, to avoid doubling up `#`s under a commented parent.
fn comment_out(line: &str) -> String {
if line.trim().is_empty() {
return line.to_owned();
}
// Insert the `#` at the line's own indentation level, so that nested
// structures stay readable (`#key:` on the parent, ` #child: x` below).
let pos = line.len() - line.trim_start().len();
let (lead, rest) = line.split_at(pos);
if rest.starts_with('#') {
return line.to_owned();
}
format!("{lead}#{rest}")
}
/// Splice a `- ` list marker into the first non-blank line of an item block.
fn splice_dash(lines: &mut [String], dash_indent: usize) {
if let Some(line) = lines.iter_mut().find(|l| !l.trim().is_empty()) {
let content = line.get(dash_indent + 2..).unwrap_or("").to_owned();
*line = format!("{}- {content}", " ".repeat(dash_indent));
}
}
/// Re-indent a raw multi-line string so that its least-indented line sits at
/// `indent` columns.
fn reindent(text: &str, indent: usize) -> Vec<String> {
let pad = " ".repeat(indent);
let min = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
text.lines()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
format!("{pad}{}", &line[min..])
}
})
.collect()
}
/// Extract the `anyOf`/`oneOf` variants of a node, if any.
fn variants_of(node: &Value) -> Option<Vec<Value>> {
node.get("oneOf")
.or_else(|| node.get("anyOf"))
.and_then(Value::as_array)
.cloned()
}
/// Detect a string enum, either as a plain `enum` list or a `oneOf` of `const`
/// string variants, collecting per-variant descriptions where present.
fn enum_info(node: &Value) -> Option<EnumInfo> {
if let Some(values) = node.get("enum").and_then(Value::as_array) {
let values: Vec<(String, Option<String>)> = values
.iter()
.filter_map(|v| v.as_str().map(|s| (s.to_owned(), None)))
.collect();
if values.is_empty() {
return None;
}
return Some(EnumInfo {
values,
has_descriptions: false,
});
}
let variants = node.get("oneOf").and_then(Value::as_array)?;
let mut values = Vec::new();
for variant in variants {
let constant = variant.get("const").and_then(Value::as_str)?;
let desc = variant
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
values.push((constant.to_owned(), desc));
}
if values.is_empty() {
return None;
}
let has_descriptions = values.iter().any(|(_, d)| d.is_some());
Some(EnumInfo {
values,
has_descriptions,
})
}
/// Render a scalar/inline/block leaf value as `name: <value>` lines.
fn render_scalar(name: &str, value: &Value, resolved: &Value, indent: usize) -> Vec<String> {
let pad = " ".repeat(indent);
match value {
Value::Array(items) if items.is_empty() => vec![format!("{pad}{name}: []")],
// Strings containing a colon (URLs, mostly) are not valid plain
// scalars in a flow sequence — render those lists in block style.
Value::Array(items)
if items.iter().all(is_scalar)
&& inline_len(items) <= 60
&& !items
.iter()
.any(|i| i.as_str().is_some_and(|s| s.contains(':'))) =>
{
vec![format!("{pad}{name}: {}", inline_array(items))]
}
Value::Object(_) | Value::Array(_) => {
let mut out = vec![format!("{pad}{name}:")];
out.extend(yaml_block(value, indent + 2));
out
}
Value::String(text) if text.contains('\n') => {
let mut out = vec![format!("{pad}{name}: |")];
let inner = " ".repeat(indent + 2);
for line in text.lines() {
out.push(format!("{inner}{line}"));
}
out
}
_ => {
let mut line = format!("{pad}{name}: {}", yaml_scalar(value));
if let Some(trailing) = enum_trailing(resolved, value) {
line.push_str(&trailing);
}
vec![line]
}
}
}
/// A trailing `# or a, b, c` comment listing the other values of an
/// undescribed enum, or `None` when it does not apply.
fn enum_trailing(resolved: &Value, value: &Value) -> Option<String> {
let info = enum_info(resolved)?;
if info.has_descriptions {
return None;
}
let shown = value.as_str()?;
let others: Vec<&str> = info
.values
.iter()
.map(|(v, _)| v.as_str())
.filter(|v| *v != shown)
.collect();
// A trailing list only reads well for small enums; huge ones (e.g. the
// JSON Web Signature algorithms) are better left to the field's docs.
if others.is_empty() || others.len() > 6 {
return None;
}
Some(format!(" # or {}", others.join(", ")))
}
fn is_scalar(value: &Value) -> bool {
!matches!(value, Value::Array(_) | Value::Object(_))
}
fn inline_len(items: &[Value]) -> usize {
inline_array(items).len()
}
fn inline_array(items: &[Value]) -> String {
let rendered: Vec<String> = items.iter().map(yaml_scalar).collect();
format!("[{}]", rendered.join(", "))
}
/// Render a scalar JSON value as a single-line YAML token.
fn yaml_scalar(value: &Value) -> String {
match value {
Value::Null => "~".to_owned(),
Value::Bool(b) => b.to_string(),
// Display floats with at most 6 decimal places: whole-object defaults
// carry values like 1/1200 whose full f64 expansion is unreadable.
Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() != 0.0) => {
let formatted = format!("{:.6}", n.as_f64().unwrap_or_default());
formatted.trim_end_matches('0').to_owned()
}
Value::Number(n) => n.to_string(),
_ => serde_yaml::to_string(value)
.unwrap_or_default()
.trim_end()
.to_owned(),
}
}
/// Render a JSON value as a block of YAML lines indented to `indent` columns.
fn yaml_block(value: &Value, indent: usize) -> Vec<String> {
let yaml = serde_yaml::to_string(value).unwrap_or_default();
reindent(&yaml, indent)
}
/// A placeholder type name for a field with no example or default.
fn placeholder_type(resolved: &Value) -> &'static str {
let type_name = match resolved.get("type") {
Some(Value::String(t)) => Some(t.as_str()),
Some(Value::Array(types)) => types
.iter()
.filter_map(Value::as_str)
.find(|t| *t != "null"),
_ => None,
};
match type_name {
Some("integer") => "integer",
Some("number") => "number",
Some("boolean") => "boolean",
Some("array") => "array",
Some("object") => "object",
Some("string") => "string",
_ => {
if resolved.get("oneOf").is_some()
|| resolved.get("anyOf").is_some()
|| resolved.get("enum").is_some()
{
"string"
} else {
"value"
}
}
}
}
/// Whether a section has any fields left to render once hoisted fields and
/// the like are removed.
fn section_has_body(node: &Value, skip: &[&str]) -> bool {
// Non-object sections (e.g. the `clients` array) always have a body.
let Some(props) = node.get("properties").and_then(Value::as_object) else {
return true;
};
props.keys().any(|key| !skip.contains(&key.as_str()))
}
+3
View File
@@ -1,4 +1,5 @@
#!/bin/sh
# Copyright 2026 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -9,12 +10,14 @@ set -eu
export SQLX_OFFLINE=1
BASE_DIR="$(dirname "$0")/.."
CONFIG_SCHEMA="${BASE_DIR}/docs/config.schema.json"
CONFIG_REFERENCE="${BASE_DIR}/docs/reference/configuration.md"
API_SCHEMA="${BASE_DIR}/docs/api/spec.json"
GRAPHQL_SCHEMA="${BASE_DIR}/frontend/schema.graphql"
POLICIES_SCHEMA="${BASE_DIR}/policies/schema/"
set -x
cargo run -p mas-config > "${CONFIG_SCHEMA}"
cargo run -p mas-config --bin docs > "${CONFIG_REFERENCE}"
cargo run -p mas-handlers --bin graphql-schema > "${GRAPHQL_SCHEMA}"
cargo run -p mas-handlers --bin api-schema > "${API_SCHEMA}"
cargo run -p mas-i18n-scan -- --update "${BASE_DIR}/templates/" "${BASE_DIR}/translations/en.json"