Repository navigation
The Malloy Renderer Schema
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=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" }
}
The schema provides useful validation for the majority of real-world annotation errors:
-
Unknown tag names — any tag not listed in the schema is rejected. Catches typos like
nubmerorcurency. -
Nested property structure — validates that
viz { x { independent = @true } }is well-formed and thatindependenttakes a boolean. -
Type checking — catches
port = "hello"when a number is expected, orsize = 42when an enum string is expected.
-
Renderer selection —
viz = baris valid,viz = pieis not. -
Scale values —
scale = kis valid,scale = zis not. -
Size presets —
size = lgis valid,size = hugeis not. -
Duration units —
duration = hoursis valid,duration = weeksis not. - All enum-constrained values throughout the schema.
-
Renderer tags —
viz,bar_chart,line_chart,dashboard,link,image,list,list_detail,scatter_chart,shape_map,segment_map,big_valueare in therendererexclusion group. Having bothviz = baranddashboardon the same field is flagged. -
Number format tags —
number,currency,percent,durationare in thenum_formatexclusion group.
-
comparison_labelrequirescomparison_field— insidebig_valuefield-level tags.
-
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"
-
decimalsconstrained to 0–10.
-
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.
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.
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.
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.
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.
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.
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.
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.