wdoc · reference

Icons

16 min read · 2026-08-20 · wcl 0.33.2-alpha

Two complete icon packs, Lucide (1711 glyphs) and Bootstrap Icons (2078), are compiled into the wcl binary. The build downloads nothing and reads nothing from disk, and you declare nothing to use them. This chapter covers the three places an icon may go, the iconset block that renames a pack and styles it, the rules that turn a name into a glyph, the shared sprite the build emits, and what each of the three output targets does with an icon. It ends with both packs in full, every glyph drawn beside its name.

Every file and command below was run before it was written down. Type them and compare.

One file, every way in

Save this as icons.wcl. It uses all three ways to place an icon and declares one custom set:

icons.wclwcl
# icons.wcl — every way to put an icon on a page.
import <wdoc.wcl>

iconset ui {
  pack  = "lucide"
  size  = "1.25em"
  color = "#88c0d0"
  icon_def "heart" { color = "#bf616a" }
}

site demo {
  title            = "Icon lab"
  default_template = :book
  toc { chapter "Icons" { page = lab } }
}

page lab {
  title = "Icon lab"

  h1 "Icon lab"

  p "Bare :heart:, prefixed :bootstrap.house:, and a time like 10:30 that stays prose."

  diagram {
    width = 240  height = 90
    icon "lucide.compass" { x = 20.0  y = 13.0  width = 64.0  height = 64.0 }
    process "Validate" {
      id = v  x = 110.0  y = 20.0  width = 110.0  height = 50.0
      icon = "lucide.shield-check"
    }
  }
}
console
$ wcl wdoc build icons.wcl --out site
wrote 1 page
$ ls site/_wdoc/icons.svg
site/_wdoc/icons.svg

The paragraph renders as prose with two glyphs in it, and the diagram draws a third and a fourth. Both are reproduced below. They are unstyled, because this book declares no ui set of its own:

Bare , prefixed , and a time like 10:30 that stays prose.

Validate

Four icons, three mechanisms. :heart: and :bootstrap.house: are the inline pattern: a :name: token inside any prose string. The compass is the icon block, a placeable shape inside a diagram. The shield on the Validate box is a badge: the icon field that every box-like shape carries. All four resolve through one registry, and all four end up in one file, site/_wdoc/icons.svg, holding exactly the four glyphs the build used.

Note the last clause of the paragraph too. 10:30 sits in prose that the pattern engine scans for icon tokens, and comes out untouched. Neither the pattern nor the lookup ever fails a build.

The two bundled packs

The packs are vendored into the repository, and crates/wcl_wdoc/build.rs bundles them. It concatenates every *.svg in a pack directory into one blob and emits a name-sorted index into it. An icon lookup is a binary search and a slice. There is no per-icon file and no runtime read.

PackUpstreamLicenceGlyphsPaints with
lucidegithub.com/lucide-icons/lucideISC1711stroke="currentColor", outline style
bootstrapgithub.com/twbs/iconsMIT2078fill="currentColor", solid style

An icon name is the file stem. house.svg is the icon house. alarm-clock-check.svg is alarm-clock-check. Names are lower-case, digits and hyphens only, so a set.name token splits on its dot without ambiguity. The two galleries at the end of this chapter list every name in both packs.

The two packs overlap in places and diverge in most. Both have house, heart, wrench and zoom-in. Only Lucide has shield-check, and only Bootstrap has windows. When both provide a name and you have not said which you mean, the resolution order decides.

Licensing

Both licences are permissive and both require the licence text to travel with the files. This repository already meets that obligation: each pack directory keeps its upstream LICENSE beside the SVGs, and the two files are vendored verbatim. Only icons/*.svg and LICENSE are copied from upstream, nothing else.

You are redistributing the glyphs

A built site embeds the icons it uses into _wdoc/icons.svg, and a PDF embeds them into the document itself. Embedding the glyphs is redistribution, and the ISC and MIT terms follow the glyphs out of your build. Neither licence asks for attribution on the page. Both ask that the licence text be included with the distributed copies. If you ship the output rather than the source, ship the two licence files with it. crates/wcl_wdoc/assets/icons/README.md records where each pack came from and how to update it.

Inline icons

Inside any prose string, :name: becomes an icon. The pattern is one of the built-in inline patterns, so it works everywhere the others do: a p, a text block's span, a li, a table cell, a callout body, a chart label. Text and formatting covers the pattern engine as a whole.

The token is deliberately narrow. It matches a colon, a character from az or 09, then any run of lower-case letters, digits, dots, hyphens and underscores, then a closing colon. Three consequences follow:

If a token matches but resolves to nothing, the build emits the literal text you wrote. That is the whole safety story for the pattern: prose that happens to look like an icon reference stays prose.

Write this:

wcl
p "Bare :heart:, prefixed :bootstrap.house:, and :not-an-icon: falls through."

…and the build emits this (line-broken here to fit):

site/lab.htmltext
<p>Bare
  <svg class="wdoc-icon" style="width:1.25em;height:1.25em;color:#bf616a;">
    <use href="_wdoc/icons.svg#lucide-heart"/>
  </svg>,
  prefixed
  <svg class="wdoc-icon"><use href="_wdoc/icons.svg#bootstrap-house"/></svg>,
  and :not-an-icon: falls through.
</p>

Two things to read out of that. First, each icon is a <use> of the shared sprite rather than a copy of the glyph. The sprite explains why. Second, the styling on the heart came from the ui iconset and its icon_def, while bootstrap.house names a set that declares no defaults and so carries no inline style at all.

An inline icon carries no classes of its own

The :name: token has no syntax for a class or a size. There is nowhere to put one. Every class and every style on an inline icon comes from the iconset (or its icon_def) that resolved it. If you need one glyph styled differently from the rest of its set, give the set an icon_def for it, or declare a second set.

Iconsets

An iconset is a named reference to a bundled pack plus the default styling for icons drawn from it:

wcl
iconset ui {
  pack       = "lucide"                 # which bundled pack to read
  size       = "1.25em"                 # default inline size
  color      = "var(--wdoc-accent)"     # default foreground
  class      = ["ui-glyph"]             # added to every icon in the set

  icon_def "heart"  { color = "#bf616a" }
  icon_def "circle-x" { color = "#bf616a"  class = ["shout"] }
}

Its fields:

FieldTypeMeans
labelidentifierThe set's own name. It is what :ui.heart: and a diagram icon's set = ui refer to.
packutf8?The bundled pack to read glyphs from: "lucide" or "bootstrap". Defaults to the set's own name, so iconset lucide {} needs no pack.
sizeutf8?Default size for inline icons, as a CSS length: "1.25em", "20px". Ignored by diagram icons and badges.
colorutf8?Default foreground. Every glyph paints with currentColor, so color is the one setting that recolours both packs.
fillutf8?Explicit fill override, for the rare case where color is not enough.
backgroundutf8?Background behind the glyph. On an inline icon it is a CSS background. On a diagram icon it becomes a <rect> drawn under the glyph.
classlist<utf8>?Classes added to every icon from this set, on top of the built-in wdoc-icon. See Styling rules.
icon_defchild blocksPer-icon overrides, below.

Per-icon overrides

An icon_def child names one icon in the set and overrides the set's defaults for it. It carries the same five styling fields and nothing else: size, color, fill, background, class. Sizing and colour has the layering rule that decides what an override replaces.

wcl
iconset ui {
  pack  = "lucide"
  color = "#88c0d0"          # everything in `ui` is blue …
  class = ["ui-glyph"]

  icon_def "heart" {         # … except the heart
    color = "#bf616a"
    class = ["shout"]        # ends up with class="wdoc-icon ui-glyph shout"
  }
}

An iconset is a document-root declaration

wdoc builds the registry from the iconset blocks at the root of the document, not from the page an icon appears on. An iconset written inside a page is not a root block, and the registry never finds it. In a multi-file document every imported file's root blocks merge into one root, so a set declared in any imported file styles icons in every page of every site the document builds. That is the reach to keep in mind before you rename lucide.

How a name resolves

A name reaches a glyph through one registry, loaded once per build from those root iconset blocks. Four rules, in this order:

The library declares iconset lucide {} and iconset bootstrap {} unconditionally, so both packs work in a document that declares nothing. Because a set's pack defaults to its own name, those two declarations are the whole of their configuration.

Declaring a set with one of those names replaces it, because the lookup reaches a root set before a library one:

override.wclwcl
import <wdoc.wcl>

# `lucide` now means the bootstrap pack, everywhere in the document.
iconset lucide { pack = "bootstrap" }

site demo { title = "s"  default_template = :book
  toc { chapter "L" { page = lab } }
}

page lab {
  title = "L"
  h1 "L"
  callout "Careful" { class = ["warning"]  body = "text" }
  p "inline :lucide.triangle-alert: here"
}

Build that and the sprite holds one symbol, lucide-triangle-alert, while the paragraph renders the literal text :lucide.triangle-alert:. Both halves of that are worth understanding, because they are the one place the rules above are not the whole story.

The warning callout's default glyph is lucide.triangle-alert, and Bootstrap has no icon by that name, so the set lookup failed. One path has a second chance after that failure. An icon named through the Html::Icon value falls back to a compiled-in pack whose name matches the prefix. That value covers the icon(name, class) constructor, and so a callout's default glyph and a sidebar_footer button. That is why the callout drew the real Lucide glyph.

Nothing else gets that fallback. The author's own inline token did not. Rule 1 applied, the lookup failed, and the build emitted the literal text. Nor does a diagram icon block, nor a shape badge. Both resolve through the set list and stop there.

Renaming a built-in set moves the furniture

Callout glyphs (lucide.info, lucide.lightbulb, lucide.triangle-alert, lucide.circle-x, lucide.circle-check) and sidebar_footer button icons are named by the stdlib, not by you. Declaring your own iconset lucide re-points every one of them at whatever pack you gave it, and they change silently wherever the new pack happens to have a matching name. To restyle a pack without moving it, declare a set under a new name and use that name.

The placeable icon block

icon is also a shape. It extends SvgBlock, so it is a legal child of a diagram or a container. You place it by x / y or by anchors, and an edge may attach to it exactly as to a rect. The diagram canvas covers placement and Connections and routing covers the edges.

wcl
diagram {
  width = 260  height = 100
  icon "lucide.database" { id = db  x = 20.0   y = 26.0  width = 48.0  height = 48.0 }
  icon "lucide.cloud"    { id = cl  x = 190.0  y = 26.0  width = 48.0  height = 48.0 }
  db -> cl
}

The name is the block's label, so icon "lucide.compass" { … } and icon { name = "lucide.compass" … } are the same block written two ways. Its fields:

FieldTypeDefaultMeans
labelidentifierThe icon name, optionally set.name.
setidentifier?Which declared iconset to read from. Beats a set. prefix on the name.
x / yf640.0Top-left placement in diagram units, when the layout is manual.
width / heightf6424.0Size in diagram units. width and height, not size, are what size a diagram icon.
scalef64?Extra multiplier on width and height. It scales from the top-left corner: x / y do not move.
colorutf8?set'sForeground, over the set's default.
fillutf8?set'sFill override.
backgroundutf8?set'sDrawn as a <rect> behind the glyph, filling the same box.
classlist<utf8>?set'sClasses, added to the set's.
ididentifier?The shape's id, so an edge can name it.
sizeutf8?Declared for symmetry with iconset. A diagram icon ignores it. See the warning below.
anchorsf64?anchor_left and its siblings place the icon against the parent's edges instead of by x / y.
connect_pointslist<AnchorSide>?Which sides an edge may attach to.

size does not size a diagram icon

size is a CSS length, and the SVG render path never writes one. A diagram icon's geometry is width × height, multiplied by scale. Setting size on the block, or on the iconset it draws from, changes nothing in the output. Both were tried. Use width / height in a diagram, and keep size for the inline path.

A set on the block and a set prefix on the name do different jobs, and the difference shows up in the styling. icon "lucide.compass" reads from the lucide set, which is the library one with no defaults, so it inherits no colour even if you have a beautifully styled set of your own. icon "compass" { set = ui } reads from ui, and picks up its color, background and classes.

Icon badges on shapes

The third mechanism puts an icon on a shape rather than beside it. Every box-like shape carries four badge fields, and that means rect, circle, container, process, decision, terminator, and any custom SvgBlock shape that declares the same fields:

FieldTypeDefaultMeans
iconutf8?The icon name, optionally set.name. Absent means no badge.
icon_sizef64?min(w, h) * 0.4Badge size in diagram units.
icon_posIconPos?:leftWhere in the shape's box the badge sits.
icon_classlist<utf8>?Classes on the badge.

IconPos is a symbol set with seven members. Corner and edge placements are inset from the box by a tenth of its shorter side. :center is not inset.

SymbolPlaces the badge
:leftAgainst the left edge, vertically centred. The default.
:rightAgainst the right edge, vertically centred.
:centerIn the middle of the box. Every badge draws over the shape, so this one overlaps a centred label.
:top_leftTop-left corner.
:top_rightTop-right corner.
:bottom_leftBottom-left corner.
:bottom_rightBottom-right corner.

:left is the default for two reasons. The label-bearing flowchart shapes reserve a matching strip on their left, so the badge and the label sit side by side instead of on top of one another. And a corner badge pokes out of a rounded or oval outline. Compare the two:

ValidateValidate
wcl
process "Validate" {
  id = a  x = 10.0  y = 20.0  width = 180.0  height = 50.0
  icon = "lucide.shield-check"                      # :left, the default
}
process "Validate" {
  id = b  x = 230.0  y = 20.0  width = 180.0  height = 50.0
  icon = "lucide.shield-check"  icon_pos = :top_right
}

A badge takes no set field. Write the set into the name, as in icon = "ui.shield-check", when you want a set other than the first one that answers.

Two shapes have no box to badge

line and label have no bounding box to anchor a badge to, so they carry no badge even though they are shapes. Everything else resolves a box: circle, container and polygon from their own geometry, and every remaining kind from its rectangle.

Sizing and colour

Styling arrives in three layers, and each later layer wins field by field:

Scalar fields replace. class lists concatenate, so an icon ends up wearing wdoc-icon, then the set's classes, then the icon's own.

What each field does depends on where the icon is drawn, and the table below is the comparison worth having in one place:

FieldInline :name:Diagram icon blockShape badge
sizeCSS width and heightIgnoredIgnored. Use icon_size
colorCSS color, driving currentColorSame, on the <use>From the set only
fillCSS fillSameFrom the set only
backgroundCSS backgroundA <rect> behind the glyphFrom the set only
classFrom the set onlySet's + the block's classSet's + icon_class
geometry1em, the surrounding font sizewidth × height × scaleicon_size, default min(w, h) * 0.4

The default inline size is not a hard-coded number in the renderer. The library ships a base "svg.wdoc-icon" rule setting width: 1em; height: 1em, so an inline icon is as tall as the text around it and moves with it. An iconset's size overrides that rule with an inline style. The selector is deliberately svg.wdoc-icon, element and class, so it cannot touch a diagram icon, which is a <use> and not an <svg>.

currentColor is the whole colour story

Lucide draws with stroke="currentColor" and Bootstrap with fill="currentColor", and the sprite preserves those attributes. So one property recolours both packs: the CSS color of whatever contains the icon. An icon with no color of its own takes the colour of the text or the shape it sits in. That is why an icon in a themed callout is already the callout's colour, and why a class from Styling rules restyles icons for free.

The shared sprite

The build never copies a glyph into a page. Each use emits a <use href="_wdoc/icons.svg#pack-name"/>, and after every page has rendered, the build writes one _wdoc/icons.svg. That file holds a <symbol> for each icon the build used. A document that references four icons ships four symbols, out of the 3789 available.

The symbol is the pack's own SVG with its root tag rewritten. The rewrite keeps viewBox. It also keeps the presentation attributes fill, stroke, stroke-width and stroke-linecap, because they are what makes the glyph draw. It drops width, height, xmlns, id, class, role, aria-hidden and focusable, because the <use> supplies the size and the rest is noise. The id is {pack}-{name}.

site/_wdoc/icons.svgtext
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
  <symbol id="lucide-heart" viewBox="0 0 24 24" fill="none"
          stroke="currentColor" stroke-width="2"
          stroke-linecap="round" stroke-linejoin="round">
    <path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 …" />
  </symbol>
</svg>

Three facts follow from the sprite being a separate file. A page with fifty repetitions of one icon carries fifty short <use> elements and one copy of the path data. The build writes the href relative, _wdoc/icons.svg, so it resolves both under the dev server and on a static host. And it is a fetch, so the icons appear when the output is served rather than when you open a page file directly from disk.

A malformed glyph is dropped, not fatal

If a pack SVG has no root <svg> tag and no usable viewBox (and no width/height pair to synthesise one from), the build leaves the icon out of the sprite, and the <use> referring to it renders nothing. Nothing about icons fails a build: not a missing name, not a malformed glyph, not an unknown set.

One registry, every caller

There is exactly one icon registry per build. The renderer threads it through as patterns.icons(), and every caller that can name an icon asks the same object: the inline pattern engine, the diagram shape renderer, the badge renderer, a callout's default glyph, a sidebar_footer button's icon, and the icon(name, class) constructor available to your own blocks. One consequence matters in practice: the sprite is complete because everything records its usage in the same place, so a glyph that only a callout asked for is still in the file.

Icons across the three output targets

Icons are one of the features where the three targets genuinely differ, so it is worth seeing all three at once. Output targets covers the targets themselves.

TargetInline :name:Diagram and badge iconsThe sprite
HTML<svg class="wdoc-icon"><use …/></svg>, sized 1em<use> inside the page's SVGWritten to _wdoc/icons.svg
PDFThe pack glyph embedded directly, one em tall, painted in the page's foreground colourThe <symbol>s are spliced into the embedded SVG's <defs> and the sprite href rewritten to a local #idNone. There is no file to fetch
MarkdownDegrades to the literal :name: textSurvives: the build exports the diagram as an .svg file that references the spriteWritten to _wdoc/icons.svg

Two of those rows deserve a sentence each. In PDF there is no sprite file to fetch, so the glyph must travel inside the document. The PDF renderer embeds an inline icon as the raw pack glyph, so an iconset's size and color do not reach it. It is one em tall, and it is the page's text colour. In Markdown the inline pattern has nowhere to go, because GitHub-flavoured Markdown has no inline SVG. The token you wrote comes back out as text. Diagrams are unaffected either way, because a diagram was always going to be an image.

Build the file from the top of this chapter as Markdown and the difference is plain:

console
$ wcl wdoc build icons.wcl --out md --type markdown
wrote 1 page
$ cat md/lab.md
# Icon lab

Bare :heart:, prefixed :bootstrap.house:, and a time like 10:30 that stays prose.

![diagram](_wdoc/lab-diagram-1.svg)

If an icon has to survive into Markdown, put it in a diagram, or use a text marker instead. Visibility is the other tool here: put @except(backends = [:markdown]) on the block that carries the decorative icon, and a plain-text block in its place. The axis is per block, though, and an inline :name: is not a block. A paragraph is the smallest thing you can hide.

Icons in your own blocks

A block you declare yourself lowers to the HTML vocabulary, and that vocabulary has an icon in it. The icon(name, class) constructor builds one:

wcl
@block("status")
type Status extends ContentBlock {
  @inline(0) label: utf8
  glyph: utf8?

  lower = fn(s: &Status) -> list<Html>
    [el("span", ["status"], [
      icon(s.glyph ?? "lucide.circle-check", ["status-glyph"]),
      inl(s.label),
    ])]
}

The name resolves through the same registry and by the same rules, and icon() records its usage, so a glyph only your block asks for is still in the sprite. icon() is also the constructor that carries the compiled-in pack fallback: a pack.name that no declared set covers still resolves, exactly as a callout's default glyph does. A name that even the fallback cannot place emits nothing rather than failing. Writing your own blocks covers the lowering interface, and inl alongside icon in the el family.

The other route needs no lowering at all. Because Html::Inline and every prose string run through the inline pattern engine, a block that emits prose gets :name: support for nothing.

The full galleries

Every glyph in both bundled packs — 1711 Lucide and 2078 Bootstrap, 3789 in all — is drawn out in Icon galleries, one page of nothing else. It is a lookup table rather than a chapter, so it sits beside this one instead of inside it: browser find over 3,789 names is the way to use it, and it is the only page in the book long enough to be worth loading on its own.

Where to go next