@frontierjs/toolbelt
v0.1.8
Published
FrontierJS pure-function kits — zero dependencies, no I/O, importable from any package
Maintainers
Readme
@frontierjs/toolbelt
Pure functions, shared across every FrontierJS package and app, so the same string/date/object logic is not written a fourth time.
One package, one kit per subpath. Each kit stands on its own — import @frontierjs/toolbelt/glow and nothing else comes with it — and they ship together because a project that wants one usually ends up wanting two.
import { glow } from '@frontierjs/toolbelt/glow'| Subpath | Kit | State |
| --- | --- | --- |
| /glow | source code → highlighted HTML | shipping |
| /cron | what a five-field cron expression admits | shipping |
| /datetime | an instant read as a place's wall clock, formatted, and written back | shipping |
| /inflect | how a name is spelled — number and shape | shipping |
| /directives | the $ convention — filters vs directives | shipping |
| /gate | the access ladder — levels, levelPasses, gradeStanding, canAtLevel | shipping |
| /aria | what a css VOCAB term owes the accessibility tree — role, required relationships, tracked state | shipping |
| /history | the key naming one occurrence of change | shipping |
| /frontier | a Frontier member's chain of machines, and the post's signed row for it | shipping |
| /hooks | the four-phase resource pipeline | shipping |
| /json | reading and editing a JSON document nothing describes | shipping |
| /jsonschema | follow a $ref; what a blank record looks like | shipping |
| /match | does this record belong in that query's results | shipping |
| /predicate | does this record satisfy a declared .lite expression | shipping |
| /query | what a query string MEANS — types, structure, both directions | shipping |
| /redact | is this key a credential, and redacting a value by that answer | shipping |
| /seal | a value only one key can open — HPKE base mode over X25519, in WebCrypto | shipping |
| /search | ranking a corpus nobody indexed | shipping |
| /signature | what a signed machine-to-machine request is, and a short signed token for an address | shipping |
| /tty | @frontierjs/css's tones as a terminal's own 16 colors | shipping |
| /units | a magnitude with a unit, as a person reads it | shipping |
The whole package holds to one rule:
Every export is a pure function. Same input, same output. No I/O, no clock, no filesystem, no network, no globals, no framework imports, no mutation of its arguments.
That rule is not a style preference — it is what buys the package its standing (below). A helper that needs the current time is handed it — as an argument, or as a clock function the app passes in (FJS-D268). One that needs an env var or a Junction ctx does not go in a new subpath here; it goes in the package that needs it.
Realm
Cross-cutting. Not a realm noun — toolbelt introduces no Model, Service, or Resource.
Dependency direction
Zero dependencies, ever — workspace or otherwise. Toolbelt is substrate: it sits below the dependency graph rather than being a member of it, so importing it can never create a cycle or route a package around Litestone ← Junction ← Sierra. Any package may import it, including litestone and mesa.
Ruled FJS-D26, DECISIONS.md § Dependencies & the ecosystem. The purity rule above is what the ruling rests on: a single Date.now() in src/ costs the exemption, not just the style.
query — what a query string means
import { parseQueryString, encodeQueryString } from '@frontierjs/toolbelt/query'
parseQueryString('?qty=5&live=true&sku=007&id[in][]=1&id[in][]=2')
// { qty: 5, live: true, sku: '007', id: { in: [1, 2] } }A URL carries text, so something has to decide whether ?qty=5 is a number.
Three boundaries decide it — Junction's transport, Junction's client writing one,
Sierra's router — and they used to give three answers (FJS-D125).
A string is a number only if it round-trips: String(Number(v)) === v. One
test, and the traps of the usual parseFloat version fall out of it — '007'
stays a SKU, '+1' stays a phone number, '1.50' keeps its cents, and
'9007199254740993' stays a string because the round trip loses its last digit.
true, false and null are themselves. Structure is bracket notation, never a
sigil. A repeated key is an array. ?code="5" is the one escape, for text that
would otherwise read back as something else — and it is exactly what
encodeQueryString emits for such a string, which is what makes the two halves
inverses rather than approximately so.
It is not validation and not schema coercion. It answers what did the caller
type, with no model in the room. Where a model exists it has the last word:
id String filtered by ?id=5 reads 5 here and Litestone converts it back.
cron — what a five-field expression admits
import { parseCron, cronMatches } from '@frontierjs/toolbelt/cron'
const fields = parseCron('0 9-17/2 * * mon-fri') // → Sets, one per field
cronMatches(fields, { minutes: 0, hours: 11, date: 4, month: 3, day: 3 }) // trueparseCron answers the set of values each field admits and throws on anything
it cannot mean, naming the field and the bound — 0 25 * * * is hour value is
25, outside 0-23 rather than a schedule that registers and matches no minute
for the life of the process. 0 9 31 2 * is refused the same way: every field
is in range and no month it admits is long enough.
cronMatches takes clock parts rather than a Date, with month 1-12 and day
0-6. Which clock, and when to look, is the caller's — Caravan reads a named
zone and walks a wall clock across daylight boundaries, junction's in-process
scheduler reads the host clock, and neither is a fact about the grammar.
Day of month and day of week are OR'd where BOTH are restricted, and AND'd
with the other three: 0 0 1 * mon is the first of the month AND every Monday,
which is what it means in a crontab. The asymmetry reads as a bug and is the
grammar — under a uniform AND the same line fires only when the 1st happens to
BE a Monday, about one month in seven, which is a schedule that runs and looks
alive (FJS-1008). Restricted means not written as a star — cron's own test
is the field's FIRST CHARACTER, so 0-6 names every day and is still restricted,
and 0 0 1 * 0-6 fires daily. Reading set COMPLETENESS instead looks tidier and
diverges on exactly that line, silently.
A name is resolved in its own field's table, never over the line: sat is a
day and jan is a month, and each is refused in the other's field rather than
becoming a number (FJS-1009). Any prefix of two letters or more naming exactly
one entry, so jun resolves and ju is refused naming what it could be.
cronMatches throws where parts is missing one of the five keys, because
false there is a schedule that never fires and never says so.
Sunday is 0 and 7. A term may combine a list, a range and a step (0,2-4,9-15/3).
datetime — an instant, a place, and the words between them
import { createDatetime } from '@frontierjs/toolbelt/datetime'
// once, in the app — the kit reads no clock and keeps no settings of its own
export const dt = createDatetime({ timeZone: 'America/Denver', now: Date.now })
dt.format(order.createdAt, 'DDDD, MMMM D [at] h:mm aa') // 'Saturday, July 4 at 10:05 AM'
dt.relativeToNow(order.createdAt) // '2 hours ago'
dt.dayHeaderNow(message.sentAt) // 'Today' · 'Yesterday' · 'Thursday' · 'Jun 27' · 'Dec 31, 2025'
dt.fromWall({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, { disambiguation: 'reject' })
// RangeError: datetime: 2026-11-01T01:30:00 in America/Denver happens twice — the clock went back. …Every function also stands alone with its arguments explicit: format(instant, pattern, { timeZone, locale }), relative(instant, now, { style }), partsIn(instant, timeZone), resolveWall(fields, timeZone) and fromWall(fields, timeZone, { disambiguation }). An instant is epoch milliseconds, a Date, or an ISO string with Z or an offset — a string without one is refused, because it names a different moment wherever it is read.
Tokens. Uppercase is the date, lowercase is the time. Literal text goes in brackets, and a run of letters that is not made of tokens is an error that names it.
| Token | | Token | |
| --- | --- | --- | --- |
| YYYY YY | year | hh h | hour — 12-hour only when a/aa/aaa is in the pattern |
| GGGG | the ISO week's year | mm m | minute |
| QQQQ QQQ QQ Q | 3rd quarter Q3 03 3 | ss s | second |
| MMMM MMM MM M | July Jul 07 7 | aaa aa a | in the morning AM A |
| WWWW WWW | week of the month: 1st week W1 | ttt tt | Mountain Daylight Time MDT |
| WW W | ISO week | t | offset, -06:00 |
| DDDD DDD DD D | Saturday Sat 04 4 | | |
Names follow locale; digits are always Latin. QQQQ and WWWW are English and refuse another locale. Zone rules are the runtime's own ICU table, and node and bun can disagree where a country changed its rules recently (FJS-D268).
Plain dates — a day in no zone, which is what a String @date column holds and what a billing period, a due date or a birthday is (FJS-D288).
plainDateIn('2026-01-31T01:00:00Z', 'America/New_York') // '2026-01-30' — an evening there
addToDate('2026-01-31', { months: 1 }) // '2026-02-28' — clamped, Temporal's 'constrain'
daysBetween('2026-08-30', '2026-09-29') // 30
startOfDay('2026-09-06', 'America/Santiago') // the instant that day begins — 01:00, not midnight
dt.today() // the day it is now, in the instance's zoneA zone is asked for at exactly two crossings — an instant to a day, and a day to the instant it starts — and the calendar arithmetic between them has none to disagree with. A day is [startOfDay(d), startOfDay(addToDate(d, { days: 1 }))), 23 or 25 hours long on a transition day, so it is never start + 24h. addToDate's clamp does not remember the day it came from: '2026-01-31' plus a month twice is 28 February and then 28 March, so a recurring date is computed from its anchor rather than by stepping.
units — a magnitude with a unit
import { formatBytes } from '@frontierjs/toolbelt/units'
formatBytes(5 * 1024 ** 2) // '5.0 MB'
formatBytes(45 * 1024 ** 2) // '45 MB'
formatBytes(500 * 1024 ** 3) // '500 GB'
formatBytes(45 * 1024 ** 2, { decimals: 2 }) // '45.00 MB'
formatBytes(undefined) // '' — absent is not zeroBinary steps with the familiar labels: 1024 to the step, and the step is called
MB rather than MiB, which is what almost every tool a reader has used shows them.
Precision is adaptive — one decimal below ten of a unit, none above, and never on
bytes — because a long list is scanned for magnitudes, and 503.2 GB carries a
digit nobody is reading. decimals fixes it for a column that must not jitter.
'' for undefined, null, NaN, [] and a non-numeric string: answering
0 B for a missing size is how we do not know reads as an empty file.
Number(null) and Number('') are both 0, so every function here asks before
it divides.
What it does about that answer depends on the surface, and the three differ on
purpose. A formatter says '' — display can say nothing. fromMinor answers
NaN, which formatMoney turns into '', so a nullable money column reaches the
cell as blank rather than as $0.00, and one missing amount does not take a
screen down. toMinor and roundMinor throw: those are the write and the
arithmetic, where a silent 0 is a free order or a receipt that balances and is
wrong (FJS-1011). The currency CODE is strict on all of them.
Four copies of this function existed before it did, and two of them disagreed —
@frontierjs/ui said 5.0 MB where three basecamp screens said 5 MB, so one
application showed one disk two ways (FJS-408).
inflect — how a name is spelled
Three axes of one question. NUMBER is post ⇄ posts; SHAPE is
product_variants ⇄ ProductVariant ⇄ productVariant; READER is
postal_code → Postal Code.
import { pluralize, singularize, modelName, camel, snake, slug, toolName } from '@frontierjs/toolbelt/inflect'
pluralize('category') // 'categories'
singularize('statuses') // 'status'
modelName('product-variants') // 'ProductVariant' — Invariant 2, composed once
camel('HTTPStatus') // 'httpStatus' — a leading initialism lowers WHOLE
snake('ProductVariant') // 'product_variant'
slug("It's a C++ thing") // 'its-a-c-thing'
slug('add product variants', { sep: '_' }) // 'add_product_variants'
toolName('orders', 'refund') // 'orders_refund' — the MCP spelling; a dot is illegal theremodelName is exported rather than left to the caller. Six sites composed
pascal(singularize(t)) by hand with four different splitters, and every one of
them already imported singularize from here — so half of Invariant 2's
derivation was shared and the other half of the same expression was not.
order-item was OrderItem to one and Order-item to two others.
humanize is the third axis and it answers to somebody else. postal_code
and postalCode are both Postal Code: @frontierjs/ui labels a control whose
field declared no @label, and sierra falls back to it when a stored value has
no row to read a label from (FJS-D225). It is words() plus exactly one added
rule — a digit starts a word of its own — and that rule may not be lifted
upward, because kebab('address1') becoming address-1 renames a column. What
NUMBER and SHAPE answer must not change when a reader changes; what humanize
answers must (FJS-D236). Only _, - and whitespace separate in either half,
so partman.template_x comes back visibly wrong rather than as a plausible
PartmanTemplateX.
slug is one rule and one stated exception: every run of non-alphanumerics
becomes one separator, and an apostrophe is deleted, because it sits inside a
word where every other mark sits between two. sep is a parameter (a migration
filename wants _), max truncates and re-trims (a .slice() at the call site
is what stores strategy-and-), and accents fold rather than drop — five copies
of this answered caf for Café.
One definition, five callers. FrontierJS names one thing three ways —
model Post in the schema, posts for the service and the URL, db.post for
the accessor — and a table name and the model name derived back from it have to
be the same rules run twice. They were five copies that disagreed: two knew
twenty irregulars, one knew none but had the guards that stop status becoming
statu, and one was endsWith('s').
English's regular rules plus a fixed irregular table (person/people,
index/indices, datum/data, 21 in all). Not a dictionary, and it cannot
become one — the gaps are structural: bases is basis and never base,
houses singularizes to hous, lens to len, because pens is a real
plural with the same ending. A word the rules cannot reach is said by hand:
@@map in the Data realm, createResource('lenses', { model: 'Lens' }) in the
UI.
directives — the $ convention
import { splitParams, RESERVED_PARAMS } from '@frontierjs/toolbelt/directives'
splitParams({ status: 'active', $limit: '20', $orderBy: '-createdAt', $first: '1', $limitt: '5' })
// { query: { status: 'active' },
// directives: { limit: 20, orderBy: '-createdAt' },
// flags: { first: true },
// unknown: ['$limitt'], listed: [], invalid: [] }FrontierJS carries two different kinds of thing in one bag of parameters: the
filters (status=active — columns, values, a WHERE) and the directives
($limit=20 — how much, in what order, which fields). The $ is what tells
them apart, and it is transport syntax: nothing past the boundary that reads it
should ever see one.
Two boundaries read it, not one — Junction's bridge, off an HTTP query
string or a WebSocket frame, and Sierra's router, off a URL's search string.
Same grammar, two realms, which is why the table is here rather than in either.
A $ key the table does not name never reaches the filters: splitParams
drops every $ key but COMBINATORS ($or, $and, $not — filters written
with a $, left in query for the where grammar), and its unknown names the
ones it did not recognize, so a boundary can refuse them by name (FJS-D237,
FJS-D910). listed and invalid are the other two refusals, and flags is
$first / $wrap, so a request is graded off one call; a bag with no $ key
answers all of them empty without walking the table.
Both directions: parseDirectives reads the $ names into the structured
form, and directiveParams writes that form back out as $ names, so a
client building a URL uses the same table the boundary reads it with.
Value shapes are deliberately not fixed: over HTTP everything is a string, and a caller that has already parsed a URL passes numbers and booleans. Both are read, and absent stays absent — nothing was asked is not the defaults.
docfences — the fenced blocks of a markdown page
import { docFences, isRunnable } from '@frontierjs/toolbelt/docfences'
docFences('```lite\nmodel A { id Int }\n```\n```js throws\ndb.a.create()\n```\n')
// → [{ lang: 'lite', line: 1, body: 'model A { id Int }', schema: null, … },
// { lang: 'js', line: 4, words: Set { 'throws' }, schema: 'model A { id Int }', … }]One reader for litestone's doc-example runner, fli check's doc-examples-run and the doc audit. schema is the nearest preceding non-fragment ```lite block; fragment, throws and failing on the info string are the contract, and isRunnable is a runnable tag not marked fragment. A package supplies its own prelude, since what a block runs under is the package's.
frontmatter — what a --- block means
import { parseFrontmatter } from '@frontierjs/toolbelt/frontmatter'
parseFrontmatter('---\ntitle: About\nitems:\n- name: Home\n link: /\n---\nBody')
// → { frontmatter: { title: 'About', items: [{ name: 'Home', link: '/' }] }, body: 'Body' }Sierra's route table, mesa's .md compiler and fli's command files all read it, so a page's page.meta and its module's frontmatter are the same object, and a command means to fli what it means to any other reader. It reads a declared subset of YAML (FJS-D549):
| Reads | Refuses, naming the line |
| --- | --- |
| block maps and lists, nested up to 100 deep, including a list at its key's own column | anchors &, aliases *, tags !, merge keys << |
| \| and > blocks, with -/+ and an indent digit | complex keys ?, directives %, a ... line |
| flow [ ] and { }, over several lines | a tab in the indentation |
| 'single' and "double" quotes with YAML's escapes | anything YAML itself refuses, such as an unquoted : in a value or a duplicate key |
Scalars resolve by YAML 1.2's core schema: 2024-01-05, yes and on stay strings. A refusal throws an Error whose message starts line N: and whose .line is N, counted from the top of the file. parseFrontmatterBlock(text) reads the text between the fences. splitFrontmatter(source) answers { block, body } without reading the block, so a caller that reports a refusal can still show the body.
A generator writing a person's words into a block uses frontmatterValue(value) — the text to put after key: that this reader reads back as exactly that string. It is plain where plain already means it and double-quoted otherwise, so Fix: login becomes "Fix: login" and @scope/app stops being refused.
glow — source code to highlighted HTML
import { glow } from '@frontierjs/toolbelt/glow'
glow('.btn { color: red }', { language: 'css', prefix: false })
// → <code language="css">…</code>| Option | Default | What it does |
| --- | --- | --- |
| language | inferred from the first character | css, html, js, bash, md, json, yaml, … |
| prefix | true | Treat a leading +, - or > as a line marker. See the trap below. |
| mark | true | Treat •text• as a highlight and ••text•• as an error |
| numbered | false | Wrap each line in a <span> so a CSS counter can number it |
The output carries no CSS classes. A token is marked with the HTML element that already means it, so the theme is element selectors and one attribute:
| Element | Marks |
| --- | --- |
| <sup> | comment |
| <i> | punctuation |
| <b> | identifier — property, function, key |
| <em> | value — string, number, CSS custom property |
| <strong> | keyword, tag name, hex color |
| <label> | @rule, decorator, !important |
| <ins> <del> <dfn> | a whole line: added, removed, noted |
| <mark> <u> | an author's highlight, an author's error |
@frontierjs/css ships that theme in components/code.css and needs nothing from this package at runtime to do it.
The line-prefix trap
With prefix on, a line beginning +, - or > is a marker and the character is removed from the output. In CSS all three are legal first characters — --custom-prop, > .child, + .sibling — so highlighting a stylesheet with prefixes on silently eats one character per line.
-- is handled for you: two dashes are never a diff marker. The combinators are not, and cannot be — + .sibling and a diff-added line are the same three characters. A caller highlighting CSS wants prefix: false.
Install
bun add @frontierjs/toolbeltTesting
bun run test # all specs
bun run test inflect # only specs whose filename matchesZero dependencies — test/run.js is the whole harness, and it runs under node as well as bun.
The corpus in test/fixtures/guide-samples.json is 137 real code samples lifted from the @frontierjs/css guide — CSS, HTML, JS, shell. Every one is round-tripped through every language and must come back byte-identical, because the one way a highlighter can be catastrophically wrong is silently: it drops a character, the output still looks like code, and the reader copies a sample that does not work. Refresh it with node test/fixtures/extract.mjs.
Candidate contents
Nothing here is committed to. Listed so the boundary is legible:
- string: case conversion, slugify, truncate
- object: pick, omit, deep equal, deep clone, dot-path get/set
- array: groupBy, chunk, uniqueBy, partition
- type guards and small predicates
- result/option helpers, if the framework settles on a shape
Date and time helpers are not on that list: they are the /datetime kit.
Open questions
- ~~Is purity enforced, or only documented?~~ Enforced —
scripts/ci.mjs§ hygiene fails the build on a dependency in the manifest, or a clock, a global, a network call or any non-relative import undersrc/(FJS-258). - Whether there is ever a root
.entry. Today every kit is a subpath, so an app that imports one pulls in one. - Does toolbelt duplicate anything already living inside litestone or sierra? If so, the copy there moves here and the original re-exports — never two implementations. The two that were open closed this way:
FJS-191(mesa's forkedglow) andFJS-192(five inflection rule sets). glowwas written elsewhere and adopted;docs/glow/keeps the Svelte editor and SCSS theme it arrived with, as reference only. Neither is shipped, and neither is FrontierJS code — the repo has no Svelte, and the SCSS uses UnoCSS's@apply.
