@rip-lang/ui 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +133 -25
  2. package/package.json +3 -3
  3. package/serve.rip +36 -17
  4. package/ui.rip +40 -11
package/README.md CHANGED
@@ -16,7 +16,7 @@ import { get, use, start, notFound } from '@rip-lang/api'
16
16
  import { ripUI } from '@rip-lang/ui/serve'
17
17
 
18
18
  dir = import.meta.dir
19
- use ripUI dir: dir, watch: true, title: 'My App'
19
+ use ripUI dir: dir, components: 'pages', includes: ['ui'], watch: true, title: 'My App'
20
20
  get '/css/*', -> @send "#{dir}/css/#{@req.path.slice(5)}"
21
21
  notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
22
22
  start port: 3000
@@ -25,47 +25,101 @@ start port: 3000
25
25
  **`index.html`** — the page:
26
26
 
27
27
  ```html
28
- <script type="module" src="/rip/browser.js"></script>
28
+ <script type="module" src="/rip/rip-ui.min.js"></script>
29
29
  <script type="text/rip">
30
- { launch } = importRip! '/rip/ui.rip'
30
+ { launch } = importRip! 'ui.rip'
31
31
  launch()
32
32
  </script>
33
33
  ```
34
34
 
35
- **`components/index.rip`** — a component:
35
+ **`pages/index.rip`** — a page component:
36
36
 
37
37
  ```coffee
38
38
  export Home = component
39
39
  @count := 0
40
40
  render
41
- div
41
+ .
42
42
  h1 "Hello from Rip UI"
43
43
  button @click: (-> @count += 1), "Clicked #{@count} times"
44
44
  ```
45
45
 
46
46
  Run `bun index.rip`, open `http://localhost:3000`.
47
47
 
48
- ## How It Works
48
+ ## Component Composition
49
49
 
50
- The browser loads two things from the `/rip/` namespace:
50
+ Page components in `pages/` map to routes via file-based routing. Shared
51
+ components in `ui/` (or any `includes` directory) are available by PascalCase
52
+ name. No imports needed:
51
53
 
52
- - `/rip/browser.js` — the Rip compiler (~45KB gzip, cached forever)
53
- - `/rip/ui.rip` — the UI framework (compiled in the browser in ~10-20ms)
54
+ ```coffee
55
+ # ui/card.rip
56
+ export Card = component
57
+ title =! ""
58
+ render
59
+ .card
60
+ if title
61
+ h3 "#{title}"
62
+ @children
63
+
64
+ # pages/about.rip
65
+ export About = component
66
+ render
67
+ .
68
+ h1 "About"
69
+ Card title: "The Idea"
70
+ p "Components compose naturally."
71
+ Card title: "Architecture"
72
+ p "PascalCase resolution, signal passthrough, children blocks."
73
+ ```
74
+
75
+ Reactive props via `:=` signal passthrough. Readonly props via `=!`.
76
+ Children blocks passed as DOM nodes via `@children`.
77
+
78
+ ## How It Works
79
+
80
+ The browser loads one file — `rip-ui.min.js` (~52KB Brotli) — which bundles the
81
+ Rip compiler and the pre-compiled UI framework. No runtime compilation of the
82
+ framework, no extra network requests.
54
83
 
55
84
  Then `launch()` fetches the app bundle, hydrates the stash, and renders.
56
85
 
