mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-15 05:12:28 +01:00
feat(docgen): add wrapper type detection and improved tuple/float display
Implement intelligent detection of "wrapper types" (anyOf patterns with map
variant and inner type) to render them more meaningfully in documentation.
Wrapper Type Detection:
- Detect anyOf patterns like `[{object with additionalProperties: T}, T]`
- Mark detected wrappers with `_wrapper_type` and `_wrapper_inner` metadata
- Display inner type directly instead of showing misleading "enum" badge
- Link wrapper types to their inner type definitions
- Add `_display_name` field for wrapper types with numeric suffixes
Float Display Improvements:
- Display "float" instead of "number" for JSON Schema number types in tuples
- Use `type-float` CSS class for consistent styling
- Homogeneous tuples now show as "float; N" for number arrays
Navigation Enhancements:
- Build navigation from prepared schema (includes wrapper display names)
- Add `display_names` map to template context for type name resolution
- Track type usages for "Usages" section in definitions
Test Coverage:
- Add comprehensive tests for wrapper type rendering
- Test tuple float/integer display formatting
- Verify wrapper types don't show "Enum variants" dropdown
- Test non-wrapper anyOf still correctly shows enum badge
This commit is contained in:
1 parent
c85edf242f
commit
7d4b7857bb
7 files changed
+988
-54
No files matched your search
Generated
+1
@@ -740,6 +740,7 @@ dependencies = [
|
||||
"clap",
|
||||
"pretty_assertions",
|
||||
"pulldown-cmark",
|
||||
"regex",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -24,3 +24,4 @@ thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
regex = "1"
|
||||
+479
-2
@@ -36,8 +36,8 @@ impl Generator {
|
||||
// Prepare the schema for template rendering
|
||||
let prepared_schema = prepare_for_template(schema.as_value());
|
||||
|
||||
// Build navigation data
|
||||
let navigation = build_navigation(schema.as_value());
|
||||
// Build navigation data from the prepared schema (includes _display_name for wrapper types)
|
||||
let navigation = build_navigation(&prepared_schema);
|
||||
|
||||
// Build the template context
|
||||
let context = self.build_context(&prepared_schema, &navigation, options)?;
|
||||
@@ -93,6 +93,9 @@ impl Generator {
|
||||
// Type usages for "Usages" section in type definitions
|
||||
context.insert("usages", &navigation.usages);
|
||||
|
||||
// Display names for wrapper types (type_name -> display_name)
|
||||
context.insert("display_names", &navigation.display_names);
|
||||
|
||||
// Build required set for the root schema
|
||||
let required_set: Vec<String> = schema
|
||||
.get("required")
|
||||
@@ -1201,4 +1204,478 @@ mod tests {
|
||||
);
|
||||
assert!(html.contains("Max length:"), "Should render maxLength");
|
||||
}
|
||||
|
||||
// Test: Homogeneous tuple with floats should display "float" instead of "number"
|
||||
#[test]
|
||||
fn test_tuple_float_display() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"point": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "number" },
|
||||
{ "type": "number" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Should show "float" instead of "number" for tuple display
|
||||
assert!(
|
||||
html.contains("float; 2"),
|
||||
"Should display 'float; 2' for number tuples"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("number; 2"),
|
||||
"Should NOT display 'number; 2' for float tuples"
|
||||
);
|
||||
assert!(
|
||||
html.contains("type-float"),
|
||||
"Should use type-float CSS class"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Homogeneous tuple with integers should still display "integer"
|
||||
#[test]
|
||||
fn test_tuple_integer_display_unchanged() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"coords": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "integer" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Should still show "integer" for integer tuples
|
||||
assert!(
|
||||
html.contains("integer; 3"),
|
||||
"Should display 'integer; 3' for integer tuples"
|
||||
);
|
||||
assert!(
|
||||
html.contains("type-integer"),
|
||||
"Should use type-integer CSS class"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Float tuple with format (e.g., format: "double") still displays "float"
|
||||
#[test]
|
||||
fn test_tuple_float_with_format() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bezier": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{ "type": "number", "format": "double" },
|
||||
{ "type": "number", "format": "double" },
|
||||
{ "type": "number", "format": "double" },
|
||||
{ "type": "number", "format": "double" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Should show "float" for number tuples regardless of format
|
||||
assert!(
|
||||
html.contains("float; 4"),
|
||||
"Should display 'float; 4' for number tuples with format"
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Wrapper Type Rendering Tests ====================
|
||||
|
||||
// Test: Wrapper type with $ref renders inner type as link
|
||||
#[test]
|
||||
fn test_wrapper_type_renders_inner_ref() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"InnerType": {
|
||||
"type": "string",
|
||||
"description": "The inner type"
|
||||
},
|
||||
"WrapperType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/InnerType" }
|
||||
},
|
||||
{ "$ref": "#/$defs/InnerType" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"config": { "$ref": "#/$defs/WrapperType" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// WrapperType definition should show InnerType as a link
|
||||
assert!(
|
||||
html.contains("href=\"#innertype\""),
|
||||
"Should link to inner type definition"
|
||||
);
|
||||
// Should NOT show "enum" badge for wrapper type
|
||||
// (Note: The wrapper type is referenced via $ref, so we check the definition section)
|
||||
}
|
||||
|
||||
// Test: Wrapper type with primitive renders primitive badge
|
||||
#[test]
|
||||
fn test_wrapper_type_renders_primitive_badge() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"BoolWrapper": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" }
|
||||
},
|
||||
{ "type": "boolean" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"enabled": { "$ref": "#/$defs/BoolWrapper" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Should show boolean type badge in the BoolWrapper definition
|
||||
assert!(
|
||||
html.contains("type-boolean"),
|
||||
"Should show boolean type badge for wrapper with primitive inner type"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Wrapper type with integer constraints renders integer badge
|
||||
#[test]
|
||||
fn test_wrapper_type_with_integer_renders_correctly() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"IntWrapper": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"duration": { "$ref": "#/$defs/IntWrapper" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Should show integer type badge
|
||||
assert!(
|
||||
html.contains("type-integer"),
|
||||
"Should show integer type badge for wrapper with integer inner type"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Non-wrapper anyOf still shows enum badge
|
||||
#[test]
|
||||
fn test_non_wrapper_anyof_still_shows_enum() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"NotWrapper": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"value": { "$ref": "#/$defs/NotWrapper" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Should show enum badge since inner types don't match
|
||||
assert!(
|
||||
html.contains(">enum<"),
|
||||
"Should show enum badge for non-wrapper anyOf"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Wrapper type does not show "Enum variants" dropdown
|
||||
#[test]
|
||||
fn test_wrapper_type_no_enum_variants_dropdown() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"StringWrapper": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
{ "type": "string" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"name": { "$ref": "#/$defs/StringWrapper" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Count occurrences of "Enum variants" - should not appear for wrapper types
|
||||
// (The StringWrapper definition should not have an "Enum variants" dropdown)
|
||||
let enum_variants_in_stringwrapper = html
|
||||
.find("StringWrapper")
|
||||
.map(|pos| {
|
||||
let section = &html[pos..pos.min(html.len()).saturating_add(500)];
|
||||
section.contains("Enum variants")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
assert!(
|
||||
!enum_variants_in_stringwrapper,
|
||||
"Wrapper type should NOT show 'Enum variants' dropdown"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Wrapper type with $ref to AnimationStyle (like PerAnimationPrefixConfig3)
|
||||
#[test]
|
||||
fn test_wrapper_type_like_per_animation_prefix_config() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"AnimationStyle": {
|
||||
"type": "string",
|
||||
"enum": ["Linear", "EaseIn", "EaseOut"]
|
||||
},
|
||||
"PerAnimationPrefixConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/AnimationStyle" }
|
||||
},
|
||||
{ "$ref": "#/$defs/AnimationStyle" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"style": { "$ref": "#/$defs/PerAnimationPrefixConfig" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// The PerAnimationPrefixConfig definition should link to AnimationStyle
|
||||
assert!(
|
||||
html.contains("href=\"#animationstyle\""),
|
||||
"Wrapper type should link to inner type (AnimationStyle)"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: Wrapper types display base name without numeric suffix
|
||||
#[test]
|
||||
fn test_wrapper_type_display_name() {
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema = make_schema(json!({
|
||||
"title": "Test",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"PerAnimationPrefixConfig": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" }
|
||||
},
|
||||
{ "type": "boolean" }
|
||||
]
|
||||
},
|
||||
"PerAnimationPrefixConfig2": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "integer" }
|
||||
},
|
||||
{ "type": "integer" }
|
||||
]
|
||||
},
|
||||
"PerAnimationPrefixConfig3": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/AnimationStyle" }
|
||||
},
|
||||
{ "$ref": "#/$defs/AnimationStyle" }
|
||||
]
|
||||
},
|
||||
"AnimationStyle": {
|
||||
"type": "string",
|
||||
"enum": ["Linear", "EaseIn"]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"config1": { "$ref": "#/$defs/PerAnimationPrefixConfig" },
|
||||
"config2": { "$ref": "#/$defs/PerAnimationPrefixConfig2" },
|
||||
"config3": { "$ref": "#/$defs/PerAnimationPrefixConfig3" }
|
||||
}
|
||||
}));
|
||||
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Definition headers should show base name without numeric suffix
|
||||
// PerAnimationPrefixConfig2 header should show "PerAnimationPrefixConfig"
|
||||
|
||||
// Count definition-name headers containing "PerAnimationPrefixConfig" (base name)
|
||||
// The HTML has whitespace/newlines in formatting
|
||||
let header_pattern = "<h3 class=\"definition-name\">";
|
||||
let per_anim_header_count = html
|
||||
.match_indices(header_pattern)
|
||||
.filter(|(i, _)| {
|
||||
let after = &html[*i + header_pattern.len()..];
|
||||
after.trim_start().starts_with("PerAnimationPrefixConfig")
|
||||
})
|
||||
.count();
|
||||
|
||||
assert_eq!(
|
||||
per_anim_header_count, 3,
|
||||
"All 3 wrapper types should show 'PerAnimationPrefixConfig' in header (found {})",
|
||||
per_anim_header_count
|
||||
);
|
||||
|
||||
// Verify that PerAnimationPrefixConfig2 and PerAnimationPrefixConfig3 do NOT appear in headers
|
||||
// (They should only appear in anchor IDs and href links)
|
||||
assert!(
|
||||
!html.contains("class=\"definition-name\">PerAnimationPrefixConfig2") &&
|
||||
!html.contains("class=\"definition-name\">\n PerAnimationPrefixConfig2"),
|
||||
"Should NOT show numeric suffix 2 in definition header"
|
||||
);
|
||||
assert!(
|
||||
!html.contains("class=\"definition-name\">PerAnimationPrefixConfig3") &&
|
||||
!html.contains("class=\"definition-name\">\n PerAnimationPrefixConfig3"),
|
||||
"Should NOT show numeric suffix 3 in definition header"
|
||||
);
|
||||
|
||||
// Sidebar should also show base names (navigation uses display_name)
|
||||
let sidebar_base_name_count = html.matches(">PerAnimationPrefixConfig<").count();
|
||||
assert!(
|
||||
sidebar_base_name_count >= 3,
|
||||
"Sidebar should show base name for wrapper types (found {})",
|
||||
sidebar_base_name_count
|
||||
);
|
||||
|
||||
// Anchors should still use full names for correct linking
|
||||
assert!(
|
||||
html.contains("id=\"peranimationprefixconfig\""),
|
||||
"Anchor should use full name"
|
||||
);
|
||||
assert!(
|
||||
html.contains("id=\"peranimationprefixconfig2\""),
|
||||
"Anchor should use full name with suffix"
|
||||
);
|
||||
assert!(
|
||||
html.contains("id=\"peranimationprefixconfig3\""),
|
||||
"Anchor should use full name with suffix"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: No broken anchor links in rendered output
|
||||
#[test]
|
||||
fn test_no_broken_anchor_links() {
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let generator = Generator::new().unwrap();
|
||||
let schema_json = include_str!("../schema.json");
|
||||
let schema: Schema = serde_json::from_str(schema_json).unwrap();
|
||||
let options = Options::default();
|
||||
let html = generator.generate(&schema, &options).unwrap();
|
||||
|
||||
// Extract all href="#..." targets (excluding JavaScript template literals like ${...})
|
||||
let href_re = Regex::new(r##"href="#([^"]+)""##).unwrap();
|
||||
let hrefs: HashSet<String> = href_re
|
||||
.captures_iter(&html)
|
||||
.map(|c| c[1].to_string())
|
||||
.filter(|href| !href.starts_with("${")) // Exclude JS template literals
|
||||
.collect();
|
||||
|
||||
// Extract all id="..." values
|
||||
let id_re = Regex::new(r##"id="([^"]+)""##).unwrap();
|
||||
let ids: HashSet<String> = id_re
|
||||
.captures_iter(&html)
|
||||
.map(|c| c[1].to_string())
|
||||
.collect();
|
||||
|
||||
// Find broken links
|
||||
let broken: Vec<&String> = hrefs.iter().filter(|href| !ids.contains(*href)).collect();
|
||||
|
||||
if !broken.is_empty() {
|
||||
eprintln!("Broken anchor links found: {:?}", broken);
|
||||
}
|
||||
|
||||
assert!(
|
||||
broken.is_empty(),
|
||||
"Found {} broken anchor links: {:?}",
|
||||
broken.len(),
|
||||
broken.iter().take(20).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
+476
-44
@@ -22,9 +22,31 @@ use std::collections::HashSet;
|
||||
pub fn prepare_for_template(value: &Value) -> Value {
|
||||
let mut prepared = value.clone();
|
||||
process_value(&mut prepared);
|
||||
detect_wrapper_types(&mut prepared);
|
||||
add_wrapper_display_names(&mut prepared);
|
||||
prepared
|
||||
}
|
||||
|
||||
/// Add display names to wrapper types by stripping numeric suffixes
|
||||
///
|
||||
/// For wrapper types like `PerAnimationPrefixConfig2`, this adds a
|
||||
/// `_display_name` field with the base name `PerAnimationPrefixConfig`.
|
||||
fn add_wrapper_display_names(schema: &mut Value) {
|
||||
if let Some(defs) = schema.get_mut("$defs").and_then(|v| v.as_object_mut()) {
|
||||
for (name, def) in defs.iter_mut() {
|
||||
if def.get("_wrapper_type") == Some(&Value::Bool(true)) {
|
||||
let display_name = name.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||
if let Some(obj) = def.as_object_mut() {
|
||||
obj.insert(
|
||||
"_display_name".to_string(),
|
||||
Value::String(display_name.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively process a value, rendering markdown descriptions
|
||||
fn process_value(value: &mut Value) {
|
||||
match value {
|
||||
@@ -49,6 +71,130 @@ fn process_value(value: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect and annotate wrapper type patterns in the schema
|
||||
///
|
||||
/// A wrapper type is an anyOf with exactly 2 variants where:
|
||||
/// - One variant is an object with additionalProperties of type T
|
||||
/// - The other variant is directly type T
|
||||
///
|
||||
/// This pattern is common for types like `PerAnimationPrefixConfig<T>` which
|
||||
/// can be either a map of animation prefixes to T, or directly a T value.
|
||||
fn detect_wrapper_types(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
// Check for wrapper type pattern in anyOf
|
||||
if let Some(any_of) = map.get("anyOf").and_then(|v| v.as_array())
|
||||
&& let Some(inner) = detect_wrapper_pattern(any_of) {
|
||||
map.insert("_wrapper_type".to_string(), Value::Bool(true));
|
||||
map.insert("_wrapper_inner".to_string(), inner);
|
||||
}
|
||||
|
||||
// Recurse into all nested values
|
||||
for v in map.values_mut() {
|
||||
detect_wrapper_types(v);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr.iter_mut() {
|
||||
detect_wrapper_types(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if anyOf array matches the wrapper type pattern
|
||||
///
|
||||
/// Returns the inner type if the pattern matches, None otherwise.
|
||||
fn detect_wrapper_pattern(any_of: &[Value]) -> Option<Value> {
|
||||
// Must have exactly 2 variants
|
||||
if any_of.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = &any_of[0];
|
||||
let second = &any_of[1];
|
||||
|
||||
// Skip if either variant is null (that's Option<T>, not wrapper)
|
||||
if is_null_type(first) || is_null_type(second) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try first as map variant, second as inner type
|
||||
if let Some(map_inner) = get_map_inner_type(first)
|
||||
&& types_are_equal(map_inner, second) {
|
||||
return Some(second.clone());
|
||||
}
|
||||
|
||||
// Try second as map variant, first as inner type
|
||||
if let Some(map_inner) = get_map_inner_type(second)
|
||||
&& types_are_equal(map_inner, first) {
|
||||
return Some(first.clone());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a schema represents the null type
|
||||
fn is_null_type(schema: &Value) -> bool {
|
||||
schema
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t == "null")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Extract the inner type from a map schema (object with additionalProperties)
|
||||
///
|
||||
/// Returns the additionalProperties schema if this is an object type with
|
||||
/// additionalProperties defined, None otherwise.
|
||||
fn get_map_inner_type(schema: &Value) -> Option<&Value> {
|
||||
let obj = schema.as_object()?;
|
||||
|
||||
// Must be type: "object"
|
||||
let type_val = obj.get("type")?;
|
||||
if type_val.as_str()? != "object" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Must have additionalProperties (and it must be an object, not boolean)
|
||||
let additional = obj.get("additionalProperties")?;
|
||||
if additional.is_object() {
|
||||
Some(additional)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two type schemas are equivalent
|
||||
///
|
||||
/// Handles:
|
||||
/// - $ref comparison (by ref string)
|
||||
/// - Primitive type comparison (by type string)
|
||||
/// - Deep equality for complex types
|
||||
fn types_are_equal(a: &Value, b: &Value) -> bool {
|
||||
// Handle $ref comparison
|
||||
if let (Some(ref_a), Some(ref_b)) = (
|
||||
a.get("$ref").and_then(|v| v.as_str()),
|
||||
b.get("$ref").and_then(|v| v.as_str()),
|
||||
) {
|
||||
return ref_a == ref_b;
|
||||
}
|
||||
|
||||
// Handle primitive type comparison
|
||||
if let (Some(type_a), Some(type_b)) = (
|
||||
a.get("type").and_then(|v| v.as_str()),
|
||||
b.get("type").and_then(|v| v.as_str()),
|
||||
) {
|
||||
// For primitive types, just compare the type string
|
||||
// (ignoring format, constraints, etc. for matching purposes)
|
||||
return type_a == type_b;
|
||||
}
|
||||
|
||||
// Deep equality for complex types
|
||||
a == b
|
||||
}
|
||||
|
||||
/// Render markdown text to HTML
|
||||
pub fn render_markdown(text: &str) -> String {
|
||||
let parser = Parser::new(text);
|
||||
@@ -76,6 +222,7 @@ pub struct DefinitionInfo {
|
||||
pub name: String,
|
||||
pub anchor: String,
|
||||
pub description: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// An outline entry for hierarchical navigation
|
||||
@@ -130,6 +277,8 @@ pub struct NavigationData {
|
||||
pub search_index: Vec<SearchEntry>,
|
||||
/// Map from type name to its usages
|
||||
pub usages: HashMap<String, TypeUsages>,
|
||||
/// Map from type name to display name (for wrapper types with numeric suffixes)
|
||||
pub display_names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Maximum depth for outline tree to prevent infinite recursion
|
||||
@@ -241,30 +390,32 @@ fn resolve_schema_for_outline<'a>(
|
||||
|
||||
// Handle array - unwrap to items schema
|
||||
if is_array_type(schema)
|
||||
&& let Some(items) = schema.get("items") {
|
||||
return resolve_schema_for_outline(items, defs, current_path);
|
||||
}
|
||||
&& let Some(items) = schema.get("items")
|
||||
{
|
||||
return resolve_schema_for_outline(items, defs, current_path);
|
||||
}
|
||||
|
||||
// Handle anyOf - only unwrap if it's Option<T> (exactly 2 variants, one null)
|
||||
// For true unions (multiple non-null variants), don't unwrap - treat as leaf
|
||||
if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array())
|
||||
&& any_of.len() == 2 {
|
||||
let null_count = any_of
|
||||
.iter()
|
||||
.filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
|
||||
.count();
|
||||
&& any_of.len() == 2
|
||||
{
|
||||
let null_count = any_of
|
||||
.iter()
|
||||
.filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
|
||||
.count();
|
||||
|
||||
// Only unwrap if exactly one null variant (Option<T> pattern)
|
||||
if null_count == 1 {
|
||||
for variant in any_of {
|
||||
let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null");
|
||||
if !is_null {
|
||||
return resolve_schema_for_outline(variant, defs, current_path);
|
||||
}
|
||||
// Only unwrap if exactly one null variant (Option<T> pattern)
|
||||
if null_count == 1 {
|
||||
for variant in any_of {
|
||||
let is_null = variant.get("type").and_then(|t| t.as_str()) == Some("null");
|
||||
if !is_null {
|
||||
return resolve_schema_for_outline(variant, defs, current_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap
|
||||
}
|
||||
// For true unions (0 or 2+ null variants, or 3+ total variants), don't unwrap
|
||||
|
||||
// Inline object or leaf type - keep current path
|
||||
(schema, current_path.to_string())
|
||||
@@ -278,9 +429,10 @@ fn is_array_type(schema: &Value) -> bool {
|
||||
}
|
||||
// Check type: ["array", "null"] (Option<Vec<T>>)
|
||||
if let Some(type_arr) = schema.get("type").and_then(|v| v.as_array())
|
||||
&& type_arr.iter().any(|t| t.as_str() == Some("array")) {
|
||||
return true;
|
||||
}
|
||||
&& type_arr.iter().any(|t| t.as_str() == Some("array"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -324,8 +476,10 @@ fn collect_config_usages(
|
||||
// At config root - anchor points to config section property
|
||||
// e.g., .monitors[] -> "monitors" (template IDs don't have [])
|
||||
// Remove all bracket notation for config paths
|
||||
// Also remove [*] notation from patternProperties/additionalProperties
|
||||
let clean_path = path
|
||||
.trim_start_matches('.')
|
||||
.replace("[*]", "")
|
||||
.replace("[]", "")
|
||||
.replace("[", "-")
|
||||
.replace("]", "");
|
||||
@@ -345,19 +499,20 @@ fn collect_config_usages(
|
||||
// Follow the $ref into the type definition (if not already visited)
|
||||
// When following, we enter the new type with an empty relative path
|
||||
if !visited.contains(&type_name)
|
||||
&& let Some(def) = defs.and_then(|d| d.get(&type_name)) {
|
||||
visited.insert(type_name.clone());
|
||||
collect_config_usages(
|
||||
def,
|
||||
path,
|
||||
usages,
|
||||
defs,
|
||||
visited,
|
||||
Some(&type_name),
|
||||
String::new(), // Reset relative path when entering new type
|
||||
);
|
||||
visited.remove(&type_name); // Allow revisiting from different paths
|
||||
}
|
||||
&& let Some(def) = defs.and_then(|d| d.get(&type_name))
|
||||
{
|
||||
visited.insert(type_name.clone());
|
||||
collect_config_usages(
|
||||
def,
|
||||
path,
|
||||
usages,
|
||||
defs,
|
||||
visited,
|
||||
Some(&type_name),
|
||||
String::new(), // Reset relative path when entering new type
|
||||
);
|
||||
visited.remove(&type_name); // Allow revisiting from different paths
|
||||
}
|
||||
return; // $ref schema has no other content to process
|
||||
}
|
||||
|
||||
@@ -467,18 +622,19 @@ fn collect_config_usages(
|
||||
|
||||
// Recurse into additionalProperties
|
||||
if let Some(additional) = schema.get("additionalProperties")
|
||||
&& additional.is_object() {
|
||||
let new_path = format!("{}[*]", path);
|
||||
collect_config_usages(
|
||||
additional,
|
||||
new_path,
|
||||
usages,
|
||||
defs,
|
||||
visited,
|
||||
current_type,
|
||||
type_relative_path.clone(),
|
||||
);
|
||||
}
|
||||
&& additional.is_object()
|
||||
{
|
||||
let new_path = format!("{}[*]", path);
|
||||
collect_config_usages(
|
||||
additional,
|
||||
new_path,
|
||||
usages,
|
||||
defs,
|
||||
visited,
|
||||
current_type,
|
||||
type_relative_path.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect references to types from within $defs
|
||||
@@ -561,10 +717,16 @@ pub fn build_navigation(schema: &Value) -> NavigationData {
|
||||
}
|
||||
});
|
||||
|
||||
let display_name = def
|
||||
.get("_display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
definitions.push(DefinitionInfo {
|
||||
name: name.clone(),
|
||||
anchor: anchor.clone(),
|
||||
description,
|
||||
display_name,
|
||||
});
|
||||
|
||||
search_index.push(SearchEntry {
|
||||
@@ -576,9 +738,30 @@ pub fn build_navigation(schema: &Value) -> NavigationData {
|
||||
}
|
||||
}
|
||||
|
||||
// Build display_names map (type_name -> display_name for wrapper types)
|
||||
let mut display_names: HashMap<String, String> = HashMap::new();
|
||||
for def in &definitions {
|
||||
if let Some(display) = &def.display_name {
|
||||
display_names.insert(def.name.clone(), display.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Sort definitions alphabetically
|
||||
definitions.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
// Deduplicate definitions by display_name (for wrapper types with same base name)
|
||||
// Keep only the first occurrence of each display_name
|
||||
let mut seen_display_names: HashSet<String> = HashSet::new();
|
||||
definitions.retain(|def| {
|
||||
let display = def.display_name.as_ref().unwrap_or(&def.name);
|
||||
if seen_display_names.contains(display) {
|
||||
false // Skip duplicate
|
||||
} else {
|
||||
seen_display_names.insert(display.clone());
|
||||
true // Keep first occurrence
|
||||
}
|
||||
});
|
||||
|
||||
// Collect configuration usages (from main schema, following $refs transitively)
|
||||
let mut config_usages: HashMap<String, Vec<ConfigUsage>> = HashMap::new();
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
@@ -623,6 +806,7 @@ pub fn build_navigation(schema: &Value) -> NavigationData {
|
||||
definitions,
|
||||
search_index,
|
||||
usages,
|
||||
display_names,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1664,4 +1848,252 @@ mod tests {
|
||||
// Anchor should NOT have brackets
|
||||
assert_eq!(item_usages.config_usages[0].anchor, "items");
|
||||
}
|
||||
|
||||
// ==================== Wrapper Type Detection Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_type_detection_with_ref() {
|
||||
// Wrapper type pattern: anyOf with object+additionalProperties and direct $ref
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/InnerType" }
|
||||
},
|
||||
{ "$ref": "#/$defs/InnerType" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert_eq!(
|
||||
prepared.get("_wrapper_type"),
|
||||
Some(&Value::Bool(true)),
|
||||
"Should detect wrapper type pattern"
|
||||
);
|
||||
assert!(
|
||||
prepared.get("_wrapper_inner").is_some(),
|
||||
"Should have _wrapper_inner"
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.get("_wrapper_inner").unwrap().get("$ref"),
|
||||
Some(&Value::String("#/$defs/InnerType".to_string())),
|
||||
"Inner type should be the $ref"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_type_detection_with_primitive() {
|
||||
// Wrapper type with primitive inner type
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "boolean" }
|
||||
},
|
||||
{ "type": "boolean" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert_eq!(
|
||||
prepared.get("_wrapper_type"),
|
||||
Some(&Value::Bool(true)),
|
||||
"Should detect wrapper type with primitive"
|
||||
);
|
||||
assert_eq!(
|
||||
prepared
|
||||
.get("_wrapper_inner")
|
||||
.unwrap()
|
||||
.get("type")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
Some("boolean"),
|
||||
"Inner type should be boolean"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_type_detection_reversed_order() {
|
||||
// Wrapper type with map variant second
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{ "type": "integer" },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "integer" }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert_eq!(
|
||||
prepared.get("_wrapper_type"),
|
||||
Some(&Value::Bool(true)),
|
||||
"Should detect wrapper type regardless of variant order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_wrapper_mismatched_types() {
|
||||
// Not a wrapper: inner types don't match
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
{ "type": "integer" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert!(
|
||||
prepared.get("_wrapper_type").is_none(),
|
||||
"Should NOT detect wrapper when inner types don't match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_option_not_detected_as_wrapper() {
|
||||
// Option<T> pattern (anyOf with null) should NOT be detected as wrapper
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
{ "type": "null" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert!(
|
||||
prepared.get("_wrapper_type").is_none(),
|
||||
"Option<Map<K,V>> should NOT be detected as wrapper type"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regular_anyof_not_wrapper() {
|
||||
// Regular anyOf (no additionalProperties pattern)
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert!(
|
||||
prepared.get("_wrapper_type").is_none(),
|
||||
"Regular anyOf should NOT be detected as wrapper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_detection_nested() {
|
||||
// Wrapper type nested inside a larger schema
|
||||
let schema = json!({
|
||||
"$defs": {
|
||||
"WrapperType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/Inner" }
|
||||
},
|
||||
{ "$ref": "#/$defs/Inner" }
|
||||
]
|
||||
},
|
||||
"Inner": { "type": "string" }
|
||||
}
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
let wrapper_def = prepared.get("$defs").unwrap().get("WrapperType").unwrap();
|
||||
assert_eq!(
|
||||
wrapper_def.get("_wrapper_type"),
|
||||
Some(&Value::Bool(true)),
|
||||
"Should detect nested wrapper type"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_with_integer_constraints() {
|
||||
// Wrapper type where inner type has constraints (should still match by type)
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert_eq!(
|
||||
prepared.get("_wrapper_type"),
|
||||
Some(&Value::Bool(true)),
|
||||
"Should detect wrapper even with constraints on inner type"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_three_variant_anyof_not_wrapper() {
|
||||
// anyOf with 3+ variants should NOT be detected as wrapper
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
{ "type": "string" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert!(
|
||||
prepared.get("_wrapper_type").is_none(),
|
||||
"anyOf with 3+ variants should NOT be detected as wrapper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_without_additional_properties_not_wrapper() {
|
||||
// Object without additionalProperties should NOT be detected as wrapper
|
||||
let schema = json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "name": { "type": "string" } }
|
||||
},
|
||||
{ "type": "string" }
|
||||
]
|
||||
});
|
||||
|
||||
let prepared = prepare_for_template(&schema);
|
||||
|
||||
assert!(
|
||||
prepared.get("_wrapper_type").is_none(),
|
||||
"Object with properties (not additionalProperties) should NOT be wrapper"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@
|
||||
href="#{{ def.anchor }}"
|
||||
class="nav-link"
|
||||
data-search-name="{{ def.name | lower }}"
|
||||
>{{ def.name }}</a
|
||||
>{{ def.display_name | default(value=def.name)
|
||||
}}</a
|
||||
>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
@@ -88,7 +89,9 @@
|
||||
class="definition"
|
||||
data-search-name="{{ def_name | lower }}"
|
||||
>
|
||||
<h3 class="definition-name">{{ def_name }}</h3>
|
||||
<h3 class="definition-name">
|
||||
{{ def._display_name | default(value=def_name) }}
|
||||
</h3>
|
||||
{%- if def.description_html %}
|
||||
<div class="description">
|
||||
{{ def.description_html | safe }}
|
||||
@@ -119,10 +122,12 @@
|
||||
<h4>Referenced by</h4>
|
||||
<ul class="usage-list">
|
||||
{%- for ref in type_usages.type_references
|
||||
-%}
|
||||
-%} {%- set ref_display =
|
||||
display_names[ref.type_name] |
|
||||
default(value=ref.type_name) -%}
|
||||
<li>
|
||||
<a href="#{{ ref.anchor }}"
|
||||
>{{ ref.type_name }}</a
|
||||
>{{ ref_display }}</a
|
||||
>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
|
||||
@@ -45,7 +45,13 @@
|
||||
{% macro render_type_badge(node) %}
|
||||
{%- if node["$ref"] -%}
|
||||
{%- set ref_name = node["$ref"] | replace(from="#/$defs/", to="") -%}
|
||||
<a href="#{{ ref_name | lower }}" class="type-ref">{{ ref_name }}</a>
|
||||
{%- set display = display_names[ref_name] | default(value=ref_name) -%}
|
||||
{%- set ref_def = schema["$defs"][ref_name] -%}
|
||||
<a href="#{{ ref_name | lower }}" class="type-ref">{{ display }}</a>
|
||||
{%- if ref_def._wrapper_inner -%}
|
||||
{# Show the inner type for wrapper types #}
|
||||
{{ self::render_type_badge(node=ref_def._wrapper_inner) }}
|
||||
{%- endif -%}
|
||||
{%- elif node.type -%}
|
||||
{%- if node.type is string -%}
|
||||
{%- if node.type == "array" and node.prefixItems -%}
|
||||
@@ -58,7 +64,7 @@
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if is_homogeneous and first_type.type -%}
|
||||
<span class="type-badge type-array">array</span> <span class="type-badge type-{{ first_type.type }}">{{ first_type.type }}; {{ node.prefixItems | length }}</span>
|
||||
<span class="type-badge type-array">array</span> {%- if first_type.type == "number" -%}<span class="type-badge type-float">float; {{ node.prefixItems | length }}</span>{%- else -%}<span class="type-badge type-{{ first_type.type }}">{{ first_type.type }}; {{ node.prefixItems | length }}</span>{%- endif -%}
|
||||
{%- else -%}
|
||||
<span class="type-badge type-array">array</span> <span class="type-badge type-array">tuple</span>
|
||||
{%- endif -%}
|
||||
@@ -86,8 +92,12 @@
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- elif node.anyOf -%}
|
||||
{# Check for wrapper type pattern first (detected in prepare.rs) #}
|
||||
{%- if node._wrapper_type -%}
|
||||
{# Wrapper type: show inner type badge (type name shown separately in type definitions) #}
|
||||
{{ self::render_type_badge(node=node._wrapper_inner) }}
|
||||
{# Check for Option<T> pattern: anyOf with exactly 2 variants where one is null #}
|
||||
{%- if node.anyOf | length == 2 -%}
|
||||
{%- elif node.anyOf | length == 2 -%}
|
||||
{%- set first = node.anyOf | first -%}
|
||||
{%- set second = node.anyOf | last -%}
|
||||
{%- if second.type and second.type == "null" -%}
|
||||
@@ -254,8 +264,12 @@
|
||||
</details>
|
||||
{%- endif -%}
|
||||
{%- elif node.anyOf -%}
|
||||
{# Check for wrapper type pattern first (detected in prepare.rs) #}
|
||||
{%- if node._wrapper_type -%}
|
||||
{# Wrapper type - render the inner type's body #}
|
||||
{{ self::render_schema_body(node=node._wrapper_inner, depth=depth, path=path) }}
|
||||
{# Check for Option<T> pattern: anyOf with exactly 2 variants where one is null #}
|
||||
{%- if node.anyOf | length == 2 -%}
|
||||
{%- elif node.anyOf | length == 2 -%}
|
||||
{%- set first = node.anyOf | first -%}
|
||||
{%- set second = node.anyOf | last -%}
|
||||
{%- if second.type and second.type == "null" -%}
|
||||
|
||||
@@ -570,6 +570,10 @@ body {
|
||||
background-color: #0a58ca;
|
||||
color: white;
|
||||
}
|
||||
.type-badge.type-float {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
.type-badge.type-boolean {
|
||||
background-color: #fd7e14;
|
||||
color: white;
|
||||
|
||||
Reference in new issue
Block a user