Skip to content

Expression Language Spec

API Reference: This Page · Engine · Types · Events · Security · Servers

Formal specification of the Alap expression language. For a tutorial introduction, see Expressions.

Design principle

The query lives inside the link itself. An Alap expression is a self-contained instruction that selects what to show. The element (<alap-link>, <alap-lightbox>, <alap-lens>) decides how to show it.

html
<!-- same query, different presentations -->
<alap-link query=".coffee + .nyc">menu</alap-link>
<alap-lightbox query=".coffee + .nyc">carousel</alap-lightbox>
<alap-lens query=".coffee + .nyc">details</alap-lens>

Grammar

query     = segment (',' segment)*
segment   = term (operator term)*
operator  = '+' | '|' | '-'
term      = '(' segment ')' | atom
atom      = MACRO | SEARCH | PROTOCOL | REFINER | TAG | ITEM_ID

TAG       = '.' IDENTIFIER
MACRO     = '@' IDENTIFIER?
SEARCH    = '/' KEY '/' OPTIONS?
PROTOCOL  = ':' NAME ':' ARGS ':'
REFINER   = '*' NAME ':' ARGS '*'
ITEM_ID   = IDENTIFIER

IDENTIFIER is one or more characters excluding whitespace, operators (,, +, |, -), parentheses, and reserved prefixes (., @, /, :, *). Underscores are allowed; hyphens are not (the - character is the WITHOUT operator).

Evaluation rules

  1. Comma-separated segments are evaluated independently. Results are concatenated in order and deduplicated.
  2. Operators evaluate strictly left-to-right. There is no operator precedence. A | B + C means (A | B) + C.
  3. Parentheses override left-to-right evaluation. Nest up to 32 levels deep.
  4. Deduplication preserves first-seen order. If an item appears in multiple segments or operations, only the first occurrence is kept.
  5. Unknown atoms (missing item IDs, missing tags, missing macros) resolve to an empty set. They do not produce errors.

Operator semantics

Every expression operates on sets of item IDs.

OperatorNameSemanticsSet operation
+ANDItems in both operandsIntersection
|ORItems in either operandUnion
-WITHOUTItems in left but not in rightDifference

Atom types

AtomSyntaxResolves to
Item IDbrooklynSingle item if it exists, else empty set
Tag.bridgeAll items with that tag
Macro@favoritesExpand the named macro's linkItems expression
Bare macro@Expand the macro whose name matches the trigger's DOM id
Search/bridges/Items matching the search pattern defined in config
Protocol:web:query:Items generated by the protocol handler
Refiner*sort:label*Shapes the result set (sort, limit, shuffle, etc.)

Worked examples

Given this config:

json
{
  "macros": {
    "nycbridges": { "linkItems": ".nyc + .bridge" },
    "everything": { "linkItems": ".nyc | .sf" }
  },
  "allLinks": {
    "brooklyn":    { "tags": ["nyc", "bridge", "landmark"] },
    "manhattan":   { "tags": ["nyc", "bridge"] },
    "highline":    { "tags": ["nyc", "park", "landmark"] },
    "centralpark": { "tags": ["nyc", "park"] },
    "goldengate":  { "tags": ["sf", "bridge", "landmark"] },
    "dolores":     { "tags": ["sf", "park"] },
    "bluebottle":  { "tags": ["coffee", "sf", "nyc"] },
    "aqus":        { "tags": ["coffee", "sf"] },
    "bmwe36":      { "tags": ["car", "germany"] },
    "miata":       { "tags": ["car", "japan"] },
    "vwbug":       { "tags": ["car", "germany"] }
  }
}

Direct item IDs

json
{ "query": "brooklyn", "result": ["brooklyn"] }
{ "query": "doesnotexist", "result": [] }

Tag queries

