Skip to content

The Malloy Renderer Schema

Michael Toy edited this page Mar 6, 2026 · 1 revision

Malloy Renderer Tag Schema (MOTLY Schema Language)

This document contains a MOTLY schema describing the Malloy renderer annotation tags, followed by detailed notes on what the schema covers, what it cannot cover, and where application-level validation is needed.

Schema

#! schema=malloy-renderer url="./motly-meta-schema.motly"
#
# Malloy Renderer Tag Schema
#
# Validates MOTLY annotations recognized by the Malloy renderer.
# This schema covers tags parsed from # (standard), #r (render),
# and ## (model-level) annotation prefixes.
#

TYPES {

  # ================================================================
  # Shared enums and small types
  # ================================================================

  ScaleValue {
    VALUE = string { ENUM = [k, m, b, t, q, auto] }
  }

  SuffixFormat {
    VALUE = string { ENUM = [letter, lower, word, short, finance, scientific, none] }
  }

  SizePreset {
    VALUE = string { ENUM = [fill, spark, xs, sm, md, lg, xl, 2xl] }
  }

  SizeCustom {
    OPTIONAL {
      width = integer
      height = integer
    }
  }

  SizeTag = [SizePreset, SizeCustom]

  BigValueSizePreset {
    VALUE = string { ENUM = [sm, md, lg] }
  }

  ColumnWidthNumber {
    VALUE = integer
  }

  ColumnWidthName {
    VALUE = string { ENUM = [xs, sm, md, lg, xl, 2xl] }
  }

  ColumnWidth = [ColumnWidthNumber, ColumnWidthName]

  ComparisonFormat {
    VALUE = string { ENUM = [pct, ppt] }
  }

  ChartMode {
    VALUE = string { ENUM = [normal, yoy] }
  }

  SeriesLimitInt {
    VALUE = integer
  }

  SeriesLimitAuto {
    VALUE = string { ENUM = [auto] }
  }

  SeriesLimit = [SeriesLimitInt, SeriesLimitAuto]

  MaxHeightInt {
    VALUE = integer
  }

  MaxHeightNone {
    VALUE = string { ENUM = [none] }
  }

  MaxHeight = [MaxHeightInt, MaxHeightNone]

  # Image alt: string value or dynamic { field = name }
  AltString {
    VALUE = string
  }

  AltDynamic {
    REQUIRED {
      field = string
    }
  }

  AltTag = [AltString, AltDynamic]

  # Y field ref inside viz: single field name or array of field names
  SingleFieldRef {
    VALUE = string
  }

  MultiFieldRef {
    VALUE = "string[]"
  }

  YFieldRef = [SingleFieldRef, MultiFieldRef]

  # zero_baseline: bare flag or boolean value
  ZeroBaselineFlag {
    ADDITIONAL = reject
  }

  ZeroBaselineValue {
    VALUE = boolean
  }

  ZeroBaseline = [ZeroBaselineFlag, ZeroBaselineValue]

  # ================================================================
  # Number formatting
  #
  # number can appear as:
  #   number                        (bare — format as integer)
  #   number = "format"             (SSF format, keyword, or shorthand)
  #   number { scale=k decimals=2 } (verbose)
  # ================================================================

  NumberBare {
    DESCRIPTION = "Bare number tag — formats as integer"
    ADDITIONAL = reject
  }

  NumberWithValue {
    DESCRIPTION = "Number with format string, keyword, or shorthand"
    VALUE = string
    ADDITIONAL = reject
  }

  NumberVerbose {
    DESCRIPTION = "Verbose number formatting with named properties"
    OPTIONAL {
      scale = ScaleValue
      decimals = integer { MIN = 0  MAX = 10 }
      suffix = SuffixFormat
    }
  }

  NumberTag = [NumberBare, NumberWithValue, NumberVerbose]

  # ================================================================
  # Currency formatting
  #
  #   currency = usd               (legacy)
  #   currency = usd2m             (shorthand)
  #   currency { scale=m ... }     (verbose)
  # ================================================================

  CurrencyWithValue {
    DESCRIPTION = "Currency with code, legacy name, or shorthand"
    VALUE = string
    ADDITIONAL = reject
  }

  CurrencyVerbose {
    DESCRIPTION = "Verbose currency formatting"
    OPTIONAL {
      scale = ScaleValue
      decimals = integer { MIN = 0  MAX = 10 }
      suffix = SuffixFormat
    }
  }

  CurrencyTag = [CurrencyWithValue, CurrencyVerbose]

  # ================================================================
  # Duration
  #
  #   duration              (bare — default: seconds)
  #   duration = seconds    (explicit unit)
  # ================================================================

  DurationBare {
    ADDITIONAL = reject
  }

  DurationWithUnit {
    VALUE = string { ENUM = [nanoseconds, microseconds, milliseconds, seconds, minutes, hours, days] }
    ADDITIONAL = reject
  }

  DurationTag = [DurationBare, DurationWithUnit]

  # ================================================================
  # Link
  #
  #   link                          (bare — field value as href)
  #   link { field=x url_template="..." }
  # ================================================================

  LinkTag {
    DESCRIPTION = "Renders a string field as a clickable hyperlink"
    OPTIONAL {
      field = string { DESCRIPTION = "Another field whose value is used as the href" }
      url_template = string { DESCRIPTION = "URL template — $$ is replaced with the value" }
    }
  }

  # ================================================================
  # Image
  # ================================================================

  ImageTag {
    DESCRIPTION = "Renders a string field as an img element"
    OPTIONAL {
      width = string { DESCRIPTION = "CSS width value" }
      height = string { DESCRIPTION = "CSS height value" }
      alt = AltTag { DESCRIPTION = "Alt text — static string or dynamic from field" }
    }
  }

  # ================================================================
  # Dashboard
  # ================================================================

  DashboardTableConfig {
    OPTIONAL {
      max_height = MaxHeight { DEFAULT = 361 }
    }
  }

  DashboardTag {
    DESCRIPTION = "Renders a nested query as a dashboard layout"
    OPTIONAL {
      table = DashboardTableConfig
    }
  }

  # ================================================================
  # Pivot
  # ================================================================

  PivotTag {
    DESCRIPTION = "Pivot the nested query into columns"
    OPTIONAL {
      dimensions = "string[]" { DESCRIPTION = "Explicit pivot dimensions" }
    }
  }

  # ================================================================
  # Transpose
  # ================================================================

  TransposeTag {
    DESCRIPTION = "Transpose rows and columns"
    OPTIONAL {
      limit = integer { DEFAULT = 20  DESCRIPTION = "Max columns before error" }
    }
  }

  # ================================================================
  # Big Value (query-level)
  # ================================================================

  BigValueQueryTag {
    DESCRIPTION = "Big value renderer — prominent metric cards"
    OPTIONAL {
      size = BigValueSizePreset { DEFAULT = md }
      neutral_threshold = number { DEFAULT = 0.05  DESCRIPTION = "Delta below which is neutral" }
    }
  }

  # ================================================================
  # Big Value (field-level — inside big_value queries)
  # ================================================================

  BigValueFieldTag {
    DESCRIPTION = "Big value comparison metric configuration"
    OPTIONAL {
      comparison_field = string { DESCRIPTION = "Primary metric field to compare against" }
      comparison_label = string {
        DESCRIPTION = "Label shown next to delta"
        REQUIRES = [comparison_field]
      }
      comparison_format = ComparisonFormat { DEFAULT = pct }
      down_is_good = boolean { DEFAULT = @false  DESCRIPTION = "Invert sentiment colors" }
      sparkline = string { DESCRIPTION = "Reference to a sparkline field" }
    }
  }

  # ================================================================
  # Column configuration
  # ================================================================

  ColumnTag {
    DESCRIPTION = "Column display configuration for table fields"
    OPTIONAL {
      width = ColumnWidth
      height = integer
      word_break = string { ENUM = [break_all] }
    }
  }

  # ================================================================
  # Table configuration
  # ================================================================

  TableTag {
    OPTIONAL {
      size = string { ENUM = [fill] }
    }
  }

  # ================================================================
  # Chart axis and series configuration
  #
  # In MOTLY, x = category and x { independent = @true } merge into
  # the same node: value is the field name, properties are axis config.
  # This is MOTLY's value+properties duality in action.
  # ================================================================

  VizXAxis {
    VALUE = string
    OPTIONAL {
      independent = boolean
      limit = integer { DESCRIPTION = "Max X values displayed (bar chart only)" }
    }
  }

  VizYAxis {
    VALUE = YFieldRef
    OPTIONAL {
      independent = boolean
    }
  }

  VizSeries {
    VALUE = string
    OPTIONAL {
      independent = boolean
      limit = SeriesLimit { DEFAULT = auto }
    }
  }

  # ================================================================
  # Viz (chart) tag
  #
  #   viz = bar
  #   viz = bar { x=category y=total_sales series=region }
  #   viz = line { mode=yoy zero_baseline }
  # ================================================================

  VizTag {
    DESCRIPTION = "Chart renderer configuration"
    VALUE = string { ENUM = [bar, line, table, dashboard] }
    OPTIONAL {
      # Field assignment + axis configuration (value+properties duality)
      x = VizXAxis
      y = VizYAxis
      series = VizSeries

      # Display
      title = string
      subtitle = string
      size = SizeTag

      # Behavior flags
      stack = flag { DESCRIPTION = "Stack bars (bar chart only)" }
      disable_embedded = flag { DESCRIPTION = "Ignore embedded field-level x/y/series tags" }
      disableEmbedded = flag { DEPRECATED = "Use disable_embedded" }

      # Line chart specific
      zero_baseline = ZeroBaseline { DESCRIPTION = "Include zero in Y scale" }
      mode = ChartMode { DEFAULT = normal }
    }
  }

  # ================================================================
  # Theme
  # ================================================================

  ThemeTag {
    DESCRIPTION = "Visual appearance customization"
    OPTIONAL {
      tableRowHeight = string
      tableBodyColor = string
      tableFontSize = string
      tableHeaderColor = string
      tableHeaderWeight = string
      tableBodyWeight = string
      tableBorder = string
      tableBackground = string
      tableGutterSize = string
      tablePinnedBackground = string
      tablePinnedBorder = string
      fontFamily = string
      background = string
    }
  }

  # ================================================================
  # Model-level line chart defaults
  #
  # ## viz.line_chart.defaults.y.independent = @true
  # ## viz.line_chart.defaults.zeroBaseline = @true
  # ================================================================

  LineChartDefaults {
    OPTIONAL {
      y {
        OPTIONAL {
          independent = boolean
        }
      }
      x {
        OPTIONAL {
          independent = boolean
        }
      }
      zeroBaseline = boolean
      seriesChannel {
        OPTIONAL {
          limit = integer
        }
      }
    }
  }

  LineChartDefaultsWrapper {
    OPTIONAL {
      defaults = LineChartDefaults
    }
  }

  ModelVizConfig {
    OPTIONAL {
      line_chart = LineChartDefaultsWrapper
    }
  }
}

