Skip to content

Commit 31a4cf8

Browse files
committed
bump fixi version in docs for publication
0 parents  commit 31a4cf8

42 files changed

Lines changed: 2691 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# 🚲 The Fixi Project
2+
3+
[The Fixi Project](https://fixiproject.org) is a collection of five small web libraries based on other libraries we work
4+
on.
5+
6+
Each library in The Fixi Project is constrained to have an *unminified, uncompressed* source size smaller
7+
than the excellent [Preact](https://preactjs.org) library's [min.gz'd size](https://bundlephobia.com/package/preact)
8+
(~ 4.7kb).
9+
10+
## The Libraries
11+
12+
The five libraries each provide independent bits of functionality, but are designed to compose well. You can mix and
13+
match them as you see fit.
14+
15+
### 🚲 `fixi.js` - supercharged HTML
16+
17+
`fixi.js` is the original and main library in this collection. It is based on [htmx](https://htmx.org) and, like htmx,
18+
makes it possible to issue HTTP requests from elements in response to events.
19+
20+
Here is a fixi-powered button:
21+
22+
```html
23+
<button fx-action="/like" fx-method="post">
24+
Like
25+
</button>
26+
```
27+
28+
This button will issue an HTTP POSt to `/like` when it is clicked and will replace itself with whatever HTML content the
29+
server responds with. This simple concept is a surprisingly powerful way to build web applications.
30+
31+
You can read more about how fixi works on its [homepage](https://github.com/bigskysoftware/fixi).
32+
33+
### 🥊 `moxi.js` - inline scripting & simple reactivity
34+
35+
`moxi.js` adds inline scripting and DOM-based reactivity. It is based on [hyperscript](https://hyperscript.org) and lets
36+
you put behavior directly on elements via `on-*` attributes, plus a `live` attribute that re-runs whenever the page
37+
changes.
38+
39+
Here is a moxi-powered button:
40+
41+
```html
42+
43+
<button on-click="this.disabled = true; this.innerText = 'thanks!'">
44+
Click me
45+
</button>
46+
```
47+
48+
This button disables itself and updates its text when clicked, without a separate `<script>` block. Each `on-*` handler
49+
is compiled into an async function with access to helpers like `q()` (a proxy over a set of matched elements), `trigger()`,
50+
`wait()`, and `debounce()`.
51+
52+
You can read more about how moxi works on its [homepage](https://github.com/bigskysoftware/moxi).
53+
54+
### 📡 `ssexi.js` - streaming HTML & events
55+
56+
`ssexi.js` is a companion library for fixi that
57+
adds [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) support. It is inspired
58+
by [htmx's SSE extension](https://github.com/bigskysoftware/htmx/blob/four/src/ext/hx-sse.js): whenever a fixi
59+
response comes back with `Content-Type: text/event-stream`, ssexi takes over the swap loop and streams each message into
60+
the target as it arrives.
61+
62+
Here is an ssexi-powered log:
63+
64+
```html
65+
66+
<button fx-action="/events" fx-swap="beforeend" fx-target="#log">
67+
Start
68+
</button>
69+
<div id="log"></div>
70+
```
71+
72+
When `/events` responds with an SSE stream, each `data:` line is appended to `#log`. Messages with a named `event:`
73+
field fire DOM events (`fx:sse:<name>`) instead of swapping, which gives you a clean seam for JavaScript hooks like
74+
progress or "done" signals.
75+
76+
You can read more about how ssexi works on its [homepage](https://github.com/bigskysoftware/ssexi).
77+
78+
### ♻️ `paxi.js` - DOM patching (morphing)
79+
80+
`paxi.js` adds a `morph` swap strategy to fixi. It is based on [idiomorph](https://github.com/bigskysoftware/idiomorph)
81+
and patches an existing subtree into the shape of a new one in place, matching elements by id rather than replacing them
82+
wholesale.
83+
84+
Here is a paxi-powered swap:
85+
86+
```html
87+
88+
<button fx-action="/counter" fx-swap="morph" fx-target="#count">
89+
Increment
90+
</button>
91+
<span id="count">0</span>
92+
```
93+
94+
When the server responds with an updated `<span id="count">1</span>`, paxi morphs the existing span instead of replacing
95+
the node. The practical upshot is that focus, selection, input state, and event listeners survive a swap. It also allows
96+
you to use CSS transitions.
97+
98+
You can read more about how paxi works on its [homepage](https://github.com/bigskysoftware/paxi).
99+
100+
### 🐕 `rexi.js` - an ergonomic `fetch()` wrapper
101+
102+
`rexi.js` is a small fluent wrapper around `fetch()`, inspired by [hyperscript's
103+
`fetch` command](https://hyperscript.org/commands/fetch/). It handles the usual boring parts of calling an HTTP endpoint
104+
from JavaScript: serializing a form or a plain object as a body, throwing on non-2xx responses, decoding the result, and
105+
aborting on demand.
106+
107+
Here is a rexi-powered POST:
108+
109+
```js
110+
let user = await post('/users', {name: 'carson'}).json()
111+
```
112+
113+
rexi serializes the object as a JSON body, throws if the server returns an error status, and decodes the JSON response
114+
for you. It also accepts `FormData`, `URLSearchParams`, or a form `Element` directly as the body, so it slots neatly
115+
into moxi handlers and fixi-driven pages.
116+
117+
You can read more about how rexi works on its [homepage](https://github.com/bigskysoftware/rexi).
118+
119+
## 🧰 `the-fixi-project.js`
120+
121+
All five libraries are also published as a single pre-concatenated, minified, and brotli-compressed bundle under the [
122+
`the-fixi-project`](https://www.npmjs.com/package/the-fixi-project) npm package:
123+
124+
```html
125+
126+
<script src="https://cdn.jsdelivr.net/npm/the-fixi-project/dist/the-fixi-project.min.js"></script>
127+
```
128+
129+
The entire fixi project comes in at ~4.2kb when brotli-compressed.
130+
131+
## Developing
132+
133+
From a fresh checkout of this repo:
134+
135+
```bash
136+
npm install # installs playwright + terser (dev-only)
137+
npm run clone # clones each sub-project repo into its directory
138+
npm test # runs the full test suite across all libraries (headless)
139+
npm run build # builds the all-in-one bundle into dist/
140+
npm run serve # serves the project over http://localhost:8000
141+
```
142+
143+
`npm test` accepts a subset of project names, so `npm test rexi paxi` runs only those two.
144+
145+
## License
146+
147+
BSD-0 (Zero-Clause BSD) across all libraries.

_scripts/status.mjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { readFileSync } from 'node:fs'
2+
import { execSync } from 'node:child_process'
3+
import { dirname, resolve } from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
7+
const sh = (cmd) => { try { return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null } }
8+
const fetchPublished = (pkg, version, path) => version ? sh(`curl -sf https://unpkg.com/${pkg}@${version}/${path}`) : null
9+
10+
const rootPkg = JSON.parse(readFileSync(`${ROOT}/package.json`, 'utf8'))
11+
12+
const ENTRIES = [
13+
['fixi-js', 'fixi/fixi.js', 'fixi.js'],
14+
['moxi-js', 'moxi/moxi.js', 'moxi.js'],
15+
['ssexi-js', 'ssexi/ssexi.js', 'ssexi.js'],
16+
['paxi-js', 'paxi/paxi.js', 'paxi.js'],
17+
['rexi-js', 'rexi/rexi.js', 'rexi.js'],
18+
[rootPkg.name, 'dist/the-fixi-project.js', 'dist/the-fixi-project.js', rootPkg.version],
19+
]
20+
21+
console.log('package published release?')
22+
console.log('--------------- ------------ --------')
23+
24+
for (const [pkg, localPath, remotePath, localVersion] of ENTRIES) {
25+
const local = readFileSync(`${ROOT}/${localPath}`, 'utf8')
26+
const version = sh(`npm view ${pkg} version`) || ''
27+
const published = fetchPublished(pkg, version, remotePath)
28+
let status
29+
if (!version) status = 'yes (unpublished)'
30+
else if (published == null) status = '? (fetch failed)'
31+
else if (published !== local) status = 'yes (source differs)'
32+
else if (localVersion && localVersion !== version) status = `yes (local ${localVersion})`
33+
else status = 'no'
34+
console.log(`${pkg.padEnd(15)} ${(version || '-').padEnd(12)} ${status}`)
35+
}

_scripts/test.mjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { chromium } from 'playwright'
2+
import { spawn } from 'node:child_process'
3+
import { dirname, resolve } from 'node:path'
4+
import { fileURLToPath } from 'node:url'
5+
6+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
7+
const PROJECTS = process.argv.slice(2).length ? process.argv.slice(2) : ['fixi', 'moxi', 'ssexi', 'paxi', 'rexi']
8+
const PORT = 8765 + Math.floor(Math.random() * 200)
9+
10+
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--directory', ROOT], { stdio: 'ignore' })
11+
await new Promise((r) => setTimeout(r, 400))
12+
13+
const browser = await chromium.launch()
14+
let exitCode = 0
15+
try {
16+
for (const project of PROJECTS) {
17+
const page = await browser.newPage()
18+
const pageErrors = []
19+
page.on('pageerror', (e) => pageErrors.push(e.message))
20+
try {
21+
await page.goto(`http://localhost:${PORT}/${project}/test.html`, { waitUntil: 'load', timeout: 10000 })
22+
let prev = -1, stable = 0
23+
for (let i = 0; i < 120 && stable < 4; i++) {
24+
await new Promise((r) => setTimeout(r, 250))
25+
const total = await page.evaluate(() => {
26+
const fixi = document.querySelectorAll('.test.passed, .test.failed').length
27+
const moxi = document.querySelectorAll('.pass, .fail').length
28+
return fixi + moxi
29+
})
30+
if (total === prev) stable++; else { stable = 0; prev = total }
31+
}
32+
const { passed, failed, failures } = await page.evaluate(() => {
33+
const fixiPassed = document.querySelectorAll('.test.passed').length
34+
const fixiFailed = [...document.querySelectorAll('.test.failed')].map((d) => ({
35+
name: d.querySelector('h3')?.textContent.replace(/\s+-\s+run.*$/, '').trim() || '(no h3)',
36+
error: d.querySelector('p:last-child')?.textContent || '',
37+
}))
38+
const moxiPassed = document.querySelectorAll('.pass').length
39+
const moxiFailed = [...document.querySelectorAll('.fail')].map((s) => ({
40+
name: s.textContent.replace(/^[]\s*/, '').trim(),
41+
error: '',
42+
}))
43+
return {
44+
passed: fixiPassed + moxiPassed,
45+
failed: fixiFailed.length + moxiFailed.length,
46+
failures: [...fixiFailed, ...moxiFailed],
47+
}
48+
})
49+
const mark = failed === 0 && passed > 0 ? '✓' : '✗'
50+
console.log(`${mark} ${project}: ${passed} passed, ${failed} failed`)
51+
if (failed > 0) {
52+
for (const f of failures) console.log(` ✗ ${f.name}${f.error ? `\n ${f.error}` : ''}`)
53+
exitCode = 1
54+
}
55+
if (pageErrors.length) {
56+
for (const e of pageErrors) console.log(` [pageerror] ${e}`)
57+
}
58+
if (passed === 0 && failed === 0) {
59+
console.log(` (no tests detected: check that ${project}/test.html loaded)`)
60+
exitCode = 1
61+
}
62+
} catch (e) {
63+
console.log(`✗ ${project}: runner error: ${e.message.split('\n')[0]}`)
64+
exitCode = 1
65+
} finally {
66+
await page.close()
67+
}
68+
}
69+
} finally {
70+
await browser.close()
71+
server.kill()
72+
}
73+
process.exit(exitCode)

demo/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# demo
2+
3+
A small Node-backed playground showing what the all-in-one bundle looks like against real
4+
server endpoints.
5+
6+
## Running
7+
8+
From the project root:
9+
10+
```bash
11+
npm run demo
12+
```
13+
14+
This rebuilds `dist/the-fixi-project.js` (so you're always exercising the latest) and starts the
15+
Node server on [http://localhost:8765](http://localhost:8765). No dependencies beyond
16+
Node's standard library; zero framework.
17+
18+
## What's here
19+
20+
| page | exercises |
21+
|-----------------|---------------------------------------------------------------------------|
22+
| `/` (index) | landing page |
23+
| `/chat.html` | fixi + ssexi + moxi - real shared chat over SSE broadcast |
24+
| `/bot.html` | fixi + ssexi + moxi - POST that returns a streaming gibberish response |
25+
| `/items.html` | fixi + paxi + moxi - CRUD with bulk delete/toggle, morph-swap updates |
26+
27+
All three pages load the same `/dist/the-fixi-project.js` and get every library at once.
28+
29+
## Layout
30+
31+
```
32+
demo/
33+
├── server.mjs # zero-dep Node HTTP server: chat, bot, items CRUD, static
34+
├── public/ # served from /
35+
│ ├── index.html
36+
│ ├── chat.html
37+
│ ├── bot.html
38+
│ └── items.html
39+
└── README.md # you are here
40+
```
41+
42+
## Endpoints
43+
44+
| route | method | purpose |
45+
|--------------------------|--------|---------------------------------------------------------|
46+
| `/chat/stream` | GET | SSE stream of `fx:sse:message` frames, each rendered HTML |
47+
| `/chat/send` | POST | broadcast a new chat message to all subscribers |
48+
| `/bot/ask` | POST | POST that returns `text/event-stream` of gibberish tokens |
49+
| `/items` | GET | render the items table as HTML |
50+
| `/items` | POST | add an item, return the updated table |
51+
| `/items/bulk/delete` | POST | delete items whose ids are in the `ids` form field |
52+
| `/items/bulk/toggle` | POST | toggle `done` on items whose ids are in `ids` |
53+
| `/dist/*` | GET | serves the root `dist/` build output (the bundle) |
54+
| everything else | GET | served from `public/` |

demo/apps/bot.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { esc, sseStart } from '../lib/util.mjs'
2+
3+
const VOCAB = [
4+
'the', 'a', 'and', 'of', 'in', 'that', 'is', 'it', 'for', 'with', 'attention',
5+
'transformer', 'vector', 'embedding', 'token', 'latent', 'space', 'context',
6+
'model', 'neural', 'gradient', 'optimization', 'emerges', 'operates', 'considers',
7+
'synthesizes', 'minimalist', 'hypermedia', 'recursively', 'moreover', 'however',
8+
'fundamentally', 'paradigm', 'coherent', 'framework', 'fluidly', 'notably',
9+
'when', 'where', 'while', 'therefore', 'which', 'arguably', 'ultimately',
10+
'small', 'dense', 'lean', 'composable', 'elegant', 'obvious', 'not-obvious',
11+
]
12+
let words = (n) => {
13+
let out = []
14+
for (let i = 0; i < n; i++) out.push(VOCAB[Math.floor(Math.random() * VOCAB.length)])
15+
out[out.length - 1] += '.'
16+
return out
17+
}
18+
19+
export let stream = (req, res, question) => {
20+
sseStart(res)
21+
if (!question) { res.end(); return }
22+
// first frame: the user's question (CSS gives it the "You: " prefix)
23+
res.write(`data: <div class="you">${esc(question)}</div>\n\n`)
24+
// then stream the gibberish answer one token per frame
25+
let ws = words(15 + Math.floor(Math.random() * 25))
26+
let i = 0
27+
let timer = setInterval(() => {
28+
if (i >= ws.length) { clearInterval(timer); res.end(); return }
29+
res.write(`data: <span class="tok">${esc(ws[i++])} </span>\n\n`)
30+
}, 60)
31+
req.on('close', () => clearInterval(timer))
32+
}

demo/apps/chat.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { esc, sseStart } from '../lib/util.mjs'
2+
3+
let listeners = new Set()
4+
let history = []
5+
let renderMsg = (m) => `<div class="msg"><strong>${esc(m.name)}</strong>: ${esc(m.text)} <small>${m.at}</small></div>`
6+
7+
export let send = (name, text) => {
8+
let msg = {
9+
name: (name || 'anon').slice(0, 40),
10+
text: (text || '').slice(0, 400),
11+
at: new Date().toLocaleTimeString(),
12+
}
13+
history.push(msg)
14+
if (history.length > 50) history.shift()
15+
let payload = renderMsg(msg)
16+
for (let r of listeners) r.write(`data: ${payload}\n\n`)
17+
}
18+
19+
export let stream = (req, res) => {
20+
sseStart(res)
21+
for (let m of history) res.write(`data: ${renderMsg(m)}\n\n`)
22+
listeners.add(res)
23+
req.on('close', () => listeners.delete(res))
24+
}

0 commit comments

Comments
 (0)