86
+ ### Browser Execution Contexts
87
+
88
+ Rip provides full async/await support across every browser context — no other
89
+ compile-to-JS language has this:
90
+
91
+ | Context | How async works | Returns value? |
92
+ |---------|-----------------|----------------|
93
+ | `<script type="text/rip">` | Async IIFE wrapper | No (fire-and-forget) |
94
+ | Playground "Run" button | Async IIFE wrapper | No (use console.log) |
95
+ | `rip()` console REPL | Rip `do ->` block | Yes (sync direct, async via Promise) |
96
+ | `.rip` files via `importRip()` | ES module import | Yes (module exports) |
97
+
98
+ The `!` postfix compiles to `await`. Inline scripts are wrapped in an async IIFE
99
+ automatically. The `rip()` console function wraps user code in a `do ->` block
100
+ so the Rip compiler handles implicit return and auto-async natively.
101
+
102
+ ### globalThis Exports
103
+
104
+ When `rip-ui.min.js` loads, it registers these on `globalThis`:
105
+
106
+ | Function | Purpose |
107
+ |----------|---------|
108
+ | `rip(code)` | Console REPL — compile and execute Rip code |
109
+ | `importRip(url)` | Fetch, compile, and import a `.rip` file as an ES module |
110
+ | `compileToJS(code)` | Compile Rip source to JavaScript |
111
+ | `__rip` | Reactive runtime — `__state`, `__computed`, `__effect`, `__batch` |
112
+ | `__ripComponent` | Component runtime — `__Component`, `__clsx`, `__fragment` |
113
+ | `__ripExports` | All compiler exports — `compile`, `formatSExpr`, `VERSION`, etc. |
114
+
57
115
  ## The Stash
58
116
 
59
- Everything lives in one reactive tree:
117
+ App state lives in one reactive tree:
60
118
 
61
119
  ```
62
120
  app
63
- ├── components/ component source files
64
- │ ├── index.rip
65
- │ ├── counter.rip
66
- │ └── _layout.rip
67
- ├── routes ← navigation state (path, params, query, hash)
68
- └── data ← reactive app state (title, theme, user, etc.)
121
+ ├── routes navigation state (path, params, query, hash)
122
+ └── data ← reactive app state (title, theme, user, etc.)
69
123
  ```
70
124
 
71
125
  Writing to `app.data.theme` updates any component reading it. The stash
@@ -74,13 +128,14 @@ uses Rip's built-in reactive primitives — the same signals that power
74
128
 
75
129
  ## The App Bundle
76
130
 
77
- The bundle is JSON served at `/{app}/bundle`. It populates the stash:
131
+ The bundle is JSON served at `/{app}/bundle`:
78
132
 
79
133
  ```json
80
134
  {
81
135
  "components": {
82
136
  "components/index.rip": "export Home = component...",
83
- "components/counter.rip": "export Counter = component..."
137
+ "components/counter.rip": "export Counter = component...",
138
+ "components/_lib/card.rip": "export Card = component..."
84
139
  },
85
140
  "data": {
86
141
  "title": "My App",
@@ -89,20 +144,26 @@ The bundle is JSON served at `/{app}/bundle`. It populates the stash:
89
144
  }
90
145
  ```
91
146
 
147
+ On disk you organize your app into `pages/` and `ui/`. The middleware
148
+ maps them into a flat `components/` namespace in the bundle — pages go
149
+ under `components/`, shared components under `components/_lib/`. The `_`
150
+ prefix tells the router to skip `_lib/` entries when generating routes.
151
+
92
152
  ## Server Middleware
93
153
 
94
154
  The `ripUI` middleware registers routes for the framework files, the app
95
155
  bundle, and optional SSE hot-reload:
96
156
 
97
157
  ```coffee
98
- use ripUI app: '/demo', dir: dir, title: 'My App'
158
+ use ripUI dir: dir, components: 'pages', includes: ['ui'], watch: true, title: 'My App'
99
159
  ```
100
160
 
101
161
  | Option | Default | Description |
102
162
  |--------|---------|-------------|
103
163
  | `app` | `''` | URL mount point |
104
164
  | `dir` | `'.'` | App directory on disk |
105
- | `components` | `'components'` | Components subdirectory within `dir` |
165
+ | `components` | `'components'` | Directory for page components (file-based routing) |
166
+ | `includes` | `[]` | Directories for shared components (no routes) |
106
167
  | `watch` | `false` | Enable SSE hot-reload |
107
168
  | `debounce` | `250` | Milliseconds to batch file change events |
108
169
  | `state` | `null` | Initial app state |
@@ -111,11 +172,10 @@ use ripUI app: '/demo', dir: dir, title: 'My App'
111
172
  Routes registered:
112
173
 
113
174
  ```
114
- /rip/browser.js — Rip compiler
115
- /rip/ui.rip — UI framework
175
+ /rip/rip-ui.min.js — Rip compiler + pre-compiled UI framework
116
176
  /{app}/bundle — app bundle (components + data as JSON)
117
177
  /{app}/watch — SSE hot-reload stream (when watch: true)
118
- /{app}/components/* — individual component files (for hot-reload refetch)
178
+ /{app}/components/* — individual component files (for hot-reload refetch)
119
179
  ```
120
180
 
121
181
  ## State Preservation (Keep-Alive)
@@ -184,7 +244,6 @@ get '/', -> Response.redirect('/demo/', 302)
184
244
  start port: 3002
185
245
  ```
186
246
 
187
- Each app is a directory with `components/`, `css/`, `index.html`, and `index.rip`.
188
247
  The `/rip/` namespace is shared — all apps use the same compiler and framework.
189
248
 
190
249
  ## File Structure
@@ -193,16 +252,65 @@ The `/rip/` namespace is shared — all apps use the same compiler and framework
193
252
  my-app/
194
253
  ├── index.rip # Server
195
254
  ├── index.html # HTML page
196
- ├── components/
255
+ ├── pages/ # Page components (file-based routing)
197
256
  │ ├── _layout.rip # Root layout
198
257
  │ ├── index.rip # Home → /
199
258
  │ ├── about.rip # About → /about
200
259
  │ └── users/
201
260
  │ └── [id].rip # User profile → /users/:id
261
+ ├── ui/ # Shared components (no routes)
262
+ │ └── card.rip # Card → available as Card
202
263
  └── css/
203
264
  └── styles.css # Styles
204
265
  ```
205
266
 
267
+ Files starting with `_` don't generate routes (`_layout.rip` is a layout,
268
+ not a page). Directories starting with `_` are also excluded, which is how
269
+ shared components from `includes` stay out of the router.
270
+
271
+ ## Hash Routing
272
+
273
+ For static hosting (GitHub Pages, S3, etc.) where the server can't handle
274
+ SPA fallback routing, use hash-based URLs:
275
+
276
+ ```coffee
277
+ launch '/app', hash: true
278
+ ```
279
+
280
+ This switches from `/about` to `page.html#/about`. Back/forward navigation,
281
+ direct URL loading, and `href="#/path"` links all work correctly.
282
+
283
+ ## Static Deployment — `launch bundle:`
284
+
285
+ Inline all components in a single HTML file for zero-server deployment.
286
+ Use `rip-ui.min.js` (~52KB Brotli) — a combined bundle with the compiler
287
+ and pre-compiled UI framework. No extra network requests, no runtime
288
+ compilation of the framework:
289
+
290
+ ```html
291
+ <script type="module" src="dist/rip-ui.min.js"></script>
292
+ <script type="text/rip">
293
+ { launch } = importRip! 'ui.rip'
294
+
295
+ launch bundle:
296
+ '/': '''
297
+ export Home = component
298
+ render
299
+ h1 "Hello"
300
+ '''
301
+ '/about': '''
302
+ export About = component
303
+ render
304
+ h1 "About"
305
+ '''
306
+ , hash: true
307
+ </script>
308
+ ```
309
+
310
+ See `docs/demo.html` for a complete example — the full Rip UI Demo app
311
+ (6 components, router, reactive state, persistence) in 337 lines of
312
+ static HTML.
313
+
206
314
  ## License
207
315
 
208
316
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Zero-build reactive web framework — rip.js + ui.rip + launch(url)",
5
5
  "type": "module",
6
6
  "main": "ui.rip",
@@ -32,7 +32,7 @@
32
32
  "author": "Steve Shreeve <steve.shreeve@gmail.com>",
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
- "rip-lang": "^3.6.0"
35
+ "rip-lang": "^3.9.1"
36
36
  },