# ================================================================
# Root — top-level field annotation tags
# ================================================================

OPTIONAL {
  # --- Renderer selection (mutually exclusive) ---
  viz = VizTag { EXCLUSIVE = renderer }
  bar_chart = flag { EXCLUSIVE = renderer  DEPRECATED = "Use viz = bar" }
  line_chart = flag { EXCLUSIVE = renderer  DEPRECATED = "Use viz = line" }
  dashboard = DashboardTag { EXCLUSIVE = renderer }
  link = LinkTag { EXCLUSIVE = renderer }
  image = ImageTag { EXCLUSIVE = renderer }
  list = flag { EXCLUSIVE = renderer }
  list_detail = flag { EXCLUSIVE = renderer }
  scatter_chart = flag { EXCLUSIVE = renderer  DEPRECATED = "Legacy Vega-Lite renderer" }
  shape_map = flag { EXCLUSIVE = renderer  DEPRECATED = "Legacy Vega-Lite renderer" }
  segment_map = flag { EXCLUSIVE = renderer  DEPRECATED = "Legacy Vega-Lite renderer" }
  big_value = BigValueQueryTag { EXCLUSIVE = renderer }

  # --- Table modifiers ---
  pivot = PivotTag
  transpose = TransposeTag
  flatten = flag { DEPRECATED = "Legacy renderer only" }

  # --- Universal field tags ---
  hidden = flag { DESCRIPTION = "Hides the field from display" }
  label = string { DESCRIPTION = "Custom display label (replaces field name)" }
  description = string { DESCRIPTION = "Description text (used by big_value cards)" }
  break = flag { DESCRIPTION = "In dashboards, starts a new visual group" }

  # --- Number formatting (mutually exclusive) ---
  number = NumberTag { EXCLUSIVE = num_format }
  currency = CurrencyTag { EXCLUSIVE = num_format }
  percent = flag { EXCLUSIVE = num_format  DESCRIPTION = "Format as percentage" }
  duration = DurationTag { EXCLUSIVE = num_format }

  # --- Size (top-level) ---
  size = SizeTag

  # --- Theme ---
  theme = ThemeTag

  # --- Table and column configuration ---
  table = TableTag
  column = ColumnTag

  # --- Embedded field tags (inside chart queries) ---
  x = flag { DESCRIPTION = "Assign this field to the X axis" }
  y = flag { DESCRIPTION = "Assign this field to the Y axis" }
  series = flag { DESCRIPTION = "Assign this field to the series channel" }
  tooltip = flag { DESCRIPTION = "Include in chart tooltips" }

  # --- Model-level ---
  renderer_legacy = flag { DESCRIPTION = "Use the legacy HTML renderer" }
}

What the Schema Covers

The schema provides useful validation for the majority of real-world annotation errors:

Structural Validation

  • Unknown tag names — any tag not listed in the schema is rejected. Catches typos like nubmer or curency.
  • Nested property structure — validates that viz { x { independent = @true } } is well-formed and that independent takes a boolean.
  • Type checking — catches port = "hello" when a number is expected, or size = 42 when an enum string is expected.

Enum Validation

  • Renderer selection — viz = bar is valid, viz = pie is not.
  • Scale values — scale = k is valid, scale = z is not.
  • Size presets — size = lg is valid, size = huge is not.
  • Duration units — duration = hours is valid, duration = weeks is not.
  • All enum-constrained values throughout the schema.

Mutual Exclusion

  • Renderer tags — viz, bar_chart, line_chart, dashboard, link, image, list, list_detail, scatter_chart, shape_map, segment_map, big_value are in the renderer exclusion group. Having both viz = bar and dashboard on the same field is flagged.
  • Number format tags — number, currency, percent, duration are in the num_format exclusion group.

Dependency Validation

  • comparison_label requires comparison_field — inside big_value field-level tags.

Deprecation Warnings

  • bar_chart → "Use viz = bar"
  • line_chart → "Use viz = line"
  • scatter_chart, shape_map, segment_map → "Legacy Vega-Lite renderer"
  • flatten → "Legacy renderer only"
  • disableEmbedded → "Use disable_embedded"

