Standard Library
Quill comes with 130+ built-in functions that are available everywhere — no imports needed. This page documents every function with plain English explanations and practical examples.
Quick Reference
Jump to any category:
| Category | Key Functions |
|---|---|
| Lists / Arrays | length, push, pop, sort, sortBy, filter, map_list, find, includes |
| Strings / Text | trim, upper, lower, split, join, replace_text, startsWith |
| Numbers & Math | round, floor, ceil, abs, random, randomInt, toNumber |
| Objects | keys, values, entries, merge, pick, omit |
| Type Checking | isNumber, isText, isList, isObject, isNothing |
| File I/O | readFile, writeFile, exists, listFiles, deleteFile |
| HTTP / Fetch | fetch, fetchJSON, serve, putJSON, patchJSON, deleteJSON |
| Number Formatting | formatNumber, toFixed, percent, currency, ordinal, clamp, lerp, mapRange |
| Drawing & Charts | drawCircle, drawRect, drawLine, drawText, chart, showCanvas |
| Patterns & RegExp | matches, matchesPattern, replacePattern, regex, matchGroups, matchFirst |
| Environment | env, args, run, exit, platform |
| Crypto & Encoding | hash, encrypt, decrypt, generateKeys, hmac, randomBytes, uuid |
| Date & Time | now, formatDate, parseDate, timeAgo |
| Buffer & Encoding | toBuffer, fromBuffer, toBase64, fromBase64, toHex, fromHex |
| CLI Tools | arg, args, flag, hasFlag, colors, exitWith |
| Cron Jobs | every N seconds, every N minutes, every N hours |
| HTTPS Server | createSecureServer |
| Secure Storage | SecureStorage.set, SecureStorage.get, SecureStorage.remove, SecureStorage.clear |
| WebRTC | createPeer, PeerConnection |
| Signal Protocol Primitives | hkdf, hkdfBuffer, diffieHellman, argon2, bcryptHash, constantTimeEqual, aesEncrypt, hmacBuffer, randomBytesBuffer, secureErase, defineSchema |
| AI & LLM | ask claude, ask openai, ask gemini, ask ollama, stream, embed, createVectorStore, extract, chunk, agent — see AI docs |
Working with Lists
Lists are one of the most useful things in programming. A list is just a collection of items — like a shopping list, a list of names, or a list of scores. These functions help you create, search, filter, transform, and analyze lists.
length(x) — Count items
Returns the number of items in a list (or characters in text). This is one of the most common functions you'll use.
-- How many items in my list?
fruits are ["apple", "banana", "cherry"]
say length(fruits) -- 3
-- Also works on text
say length("hello") -- 5
-- Practical: check if a list is empty
if length(results) is 0:
say "No results found"
push(list, item) — Add an item
Adds an item to the end of a list. Like adding something to the bottom of your to-do list.
tasks are ["buy milk", "walk dog"]
push(tasks, "call mom")
say tasks -- ["buy milk", "walk dog", "call mom"]
pop(list) — Remove the last item
Removes and returns the last item from a list.
stack are [1, 2, 3]
last is pop(stack)
say last -- 3
say stack -- [1, 2]
sort(list) — Sort items
Returns a new list with items sorted in order (smallest to largest for numbers, A-Z for text). The original list is not changed.
scores are [85, 92, 71, 100, 63]
say sort(scores) -- [63, 71, 85, 92, 100]
names are ["Charlie", "Alice", "Bob"]
say sort(names) -- ["Alice", "Bob", "Charlie"]
reverse(list) — Reverse order
Returns a new list with items in the opposite order.
countdown are [1, 2, 3, 4, 5]
say reverse(countdown) -- [5, 4, 3, 2, 1]
unique(list) — Remove duplicates
Returns a new list with duplicate items removed. Like removing repeat entries from a guest list.
tags are ["js", "python", "js", "go", "python"]
say unique(tags) -- ["js", "python", "go"]
includes(list, item) — Check if item exists
Returns yes if the list contains the item, no otherwise. Like checking if a name is on a guest list.
colors are ["red", "green", "blue"]
say includes(colors, "green") -- true
say includes(colors, "purple") -- false
indexOf(list, item) — Find position
Returns the position (index) of an item in a list. Returns -1 if not found. Remember: positions start at 0, not 1.
letters are ["a", "b", "c", "d"]
say indexOf(letters, "c") -- 2 (third item, but index starts at 0)
say indexOf(letters, "z") -- -1 (not found)
slice(list, start, end) — Get a portion
Returns a portion of a list from start to end (not including end). Like cutting a section out of a list.
numbers are [10, 20, 30, 40, 50]
say slice(numbers, 1, 4) -- [20, 30, 40]
say slice(numbers, 2) -- [30, 40, 50] (from index 2 to the end)
concat(a, b) — Combine two lists
Joins two lists together into one new list.
first are [1, 2]
second are [3, 4]
say concat(first, second) -- [1, 2, 3, 4]
flat(list) — Flatten nested lists
Takes a list of lists and flattens it into a single list.
nested are [[1, 2], [3, 4], [5]]
say flat(nested) -- [1, 2, 3, 4, 5]
range(start, end) — Generate number sequences
Creates a list of numbers from start up to (but not including) end. Very useful for generating sequences.
say range(1, 6) -- [1, 2, 3, 4, 5]
say range(0, 10) -- [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
zip(a, b) — Pair up items
Takes two lists and pairs them up into a list of pairs. Like matching students with their grades.
names are ["Alice", "Bob", "Charlie"]
scores are [95, 82, 91]
say zip(names, scores)
-- [["Alice", 95], ["Bob", 82], ["Charlie", 91]]
sum(list) — Add up numbers
Returns the total of all numbers in a list.
prices are [9.99, 14.50, 3.25]
say sum(prices) -- 27.74
smallest(list) / largest(list) — Find extremes
Find the smallest or largest value in a list of numbers.
temps are [72, 68, 75, 61, 80]
say smallest(temps) -- 61
say largest(temps) -- 80
filter(list, function) — Keep matching items
Creates a new list containing only the items that pass a test. Think of it like a sieve — only items that match your criteria get through.
-- Keep only even numbers
numbers are [1, 2, 3, 4, 5, 6, 7, 8]
evens is filter(numbers, with n: n % 2 is 0)
say evens -- [2, 4, 6, 8]
-- Keep only names that start with "A"
names are ["Alice", "Bob", "Anna", "Charlie"]
aNames is filter(names, with n: startsWith(n, "A"))
say aNames -- ["Alice", "Anna"]
map_list(list, function) — Transform every item
Creates a new list by applying a function to every item. Like running each item through a machine that changes it.
-- Double every number
numbers are [1, 2, 3, 4, 5]
doubled is map_list(numbers, with n: n * 2)
say doubled -- [2, 4, 6, 8, 10]
-- Make all names uppercase
names are ["alice", "bob"]
say map_list(names, with n: upper(n)) -- ["ALICE", "BOB"]
find(list, function) — Find first match
Returns the first item that passes a test. Returns nothing if no match is found.
users are [
{name: "Alice", age: 30},
{name: "Bob", age: 17},
{name: "Charlie", age: 25}
]
adult is find(users, with u: u.age is greater than 18)
say adult.name -- "Alice"
every(list, function) — Do ALL items match?
Returns yes if every single item passes the test.
ages are [25, 30, 18, 42]
allAdults is every(ages, with a: a is greater than 17)
say allAdults -- true
some(list, function) — Does ANY item match?
Returns yes if at least one item passes the test.
scores are [45, 62, 38, 91]
anyPassing is some(scores, with s: s is greater than 60)
say anyPassing -- true
reduce(list, function, initial) — Combine into one value
Takes a list and combines all items into a single value by applying a function repeatedly. This is like folding a list down into one result.
-- Sum all numbers (the long way)
numbers are [1, 2, 3, 4, 5]
total is reduce(numbers, with a, b: a + b)
say total -- 15
-- Find the longest word
words are ["cat", "elephant", "dog"]
longest is reduce(words, with a, b: if length(a) is greater than length(b) then a otherwise b)
countWhere(list, function) — Count matches
Counts how many items pass a test.
ages are [15, 22, 17, 30, 12]
adults is countWhere(ages, with a: a is greater than 17)
say adults -- 2
groupBy(list, function) — Group items
Splits a list into groups based on a function. Returns an object where keys are group names and values are lists.
people are [
{name: "Alice", dept: "engineering"},
{name: "Bob", dept: "design"},
{name: "Charlie", dept: "engineering"}
]
teams is groupBy(people, with p: p.dept)
-- { engineering: [{...}, {...}], design: [{...}] }
sortBy(list, field) — Sort by a field
Sorts a list of objects by a given field name.
users are [{name: "Charlie", age: 30}, {name: "Alice", age: 25}, {name: "Bob", age: 35}]
sorted is sortBy(users, "name")
-- [{name: "Alice", age: 25}, {name: "Charlie", age: 30}, {name: "Bob", age: 35}]
sorted is sortBy(users, "age")
-- [{name: "Alice", age: 25}, {name: "Charlie", age: 30}, {name: "Bob", age: 35}]
Working with Text
Text (also called "strings" in other languages) is everywhere in programming — names, messages, file contents, web pages. These functions help you search, transform, and manipulate text.
trim(text) — Remove extra spaces
Removes spaces (and tabs, newlines) from the beginning and end of text. Very useful when processing user input.
input is " hello world "
say trim(input) -- "hello world"
upper(text) / lower(text) — Change case
Converts text to ALL UPPERCASE or all lowercase.
say upper("hello") -- "HELLO"
say lower("HELLO") -- "hello"
-- Useful for case-insensitive comparisons
if lower(answer) is "yes":
say "Confirmed!"
capitalize(text) — Capitalize first letter
Makes the first letter uppercase. Good for formatting names and titles.
say capitalize("hello") -- "Hello"
say capitalize("alice") -- "Alice"
replace_text(text, old, new) — Find and replace
Replaces all occurrences of old with new in the text.
msg is "Hello, World! Hello, Everyone!"
say replace_text(msg, "Hello", "Hi")
-- "Hi, World! Hi, Everyone!"
startsWith(text, prefix) / endsWith(text, suffix)
Check if text begins or ends with a specific string. Returns yes or no.
filename is "photo.jpg"
say endsWith(filename, ".jpg") -- true
say startsWith(filename, "doc") -- false
split(text, separator) — Break text apart
Splits text into a list using the separator. Like cutting a sentence into individual words.
-- Split by comma
csv is "apple,banana,cherry"
fruits is split(csv, ",")
say fruits -- ["apple", "banana", "cherry"]
-- Split by space to get words
sentence is "the quick brown fox"
say split(sentence, " ") -- ["the", "quick", "brown", "fox"]
join(list, separator) — Combine into text
The opposite of split — joins a list of items into a single text string. Defaults to ", " if no separator given.
colors are ["red", "green", "blue"]
say join(colors, " and ") -- "red and green and blue"
say join(colors) -- "red, green, blue"
words(text) — Split into words
Splits text into a list of words (by whitespace). Handles multiple spaces gracefully.
say words("hello beautiful world")
-- ["hello", "beautiful", "world"]
lines(text) — Split into lines
Splits text into a list of lines.
poem is "roses are red\nviolets are blue"
say lines(poem) -- ["roses are red", "violets are blue"]
padStart(text, length, char) / padEnd(text, length, char)
Pads text to a certain length by adding characters to the start or end. Great for formatting numbers and aligning output.
-- Pad a number with leading zeros
say padStart("42", 6, "0") -- "000042"
-- Pad the end with dots
say padEnd("Loading", 10, ".") -- "Loading..."
repeat(text, count) — Repeat text
Repeats text a given number of times.
say repeat("ha", 3) -- "hahaha"
say repeat("=-", 20) -- a nice separator line
truncate(text, length) — Shorten text
Cuts text to a maximum length and adds ... if it was shortened. Perfect for previews.
title is "This is a very long article title that needs shortening"
say truncate(title, 25) -- "This is a very long artic..."
Math & Numbers
Functions for rounding, random numbers, and converting between types.
round(n) / floor(n) / ceil(n) — Rounding
round rounds to the nearest whole number. floor always rounds down. ceil always rounds up.
say round(3.7) -- 4
say round(3.2) -- 3
say floor(3.9) -- 3 (always rounds down)
say ceil(3.1) -- 4 (always rounds up)
abs(n) — Absolute value
Returns the positive version of a number. Removes the negative sign if present.
say abs(-5) -- 5
say abs(5) -- 5
random() — Random decimal
Returns a random number between 0 and 1.
say random() -- 0.7234... (different each time)
randomInt(min, max) — Random whole number
Returns a random whole number between min and max (inclusive). Like rolling dice.
-- Roll a 6-sided die
roll is randomInt(1, 6)
say "You rolled: " + toText(roll)
-- Pick a random card number
card is randomInt(1, 13)
toNumber(x) / toText(x) — Convert types
Convert between numbers and text. You'll use toText often when combining numbers with text in output.
say toNumber("42") -- 42 (now a number)
say toText(42) -- "42" (now text)
say "Score: " + toText(100) -- "Score: 100"
typeOf(x) — Check what type something is
Returns the type of a value as text: "text", "number", "boolean", "list", "object", "nothing", or "function".
say typeOf("hello") -- "string"
say typeOf(42) -- "number"
say typeOf([1,2]) -- "list"
say typeOf(nothing) -- "nothing"
Number Formatting
Format numbers for display — add commas, decimal places, percentages, currency symbols, and more. Also includes utility functions for clamping and interpolation.
formatNumber(n) — Format with locale separators
Formats a number with commas (or your locale's separator) for readability.
say formatNumber(1234567) -- "1,234,567"
toFixed(n, digits) — Fixed decimal places
Returns a text representation of a number with exactly the specified number of decimal places.
say toFixed(3.14159, 2) -- "3.14"
percent(n) — Convert to percentage
Converts a decimal number to a percentage string.
say percent(0.75) -- "75%"
currency(n, symbol) — Format as currency
Formats a number as currency with an optional symbol (defaults to "$").
say currency(9.99, "$") -- "$9.99"
say currency(1234.5) -- "$1,234.50"
ordinal(n) — Add ordinal suffix
Returns a number with its ordinal suffix (st, nd, rd, th).
say ordinal(1) -- "1st"
say ordinal(2) -- "2nd"
say ordinal(3) -- "3rd"
say ordinal(11) -- "11th"
clamp(n, min, max) — Constrain to range
Constrains a number to be within a given range. If the number is below min, returns min. If above max, returns max.
say clamp(150, 0, 100) -- 100
say clamp(-5, 0, 100) -- 0
lerp(start, end, t) — Linear interpolation
Linearly interpolates between start and end by the factor t (where 0 = start, 1 = end).
say lerp(0, 100, 0.5) -- 50
say lerp(0, 100, 0.25) -- 25
mapRange(value, inMin, inMax, outMin, outMax) — Map from one range to another
Maps a value from one range to another. Useful for converting between different scales.
say mapRange(50, 0, 100, 0, 1) -- 0.5
Objects & Data
Objects are like labeled containers — they store values with names (keys). Think of an object as a form with labeled fields: name, age, email. These functions help you inspect, combine, and transform objects.
keys(obj) / values(obj) — Get keys or values
keys returns a list of all the field names. values returns a list of all the values.
user is {name: "Alice", age: 30, city: "NYC"}
say keys(user) -- ["name", "age", "city"]
say values(user) -- ["Alice", 30, "NYC"]
entries(obj) — Get key-value pairs
Returns a list of [key, value] pairs. Useful for looping through an object.
config is {theme: "dark", lang: "en"}
for each pair in entries(config):
say pair[0] + " = " + pair[1]
-- "theme = dark"
-- "lang = en"
fromEntries(pairs) — Build object from pairs
The opposite of entries — creates an object from a list of [key, value] pairs.
pairs are [["name", "Bob"], ["age", 25]]
say fromEntries(pairs) -- {name: "Bob", age: 25}
merge(obj1, obj2, ...) — Combine objects
Merges multiple objects into one. If the same key appears in multiple objects, the last one wins.
defaults is {theme: "light", lang: "en", fontSize: 14}
userPrefs is {theme: "dark", fontSize: 18}
settings is merge(defaults, userPrefs)
say settings
-- {theme: "dark", lang: "en", fontSize: 18}
pick(obj, keys...) — Select specific fields
Creates a new object with only the fields you want. Like picking columns from a spreadsheet.
user is {name: "Alice", age: 30, email: "alice@example.com", password: "secret"}
safe is pick(user, "name", "email")
say safe -- {name: "Alice", email: "alice@example.com"}
omit(obj, keys...) — Remove specific fields
The opposite of pick — creates a new object without the specified fields.
safeUser is omit(user, "password")
-- {name: "Alice", age: 30, email: "alice@example.com"}
hasKey(obj, key) — Check if a field exists
say hasKey(user, "name") -- true
say hasKey(user, "phone") -- false
deepCopy(x) — Clone a value
Creates a complete independent copy of an object or list. Changes to the copy won't affect the original.
original is {scores: [90, 85, 92]}
copy is deepCopy(original)
push(copy.scores, 100)
say length(original.scores) -- 3 (unchanged!)
say length(copy.scores) -- 4
parseJSON(text) / toJSON(value) — JSON conversion
Convert between text (JSON format) and Quill values. JSON is a standard format for storing and exchanging data.
-- Text to object
data is parseJSON("{\"name\": \"Alice\", \"age\": 30}")
say data.name -- "Alice"
-- Object to formatted text
say toJSON({name: "Bob", age: 25})
Type Checking
Sometimes you need to check what kind of value you're working with before doing something with it. These functions return yes or no.
isText(x) / isNumber(x) / isList(x) / isObject(x)
say isText("hello") -- true
say isNumber(42) -- true
say isList([1,2,3]) -- true
say isObject({a: 1}) -- true
say isNumber("42") -- false (it's text, not a number!)
isNothing(x) / isFunction(x)
say isNothing(nothing) -- true
say isNothing(0) -- false (0 is a number, not nothing)
say isNothing("") -- false (empty text is still text)
to display value:
if isText(value):
say value
otherwise if isList(value):
say join(value, ", ")
otherwise:
say toText(value)
Files & Folders
Read, write, and manage files on your computer. These functions only work when running Quill with the CLI (quill run), not in the browser.
read(path) / write(path, content) — Basic file I/O
Read the entire contents of a file, or write content to a file (creates it if it doesn't exist, overwrites if it does).
-- Read a file
content is read("notes.txt")
say content
-- Write to a file
write("output.txt", "Hello from Quill!")
append_file(path, content) — Add to a file
Adds content to the end of a file without erasing what's already there. Like adding a new entry to a log.
append_file("log.txt", "User logged in at " + now())
readJSON(path) / writeJSON(path, data) — JSON files
Read and write JSON files directly. Quill handles the parsing and formatting for you.
-- Read a config file
config is readJSON("config.json")
say config.name
-- Save data to a file
writeJSON("users.json", [{name: "Alice"}, {name: "Bob"}])
readLines(path) / writeLines(path, list)
Read a file as a list of lines, or write a list of lines to a file.
todos is readLines("todo.txt")
for each todo in todos:
say "- " + todo
fileExists(path) / exists(path) — Check if file exists
if fileExists("config.json"):
config is readJSON("config.json")
otherwise:
say "No config found, using defaults"
listFiles(dir) / listFilesDeep(dir) — List files
listFiles lists files in a directory. listFilesDeep includes all subdirectories too.
files is listFiles(".")
say "Files in current folder: " + join(files, ", ")
fileInfo(path) — Get file details
Returns an object with file size, modification date, and type.
info is fileInfo("photo.jpg")
say "Size: " + toText(info.size) + " bytes"
say "Modified: " + info.modified
copyFile(src, dest) / moveFile(src, dest) / deleteFile(path)
copyFile("report.pdf", "backup/report.pdf")
moveFile("old.txt", "archive/old.txt")
deleteFile("temp.txt")
makeDir(path) — Create folders
Creates a directory (and any parent directories that don't exist yet).
makeDir("output/reports/2026")
Path helpers: joinPath, fileName, fileExtension, parentDir
say joinPath("docs", "guide.txt") -- "docs/guide.txt"
say fileName("/home/user/photo.jpg") -- "photo.jpg"
say fileExtension("photo.jpg") -- ".jpg"
say parentDir("/home/user/photo.jpg") -- "/home/user"
say currentDir() -- your current working directory
say homePath() -- your home folder path
Date & Time
Functions for working with dates, times, and timestamps.
now() / today() — Current date/time
say now() -- "2026-04-04T14:30:00.000Z" (full date and time)
say today() -- "2026-04-04" (just the date)
timestamp() — Unix timestamp
Returns the current time as milliseconds since January 1, 1970. Useful for measuring how long something takes.
start is timestamp()
-- ... do some work ...
elapsed is timestamp() - start
say "Took " + toText(elapsed) + " ms"
formatDate(date, format) — Format a date
Formats a date string using a pattern. Available placeholders: YYYY (year), MM (month), DD (day), HH (hour), mm (minute), ss (second).
say formatDate(now(), "YYYY-MM-DD") -- "2026-04-04"
say formatDate(now(), "MM/DD/YYYY") -- "04/04/2026"
say formatDate(now(), "HH:mm:ss") -- "14:30:00"
addDays(date, days) / diffDays(date1, date2)
-- What date is 7 days from now?
nextWeek is addDays(today(), 7)
say nextWeek
-- How many days between two dates?
say diffDays("2026-01-01", "2026-04-04") -- 93
Web & HTTP
Fetch data from the internet and build web servers.
fetchJSON(url) — Get data from an API
Fetches data from a URL and automatically parses it as JSON. This is how you get data from web APIs.
data is await fetchJSON("https://api.example.com/users")
say data
postJSON(url, body) — Send data to an API
result is await postJSON("https://api.example.com/users", {
name: "Alice",
email: "alice@example.com"
})
putJSON(url, body) — Update an existing resource (full replacement)
result is await putJSON("https://api.example.com/users/1", {
name: "Alice Updated",
email: "alice@new.com"
})
patchJSON(url, body) — Partially update a resource
result is await patchJSON("https://api.example.com/users/1", {
email: "alice@new.com"
})
deleteJSON(url) — Delete a resource
result is await deleteJSON("https://api.example.com/users/1")
createServer() — Build a web server
Creates an HTTP server with routing. This is how you build web applications and APIs with Quill.
server is createServer()
server.get("/", "Welcome to my site!")
server.get("/api/hello", {message: "Hello from Quill!"})
server.post("/api/data", with req, res:
say "Got data!"
give back {status: "ok"}
)
server.listen(3000)
say "Server running on port 3000"
serveStatic(dir) — Serve files
Serves static files (HTML, CSS, images) from a directory.
serveStatic("./public")
Crypto & Encoding
Functions for hashing, generating unique IDs, and encoding/decoding data.
hash(text, algorithm) — Hash a string
Creates a hash (fingerprint) of text. Useful for checksums and verification. Default algorithm is SHA-256.
say hash("hello") -- "2cf24dba..." (SHA-256)
say hash("hello", "md5") -- "5d41402a..." (MD5)
uuid() — Generate unique ID
Generates a random unique identifier. Perfect for giving each item a unique ID.
id is uuid()
say id -- "550e8400-e29b-41d4-a716-446655440000"
encodeBase64(text) / decodeBase64(text)
Encode text to Base64 format and back. Base64 is a way to represent binary data as text.
encoded is encodeBase64("Hello, World!")
say encoded -- "SGVsbG8sIFdvcmxkIQ=="
say decodeBase64(encoded) -- "Hello, World!"
encodeURL(text) / decodeURL(text)
Encode and decode text for safe use in URLs.
say encodeURL("hello world & more") -- "hello%20world%20%26%20more"
say decodeURL("hello%20world") -- "hello world"
encrypt(data, password) / decrypt(encrypted, password) — Symmetric encryption
Encrypt and decrypt data using a password. Uses AES-256-GCM under the hood. No imports needed.
encrypted is encrypt("secret data", "my-password")
say encrypted -- encrypted string
decrypted is decrypt(encrypted, "my-password")
say decrypted -- "secret data"
generateKeys() — Generate a public/private key pair
Creates an RSA key pair for asymmetric encryption. Returns an object with publicKey and privateKey.
keys is generateKeys()
say keys.publicKey -- PEM-encoded public key
say keys.privateKey -- PEM-encoded private key
hmac(data, secret, algorithm) — HMAC signature
Creates an HMAC (Hash-based Message Authentication Code) for verifying data integrity and authenticity. Default algorithm is SHA-256.
signature is hmac("my data", "my-secret")
say signature -- hex-encoded HMAC
-- With a specific algorithm
sig is hmac("my data", "my-secret", "sha512")
randomBytes(size) — Generate random bytes
Generates a buffer of cryptographically secure random bytes. Useful for generating tokens, salts, and nonces.
token is randomBytes(32)
say toHex(token) -- 64-character hex string
Patterns & RegExp
Search and replace using patterns (regular expressions). Patterns are like wildcards on steroids — they let you match complex text patterns.
matches(text, pattern, flags) — Find all matches
Returns a list of all parts of the text that match the pattern. The optional flags parameter accepts regex flags like "i" (case-insensitive) or "g" (global).
-- Find all numbers in text
say matches("I have 3 cats and 2 dogs", "\\d+")
-- ["3", "2"]
-- Find all email-like patterns
say matches("Contact alice@test.com or bob@test.com", "\\w+@\\w+\\.\\w+")
-- ["alice@test.com", "bob@test.com"]
-- Case-insensitive matching
say matches("Hello HELLO hello", "hello", "gi")
-- ["Hello", "HELLO", "hello"]
matchesPattern(text, pattern, flags) — Test if text matches
Returns yes or no based on whether the text matches the pattern. The optional flags parameter accepts regex flags.
say matchesPattern("hello123", "\\d+") -- true (contains digits)
say matchesPattern("hello", "\\d+") -- false (no digits)
replacePattern(text, pattern, replacement, flags)
Replaces all matches of a pattern with the replacement text. The optional flags parameter accepts regex flags.
-- Remove all digits
say replacePattern("abc123def456", "\\d+", "")
-- "abcdef"
-- Case-insensitive replace
say replacePattern("Hello HELLO", "hello", "hi", "gi")
-- "hi hi"
regex(pattern, flags) — Create a regex object
Creates a reusable regex object with the given pattern and flags.
pattern is regex("\\d+", "gi")
say pattern -- a regex object
matchGroups(text, pattern, flags) — Get capture group matches
Returns the capture group matches from a pattern. Useful for extracting structured data from text.
result is matchGroups("2026-04-14", "(\\d{4})-(\\d{2})-(\\d{2})")
-- ["2026", "04", "14"]
matchFirst(text, pattern, flags) — Get the first match only
Returns just the first match of a pattern in the text, or nothing if no match is found.
first is matchFirst("Order #123 and #456", "#\\d+")
say first -- "#123"
Drawing & Charts
Create drawings and charts programmatically. Draw shapes, text, and visualize data with bar charts, line charts, and pie charts.
drawCircle(x, y, size, color) — Draw a circle at position
drawCircle(200, 200, 50, "blue")
drawRect(x, y, width, height, color) — Draw a rectangle
drawRect(100, 100, 80, 60, "red")
drawLine(x1, y1, x2, y2, color) — Draw a line between two points
drawLine(0, 0, 400, 400, "green")
drawText(x, y, message, color) — Draw text at position
drawText(150, 50, "Hello Canvas!", "black")
fillBackground(color) — Fill the canvas background
fillBackground("white")
chart(data, type, title) — Draw a chart
Draws a chart from data. Supported types: "bar", "line", and "pie".
chart([
{label: "Jan", value: 100},
{label: "Feb", value: 150},
{label: "Mar", value: 120}
], "bar", "Monthly Sales")
showCanvas(title) — Display the canvas
Displays the canvas with an optional title. Drawing commands are buffered and rendered when showCanvas() is called.
showCanvas("My Drawing")
Full Drawing Example
fillBackground("white")
drawCircle(200, 200, 50, "blue")
drawRect(100, 100, 80, 60, "red")
drawLine(0, 0, 400, 400, "green")
drawText(150, 50, "Hello Canvas!", "black")
showCanvas("My Drawing")
showCanvas() is called (or automatically on exit). In the browser, drawing happens directly on an HTML canvas element.
System & Environment
Interact with the operating system, run commands, and access environment variables.
env(key) / env(key, default) / setEnv(key, value) — Environment variables
Read and set environment variables. These are settings stored by your operating system — things like API keys, database URLs, and configuration that changes between development and production.
-- Read an environment variable
say env("HOME") -- "/Users/alice"
say env("PATH") -- your system PATH
-- Provide a default value if the variable is not set
port is env("PORT", "3000")
say port -- "3000" if PORT is not defined
-- Require a variable (throws an error if not set)
apiKey is env.require("API_KEY")
-- If API_KEY is not set, your program stops with an error message
-- Set an environment variable
setEnv("MY_APP", "production")
.env file in your project directory. Create a file called .env with one variable per line:
API_KEY=sk-abc123
DATABASE_URL=postgres://localhost/mydb
PORT=3000
These variables are then available via env("API_KEY") etc. Never commit your .env file to version control — add it to .gitignore.
args() — Command-line arguments
Returns a list of arguments passed to your program.
-- If you run: quill run app.quill hello world
say args() -- ["hello", "world"]
run(command) — Run a shell command
Runs a command on your computer and returns the output as text.
say run("date") -- current date from the system
say run("ls") -- list files in current directory
say run("whoami") -- your username
runAsync(command) — Run command in background
Like run but doesn't block your program while waiting.
output is await runAsync("curl https://api.example.com")
say output
platform() / cpuCount() / memory()
say platform() -- "darwin" (macOS), "linux", or "win32"
say cpuCount() -- 8
say memory() -- {total: 17179869184, free: 5368709120}
exit(code) — Stop the program
if somethingWrong:
say "Fatal error!"
exit(1) -- exit with error code
wait(ms) — Pause execution
say "Starting..."
wait(2000) -- pause for 2 seconds
say "Done!"
Concurrency
Run multiple tasks at the same time for better performance.
parallel(...functions) — Run tasks simultaneously
Runs multiple functions at the same time and waits for all of them to finish. Returns a list of results.
-- Fetch multiple URLs at once instead of one by one
results is await parallel(
with: fetchJSON("https://api.example.com/users"),
with: fetchJSON("https://api.example.com/posts")
)
race(...functions) — First one wins
Runs multiple functions and returns the result of whichever finishes first.
delay(ms) — Async pause
Pauses for a number of milliseconds without blocking (unlike wait).
await delay(1000) -- wait 1 second
Templates
template(text, data) — Fill in placeholders
Replaces {{key}} placeholders in text with values from a data object. Great for generating emails, HTML, or any text with dynamic content.
msg is template("Hello, {{name}}! You have {{count}} messages.", {
name: "Alice",
count: 5
})
say msg -- "Hello, Alice! You have 5 messages."
Template Engine
Quill's built-in template engine provides functions for programmatically generating HTML. All output is escaped by default for XSS protection.
tag(name, attrsOrChildren, children) — Build an HTML tag
Creates an HTML element. The second argument can be an object of attributes or a string/array of children. If an attributes object is provided, pass children as the third argument.
tag("div", {class: "box"}, "Hello")
-- <div class="box">Hello</div>
tag("ul", [tag("li", "One"), tag("li", "Two")])
-- <ul><li>One</li><li>Two</li></ul>
page(options) — Generate a full HTML page
Produces a complete HTML document. Options: title, body, styles, scripts, lang, charset, viewport.
html is page({
title: "My App",
body: tag("h1", "Welcome"),
styles: ["/css/app.css"],
lang: "en"
})
say html
escapeHTML(str) — Escape HTML entities
Converts &, <, >, ", and ' to their HTML entity equivalents. Used internally by tag(), but available for manual use.
say escapeHTML("<script>alert('xss')</script>")
-- "<script>alert('xss')</script>"
eachItem(items, fn) — Map items to HTML
Applies a function to each item in a list, joins the resulting HTML strings, and returns them as a single string.
fruits is ["Apple", "Banana", "Cherry"]
list is tag("ul", eachItem(fruits, with f: tag("li", f)))
say list
-- <ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>
showIf(condition, content) — Conditional rendering
Returns content when condition is true, or an empty string when false.
isAdmin is yes
html is showIf(isAdmin, tag("span", {class: "badge"}, "Admin"))
say html -- <span class="badge">Admin</span>
layout(name, templateFn) — Register a layout
Registers a reusable layout template that can be rendered later with renderLayout().
layout("main", with data:
page({
title: data.title,
body: tag("div", {class: "wrapper"}, [
tag("header", tag("h1", data.title)),
tag("main", data.content),
tag("footer", "© 2026")
])
})
)
renderLayout(name, data) — Render a layout
Renders a previously registered layout with the provided data object.
html is renderLayout("main", {
title: "About Us",
content: tag("p", "We build great software.")
})
say html
partial(name, templateFn) — Register/render partials
Register a reusable HTML fragment. Call with a function to register; call with just a name to render.
-- Register a partial
partial("userCard", with user:
tag("div", {class: "card"}, [
tag("h3", user.name),
tag("p", user.email)
])
)
-- Use the partial
html is partial("userCard", {name: "Alice", email: "alice@example.com"})
raw(str) — Bypass escaping
Marks a string as safe HTML, bypassing automatic escaping. Use with caution — only for content you fully trust.
html is tag("div", raw("<strong>Already safe</strong>"))
say html -- <div><strong>Already safe</strong></div>
Shorthand tag builders
Pre-filled versions of tag() for common HTML elements:
-- Available shorthand builders:
div() span() h1() h2() h3() p() a() img()
ul() ol() li() form() input() button() label() textarea()
table() tr() th() td()
nav() header() footer() section() article() main()
-- Usage is identical to tag():
div({class: "container"}, [
h1("Hello"),
p({class: "lead"}, "Welcome to Quill.")
])
Database (SQLite)
Store and query data using SQLite, a simple file-based database. Requires the better-sqlite3 npm package (quill add better-sqlite3).
openDB(path) / closeDB(db)
db is openDB("mydata.db")
-- ... use the database ...
closeDB(db)
execute(db, sql, params) — Run a command
execute(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)", [])
execute(db, "INSERT INTO users (name, age) VALUES (?, ?)", ["Alice", 30])
query(db, sql, params) — Fetch data
users is query(db, "SELECT * FROM users WHERE age > ?", [18])
for each user in users:
say user.name + " is " + toText(user.age)
Iterators & Lazy Evaluation
Quill includes a lazy evaluation system that lets you process large (even infinite) data sets efficiently. Values are only computed when they are actually needed. All of these methods are available on lazy iterators created by generators or the range() function.
Creating iterators
-- range() creates a lazy iterator
nums is range(1, 1000000)
-- Generator functions create lazy iterators
to fibonacci:
a is 0
b is 1
loop:
yield a
temp is a
a is b
b is temp + b
filter — Keep items that match a condition
Returns a new lazy iterator that only includes items where the function returns yes.
evens is range(1, 100) | filter with n: n % 2 is 0
map_list — Transform each item
Returns a new lazy iterator where each item has been transformed by the function.
squares is range(1, 10) | map_list with n: n * n
take n — Keep only the first n items
first5 is fibonacci() | take 5 | collect
say first5 -- [0, 1, 1, 2, 3]
skip n — Skip the first n items
result is range(1, 20) | skip 5 | take 3 | collect
say result -- [6, 7, 8]
takeWhile — Keep items while a condition holds
result is range(1, 100) | takeWhile with n: n is less than 10 | collect
say result -- [1, 2, 3, 4, 5, 6, 7, 8, 9]
skipWhile — Skip items while a condition holds
result is [1, 2, 3, 10, 2] | skipWhile with n: n is less than 5 | collect
say result -- [10, 2]
zip — Combine two iterators into pairs
names are ["Alice", "Bob", "Charlie"]
scores are [95, 87, 92]
pairs is zip(names, scores) | collect
say pairs -- [["Alice", 95], ["Bob", 87], ["Charlie", 92]]
enumerate — Add an index to each item
items are ["apple", "banana", "cherry"]
result is items | enumerate | collect
say result -- [[0, "apple"], [1, "banana"], [2, "cherry"]]
flatten — Flatten nested iterators
nested are [[1, 2], [3, 4], [5]]
result is nested | flatten | collect
say result -- [1, 2, 3, 4, 5]
collect — Gather all items into a list
This is a terminal operation. It consumes the lazy iterator and produces a concrete list.
result is range(1, 5) | collect
say result -- [1, 2, 3, 4, 5]
reduce — Combine all items into one value
total is range(1, 10) | reduce with a, b: a + b
say total -- 55
first — Get the first item
result is range(1, 1000000) | filter with n: n % 7 is 0 | first
say result -- 7
last — Get the last item
result is range(1, 10) | last
say result -- 10
any — True if any item matches
hasNegative is [1, -3, 5] | any with n: n is less than 0
say hasNegative -- yes
every — True if all items match
allPositive is [1, 2, 3] | every with n: n is greater than 0
say allPositive -- yes
Chaining it all together
The real power comes from combining these operations into pipelines. Nothing is computed until a terminal operation (collect, reduce, first, last, any, or every) is called:
-- Process a million numbers without using a million items of memory
result is range(1, 1000000)
| filter with n: n % 2 is 0
| map_list with n: n * n
| take 10
| collect
say result -- [4, 16, 36, 64, 100, 144, 196, 256, 324, 400]
range(1, 1000000) would create a list of a million numbers in memory. With lazy evaluation, items are produced one at a time and only as many as needed. The take 10 above means only 10 items are ever computed, no matter how large the range.
Recipes
Common tasks showing how to combine multiple functions together.
names are [" alice ", "BOB", " Charlie "]
-- Clean up, capitalize, sort, and display
cleaned is names
| map_list(with n: trim(n))
| map_list(with n: capitalize(lower(n)))
| sort
say join(cleaned, ", ") -- "Alice, Bob, Charlie"
-- Read a CSV file and get the data
raw is read("data.csv")
rows is lines(raw)
headers is split(rows[0], ",")
say "Columns: " + join(headers, ", ")
say "Total rows: " + toText(length(rows) - 1)
users are []
server is createServer()
server.get("/api/users", users)
server.post("/api/users", with req, res:
push(users, {id: uuid(), name: "New User"})
give back {status: "created"}
)
server.listen(3000)
say "API running on http://localhost:3000"
-- Back up all .quill files to a backup folder
makeDir("backup")
files is listFiles(".")
quillFiles is filter(files, with f: endsWith(f, ".quill"))
for each file in quillFiles:
copyFile(file, joinPath("backup", file))
say "Backed up: " + file
say "Done! Backed up " + toText(length(quillFiles)) + " files"
Tagged Template Tags
Tagged templates let you prefix a string with a function name. The function receives the string parts and the interpolated values separately, so it can process them safely — for example, escaping SQL parameters to prevent injection, or sanitizing HTML to prevent cross-site scripting. Quill ships with three built-in tags.
query — Safe SQL queries
The query tag automatically escapes interpolated values so they are safe to use in SQL. You never have to worry about SQL injection when you use this tag.
minAge is 18
result is query`SELECT * FROM users WHERE age > {minAge}`
-- The value 18 is passed as a parameter, not concatenated into the string
name is "O'Brien"
result is query`SELECT * FROM users WHERE name = {name}`
-- The apostrophe is safely escaped
html — Safe HTML rendering
The html tag sanitizes interpolated values so that any HTML special characters are escaped. This prevents malicious scripts from being injected into your pages.
title is "Hello <World>"
body is "This is <b>safe</b>"
page is html`<h1>{title}</h1><p>{body}</p>`
-- Output: <h1>Hello <World></h1><p>This is <b>safe</b></p>
css — Scoped CSS generation
The css tag lets you write CSS with interpolated values for dynamic theming. Values are validated to prevent CSS injection.
spacing is 16
color is "#333"
styles is css`
.card:
padding: {spacing}px;
color: {color};
`
Result Type Helpers
The Result type represents an operation that can either succeed or fail. Instead of throwing exceptions, functions return Success(value) or Error(message). This makes error handling explicit and predictable.
Success(value) — Wrap a successful value
Creates a Result that represents a successful outcome. The value inside can be anything — a number, text, object, or list.
to divide a as number, b as number -> Result of number:
if b is 0:
give back Error("cannot divide by zero")
give back Success(a / b)
result is divide(10, 2)
say result.ok -- yes
say result.value -- 5
Error(message) — Wrap an error
Creates a Result that represents a failure. The message describes what went wrong.
result is divide(10, 0)
say result.ok -- no
say result.error -- "cannot divide by zero"
Working with Results
Check whether a Result is a success or an error, then use the appropriate field.
result is loadUser(42)
if result.ok:
say "Found user: {result.value.name}"
otherwise:
say "Error: {result.error}"
-- Or use the ? operator for auto-propagation
to loadProfile id as number -> Result of Profile:
user is loadUser(id)? -- returns Error early if loadUser fails
give back Success(user.profile)
-- Or use try as an expression with a fallback
name is try loadUser(42) otherwise "anonymous"
Auth (Authentication and Security)
The Auth package gives you everything you need to handle user authentication, password hashing, tokens (like login tickets), and sessions. Think of it like the security system for your application: it checks IDs at the door and makes sure only the right people get in.
Auth.hash(password) — Hash a password
Turns a plain text password into a scrambled version that is safe to store in a database. Even if someone steals your database, they cannot reverse the hash to get the original password. Think of it like putting a letter through a shredder, you can verify it was the right letter, but you cannot unshred it.
-- When a user signs up, hash their password before saving
password is "mySecretPassword123"
hashed is Auth.hash(password)
say hashed -- "$2b$10$xK8f..." (a long scrambled string)
-- Save the hashed version to your database
saveUser({name: "Alice", password: hashed})
Auth.verify(password, hash) — Check a password
Compares a plain text password against a hashed version. Returns true if they match, false otherwise. Use this when a user logs in.
-- When a user logs in, check their password
user is findUser("alice@example.com")
isCorrect is Auth.verify("mySecretPassword123", user.password)
if isCorrect:
say "Welcome back, {user.name}!"
otherwise:
say "Wrong password. Try again."
Auth.createToken(payload, secret, expiresIn) — Create a JWT
Creates a JSON Web Token (JWT), a small encrypted "ticket" that proves a user is logged in. The payload is the data you want to include (like the user ID), the secret is a private key only your server knows, and expiresIn is how long the token is valid.
-- After successful login, give the user a token
token is Auth.createToken(
{userId: user.id, role: "admin"},
"my-secret-key",
"24h"
)
say "Your token: {token}"
-- Send this token back to the user
-- They will include it in future requests to prove who they are
Auth.verifyToken(token, secret) — Verify a JWT
Checks if a token is valid and has not expired. Returns the payload data if valid, or throws an error if the token is fake or expired.
-- When the user makes a request, check their token
try:
payload is Auth.verifyToken(token, "my-secret-key")
say "User {payload.userId} is authenticated"
say "Role: {payload.role}"
catch error:
say "Invalid or expired token. Please log in again."
Auth.session(options) — Manage sessions
Creates a session manager that remembers users across requests. Think of it like a wristband at an event, once you get one, you don't need to show your ID again until the wristband expires.
-- Set up session management for your server
sessions is Auth.session({
secret: "my-session-secret",
maxAge: "7d", -- sessions last 7 days
secure: true -- only send over HTTPS
})
-- Use it in your server
server.use(sessions)
-- In a login route: create a session
server.post("/login", with req, res:
user is authenticate(req.body)
req.session.userId is user.id
give back {message: "Logged in!"}
)
-- In a protected route: check the session
server.get("/profile", with req, res:
if req.session.userId:
user is getUser(req.session.userId)
give back user
otherwise:
give back {error: "Not logged in"}
)
ORM (Object-Relational Mapping)
The ORM package lets you work with databases using Quill code instead of writing raw SQL queries. Think of it like a translator between your Quill objects and the database tables, you describe your data as Quill objects, and the ORM figures out how to store and retrieve them from the database.
DB.connect(config) — Connect to a database
Opens a connection to your database. You need to do this once when your application starts.
-- Connect to a PostgreSQL database
db is DB.connect({
type: "postgres",
host: "localhost",
port: 5432,
database: "my_app",
user: "admin",
password: "secret"
})
-- Or connect to SQLite (simpler, file-based)
db is DB.connect({
type: "sqlite",
file: "data.db"
})
DB.model(name, schema) — Define a model
A model describes what your data looks like, the fields, their types, and any rules. Think of it like a form template: it defines what information you need to collect.
-- Define a User model
User is DB.model("User", {
name: {type: "text", required: true},
email: {type: "text", required: true, unique: true},
age: {type: "number"},
role: {type: "text", default: "user"},
createdAt: {type: "date", default: now()}
})
-- Define a Post model
Post is DB.model("Post", {
title: {type: "text", required: true},
body: {type: "text"},
author: {type: "text"},
likes: {type: "number", default: 0},
published: {type: "boolean", default: false}
})
DB.migrate() — Create or update tables
Syncs your models with the actual database. If the tables don't exist yet, it creates them. If you added new fields to a model, it updates the tables.
-- After defining your models, run migrate
DB.migrate()
say "Database tables are ready!"
CRUD Operations (Create, Read, Update, Delete)
Once you have a model, you can create, find, update, and delete records:
-- CREATE: add a new user
newUser is User.create({
name: "Alice",
email: "alice@example.com",
age: 28
})
say "Created user: {newUser.name} (id: {newUser.id})"
-- READ: find users
allUsers is User.find() -- get everyone
admins is User.find({role: "admin"}) -- filter by role
alice is User.findOne({email: "alice@example.com"}) -- find one
-- UPDATE: change a user's data
User.update({email: "alice@example.com"}, {age: 29})
-- DELETE: remove a user
User.delete({email: "old-user@example.com"})
Query Builder
For more complex queries, chain methods together to build exactly the query you need:
-- Find the 10 most recent admin users
recentAdmins is User
.where({role: "admin"})
.orderBy("createdAt", "desc")
.limit(10)
-- Find published posts with more than 100 likes
popularPosts is Post
.where({published: true})
.where("likes", ">", 100)
.orderBy("likes", "desc")
.limit(20)
DB.raw() — Raw SQL
If you need to write SQL directly, you can:
results is DB.raw("SELECT * FROM users WHERE age > $1", [21])
DB.transaction() — Transactions
A transaction groups multiple database operations so they either all succeed or all fail. Think of it like transferring money, you want the debit and credit to happen together, not one without the other.
-- Transfer money between accounts
DB.transaction(with tx:
tx.update("accounts", {id: 1}, {balance: balance - 100})
tx.update("accounts", {id: 2}, {balance: balance + 100})
say "Transfer complete!"
)
Validation
The Validation package helps you check that data is correct before you use it. Think of it like a bouncer at a club, it checks that everyone meets the requirements before letting them in. Is the email address actually an email? Is the age a positive number? Is the name at least 2 characters long? Validation answers these questions.
Validate.schema(rules) — Define validation rules
Creates a set of rules that data must follow. Each field gets one or more rules.
-- Define rules for user registration
userSchema is Validate.schema({
name: {required: true, minLength: 2, maxLength: 50},
email: {required: true, email: true},
age: {required: true, min: 13, max: 120},
website: {url: true},
password: {required: true, minLength: 8, pattern: "[A-Z]"},
role: {oneOf: ["user", "admin", "moderator"]}
})
Available rules
| Rule | What it checks | Example |
|---|---|---|
required | Field must be present and not empty | required: true |
minLength | Text must be at least this many characters | minLength: 2 |
maxLength | Text must be no more than this many characters | maxLength: 100 |
min | Number must be at least this value | min: 0 |
max | Number must be no more than this value | max: 999 |
email | Must be a valid email address | email: true |
url | Must be a valid URL | url: true |
pattern | Must match a regular expression | pattern: "[0-9]+" |
oneOf | Must be one of the listed values | oneOf: ["a", "b"] |
custom | Your own validation function | custom: with v: v > 0 |
arrayOf | Each item in the list must match a schema | arrayOf: {type: "text"} |
schema.check(data) — Validate data
Runs the rules against actual data and tells you what (if anything) is wrong.
-- Check valid data
result is userSchema.check({
name: "Alice",
email: "alice@example.com",
age: 28,
password: "SecurePass1",
role: "user"
})
say result.valid -- true
say result.errors -- [] (empty -- no problems)
-- Check invalid data
result is userSchema.check({
name: "",
email: "not-an-email",
age: 5,
password: "short"
})
say result.valid -- false
say result.errors
-- ["name: must be at least 2 characters",
-- "email: must be a valid email address",
-- "age: must be at least 13",
-- "password: must be at least 8 characters"]
Quick validators
For simple one-off checks, use these shorthand functions:
say Validate.isEmail("alice@example.com") -- true
say Validate.isEmail("not-valid") -- false
say Validate.isUrl("https://quill.dev") -- true
say Validate.isUrl("just some text") -- false
say Validate.isNumber(42) -- true
say Validate.isNumber("hello") -- false
Logging
The Logging package helps you record what your application is doing. Think of it like a ship's log, it keeps a record of important events so you can look back and understand what happened. Good logging is essential for finding and fixing bugs in production.
say? The say keyword prints text to the screen, which is fine for simple programs. But for real applications, you need different levels of importance (is this an error or just info?), timestamps, structured data, and the ability to send logs to files or monitoring services. That is what the Logging package provides.
Log.create(options) — Create a logger
Creates a logger with the settings you want. You can choose the minimum level, the output format, and where logs go.
-- Create a logger for your application
logger is Log.create({
level: "info", -- minimum level to show (debug/info/warn/error/fatal)
format: "pretty", -- "pretty" for console, "json" for production
name: "my-app" -- label for your logs
})
Log levels
Each level represents a different severity. From least to most severe:
logger.debug("Loading config file...") -- Detailed info for developers
logger.info("Server started on port 3000") -- Normal operational info
logger.warn("Disk space is getting low") -- Something is not ideal
logger.error("Failed to connect to DB") -- Something went wrong
logger.fatal("Out of memory, shutting down") -- Critical failure
When you set the level to "info", you will see info, warn, error, and fatal messages, but not debug messages. Set it to "debug" during development to see everything.
Structured logging
You can attach data to your log messages. This is especially useful in production where you want to search and filter logs.
-- Log with extra data
logger.info("User logged in", {userId: 42, ip: "192.168.1.1"})
-- Output (pretty): [INFO] User logged in {userId: 42, ip: "192.168.1.1"}
-- Output (json): {"level":"info","msg":"User logged in","userId":42,"ip":"192.168.1.1","time":"..."}
logger.error("Payment failed", {orderId: "abc-123", amount: 49.99, reason: "card declined"})
logger.child() — Create a child logger
Creates a new logger that inherits all settings but adds extra default fields. Useful for adding context like a request ID.
-- Create a child logger for a specific request
requestLogger is logger.child({requestId: "req-abc-123"})
requestLogger.info("Processing request")
-- Output: [INFO] Processing request {requestId: "req-abc-123"}
requestLogger.info("Fetching user data")
-- Output: [INFO] Fetching user data {requestId: "req-abc-123"}
-- The requestId is automatically included in every log
Log.middleware() — HTTP request logging
Automatically logs every HTTP request that comes into your server, including the method, URL, status code, and how long it took.
-- Add logging to your server
server.use(Log.middleware())
-- Now every request is automatically logged:
-- [INFO] GET /api/users 200 12ms
-- [INFO] POST /api/login 200 45ms
-- [WARN] GET /api/missing 404 3ms
-- [ERROR] POST /api/crash 500 2ms
Complete example
-- Set up logging for a production application
logger is Log.create({
level: "info",
format: "json",
name: "my-api"
})
logger.info("Application starting...")
try:
db is DB.connect(config)
logger.info("Connected to database", {host: config.host})
catch error:
logger.fatal("Cannot connect to database", {error: error.message})
server.use(Log.middleware())
server.listen(3000)
logger.info("Server ready", {port: 3000})
Discord Bots
Quill makes it easy to build Discord bots using the discord.js npm package. Use the use statement to import the library and the on event handler syntax for clean, readable bot code.
quill discord my-bot to scaffold a full Discord bot project with package.json, .env, and a starter bot.quill file.
Setting up a Discord bot
use "discord.js" as Discord
intents is [Discord.GatewayIntentBits.Guilds, Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.MessageContent]
options is {intents: intents}
bot is new Discord.Client(options)
Handling events
Use the on keyword to register event handlers. The with keyword introduces callback parameters.
bot on "ready" with:
say "Bot is online as {bot.user.tag}!"
bot on "messageCreate" with msg:
if msg.author.bot:
give back nothing
if msg.content is "!hello":
msg.reply("Hello from Quill!")
if startsWith(msg.content, "!say "):
text is msg.content.slice(5)
msg.reply(text)
Starting the bot
bot.login("YOUR_BOT_TOKEN")
For production, use environment variables instead of hardcoding tokens:
bot.login(process.env.DISCORD_TOKEN)
Helper function
Quill also provides a createBot helper that creates a Discord client with common intents pre-configured:
bot is createBot(process.env.DISCORD_TOKEN)
Buffer & Encoding
Functions for converting between buffers, text, Base64, and hex representations. Useful when working with binary data, file contents, or network protocols.
toBuffer(text) — Convert text to a buffer
Converts a string into a binary buffer (Uint8Array). Useful for binary operations and cryptography.
buf is toBuffer("hello")
say buf -- Uint8Array [104, 101, 108, 108, 111]
fromBuffer(buffer) — Convert buffer to text
Converts a binary buffer back into a string.
buf is toBuffer("hello")
text is fromBuffer(buf)
say text -- "hello"
toBase64(data) / fromBase64(text) — Base64 encoding
Encode data to Base64 and decode Base64 back to the original. Works with both text and buffers.
encoded is toBase64("Hello, World!")
say encoded -- "SGVsbG8sIFdvcmxkIQ=="
original is fromBase64(encoded)
say original -- "Hello, World!"
toHex(data) / fromHex(text) — Hex encoding
Encode data as a hexadecimal string and decode hex strings back to the original data.
hex is toHex("hello")
say hex -- "68656c6c6f"
original is fromHex(hex)
say original -- "hello"
CLI Tools
Built-in functions for building command-line tools. Parse arguments, flags, and add colored output with no imports needed.
quill cli my-tool to scaffold a CLI tool project with argument parsing and help text.
arg(index) — Get a positional argument
Returns the positional argument at the given index (0-based). Returns nothing if the index is out of bounds.
-- If you run: quill run tool.quill hello world
say arg(0) -- "hello"
say arg(1) -- "world"
say arg(2) -- nothing
args() — Get all arguments
Returns a list of all positional arguments passed to the program.
-- If you run: quill run tool.quill a b c
say args() -- ["a", "b", "c"]
flag(name) — Get a named flag value
Returns the value of a named flag (e.g. --output file.txt). Returns nothing if the flag is not present.
-- If you run: quill run tool.quill --output result.txt
output is flag("output")
say output -- "result.txt"
hasFlag(name) — Check if a flag is present
Returns yes or no depending on whether the flag was passed.
-- If you run: quill run tool.quill --verbose
if hasFlag("verbose"):
say "Verbose mode enabled"
colors — Colored terminal output
An object with methods for coloring terminal text. Available colors: red, green, blue, yellow, cyan, magenta, gray, bold, dim, underline.
say colors.green("Success!")
say colors.red("Error: something went wrong")
say colors.bold("Important message")
say colors.yellow("Warning: proceed with caution")
exitWith(code) — Exit with a status code
Exits the program with the given status code. Use 0 for success and any non-zero number for failure.
if hasFlag("help"):
say "Usage: my-tool [options]"
exitWith(0)
if arg(0) is nothing:
say colors.red("Error: missing required argument")
exitWith(1)
Cron Jobs
Schedule recurring tasks using natural English syntax. No cron expression strings needed — just say how often you want something to run.
Syntax: every N seconds/minutes/hours
Use the every keyword followed by a number and a time unit to schedule a recurring block of code.
every 5 seconds:
say "tick"
every 30 minutes:
data is await fetch("https://api.example.com/health")
say "Health check: " + data.status
every 1 hour:
say "Hourly report"
every blocks into setInterval calls with the appropriate millisecond interval. The task runs in the background and does not block the rest of your program. You can use await inside cron blocks for async operations.
HTTPS Server
Create secure HTTPS servers with TLS certificates. Works just like createServer() but with encryption enabled. The createSecureServer function wraps Node's https.createServer and gives you the same routing API you already know from Quill's HTTP server.
createSecureServer(options) — Create an HTTPS server
Creates a server that uses TLS encryption. You provide paths to your SSL certificate and private key.
Parameters:
cert— The contents of your PEM-encoded certificate file (string or Buffer)key— The contents of your PEM-encoded private key file (string or Buffer)
Returns: A server object with .get(), .post(), .put(), .delete(), .use(), and .listen() methods.
server is createSecureServer({
cert: readFile("cert.pem"),
key: readFile("key.pem")
})
server.get("/", "Hello over HTTPS!")
server.listen(443)
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
This creates cert.pem and key.pem in your current directory. Your browser will show a security warning — that is expected for self-signed certs.
Routes with handlers
Define GET, POST, PUT, and DELETE routes with handler functions. Handlers receive a request object and return a response.
server is createSecureServer({
cert: readFile("cert.pem"),
key: readFile("key.pem")
})
-- GET route with a simple string response
server.get("/", "Welcome to the secure API")
-- GET route with a handler function
server.get("/users/:id", with req:
user is await db.find("users", req.params.id)
respond { status: 200, json: user }
)
-- POST route that reads the request body
server.post("/users", with req:
newUser is await db.insert("users", req.body)
respond { status: 201, json: newUser }
)
-- DELETE route
server.delete("/users/:id", with req:
await db.remove("users", req.params.id)
respond { status: 204 }
)
server.listen(443)
Middleware
Use .use() to add middleware that runs before your route handlers. Middleware can inspect requests, add headers, or reject unauthorized access.
-- Logging middleware
server.use(with req, next:
say req.method + " " + req.url
next()
)
-- Auth middleware
server.use(with req, next:
token is req.headers["authorization"]
if token is nothing:
respond { status: 401, json: { error: "Unauthorized" } }
otherwise:
next()
)
-- CORS middleware
server.use(with req, next:
req.setHeader("Access-Control-Allow-Origin", "*")
next()
)
Using Let's Encrypt certificates
In production, use real certificates from Let's Encrypt. After running certbot, point your server at the generated files:
-- Let's Encrypt certs (typically in /etc/letsencrypt/live/yourdomain/)
server is createSecureServer({
cert: readFile("/etc/letsencrypt/live/example.com/fullchain.pem"),
key: readFile("/etc/letsencrypt/live/example.com/privkey.pem")
})
server.get("/", "Hello from production!")
server.listen(443)
certbot renew) and restart your server to pick up the new certs. You can use Quill's every block to watch for certificate changes and reload automatically.
Secure Storage
Browser-side encrypted storage for sensitive data. Works like localStorage but encrypts values before storing them. Under the hood, SecureStorage uses AES-256-GCM authenticated encryption — values are encrypted with a key derived from your password via PBKDF2 (100,000 iterations). The IV and auth tag are stored alongside the ciphertext.
SecureStorage.set(key, value, password)
Encrypts and stores a value in the browser's local storage.
Parameters:
key— A string key to identify the stored item (stored in plain text)value— The value to encrypt and store (string or JSON-serializable object)password— The encryption password used to derive the AES key
SecureStorage.set("token", "sk-abc123", "user-password")
SecureStorage.get(key, password)
Retrieves and decrypts a value from secure storage. Returns nothing if the key does not exist or the password is wrong.
Parameters:
key— The key to look uppassword— The same password used when callingset()
Returns: The decrypted value, or nothing on failure.
token is SecureStorage.get("token", "user-password")
say token -- "sk-abc123"
SecureStorage.remove(key)
Removes an item from secure storage.
SecureStorage.remove("token")
SecureStorage.clear()
Removes all items from secure storage.
SecureStorage.clear()
Practical example: storing and retrieving an API token
-- After the user logs in, store their API token securely
say "Enter a password to protect your token:"
password is await promptPassword()
SecureStorage.set("api-token", user.apiToken, password)
say "Token saved securely."
-- Later, retrieve the token
password is await promptPassword()
token is SecureStorage.get("api-token", password)
if token is nothing:
say "Wrong password or token not found."
otherwise:
say "Token retrieved, making API call..."
result is await fetch("https://api.example.com/data", {
headers: { authorization: "Bearer " + token }
})
Error handling when password is wrong
When you provide the wrong password, SecureStorage.get() returns nothing rather than throwing an error. This means AES-GCM authentication failed and the data could not be decrypted. Always check the return value:
result is SecureStorage.get("api-token", userInput)
if result is nothing:
say "Decryption failed. Check your password and try again."
otherwise:
say "Decrypted value: " + result
WebRTC
Built-in peer-to-peer communication for real-time audio, video, and data channels. No external libraries needed.
createPeer(options) — Create a peer connection
Creates a new WebRTC peer connection. You can optionally pass ICE server configuration.
peer is createPeer({
iceServers: [{urls: "stun:stun.l.google.com:19302"}]
})
PeerConnection events
Use the on keyword to handle peer connection events.
peer on "connect" with:
say "Connected to peer!"
peer on "data" with message:
say "Received: " + message
peer on "stream" with stream:
say "Got media stream"
peer on "close" with:
say "Connection closed"
Sending data
peer.send("Hello from Quill!")
peer.send({type: "chat", text: "Hi there"})
Signaling
WebRTC requires a signaling mechanism to exchange connection info between peers. Use peer.createOffer() and peer.createAnswer() to generate SDP descriptions, and peer.signal(data) to process incoming signals.
-- Initiator side
offer is await peer.createOffer()
-- Send offer to the other peer via your signaling server
-- Receiver side
peer.signal(offer)
answer is await peer.createAnswer()
-- Send answer back to initiator
Signal Protocol Primitives
Advanced cryptographic primitives for building secure messaging protocols, end-to-end encryption, and high-security applications. These functions wrap battle-tested implementations from Node.js crypto module.
hkdf(inputKey, salt, info, outputLength) — HMAC-based Key Derivation
Derives a cryptographic key from input keying material using HKDF (RFC 5869). Essential for key derivation in protocols like Signal’s Double Ratchet.
-- Derive an encryption key from a shared secret
derived is hkdf(sharedSecret, salt, "encryption-key", 32)
-- Derive multiple keys from same input
encKey is hkdf(master, salt, "enc", 32)
macKey is hkdf(master, salt, "mac", 32)
diffieHellman(myPrivateKey, theirPublicKey) — X25519 Shared Secret
Computes an X25519 Diffie-Hellman shared secret. Used for key agreement in end-to-end encryption.
-- Generate key pairs for two parties
myKeys is generateX25519Keys()
theirKeys is generateX25519Keys()
-- Both sides compute the same shared secret
shared1 is diffieHellman(myKeys.privateKey, theirKeys.publicKey)
shared2 is diffieHellman(theirKeys.privateKey, myKeys.publicKey)
-- shared1 and shared2 are identical
generateX25519Keys() — Generate X25519 Key Pair
Generates a new X25519 key pair for use with diffieHellman(). Returns an object with publicKey and privateKey as Buffers.
keys is generateX25519Keys()
say toHex(keys.publicKey) -- the public key to share
-- Keep keys.privateKey secret!
argon2(password, salt, options) — Memory-Hard Password Hashing
Hashes a password using Argon2id, the recommended algorithm for password storage. This is an async operation. Options can specify memoryCost, timeCost, and parallelism.
-- Hash a password for storage
hashed is await argon2(password, salt)
-- With custom parameters
hashed is await argon2(password, salt, {
memoryCost: 65536,
timeCost: 3,
parallelism: 4
})
argon2Verify(hashed, password) — Verify Password
Verifies a password against an Argon2 hash. Returns true or false.
isValid is await argon2Verify(storedHash, userPassword)
if isValid:
say "Login successful"
bcryptHash(password, rounds) — bcrypt Password Hashing
Hashes a password using bcrypt with configurable rounds. Returns a string containing the algorithm identifier, cost factor, salt, and hash — everything needed to verify later.
Parameters:
password— The plain-text password to hash (string)rounds— The cost factor (number of iterations = 2^rounds). Default is12. Higher values are slower but more resistant to brute force. Recommended values:10— Fast, suitable for low-security or high-throughput scenarios12— Good default for most applications (about 250ms per hash)14— Stronger, suitable for high-security applications (about 1 second per hash)
Returns: A bcrypt hash string (e.g., $2b$12$...).
hashed is await bcryptHash(password, 12)
bcryptVerify(hashed, password) — Verify bcrypt Password
Verifies a password against a bcrypt hash. The round count and salt are read from the hash string automatically.
Parameters:
hashed— The bcrypt hash string returned bybcryptHash()password— The plain-text password to check
Returns: true if the password matches, false otherwise.
isValid is await bcryptVerify(storedHash, userPassword)
Full signup and login flow
-- SIGNUP: hash the password and store it
signup is with username, password:
hashed is await bcryptHash(password, 12)
await db.insert("users", {
username: username,
passwordHash: hashed
})
say "Account created for " + username
-- LOGIN: retrieve the hash and verify
login is with username, password:
user is await db.findOne("users", { username: username })
if user is nothing:
say "User not found"
respond false
isValid is await bcryptVerify(user.passwordHash, password)
if isValid:
say "Login successful"
respond true
otherwise:
say "Incorrect password"
respond false
- Argon2 (use
argon2()/argon2Verify()) is the modern recommendation. It is memory-hard, which makes GPU-based attacks expensive. Use Argon2 for new projects. - bcrypt is battle-tested and widely supported. Use it when you need compatibility with existing systems that already store bcrypt hashes, or when working with libraries/APIs that expect bcrypt format.
hmacBuffer(data, key, algorithm) — HMAC as Buffer
Like hmac() but returns a raw Buffer instead of a hex string. Useful when chaining crypto operations that expect binary input.
Parameters:
data— The data to authenticate (string or Buffer)key— The HMAC key (string or Buffer)algorithm— Hash algorithm:"sha256","sha384", or"sha512"
Returns: A Buffer containing the raw HMAC bytes.
macBuf is hmacBuffer("data", key, "sha256")
hkdfBuffer(inputKey, salt, info, outputLength) — HKDF as Buffer
Like hkdf() but returns a raw Buffer instead of a hex string. Use HKDF to derive one or more strong keys from a shared secret or master key.
Parameters:
inputKey— The input keying material (string or Buffer)salt— A non-secret random value (string or Buffer). Can be empty but should not be.info— Context string that binds the derived key to a specific purpose (e.g.,"encryption","signing")outputLength— Desired key length in bytes (e.g.,32for AES-256)
Returns: A Buffer containing the derived key bytes.
keyBuf is hkdfBuffer(sharedSecret, salt, "encryption", 32)
randomBytesBuffer(size) — Random Bytes as Buffer
Like randomBytes() but returns a raw Buffer instead of a hex string. Useful for IV generation and key material.
Parameters:
size— Number of random bytes to generate
Returns: A Buffer of cryptographically secure random bytes.
iv is randomBytesBuffer(12) -- 12-byte IV for AES-GCM
Practical example: chaining Buffer variants for encryption
The Buffer variants are designed to chain together. Here is a complete encrypt-then-send flow using randomBytesBuffer for the IV, hkdfBuffer to derive a key, and aesEncrypt to encrypt:
-- 1. Generate a random IV
iv is randomBytesBuffer(12)
-- 2. Derive an encryption key from a shared secret
salt is randomBytesBuffer(16)
encKey is hkdfBuffer(sharedSecret, salt, "encryption", 32)
-- 3. Encrypt the message
result is aesEncrypt(plaintext, encKey, iv, "aes-256-gcm")
-- 4. Compute a MAC over the ciphertext for additional integrity
macKey is hkdfBuffer(sharedSecret, salt, "signing", 32)
mac is hmacBuffer(result.ciphertext, macKey, "sha256")
-- 5. Send everything the receiver needs
say { salt: salt, iv: iv, ciphertext: result.ciphertext, tag: result.tag, mac: mac }
hmacBuffer, hkdfBuffer, randomBytesBuffer) when you are passing the output directly into another crypto function — this avoids unnecessary hex-encode/decode round trips. Use the hex string variants (hmac, hkdf, randomBytes) when you need a human-readable representation for logging, storing in a database text column, or sending in JSON.
constantTimeEqual(a, b) — Timing-Safe Comparison
Compares two buffers in constant time to prevent timing attacks. Always use this instead of is when comparing secrets, MACs, or hashes.
-- Verify a MAC safely
expected is hmac(message, key, "sha256")
if constantTimeEqual(expected, received):
say "Message is authentic"
aesEncrypt(plaintext, key, iv, mode) — AES-256 Encryption
Encrypts data using AES-256. Supports "aes-256-gcm" (recommended, returns ciphertext + auth tag) and "aes-256-cbc" modes.
-- Encrypt with AES-256-GCM (authenticated encryption)
iv is randomBytes(12)
result is aesEncrypt(data, key, iv, "aes-256-gcm")
-- result has .ciphertext and .tag
aesDecrypt(ciphertext, key, iv, tag, mode) — AES-256 Decryption
Decrypts AES-256 encrypted data. For GCM mode, pass the authentication tag to verify integrity.
-- Decrypt AES-256-GCM
plaintext is aesDecrypt(result.ciphertext, key, iv, result.tag, "aes-256-gcm")
secureErase(buffer) — Zero Memory
Overwrites a buffer with zeros to remove sensitive data from memory. Use this for keys, passwords, and secrets when you are done with them.
-- Clean up sensitive key material
key is hkdf(shared, salt, "enc", 32)
-- ... use the key ...
secureErase(key) -- key is now all zeros
secureRandomInt(min, max) — Cryptographic Random Integer
Generates a cryptographically secure random integer in the range [min, max). Unlike randomInt, this uses a CSPRNG suitable for security-sensitive code.
otp is secureRandomInt(100000, 999999)
say "Your code: " + otp
defineSchema(fields) / encode(schema, data) / decode(schema, buffer) — Binary Serialization
Compact binary serialization using tagged fields, similar to Protocol Buffers. Each field has a numeric tag and a type, so encoded messages are small, fast to parse, and forward-compatible.
Supported types
uint8,uint16,uint32— Unsigned integers (1, 2, or 4 bytes)int8,int16,int32— Signed integers (1, 2, or 4 bytes)uint64— Unsigned 64-bit integer (8 bytes, for timestamps and large counters)float32,float64— IEEE 754 floating point numbersstring— UTF-8 encoded string (length-prefixed)bytes— Raw binary data as a Buffer (length-prefixed)bool— Boolean value (1 byte)
-- Define a message schema
MessageSchema is defineSchema({
sender: { type: "string", tag: 1 },
body: { type: "bytes", tag: 2 },
timestamp: { type: "uint64", tag: 3 }
})
-- Encode to binary
wire is encode(MessageSchema, { sender: "alice", body: ciphertext, timestamp: now() })
-- Decode from binary
msg is decode(MessageSchema, wire)
Practical example: encoding a chat message for wire transfer
-- Define the schema for a chat message
ChatMessage is defineSchema({
fromUser: { type: "string", tag: 1 },
toUser: { type: "string", tag: 2 },
content: { type: "string", tag: 3 },
sentAt: { type: "uint64", tag: 4 },
isEdited: { type: "bool", tag: 5 },
attachment: { type: "bytes", tag: 6 }
})
-- Encode and send over a WebSocket
outgoing is encode(ChatMessage, {
fromUser: "alice",
toUser: "bob",
content: "Hey, check out this file!",
sentAt: now(),
isEdited: false,
attachment: fileBytes
})
ws.send(outgoing)
-- On the receiving end, decode the binary back into an object
ws on "message" with data:
msg is decode(ChatMessage, data)
say msg.fromUser + ": " + msg.content
defineSchema / encode / decode when you need compact payloads (binary is typically 2-10x smaller than JSON), when you are sending data over WebSockets or WebRTC data channels, or when you need to embed raw binary (like encrypted ciphertext) without Base64 overhead. Use JSON when readability matters, when debugging network traffic, or when interoperating with APIs that expect JSON.
catch err: — Error Handling Shorthand
Quill provides two equivalent syntaxes for catching errors. Use whichever reads better in context.
Both syntaxes side by side
-- Syntax 1: try / if it fails
try:
data is await fetchJSON("https://api.example.com/data")
say data
if it fails err:
say "Request failed: " + err
-- Syntax 2: try / catch (shorthand, same behavior)
try:
data is await fetchJSON("https://api.example.com/data")
say data
catch err:
say "Request failed: " + err
Accessing error properties
The err variable is an object with useful properties you can inspect:
try:
result is await fetch("https://api.example.com/users")
catch err:
say "Message: " + err.message -- Human-readable error text
say "Name: " + err.name -- Error type (e.g., "TypeError")
say "Stack: " + err.stack -- Full stack trace for debugging
Nested try/catch
You can nest try/catch blocks to handle errors at different levels:
try:
user is await fetchJSON("https://api.example.com/user/1")
try:
await db.insert("users", user)
say "Saved to database"
catch dbErr:
say "Database error: " + dbErr.message
catch networkErr:
say "Network error: " + networkErr.message
Static Site Generator
Scaffold a static site project with a build pipeline, templating, and hot reload. Quill compiles .quill template files into plain HTML, copies your static assets, and produces a dist/ folder ready to deploy anywhere.
Scaffold a Project
quill site my-site
This creates the following directory structure:
my-site/
pages/
index.quill -- Home page template
about.quill -- About page template
_layout.quill -- Shared layout (header, footer, nav)
public/
css/
style.css -- Your stylesheet
images/ -- Image assets
favicon.ico
quill.toml -- Project configuration (site title, base URL)
build.quill -- Build script that compiles pages and copies assets
Example page template
Pages are .quill files that use Quill's template syntax. Variables, loops, and components work inside templates:
-- pages/index.quill
layout is "_layout"
title is "Home"
content:
<h1>Welcome to {{ site.title }}</h1>
<p>This site was built with Quill.</p>
<ul>
for each post in posts:
<li><a href="{{ post.url }}">{{ post.title }}</a></li>
</ul>
Build & Serve
-- Build the site to dist/
quill run build.quill
-- Or serve with hot reload during development
quill serve
Build output
After running the build, your dist/ folder contains plain HTML and copied assets, ready to deploy:
dist/
index.html -- Compiled from pages/index.quill
about.html -- Compiled from pages/about.quill
css/
style.css -- Copied from public/css/
images/ -- Copied from public/images/
favicon.ico -- Copied from public/
Adding CSS and images
Place CSS files in public/css/ and images in public/images/. They are copied to dist/ during the build. Reference them with root-relative paths in your templates:
-- In your _layout.quill
<link rel="stylesheet" href="/css/style.css">
-- In any page template
<img src="/images/logo.png" alt="Logo">
_layout.quill file wraps every page automatically — put your <head>, navigation, and footer there once.