🫧 foam

This commit is contained in:
Chris Wanstrath 2025-11-03 16:41:02 -08:00
commit 67f8b4b25c
13 changed files with 324 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# 🫧 foam
## Quickstart
bun install
bun dev
bun cli:install
foam <your website's directory>

25
bin/foam Executable file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env bun
import { statSync } from 'fs'
import { join } from 'path'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const { startWeb } = await import(join(__dirname, '../src/server.ts'))
function main() {
const root = Bun.argv[2]
if (!isDir(root)) {
console.error('usage: foam <directory>')
process.exit(1)
}
startWeb(root)
}
function isDir(path: string): boolean {
try { return statSync(path).isDirectory() } catch { return false }
}
main()

75
bun.lock Normal file
View File

@ -0,0 +1,75 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "foam",
"dependencies": {
"hono": "^4.10.4",
"shrimp": "git+https://git.nose.space/probablycorey/shrimp#risky-business",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.19.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw=="],
"@codemirror/commands": ["@codemirror/commands@6.10.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w=="],
"@codemirror/language": ["@codemirror/language@6.11.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA=="],
"@codemirror/lint": ["@codemirror/lint@6.9.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ=="],
"@codemirror/search": ["@codemirror/search@6.5.11", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "crelt": "^1.0.5" } }, "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA=="],
"@codemirror/state": ["@codemirror/state@6.5.2", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA=="],
"@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="],
"@lezer/common": ["@lezer/common@1.3.0", "", {}, "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ=="],
"@lezer/generator": ["@lezer/generator@1.8.0", "", { "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" }, "bin": { "lezer-generator": "src/lezer-generator.cjs" } }, "sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/lr": ["@lezer/lr@1.4.3", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="],
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
"reefvm": ["reefvm@git+https://git.nose.space/defunkt/reefvm#0f39e9401eb7a0a7c906e150127f9829458a79b6", { "peerDependencies": { "typescript": "^5" } }, "0f39e9401eb7a0a7c906e150127f9829458a79b6"],
"shrimp": ["shrimp@git+https://git.nose.space/probablycorey/shrimp#4fc1a965ebedeb28c7c2a2ac981400ff0a7278d9", { "dependencies": { "@codemirror/view": "^6.38.3", "@lezer/generator": "^1.8.0", "bun-plugin-tailwind": "^0.0.15", "codemirror": "^6.0.2", "hono": "^4.9.8", "reefvm": "git+https://git.nose.space/defunkt/reefvm", "tailwindcss": "^4.1.11" } }, "4fc1a965ebedeb28c7c2a2ac981400ff0a7278d9"],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
}
}

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "foam",
"module": "src/server.tsx",
"type": "module",
"private": true,
"bin": "./bin/foam",
"scripts": {
"dev": "bun run --hot src/server.tsx",
"start": "bun run src/server.tsx",
"cli:install": "ln -s \"$(pwd)/bin/foam\" ~/.bun/bin/foam",
"cli:remove": "rm ~/.bun/bin/foam"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"hono": "^4.10.4",
"shrimp": "git+https://git.nose.space/probablycorey/shrimp#risky-business"
}
}

10
site/index.sh Normal file
View File

@ -0,0 +1,10 @@
h1 🦐 Welcome!!!
p This is a (b shrimp) page!
p 'ID:' (params.id)
p (a href='/profile' Profile)
p (a href='/pets/cat' My Cat)
hr
div style='margin:0 auto;width:50%;text-align:center;':
img src='/Honk.png' width='50%'
end

1
site/pets/cat.sh Normal file
View File

@ -0,0 +1 @@
p Meow

3
site/profile.sh Normal file
View File

@ -0,0 +1,3 @@
h1 🦐 This is the profile page.
p 'It\'s' still (b Shrimp) (nospace) .
p 'ID:' (params.id)

BIN
site/pub/Honk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

0
src/cli.ts Normal file
View File

78
src/ribbit.ts Normal file
View File

@ -0,0 +1,78 @@
import { runCode } from 'shrimp'
const buffer: string[] = []
const NOSPACE_TOKEN = '!!ribbit-nospace!!'
const TAG_TOKEN = '!!ribbit-tag!!'
const SELF_CLOSING = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]
const HTML5_TAGS = [
"a", "abbr", "address", "area", "article", "aside", "audio", "b", "base",
"bdi", "bdo", "blockquote", "body", "br", "button", "canvas", "caption",
"cite", "code", "col", "colgroup", "data", "datalist", "dd", "del",
"details", "dfn", "dialog", "div", "dl", "dt", "em", "embed", "fieldset",
"figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5",
"h6", "head", "header", "hgroup", "hr", "html", "i", "iframe", "img",
"input", "ins", "kbd", "label", "legend", "li", "link", "main", "map",
"mark", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup",
"option", "output", "p", "picture", "pre", "progress", "q", "rp", "rt",
"ruby", "s", "samp", "script", "section", "select", "slot", "small",
"source", "span", "strong", "style", "sub", "summary", "sup", "svg",
"table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead",
"time", "title", "tr", "track", "u", "ul", "var", "video", "wbr"
]
export async function wrapAndRunCode(code: string, globals?: Record<string, any>): Promise<any> {
return await runCode("ribbit:\n " + code + "\nend", Object.assign({}, ribbitGlobals, globals))
}
export const ribbitGlobals = {
ribbit: async (cb: Function) => {
await cb()
const output = buffer.join("\n")
buffer.length = 0
return output
},
tag: async (tagFn: Function, atDefaults = {}) =>
(atNamed = {}, ...args: any[]) => tagFn(Object.assign({}, atDefaults, atNamed), ...args),
nospace: () => NOSPACE_TOKEN,
echo: (...args: any[]) => console.log(...args)
}
for (const name of HTML5_TAGS) {
(ribbitGlobals as any)[name] = (atNamed: {}, ...args: any[]) => tag(name, atNamed, ...args)
; (ribbitGlobals as any)[name].tagName = name
}
const tagBlock = async (tagName: string, props = {}, fn: Function) => {
const attrs = Object.entries(props).map(([key, value]) => `${key}="${value}"`)
const space = attrs.length ? ' ' : ''
buffer.push(`<${tagName}${space}${attrs.join(' ')}>`)
await fn()
buffer.push(`</${tagName}>`)
}
const tagCall = (tagName: string, atNamed = {}, ...args: any[]) => {
args = args.map(arg => typeof arg === 'function' ? arg.tagName : arg)
const attrs = Object.entries(atNamed).map(([key, value]) => `${key}="${value}"`)
const space = attrs.length ? ' ' : ''
const children = args
.reverse()
.map(a => a === TAG_TOKEN ? buffer.pop() : a)
.reverse().join(' ')
.replaceAll(` ${NOSPACE_TOKEN} `, '')
if (SELF_CLOSING.includes(tagName))
buffer.push(`<${tagName}${space}${attrs.join(' ')} />`)
else
buffer.push(`<${tagName}${space}${attrs.join(' ')}>${children}</${tagName}>`)
}
const tag = async (tagName: string, atNamed = {}, ...args: any[]) => {
if (typeof args[0] === 'function')
await tagBlock(tagName, atNamed, args[0])
else
tagCall(tagName, atNamed, ...args)
return TAG_TOKEN
}

38
src/server.ts Normal file
View File

@ -0,0 +1,38 @@
import { Hono } from 'hono'
import { serveStatic } from 'hono/bun'
import { join, resolve } from 'path'
import { wrapAndRunCode } from './ribbit'
export function startWeb(rootPath: string) {
const root = resolve(rootPath)
const app = new Hono()
// console logging
app.use("*", async (c, next) => {
const start = Date.now()
await next()
const end = Date.now()
console.log(`${c.res.status} ${c.req.method} ${c.req.url} (${end - start}ms)`)
})
// static files get served out of pub/
app.use('/*', serveStatic({ root: join(root, 'pub') }))
app.on(['GET', 'POST'], ['/', '/:page{.+}'], async c => {
const page = c.req.param('page') || 'index'
const params = c.req.query()
const file = Bun.file(join(root, `${page}.sh`))
if (await file.exists()) {
return c.html(await wrapAndRunCode(await file.text(), { params }))
} else {
return c.text('404 Not Found', 404)
}
})
console.log('🫧 Server started at http://localhost:3000')
return Bun.serve({
fetch: app.fetch,
port: 3000,
})
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}