HTML Over The Wire On The Bun
Squirt is a (🚧 pre-alpha 🚧) do-everything SSR/HOTW/AHAH server and framework built on Bun.
- Civet support
- Next/Astro-style filesystem routing
- Live reload
- Hyperscript-style HTML/CSS (all elements, properties, and at-rules are available on
globalThis
😆) - SOON: tiny client-side runtime for declarative interactivity
bun add @squirt/markup @squirt/server
If you are using TypeScript and/or Civet, configure your tsconfig.json
:
{
"compilerOptions": {
"types": [
"bun-types",
"@squirt/markup",
"@squirt/server"
]
}
}
bun run squirt
All routes are contained in the src/routes
directory of your project.
Files with a leading dot .
or underscore _
are ignored.
Files named index.*
matching route patterns will act as default routes for the directory.
All routes are modules. Named exports matching HTTP methods (in lowercase) will route those methods (except for export del
which will match DELETE
requests.)
Pages and stylesheets can export default
to match GET
, while API routes with export default
will match all methods. All WebSocket routes should export default
, as GET
is the only valid method to upgrade the connection.
The export can be an object or a function. If it is a function, it will be called and passed a Context
object, which contains the request
, route
, and url
properties. If the route is dynamic, a second object will be passed containing the route parameters and values.
Use src/public
for static files.
*.html.ts
*.html.civet
*.html.js
Page routes return HTML, typically created with the @squirt/markup
builder DSL. Simple example:
// index.html.ts
export default ({ url }) => [
doctype.html5,
html(
head(
title("hello world!"),
liveReload(development),
),
body(
div.hello(
`hello from ${url.pathname}!`,
color("lime")
),
),
),
]
Page routes can also directly return a Response
to override rendering.
*.css.ts
*.css.civet
*.css.js
Stylesheet routes return CSS, also typically created with the @squirt/markup
builder DSL. Unlike other routes, the .css
extension is matched in the URL.
// theme.css.ts
export default [
rule("body",
backgroundColor("black"),
color("silver"),
)
]
*.api.ts
*.api.civet
*.api.js
API routes should return a Response object.
// echo.api.ts
export function get({ url }: Context) {
return new Response(url.search.substring(1))
}
*.socket.ts
*.socket.civet
*.socket.js
Socket routes should return an object with methods matching the Bun WebSocket event handlers:
// upper.socket.ts
export default <SocketHandler>{
open(ws) {
console.log("server socket connected")
},
message(ws, message) {
ws.sendText(message.toString().toUpperCase())
},
}
Routes can be parameterized with square-braced file or directory names (such as [foo].api.ts
or [user]/index.html.ts
)
Rest-style dynamic routes work, as well: [...myParam].html.ts
The liveReload()
function can be included in a page - this will embed a <script>
tag which reloads the page when the source changes. Currently this reloads any page when any source changes. You can pass an optional boolean to enable/disable this setting.
root
: absolute path to the project's root directory.production
: true in productiondevelopment
: true in developmentredirect(location: string, temporary: boolean = false)
: returns a 302 redirectResponse
, or 307 iftemporary
istrue
.
Squirt is crazy with globals, so it provides the ability to define your own. Project-specific globals can be defined in files matching these patterns:
*.global.ts
*.global.civet
*.global.js
These will work with Live Reload. You can use the default export with an object containing keys, or use named exports. Example:
// db.global.ts
import _db from "./db"
declare global {
const db: typeof _db
}
export default {
db: _db
}
Files matching these patterns are Context
extensions:
*.context.ts
*.context.civet
*.context.js
These are run on each request to augment the Context
object with your own values. They should export a default function which returns an object containing additional keys/values. Example:
// session.global.ts
declare global {
// Augmenting the global Context with a possible session field
interface Context {
session?: MySessionType
}
// Creating a Context type for when session is known to be present
interface SessionContext extends Context {
session: MySessionType
}
}
export default ({ request }: Context) => {
const session = getMySession(request)
return { session }
}
Here is an example adding a utility for marking hyperlinks matching the current page with a CSS class:
// utility.context.ts
declare global {
interface Context {
href(href: string): any
}
}
export default ({ url }: Context) => ({
href(href: string) {
return [{ href }, url.pathname === href && { class: "current" }]
}
})
Which can be then used in a
elements:
a("Profile", context.href("/profile")),
a("Inbox", context.href("/inbox")),
Layouts and partial views can be simply be defined as functions and imported.
// site.layout.ts
export default (_title: any, _content: any) => [
doctype.html5,
html(
head(
meta({ charset: "UTF-8" }),
meta({ name: "viewport", content: "width=device-width, initial-scale=1.0" }),
title(_title),
liveReload(development),
),
body(_content),
)
]
All HTML element and CSS property/at-rule names are defined globally as functions which create virtual DOM nodes. At render time, these are converted to HTML and CSS.
TypeScript/JavaScript example:
div(
span("Password: "),
input({ type: "password" })
style(
rule.someClass(
color.red,
),
),
)
Civet example:
div [
span "Password: "
input { type: "password" }
style [
rule ".some-class",
color.red
]
]
Strings, numbers, arrays, etc. are supported as children. null
, undefined
, and false
will render as empty. Anything unrecognized will be converted with .toString()
. Attributes are defined with plain {}
objects. Multiple objects can be defined for conveninent composition, and these can appear after child elements, text nodes, etc.
a.someClass("My Link", { href: "/my_link" }, { class: "another-class" })
The raw
function will skip HTML escaping for its contents:
raw("<span>hello!</span>")
The var
element is named _var
due to conflict with the JS keyword.
You can apply classes directly to element functions:
div(
div.redBold("this is bold and red!"),
div.redBold.alsoItalic("this has two classes!")
)
Class names are automatically converted to kebab-case
.
All standard and known vendor-specific CSS properties are global functions:
color("#ff0000"),
border("solid 1px red"),
webkitBorderImageWidth("4px"),
_continue("auto"), // conflicts with JS keywords are prefixed with underscore
Standard values are also available as properties on these functions:
color.red,
borderStyle.dashed,
_continue.auto,
The rule
function can be used within style elements to define CSS rules. Custom properties may use the prop
function.
style(
rule(".red-bold",
color.red,
fontWeight.bold,
prop("-some-nonstandard", "value"),
)
)
Element functions may be used as selectors:
rule(textarea,
borderColor.black,
)
Class names may be used as selectors (these are converted to kebab-case
):
rule.container(
width("1200px"),
)
You can add CSS properties directly to elements:
div(
color.red,
fontWeight.bold,
"this is bold and red!",
)
Rules may be nested:
rule(".danger",
color.red,
rule(".icon",
float.right,
),
)
Child selectors can be combined with the parent selector, similar to Sass and Less.js. This example produces two rules, the second with the selector .danger.large
:
rule(".danger",
color.red,
rule("&.large",
fontSize("40px")
),
)
Nested selectors with pseudo-classes do the same:
rule(a,
color.red,
textDecorationLine.none,
rule(":hover",
textDecorationLine.underline,
),
)
Squirt detects multiple selectors in a rule and will generate the necessary CSS:
rule("input, textarea",
border("solid 1px gray"),
rule(":hover, :focus",
borderColor.black,
),
)
Media queries and other at-rules are supported
with the $
prefix:
$media("(prefers-color-scheme: dark)",
rule(":root",
prop("--fg", "white"),
prop("--bg", "black"),
),
)
$layer(
rule("p",
color.red,
)
)