json
{ "query": ".bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".car", "result": ["bmwe36", "miata", "vwbug"] }
{ "query": ".japan", "result": ["miata"] }
{ "query": ".doesnotexist", "result": [] }

Intersection (+)

json
{ "query": ".nyc + .bridge", "result": ["brooklyn", "manhattan"] }
{ "query": ".sf + .bridge", "result": ["goldengate"] }
{ "query": ".nyc + .bridge + .landmark", "result": ["brooklyn"] }
{ "query": ".car + .coffee", "result": [] }

Union (|)

json
{ "query": ".nyc | .sf", "result": ["brooklyn", "manhattan", "highline", "centralpark", "goldengate", "dolores", "bluebottle", "aqus"] }
{ "query": ".bridge | .landmark", "result": ["brooklyn", "manhattan", "goldengate", "highline", "towerbridge"] }

Subtraction (-)

json
{ "query": ".nyc - .bridge", "result": ["highline", "centralpark", "bluebottle"] }
{ "query": ".nyc - .landmark", "result": ["manhattan", "centralpark", "bluebottle"] }
{ "query": ".car - .germany", "result": ["miata"] }
{ "query": ".car - .car", "result": [] }

Commas (concatenation)

json
{ "query": "brooklyn, goldengate", "result": ["brooklyn", "goldengate"] }
{ "query": ".car, .coffee", "result": ["bmwe36", "miata", "vwbug", "bluebottle", "aqus"] }
{ "query": "bmwe36, bmwe36", "result": ["bmwe36"], "note": "deduplicated" }

Macros (@)

json
{ "query": "@nycbridges", "result": ["brooklyn", "manhattan"] }
{ "query": "@everything", "result": ["brooklyn", "manhattan", "highline", "centralpark", "goldengate", "dolores", "bluebottle", "aqus"] }
{ "query": "@everything + .bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": "@nonexistent", "result": [] }

Parentheses (grouping)

json
{ "query": "(.nyc + .bridge) | (.sf + .bridge)", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".nyc | (.sf - .coffee)", "result": ["brooklyn", "manhattan", "highline", "centralpark", "bluebottle", "goldengate", "dolores"] }
{ "query": "(.nyc | .sf) + .bridge", "result": ["brooklyn", "manhattan", "goldengate"] }
{ "query": ".bridge - (.nyc | .london)", "result": ["goldengate"] }

Left-to-right evaluation (no precedence)

These demonstrate why parentheses matter:

json
{
  "query": ".nyc | .sf + .bridge",
  "evaluation": "(.nyc | .sf) + .bridge",
  "result": ["brooklyn", "manhattan", "goldengate"],
  "note": "OR first, then AND — not .nyc | (.sf + .bridge)"
}
{
  "query": ".nyc + .bridge | .coffee",
  "evaluation": "(.nyc + .bridge) | .coffee",
  "result": ["brooklyn", "manhattan", "bluebottle", "aqus"],
  "note": "AND first, then OR"
}
{
  "query": ".nyc | .sf - .landmark",
  "evaluation": "(.nyc | .sf) - .landmark",
  "result": ["manhattan", "centralpark", "dolores", "bluebottle", "aqus"],
  "note": "OR first, then WITHOUT"
}

Mixed operand types

json
{ "query": "bmwe36 + .car", "result": ["bmwe36"], "note": "item ID AND tag" }
{ "query": "bmwe36 + .coffee", "result": [], "note": "item lacks tag" }
{ "query": ".car - bmwe36", "result": ["miata", "vwbug"], "note": "tag WITHOUT item" }
{ "query": "aqus | .coffee", "result": ["aqus", "bluebottle"], "note": "item OR tag" }
{ "query": ".sf + .bridge, miata", "result": ["goldengate", "miata"], "note": "expression, then item" }

Edge cases

json
{ "query": "", "result": [], "note": "empty string" }
{ "query": "   ", "result": [], "note": "whitespace only" }
{ "query": ".x +", "result": "matches .x", "note": "dangling operator ignored" }
{ "query": "!@#$%^&", "result": [], "note": "invalid characters" }

Reserved characters

CharacterRoleNotes
.Tag prefix.coffee
@Macro prefix@favorites
,Segment separatora, b
+AND operator.a + .b
|OR operator.a | .b
-WITHOUT operator.a - .b
( )Grouping(.a + .b) | .c
/Search pattern delimiter/bridges/
:Protocol delimiter:web:query:
*Refiner delimiter*sort:label*
_Allowed in identifiersgolden_gate

Hyphens (-) are the WITHOUT operator and cannot appear in item IDs or tag names. Use underscores instead.

Three-phase query model

An expression can combine all three phases:

  1. Selection — tags, IDs, macros, and operators select the initial set
  2. Generation — protocols (:web:, :atproto:, :json:) add dynamically fetched items
  3. Refinement — refiners (*sort:label*, *limit:5*, *shuffle*) shape the final output
.coffee + .nyc, :json:recommendations:, *sort:label*, *limit:10*

Selection and generation produce sets of items. Refinement transforms the combined set into the final ordered result that the renderer displays.