commit 67f8b4b25cf602e783af8d4af941128666b1c0fc Author: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Mon Nov 3 16:41:02 2025 -0800 🫧 foam diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f393cd --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# 🫧 foam + +## Quickstart + + bun install + bun dev + bun cli:install + foam \ No newline at end of file diff --git a/bin/foam b/bin/foam new file mode 100755 index 0000000..e08d960 --- /dev/null +++ b/bin/foam @@ -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 ') + process.exit(1) + } + + startWeb(root) +} + +function isDir(path: string): boolean { + try { return statSync(path).isDirectory() } catch { return false } +} + +main() \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a2d8120 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..473a2ff --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/site/index.sh b/site/index.sh new file mode 100644 index 0000000..d5140e4 --- /dev/null +++ b/site/index.sh @@ -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 \ No newline at end of file diff --git a/site/pets/cat.sh b/site/pets/cat.sh new file mode 100644 index 0000000..d6a2789 --- /dev/null +++ b/site/pets/cat.sh @@ -0,0 +1 @@ +p Meow \ No newline at end of file diff --git a/site/profile.sh b/site/profile.sh new file mode 100644 index 0000000..a359ce0 --- /dev/null +++ b/site/profile.sh @@ -0,0 +1,3 @@ +h1 🦐 This is the profile page. +p 'It\'s' still (b Shrimp) (nospace) . +p 'ID:' (params.id) \ No newline at end of file diff --git a/site/pub/Honk.png b/site/pub/Honk.png new file mode 100644 index 0000000..292aef3 Binary files /dev/null and b/site/pub/Honk.png differ diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/ribbit.ts b/src/ribbit.ts new file mode 100644 index 0000000..a622f1b --- /dev/null +++ b/src/ribbit.ts @@ -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): Promise { + 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(``) +} + +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}`) +} + +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 +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..cc613a0 --- /dev/null +++ b/src/server.ts @@ -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, + }) +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +}