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:

PropertyValueMeaning
labelstringDisplay text, centred in the box. Omit it for an unlabeled box.
kindidentifierAn 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).

DeclarationValueMeaning
directionvertical · horizontalHow children are stacked. Defaults to vertical. Sugar for a single-track grid (see Grid arrangement).
cols · rowsnumber ≥ 1Arrange children as a 2-D grid: cols fixes the columns (rows grow to fit), rows fixes the rows (columns grow). See Grid arrangement.
marginnumber ≥ 0Uniform blank space reserved around the node's border box. Defaults to 8; set 0 to pack flush against siblings.
boxnone · defaultWhether the node draws its border. none makes it invisible — no rectangle — while keeping its id, label, and anchors. Defaults to default.
labeltop · bottomWhich 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).
borderWidthnumber ≥ 0Stroke width of the node's border. Defaults to 1.
borderColorhex colourColour of the node's border, e.g. #334155. Defaults to black.
backgroundColorhex colourFill colour of the node's box, e.g. #f8fafc. Defaults to white.
pathWidthnumber ≥ 0Stroke width of the arrows that start at this node. Defaults to 1.
pathColorhex colourColour 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:

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:

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 placementValueMeaning
col · rownumber ≥ 1The 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 · rowSpannumber ≥ 1How many columns / rows the cell spans. Defaults to 1.
justify · alignstart · end · stretchWhere 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].

The layout model

Layout is bottom-up and deterministic:

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).