weblang
v0.17.0
Published
Weblang programming language
Readme
Weblang
Weblang is a small programming language built around variables, pipe functions, and pipe chains. Programs describe the work; host-provided functions implement it. YAML is a convenient source notation. Program data can be stored as JSON and passed between hosts that provide the same pipe functions.
Use the recommended .w extension, such as hello.w. Select YAML as the
editor language for syntax highlighting. In VS Code, add a
file association
to user or workspace settings:
{
"files.associations": {
"*.w": "yaml"
}
}Install globally to run a source file from the command line:
npm i -g weblang
weblang app.wFor example, app.w:
=name: Vidar
=message: Hello $nameThis prints Hello Vidar. The CLI prints the program's return value (the last
assigned result by default). Errors print to stderr and exit with status 1.
The CLI does not register pipe functions; programs using custom pipes need
the JavaScript host API below.
For programmatic use:
npm i weblangvar weblang = require('weblang')
var pipes = {
up: {
handler: function (ctx, value) {
return value.toUpperCase()
}
},
slice: {
handler: function (ctx, value, start, end) {
return value.slice(start, end)
}
},
or: {
handler: function (ctx, value, fallback) {
return typeof value === 'undefined' ? fallback : value
}
}
}
async function main() {
var code = '=message: Hello $name |> up'
var ast = weblang.compile(code, { file: 'hello.w' })
var result = await weblang.run(ast, { vars: { name: 'Vidar' }, pipes })
console.log(result.state.vars.message) // HELLO VIDAR
console.log(result.state.return) // HELLO VIDAR
}
main().catch(console.error)The host supplies source text and registers every pipe by name. Examples use
these handlers and the additional handlers below. JavaScript snippets using
await belong inside an async function such as main().
=name: value assigns a variable. Assignments run in order and may replace
previous values. Dots address nested properties; numeric segments index arrays.
=name: Vidar
=message: Hello $name
=name: Storm # message is still "Hello Vidar"
=user.name: $name
=people.0.name: Vidar
=first: $people.0.name # "Vidar"
=a,b: one # assigns only a
=,discard: hello |> up # runs the pipe without storing its resultStart assignments in column one, with no spaces inside =name:. Use simple
names or camelCase: names containing _ currently get skipped. Underscores
work in object keys, host variables, and pipe names. Write $ when reading a
variable, not when naming its assignment.
metadata: ignored # ordinary root keys do not execute
=data:
=inner: ordinary data # nested keys do not create assignmentsRoot lists do not execute assignments. Forms such as = name: value,
=a b: value, and =a@call: value are invalid. Blank lines and document
boundaries do not introduce scopes.
Choose the notation that reads best; these styles express ordinary program
data. Indentation groups nested values. Comments start with # outside quotes.
# Block and inline forms produce the same value
=block:
name: Vidar
roles:
- admin
- author
=inline: { name: Vidar, roles: [admin, author] }
=people:
- name: Vidar
active: true
- { name: Storm, active: false }
=values: [42, -2, 1.5, true, false, null]
=empty: { text: '', list: [], object: {}, value: null }
=unset: # an omitted value is null
=alsoNull: ~Quotes preserve text that could otherwise be interpreted as a number, date, comment, or mapping. They do not disable Weblang variable expansion.
=id: '007'
=date: '2026-09-03'
=single: 'It''s text: # included'
=double: "Line one\nLine two\tTabbed"
=explicitText: !!str 42 # "42"Multiline styles control spaces and newlines. | preserves line breaks; >
folds ordinary adjacent lines into spaces. A blank line in folded text creates
a paragraph break. Append - to remove final newlines or + to keep them all;
without either, one final newline remains.
=literal: | # "one\ntwo\n"
one
two
=stripped: |- # "one\ntwo"
one
two
=kept: |+ # "one\ntwo\n\n"
one
two
=folded: > # "one two\n"
one
two
=foldedKeep: >+ # "one two\n\n"
one
two
=paragraphs: >- # "one two\nthree"
one
two
three
=indented: |2- # " indented": two spaces belong to the value
indentedAnchors (&name) and aliases (*name) reuse source data. $name instead reads
an executed assignment. << is an ordinary key in the current parser, not a
merge operator.
=preset: &preset { theme: dark, retries: 3 }
=copy: *preset
=runtimeCopy: $presetDocument start (---) and end (...) markers can separate source sections.
---
=name: Vidar
...
---
=greeting: Hello $name
...A whole $name expression reads a value; $user.name reads a nested path.
Strings in arrays and objects expand recursively, including their pipes.
=user: { name: Vidar, first_name: Vidar }
=copy: $user # same object, by reference
=names: [$user.name, Storm]
=record:
greeting: Hello $user.first_name!
loud: $user.name |> up
=count: 42 # number
=countText: $count # string "42"
=active: false
=activeCopy: $active # boolean false
=nothing: null
=nullCopy: $nothing # null
=missing: $unknown # undefined: assignment is skipped
=fallback: $unknown |> or guest
=inline: value $count/$active/$unknown # "value 42/false/"
=x: 1
=y: 2
=adjacent: $x$y # "12"Whole lookups preserve objects, arrays, booleans, and null; numbers become strings. Retrieved values are not recursively evaluated as source. An undefined result leaves the target and last assigned result unchanged.
Embedded variables use letters, digits, underscores, and dotted segments.
Missing values become empty text; objects stringify as [object Object] and
arrays as comma-joined text. Use a pipe to format structured values.
{{ expression }} inserts a string or number result into text. Other result
types contribute empty text. Put pipes inside the braces; to pipe the entire
result, use a subsequent assignment.
=name: vidar
=count: 2
=greeting: "Hello {{ $name |> up }}!"
=summary: '{{ $name }} has {{ $count }} messages'
=loud: $summary |> up
=flag: false
=empty: '{{ $flag }}' # empty string
=punctuation: '{{ $name }}!' # braces delimit the variable from punctuationUse \$ and \|> for literal markers in ordinary strings. Single quotes keep
backslashes; double quotes require them to be doubled. Plain string whitespace
is preserved; a pipe expression trims its initial text value.
=literal: '\$name and \|> stay literal'
=doubleQuoted: "\\$name and \\|> stay literal"
=spaced: ' hello ' # keeps both surrounding spaces
=trimmed: ' hello |> up' # "HELLO"
=plain: hello|>up # literal: the operator needs spaces
=old: hello | up # also literalKeys can use variables, interpolation, and pipes. Only strings and numbers
become key text; other results become ''. Later equal keys replace earlier
ones. A key whose original value is an object stays literal.
=field: name
=record:
$field: Vidar
'{{ $field |> up }}': Storm
'$field |> up |> slice 0, 1': Bobby
'user_{{ $field }}': [$field]
'\$literal': kept
$missing: empty key
=nested:
$field:
value: $fieldrecord has keys name, NAME, N, user_name, $literal, and ''.
nested is { $field: { value: 'name' } }.
value |> name passes a value through a registered function. Chains run left
to right, awaiting each result. Use a space on each side of |>.
=short: vidar |> up |> slice 0, 3 # "VID"
=multiline: >-
vidar
|> up
|> slice 0, 3
=names: [Vidar, Storm, Bobby]
=firstTwo: $names |> slice 0, 2
=numberText: 42 |> slice 0, 1 # input is "42", result is "4"Handlers receive (ctx, value, ...args) and return the next value. Register
objects with a handler property; synchronous and async functions both work.
pipes.wrap = {
handler: function (ctx, value, options) {
return options.prefix + value + options.suffix
}
}
pipes.args = {
handler: function (ctx, value, ...args) {
return args
}
}Commas separate arguments. Space-separated key=value pairs form one object;
commas between pairs create separate objects. Quotes preserve spaces and commas.
=user: { name: Vidar }
=suffix: '!'
=wrapped: Vidar |> wrap prefix="Hello " suffix=$suffix
=positional: x |> args 0, 3
=named: x |> args user=$user enabled=true
=separate: x |> args a=1, b=2
=mixed: x |> args 2, enabled=true, $user
=quoted: x |> args "a, b", 'hello world', "$user.name"
=typed: x |> args -2, 1.5, true, false, "2", nullwrapped is Hello Vidar!. separate is [{ a: 1 }, { b: 2 }].
typed is [-2, 1.5, true, false, '2', 'null']: argument literals recognize
numbers and booleans; other text stays text. Quoted arguments still expand
variables. Handlers may return any value type.
The argument parser is deliberately small:
| Form | Behavior |
| --- | --- |
| args enabled=true 2 | Error: separate named and positional arguments with a comma |
| args prefix=hello world | Error: quote a named value containing spaces |
| args "unfinished | Error: unterminated quote |
| args "a=b" | Treated as named syntax; quotes do not protect = |
| args "a |> b" | Split as a chain; quotes do not protect |> |
Pass complex argument values through variables. More sample handlers are in spec/lib/pipes.
compile(code, { file }) synchronously returns an AST. file labels source
metadata; the host reads files. Empty or non-string input produces [].
await run(ast, options) resolves to { state }.
| Option or result | Meaning |
| --- | --- |
| options.vars | Initial variable object, used and mutated directly; defaults to {} |
| options.pipes | Registry of pipe handler objects |
| Other options | Available to handlers through ctx.opt |
| state.vars | Variables after execution |
| state.last | Last defined result stored by an assignment |
| state.return | Explicit return value, otherwise state.last; undefined for an empty run |
State and completion
Handlers can share data and select a return value. Setting state.return to
anything except undefined stops after the current assignment finishes,
including its remaining pipes. false and null are valid return values.
pipes.save = {
handler: function (ctx, value, path) {
ctx.set(ctx.state.vars, path, value)
return ctx.get(ctx.state.vars, path)
}
}
pipes.finish = {
handler: function (ctx, value) {
ctx.state.return = value
return value
}
}=first: hello
=,discard: $first |> save snapshot
=first: $unknown # keeps "hello"
=result: $first |> finish |> up # stores "HELLO", returns "hello"
=skipped: never executedReturning undefined still feeds the next pipe; only the final undefined
result skips assignment. Returning { ok: false, error } is ordinary data.
Thrown errors and rejected promises reject run() immediately.
Handler context
| Property | Use |
| --- | --- |
| ctx.state, ctx.opt | Shared state and original run options |
| ctx.name | Current pipe name |
| ctx.pipe | Parsed { pipe, args } before expansion |
| ctx.args | Expanded arguments, also passed after value |
| ctx.get(object, path) | Read a nested path |
| ctx.set(object, path, value) | Write a nested path |
| ctx.expand(state, value, options, expandOptions) | Asynchronously expand another value |
An async handler can explicitly expand retrieved templates. A fourth argument
of { pipe: false } expands variables without interpreting pipe chains.
pipes.render = {
handler: async function (ctx, value) {
return ctx.expand(ctx.state, value, ctx.opt, { pipe: false })
}
}=name: Vidar
=template: 'Hello \$name \|> up'
=rendered: $template |> render # "Hello Vidar |> up"Errors
Compilation checks source structure; execution checks pipe arguments and
registrations. Assignment syntax errors include one-based line and column,
source, and file (default <memory>). Invalid source data can also throw
parser errors.
try {
weblang.compile('= name: Vidar', { file: 'hello.w' })
} catch (error) {
console.log(error.file, error.line, error.column) // hello.w 1 1
}
try {
await weblang.run(weblang.compile('=x: hello |> missing'), { pipes })
} catch (error) {
console.log(error.message) // missing pipe: missing
}Store executable program data as an ordered array. The current runner needs
only operation and value from each compiled node. This preserves repeated
assignments without the circular navigation links in the full AST.
var source = '=name: Vidar\n=name: Storm\n=message: Hello $name |> up'
var program = weblang.compile(source).map(function (node) {
return { operation: node.operation, value: node.value }
})
var stored = JSON.stringify(program)
var result = await weblang.run(JSON.parse(stored), { pipes })
console.log(result.state.return) // HELLO STORMThe stored program is ordinary JSON:
[
{ "operation": { "assign": "name" }, "value": "Vidar" },
{ "operation": { "assign": "name" }, "value": "Storm" },
{ "operation": { "assign": "message" }, "value": "Hello $name |> up" }
]Keep portable values to JSON types: strings, numbers, booleans, null, arrays,
and objects. Functions stay in the host's pipe registry. compile() takes
source text; run() can consume the restored records directly.
Development
Describe the workflow, then implement and register its functions:
=result: >-
$invoice
|> validate_invoice
|> enrich_invoice
|> render_invoiceConditions, loops, arithmetic, I/O, logging, and error recovery belong in host functions. Weblang has no separate call, return, or control-flow syntax. Optional input/output comments document pipe contracts; the runner does not enforce types.
/*
Pipe typedef:
Input from validate_invoice: { id: string, total: number }
Output for render_invoice: { id: string, total: number, vendor: string }
*/Use chain start or chain result at the ends of a chain. Run npm test for
the tests. Planning material remains in TODO and
NOTES.
Created by Vidar Eldøy