37
37
  "files": [
38
38
  "ui.rip",
@@ -40,7 +40,7 @@
40
40
  "README.md"
41
41
  ],
42
42
  "peerDependencies": {
43
- "@rip-lang/api": ">=1.1.4"
43
+ "@rip-lang/api": ">=1.1.6"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "@rip-lang/api": {
package/serve.rip CHANGED
@@ -7,12 +7,13 @@
7
7
  #
8
8
  # Usage:
9
9
  # import { ripUI } from '@rip-lang/ui/serve'
10
- # use ripUI app: '/demo', dir: dir, title: 'My App'
10
+ # use ripUI dir: dir, components: 'pages', includes: ['ui'], watch: true, title: 'My App'
11
11
  #
12
12
  # Options:
13
13
  # app: string — URL mount point (default: '')
14
14
  # dir: string — app directory on disk (default: '.')
15
- # components: string — components subdirectory name (default: 'components')
15
+ # components: string — directory for page components, relative to dir (default: 'components')
16
+ # includes: array — directories for shared components, relative to dir (default: [])
16
17
  # watch: boolean — enable SSE hot-reload endpoint (default: false)
17
18
  # debounce: number — ms to batch filesystem events (default: 250)
18
19
  # state: object — initial app state passed via bundle
@@ -26,27 +27,27 @@ import { watch as fsWatch } from 'node:fs'
26
27
  export ripUI = (opts = {}) ->
27
28
  prefix = opts.app or ''
28
29
  appDir = opts.dir or '.'
29
- componentsDir = opts.components or "#{appDir}/components"
30
+ componentsDir = "#{appDir}/#{opts.components or 'components'}"
31
+ includeDirs = (opts.includes or []).map (d) -> "#{appDir}/#{d}"
30
32
  enableWatch = opts.watch or false
31
33
  debounceMs = opts.debounce or 250
32
34
  appState = opts.state or null
33
35
  appTitle = opts.title or null
34
36
  uiDir = import.meta.dir
35
37
 
36
- # Resolve compiler (rip.browser.js)
37
- compilerPath = null
38
+ # Resolve rip-ui.min.js (compiler + UI framework bundled)
39
+ bundlePath = null
38
40
  try
39
- compilerPath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip.browser.js'))
41
+ bundlePath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip-ui.min.js'))
40
42
  catch
41
- compilerPath = "#{uiDir}/../../docs/dist/rip.browser.js"
43
+ bundlePath = "#{uiDir}/../../docs/dist/rip-ui.min.js"
42
44
 
43
45
  # ----------------------------------------------------------------------------
44
- # Route: /rip/* — framework files (compiler + ui.rip), registered once
46
+ # Route: /rip/* — framework files, registered once
45
47
  # ----------------------------------------------------------------------------
46
48
 
47
49
  unless ripUI._registered
48
- get "/rip/browser.js", (c) -> c.send compilerPath, 'application/javascript'
49
- get "/rip/ui.rip", (c) -> c.send "#{uiDir}/ui.rip", 'text/plain; charset=UTF-8'
50
+ get "/rip/rip-ui.min.js", (c) -> c.send bundlePath, 'application/javascript'
50
51
  ripUI._registered = true
51
52
 
52
53
  # ----------------------------------------------------------------------------
@@ -66,6 +67,9 @@ export ripUI = (opts = {}) ->
66
67
  if enableWatch
67
68
  fsWatch componentsDir, { recursive: true }, (event, filename) ->
68
69
  bundleDirty = true if filename?.endsWith('.rip')
70
+ for incDir in includeDirs
71
+ fsWatch incDir, { recursive: true }, (event, filename) ->
72
+ bundleDirty = true if filename?.endsWith('.rip')
69
73
 
70
74
  get "#{prefix}/bundle", (c) ->
71
75
  if bundleDirty or not bundleCache
@@ -75,6 +79,13 @@ export ripUI = (opts = {}) ->
75
79
  for path in paths
76
80
  components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
77
81
 
82
+ # Merge external include directories into components/_lib/
83
+ for dir in includeDirs
84
+ incPaths = Array.from(glob.scanSync(dir))
85
+ for path in incPaths
86
+ key = "components/_lib/#{path}"
87
+ components[key] = Bun.file("#{dir}/#{path}").text! unless components[key]
88
+
78
89
  data = {}
79
90
  data.title = appTitle if appTitle
80
91
  data.watch = enableWatch
@@ -94,11 +105,14 @@ export ripUI = (opts = {}) ->
94
105
  watcher = null
95
106
  heartbeat = null
96
107
 
108
+ watchers = []
109
+
97
110
  cleanup = ->
98
- watcher?.close()
111
+ w.close() for w in watchers
99
112
  clearTimeout(timer) if timer
100
113
  clearInterval(heartbeat) if heartbeat
101
- watcher = heartbeat = timer = null
114
+ watchers = []
115
+ heartbeat = timer = null
102
116
 
103
117
  new Response new ReadableStream(
104
118
  start: (controller) ->
@@ -123,11 +137,16 @@ export ripUI = (opts = {}) ->
123
137
  timer = null
124
138
  send('changed', { paths }) if paths.length > 0
125
139
 
126
- watcher = fsWatch componentsDir, { recursive: true }, (event, filename) ->
127
- return unless filename?.endsWith('.rip')
128
- pending.add "components/#{filename}"
129
- clearTimeout(timer) if timer
130
- timer = setTimeout(flush, debounceMs)
140
+ addWatcher = (dir, keyPrefix) ->
141
+ watchers.push fsWatch dir, { recursive: true }, (event, filename) ->
142
+ return unless filename?.endsWith('.rip')
143
+ pending.add "#{keyPrefix}#{filename}"
144
+ clearTimeout(timer) if timer
145
+ timer = setTimeout(flush, debounceMs)
146
+
147
+ addWatcher componentsDir, 'components/'
148
+ for incDir in includeDirs
149
+ addWatcher incDir, 'components/_lib/'
131
150
 
132
151
  cancel: -> cleanup()
133
152
  ),
package/ui.rip CHANGED
@@ -360,6 +360,10 @@ buildRoutes = (components, root = 'components') ->
360
360
 
361
361
  continue if name.startsWith('_')
362
362
 
363
+ # Skip files in _-prefixed directories (shared components, not pages)
364
+ segs = rel.split('/')
365
+ continue if segs.length > 1 and segs.some((s, i) -> i < segs.length - 1 and s.startsWith('_'))
366
+
363
367
  urlPattern = fileToPattern(rel)
364
368
  regex = patternToRegex(urlPattern)
365
369
  routes.push { pattern: urlPattern, regex, file: filePath, rel }
@@ -392,6 +396,7 @@ getLayoutChain = (routeFile, root, layouts) ->
392
396
  export createRouter = (components, opts = {}) ->
393
397
  root = opts.root or 'components'
394
398
  base = opts.base or ''
399
+ hashMode = opts.hash or false
395
400
  onError = opts.onError or null
396
401
 
397
402
  stripBase = (url) ->
@@ -400,7 +405,21 @@ export createRouter = (components, opts = {}) ->
400
405
  addBase = (path) ->
401
406
  if base then base + path else path
402
407
 
403
- _path = __state(stripBase(location.pathname))
408
+ readUrl = ->
409
+ if hashMode
410
+ h = location.hash.slice(1)
411
+ return '/' unless h
412
+ if h[0] is '/' then h else '/' + h
413
+ else
414
+ location.pathname + location.search + location.hash
415
+
416
+ writeUrl = (path) ->
417
+ if hashMode
418
+ if path is '/' then location.pathname else '#' + path.slice(1)
419
+ else
420
+ addBase(path)
421
+
422
+ _path = __state(stripBase(if hashMode then readUrl() else location.pathname))
404
423
  _params = __state({})
405
424
  _route = __state(null)
406
425
  _layouts = __state([])
@@ -418,6 +437,7 @@ export createRouter = (components, opts = {}) ->
418
437
  resolve = (url) ->
419
438
  rawPath = url.split('?')[0].split('#')[0]
420
439
  path = stripBase(rawPath)
440
+ path = if path[0] is '/' then path else '/' + path
421
441
  queryStr = url.split('?')[1]?.split('#')[0] or ''
422
442
  hash = if url.includes('#') then url.split('#')[1] else ''
423
443
 
@@ -436,7 +456,7 @@ export createRouter = (components, opts = {}) ->
436
456
  onError({ status: 404, path }) if onError
437
457
  false
438
458
 
439
- onPopState = -> resolve(location.pathname + location.search + location.hash)
459
+ onPopState = -> resolve(readUrl())
440
460
  window.addEventListener 'popstate', onPopState if typeof window isnt 'undefined'
441
461
 
442
462
  onClick = (e) ->
@@ -448,18 +468,19 @@ export createRouter = (components, opts = {}) ->
448
468
  return if url.origin isnt location.origin
449
469
  return if target.target is '_blank' or target.hasAttribute('data-external')
450
470
  e.preventDefault()
451
- router.push url.pathname + url.search + url.hash
471
+ dest = if hashMode and url.hash then (url.hash.slice(1) or '/') else (url.pathname + url.search + url.hash)
472
+ router.push dest
452
473
 
453
474
  document.addEventListener 'click', onClick if typeof document isnt 'undefined'
454
475
 
455
476
  router =
456
477
  push: (url) ->
457
478
  if resolve(url)
458
- history.pushState null, '', addBase(_path.read())
479
+ history.pushState null, '', writeUrl(_path.read())
459
480
 
460
481
  replace: (url) ->
461
482
  if resolve(url)
462
- history.replaceState null, '', addBase(_path.read())
483
+ history.replaceState null, '', writeUrl(_path.read())
463
484
 
464
485
  back: -> history.back()
465
486
  forward: -> history.forward()
@@ -482,7 +503,7 @@ export createRouter = (components, opts = {}) ->
482
503
  routes: undefined # overridden by getter
483
504
 
484
505
  init: ->
485
- resolve location.pathname + location.search + location.hash
506
+ resolve readUrl()
486
507
  router
487
508
 
488
509
  destroy: ->
@@ -794,11 +815,14 @@ export createRenderer = (opts = {}) ->
794
815
  # ==============================================================================
795
816
 
796
817
  export launch = (appBase = '', opts = {}) ->
818
+ if typeof appBase is 'object'
819
+ opts = appBase
820
+ appBase = ''
797
821
  appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
798
822
  target = opts.target or '#app'
799
823
  compile = opts.compile or null
800
824
  persist = opts.persist or false
801
- bundleUrl = "#{appBase}/bundle"
825
+ hash = opts.hash or false
802
826
 
803
827
  # Auto-detect compile function from the global rip.js module
804
828
  unless compile
@@ -810,10 +834,14 @@ export launch = (appBase = '', opts = {}) ->
810
834
  el.id = target.replace(/^#/, '')
811
835
  document.body.prepend el
812
836
 
813
- # Fetch the app bundle
814
- res = await fetch(bundleUrl)
815
- throw new Error "launch: #{bundleUrl} (#{res.status})" unless res.ok
816
- bundle = res.json!
837
+ # Get the app bundle — inline, from DOM, or fetch from server
838
+ if opts.bundle
839
+ bundle = opts.bundle
840
+ else
841
+ bundleUrl = "#{appBase}/bundle"
842
+ res = await fetch(bundleUrl)
843
+ throw new Error "launch: #{bundleUrl} (#{res.status})" unless res.ok
844
+ bundle = res.json!
817
845
 
818
846
  # Create the unified stash
819
847
  app = stash { components: {}, routes: {}, data: {} }
@@ -860,6 +888,7 @@ export launch = (appBase = '', opts = {}) ->
860
888
  router = createRouter appComponents,
861
889
  root: 'components'
862
890
  base: appBase
891
+ hash: hash
863
892
  onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
864
893
 
865
894
  # Create renderer