Range Validation

  • decimals constrained to 0–10.

Default Values

  • transpose { limit } defaults to 20.
  • dashboard { table { max_height } } defaults to 361.
  • big_value { neutral_threshold } defaults to 0.05.
  • Various other defaults documented via DEFAULT.

What the Schema Cannot Cover

Context-Dependent Tag Shapes

Several tags have different structures depending on where they appear:

big_value — at query level it configures the renderer (size, neutral_threshold). At field level inside a big_value query, it configures comparison metrics (comparison_field, comparison_label, etc.). The schema defines BigValueQueryTag and BigValueFieldTag as separate types, but the root schema can only bind one to the big_value name. The validator would need context (query vs field) to choose the right one.

x, y, series — at the top level of field annotations, these are bare flags (assign this field to an axis). Inside viz { ... }, they are structured nodes with a field name value and axis configuration properties. The schema defines both forms but can only use one at the root level. The viz schema uses the structured form internally.

big_value { size } vs top-level size — size inside big_value allows [sm, md, lg]. Top-level size allows [fill, spark, xs, sm, md, lg, xl, 2xl] or custom { width height }. Different enum sets for the same property name.

Model-Level vs Field-Level Tags

Tags parsed from ## (model-level) annotations share some names with field-level tags but have different structures:

viz — at field level, viz = bar { x=field ... } configures a chart. At model level, viz.line_chart.defaults.y.independent = @true sets global defaults. These are completely different shapes. The ModelVizConfig type is defined but not bound in the root schema because it conflicts with the field-level VizTag.

theme — structurally the same at both levels, so this one actually works fine with a single schema.

A complete solution would either use separate schemas for field-level and model-level contexts, or have a context-aware validator that selects the right type based on the annotation prefix.

Value Format Validation

number shorthand values — number = 2m, number = 0k follow a specific pattern (single digit + scale letter). The schema types this as VALUE = string which accepts any string. The pattern MATCHES = "^[0-9][kmbtq]$" could partially capture this, but it would reject valid SSF format strings like "#,##0.00". Since the number value can be an SSF format string OR a keyword OR a shorthand, and these overlap in the string type, the schema can only validate that it's a string.

currency shorthand values — currency = usd2m, currency = eur0k follow a pattern (currency code + optional digit + optional scale). Same problem as number shorthands — the value space is too heterogeneous for a single MATCHES or ENUM to capture cleanly.

SSF format strings — number = "#,##0.00" is a valid SSF format string, but validating SSF syntax is well beyond what a schema regex can do. This is application-level validation.

Field Type Applicability

The renderer reference includes a field applicability matrix: number only applies to numeric fields, link only applies to string fields, viz only applies to repeated record fields, etc. The schema has no concept of what Malloy field type an annotation is attached to — it only sees the tag structure. Field type checking is inherently application-level because it requires knowledge of the Malloy query model.

Semantic Constraints

Year-over-year mode requirements — mode = yoy requires exactly one x field (temporal, sub-year granularity), exactly one y field, and no explicit series field. This is a cross-property semantic constraint that depends on both the tag configuration and the underlying data model.

Field name references — tags like x = category, comparison_field = revenue, and sparkline = trend_line reference Malloy field names. Validating that these fields actually exist in the query requires access to the Malloy compilation context, not just the tag structure.

Renderer precedence — when multiple renderer tags are present (which the EXCLUSIVE group would flag as a warning), the actual precedence logic (plugin match > viz > last legacy tag > default) is application behavior, not something a schema can enforce.

Pivot dimension validation — pivot { dimensions = [quarter] } references field names that must exist in the nested query. Same as field name references above.

Union Dispatch Ambiguity

NumberTag dispatch — NumberBare, NumberWithValue, and NumberVerbose are structurally distinguishable (no value + no props, has value + no props, no value + has props). But number = 2m { scale = m } — a node with both value and properties — doesn't cleanly match any branch. In practice this probably shouldn't occur, but the schema doesn't explicitly reject it.

SizeTag dispatch — SizePreset (has a string value) vs SizeCustom (has properties) are distinguishable. But size = md { width = 400 } has both, which is ambiguous. Again, unlikely in practice.

These cases could be addressed by defining additional union variants that explicitly cover the value+properties combination, or by having the validator reject nodes that don't cleanly match exactly one variant.

Summary

The schema handles approximately 90% of the validation errors a user is likely to encounter: typos in tag names, wrong value types, invalid enum values, conflicting renderer or format tags, missing required companions, and use of deprecated tags. The remaining 10% — context-dependent shapes, field type applicability, field name resolution, semantic constraints — require application-level validation that has access to the Malloy compilation context.