Syntax & specification
An Arkitecture document is a small text file with a .ark extension.
It is authored in two layers: the semantic structure — the
nodes (boxes, possibly nested) and the arrows
between them — and a layout layer in @layout blocks
that says where and how they're drawn. Layout can be factored into reusable
@blocks and shared by @use or a node's kind.
The compiler parses both, resolves the layout onto the tree, validates, then lays
it out and emits SVG.
Nodes
A node is an identifier followed by a brace block. The identifier is required; everything inside is optional. Identifiers are letters, digits, and underscores, and must start with a letter or underscore.
api {
label: "API Gateway"
}
# The minimal node — just an id
worker {}
Nodes nest: put a node inside another node's block and it becomes a child. A parent sizes itself to its children; a leaf sizes to its label.
platform {
label: "Platform"
gateway { label: "API Gateway" }
worker { label: "Worker" }
}
Semantic properties
A node's block holds only semantics — what the component is, not how it's drawn:
| Property | Value | Meaning |
|---|---|---|
label | string | Display text, centred in the box. Omit it for an unlabeled box. |
kind | identifier | An optional semantic classification (e.g. kind: database). It implicitly imports the layout block of the same name as a style baseline — kind: database behaves as a leading @use database. An unknown kind (no matching block) is a harmless no-op. |
anchors | [name, …] | The connection-point names this node exposes for arrows. Their positions are layout (see below). |
userService {
label: "User Service"
kind: service
anchors: [db, api]
}
Layout (@layout)
Presentation lives in @layout blocks, separate from the semantics —
modelled on HTML/CSS, but deliberately without the cascade. A
block can sit inline in a node's body (applying to that node) or
standalone as a sheet of selectors that target nodes by
exact dotted path. No wildcards, no specificity, no inheritance:
one selector names one node, and a given property may be set only once per node
directly (an imported value from @use or kind is freely
overridden by a direct one — see Reusable layout).
| Declaration | Value | Meaning |
|---|---|---|
direction | vertical · horizontal | How children are stacked. Defaults to vertical. Sugar for a single-track grid (see Grid arrangement). |
cols · rows | number ≥ 1 | Arrange children as a 2-D grid: cols fixes the columns (rows grow to fit), rows fixes the rows (columns grow). See Grid arrangement. |
margin | number ≥ 0 | Uniform blank space reserved around the node's border box. Defaults to 8; set 0 to pack flush against siblings. |
box | none · default | Whether the node draws its border. none makes it invisible — no rectangle — while keeping its id, label, and anchors. Defaults to default. |
label | top · bottom | Which end of a parent reserves the strip for its label, so the label never overlaps the children. In a bordered parent the strip acts as an inner wall; a box: none parent reserves the same strip and packs its children flush below it. Defaults to top. |
anchor <name> | [x, y] | Positions a declared anchor name, in relative coordinates (see below). |
borderWidth | number ≥ 0 | Stroke width of the node's border. Defaults to 1. |
borderColor | hex colour | Colour of the node's border, e.g. #334155. Defaults to black. |
backgroundColor | hex colour | Fill colour of the node's box, e.g. #f8fafc. Defaults to white. |
pathWidth | number ≥ 0 | Stroke width of the arrows that start at this node. Defaults to 1. |
pathColor | hex colour | Colour of the arrows that start at this node (the arrowhead matches). Defaults to black. |
services {
label: "Microservices"
anchors: [north]
# Layout that belongs to just this node can be authored inline.
@layout { direction: horizontal }
database { label: "Database" }
}
# A standalone sheet targets nodes by exact path.
@layout {
services { anchor north: [0.5, 0.0] }
services.database { margin: 16 }
}
A node with box: none draws no border, so it groups its children for
layout only, while keeping its id (and so a path segment), label, and anchors. For
an anonymous grouping with no id — purely a layout wrapper — use a
@group in the node's arrangement (below).
A bare margin: N at the root of an @layout
sheet (not inside a selector) sets the default margin — the fallback
spacing for every node that sets none, replacing the built-in 8. It's the
one knob for spacing a whole diagram out. It is a single global default, not a cascade:
a node still overrides it with its own margin, and there is no inheritance
or specificity.
@layout {
margin: 16 # every node that sets no margin now reserves 16
webapp { margin: 0 } # … but a node still overrides the default directly
}
A bare route: straight · orthogonal at the sheet
root (like the default margin) sets the document's
arrow routing mode. straight (the default) draws each
arrow as a single cardinal line. orthogonal draws axis-aligned paths
that route around the boxes in the way instead of through them — an anchor
pinned to an edge leaves perpendicular to that edge — and gives each arrow its own
lane: where a line runs along a channel, that channel widens to hold
it, so the line sits in breathing room rather than inside a box's margin. See
Orthogonal routing on the Examples page.
@layout {
route: orthogonal # every arrow becomes an axis-aligned, obstacle-avoiding path
}
Styling — colour & stroke width
The five style properties above (borderWidth, borderColor,
backgroundColor, pathWidth, pathColor) are
ordinary @layout declarations, so they obey the same no-cascade
resolution as everything else. Colours are hex literals — #rgb,
#rgba, #rrggbb, or #rrggbbaa (a wrong length or
non-hex digit is an error); widths are lengths ≥ 0. The box
properties paint a node's own rectangle; pathWidth/pathColor
style the arrows that start at the node (its source end), and
the arrowhead matches the line colour.
Each can be set three ways: directly on a node, via an imported @block or
kind, or document-wide as a bare property at the sheet
root (like the default margin/route) — the
fallback for every node and arrow that sets none. A direct value beats an imported one
beats the document default beats the built-in plain look; there is no inheritance, so a
child never picks up its parent's colour.
@layout {
borderColor: #334155 # document-wide default for every box…
backgroundColor: #f8fafc
@block accent { borderColor: #2563eb; borderWidth: 2 }
services { @use accent } # …overridden here by the imported accent border
services.api { pathColor: #2563eb; pathWidth: 2 } # styles arrows leaving api
database { borderColor: #16a34a; borderWidth: 3 }
}
Everything defaults to the plain look — white fill, 1 px black border, 1 px
black arrows — so an unstyled diagram is unchanged. Theming beyond this (a cascade,
named palettes, per-node fonts) is intentionally out of scope; @block and
kind are how you share a look. See Styling on
the Examples page.
Reusable layout (@block & @use)
Shared layout is opt-in and explicit — a named bundle you pull
in, not a class that matches at a distance. Inside an @layout sheet,
@block <name> { … } defines a reusable, parameterless bundle of
layout properties (direction, margin,
box, the style properties, anchor positions);
@use <name> imports it
into a selector, an inline block, or another block. Blocks may compose (a block
may @use another); a composition cycle is an error,
as is a @use of an undefined block.
@layout {
# A reusable bundle: lay a node out as a borderless horizontal row.
@block row { box: none; direction: horizontal }
app { direction: vertical }
app.group1 { @use row } # imported: box none + horizontal
app.group2 { @use row; direction: vertical } # direct direction overrides the import
}
A node's kind is the implicit form of the same mechanism:
kind: database behaves as a leading @use database,
importing the block of that name as a baseline. The difference is that
@use is a layout import the author asked for — an undefined one is an
error — while kind is a semantic tag, so an unknown kind simply
contributes no baseline and is not an error. A small set of
built-in blocks ships — currently invisible
(box: none) — and a user @block of the same name
overrides the built-in.
Resolution & precedence — no cascade
Resolution has just two precedence tiers, the entire "specificity" model:
- Imported (lower) — what a node receives via its
kindbaseline first, then each@usein source order (a later@usebeats an earlier one). - Direct (higher) — declarations that name the node itself, inline or in a sheet selector for its exact path. Direct overrides imported for any property, with no conflict.
Within the direct tier a property may be set at most once: two direct rules setting the same property on the same node is a validation error, never a silent cascade. Anchor positions from different tiers union by name.
Child arrangement (@group)
Inside a node's @layout block you may list its children
to set the order they're laid out in, and wrap a run of them in an anonymous
@group { … } for purely visual nesting — the layout-layer cousin of an
HTML wrapper <div>. A bare identifier (no :) is a
child reference; a @group is a wrapper with its own
direction/margin and its own nested
arrangement.
@layout {
services {
direction: horizontal
# Stack userService over orderService, then sit that unit left of payments.
@group { direction: vertical; userService; orderService }
payments
}
}
A @group is always invisible (it renders as
box: none) and anonymous — it has no id and adds no
path segment, so a child inside a group keeps its real dotted path and its arrows
and anchors are unaffected. Two rules keep the layout tree a refinement of the
semantic tree, both enforced by the validator:
- Same-parent only — a group may contain only direct children of the enclosing node (and nested groups thereof).
- Completeness — once you arrange a node's children, reference each exactly once: no omissions, duplicates, or foreigners.
The arrangement is direct-only: it is authored on the node itself
and is never imported through @use or kind (child ids are
node-specific). In v1 a group can't be bordered or labelled, and @use
is not allowed inside one.
Grid arrangement (cols / rows)
For a 2-D layout, give a node cols (and optional rows)
instead of a direction: its children place themselves in a grid. This is the
generalisation of direction — in fact direction is just
sugar for a single-track grid: vertical is
cols: 1 (rows grow), horizontal is rows: 1
(columns grow), so a stack and a grid are one model.
@layout {
board { cols: 3 } # 3 fixed columns; rows grow with content
board.title { col: 1; row: 1; colSpan: 3 } # a full-width header spanning all three
board.web { col: 1; row: 2 } board.api { col: 2; row: 2 } board.db { col: 3; row: 2 }
}
| Per-child placement | Value | Meaning |
|---|---|---|
col · row | number ≥ 1 | The child's 1-based grid line. A child that sets neither auto-fills the next free slot (left to right, then top to bottom). |
colSpan · rowSpan | number ≥ 1 | How many columns / rows the cell spans. Defaults to 1. |
justify · align | start · end · stretch | Where a smaller child sits within its (possibly spanning) cell, horizontally / vertically. Defaults to stretch (fills the cell). |
Tracks are sized jointly on both axes — each column grows to its
widest cell and each row to its tallest — so the grid stays aligned, the thing
nested stacks cannot do without hand-padding. Placement is sparse:
a skipped slot needs no placeholder, and a track that no cell covers
reserves a minimum size, so leaving a row empty makes a visible spacer. A cell
placed outside the declared tracks, or two cells overlapping, is a constraint
error. Like @group, the grid is direct-only — never
imported through @use or kind.
Arrows & anchors
After the nodes, connect them with -->. Each endpoint is a node
path — a dotted reference like parent.child — with an optional
#anchor. A bare endpoint auto-routes to the nearest
cardinal edge (N/E/S/W) of its box facing the other node's centre, giving a clean
edge-to-edge line. Naming an anchor — including the implicit #center —
pins the end to a fixed point instead, and always wins.
By default each arrow is a straight line. Set
route: orthogonal at the @layout root
(above) to draw every arrow as an axis-aligned path that routes around intervening
boxes and travels in its own widened lane.
# Auto-routes to the nearest edges
api --> database
# Force a centre-to-centre line
api#center --> database#center
# Connect specific anchors
api#south --> services#north
# Nested node paths
services.userService#db --> database#north
Coordinate system
Anchor positions are relative to the node's bounding box, so they stay correct
whatever size the box ends up. Both coordinates are in [0, 1].
[0.0, 0.0]— top-left corner[0.5, 0.5]— centre (the default anchor on every node)[1.0, 1.0]— bottom-right corner[0.5, 0.0]— top edge, horizontally centred
The layout model
Layout is bottom-up and deterministic:
- A leaf node sizes to its label text, measured with a fixed font.
- A vertical parent stacks children top-to-bottom: its width is the widest child; children span the full width.
- A horizontal parent places children left-to-right: its height is the tallest child; children span the full height.
- Each node reserves a uniform
margin(default8) around its border box. Inside a bordered parent it acts like padding; inside an invisible parent (box: noneor the document root) the outer margins collapse, leaving only the gaps between siblings.margin: 0restores flush packing. - The canvas fits the top-level content exactly — the document root is invisible, so there is no outer padding even though inner nodes are spaced.
Comments
# begins a comment that runs to the end of the line — when it starts
a line or follows whitespace. Directly after a node path (no space), #
instead introduces an anchor reference, as in node#anchor.
Command line
arkitecture <input.ark> [output.svg] # output defaults to the input name + .svg
--validate-only parse and validate without generating SVG
--watch, -w regenerate whenever the input changes
--verbose, -v detailed processing output
--font-size <n> override the default font size (12)
--font-family <f> override the default font family (Arial)
--version
Exit codes: 0 success · 1 validation or parse errors · 2 filesystem errors.
Library API (Go)
The CLI is a thin wrapper over the library; the same API is available directly.
import "github.com/kurrik/arkitecture"
res := arkitecture.ToSVG(dsl, &arkitecture.Options{ValidateOnly: false})
if !res.Success {
for _, e := range res.Errors {
log.Printf("%s (line %d, col %d): %s", e.Type, e.Line, e.Column, e.Message)
}
return
}
fmt.Println(res.SVG)
Parse, Validate, and GenerateSVG expose
the individual pipeline stages, and every diagnostic is structured data
(line, column, message, and a
type of syntax, reference, or
constraint).