@rip-lang/ui 0.3.0 → 0.3.3

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 +236 -25
  2. package/package.json +3 -3
  3. package/serve.rip +23 -66
  4. package/ui.rip +46 -33
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: 'routes', 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,180 @@ 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
+
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:
53
+
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
+ ## Render Block Syntax
79
+
80
+ Inside a `render` block, elements are declared by tag name. Classes, attributes,
81
+ and children can be expressed inline or across multiple indented lines.
82
+
83
+ ### Classes with `.(...)`
84
+
85
+ The `.()` helper applies classes using CLSX semantics — strings are included
86
+ directly, and object keys are conditionally included based on their values:
87
+
88
+ ```coffee
89
+ button.('px-4 py-2 rounded-full') "Click"
90
+ button.('px-4 py-2', active: isActive) "Click"
91
+ ```
92
+
93
+ Arguments can span multiple lines, just like a normal function call:
94
+
95
+ ```coffee
96
+ input.(
97
+ 'block w-full rounded-lg border border-primary',
98
+ 'text-sm-plus text-tertiary shadow-xs'
99
+ )
100
+ ```
101
+
102
+ ### Indented Attributes
49
103
 
50
- The browser loads two things from the `/rip/` namespace:
104
+ Attributes can be placed on separate indented lines after the element:
51
105
 
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)
106
+ ```coffee
107
+ input.('rounded-lg border px-3.5 py-2.5')
108
+ type: "email"
109
+ value: user.email
110
+ disabled: true
111
+ ```
112
+
113
+ This is equivalent to the inline form:
114
+
115
+ ```coffee
116
+ input.('rounded-lg border px-3.5 py-2.5') type: "email", value: user.email, disabled: true
117
+ ```
118
+
119
+ ### The `class:` Attribute
120
+
121
+ The `class:` attribute works like `.()` and merges cumulatively with any
122
+ existing `.()` classes on the same element:
123
+
124
+ ```coffee
125
+ input.('block w-full rounded-lg')
126
+ class: 'text-sm text-tertiary'
127
+ type: "email"
128
+ ```
129
+
130
+ This produces a single combined class expression: `block w-full rounded-lg text-sm text-tertiary`.
131
+
132
+ The `class:` value also supports `.()` syntax for conditional classes:
133
+
134
+ ```coffee
135
+ div.('mt-4 p-4')
136
+ class: .('ring-1', highlighted: isHighlighted)
137
+ span "Content"
138
+ ```
139
+
140
+ ### Attributes and Children Together
141
+
142
+ Attributes and children can coexist at the same indentation level. Attributes
143
+ (key-value pairs) are listed first, followed by child elements:
144
+
145
+ ```coffee
146
+ button.('flex items-center rounded-lg')
147
+ type: "submit"
148
+ disabled: saving
149
+
150
+ span.('font-bold') "Submit"
151
+ span.('text-sm text-secondary') "or press Enter"
152
+ ```
153
+
154
+ Blank lines between attributes and children are fine — they don't break the
155
+ structure.
156
+
157
+ ## How It Works
158
+
159
+ The browser loads one file — `rip-ui.min.js` (~52KB Brotli) — which bundles the
160
+ Rip compiler and the pre-compiled UI framework. No runtime compilation of the
161
+ framework, no extra network requests.
54
162
 
55
163
  Then `launch()` fetches the app bundle, hydrates the stash, and renders.
56
164
 
165
+ ### Browser Execution Contexts
166
+
167
+ Rip provides full async/await support across every browser context — no other
168
+ compile-to-JS language has this:
169
+
170
+ | Context | How async works | Returns value? |
171
+ |---------|-----------------|----------------|
172
+ | `<script type="text/rip">` | Async IIFE wrapper | No (fire-and-forget) |
173
+ | Playground "Run" button | Async IIFE wrapper | No (use console.log) |
174
+ | `rip()` console REPL | Rip `do ->` block | Yes (sync direct, async via Promise) |
175
+ | `.rip` files via `importRip()` | ES module import | Yes (module exports) |
176
+
177
+ The `!` postfix compiles to `await`. Inline scripts are wrapped in an async IIFE
178
+ automatically. The `rip()` console function wraps user code in a `do ->` block
179
+ so the Rip compiler handles implicit return and auto-async natively.
180
+
181
+ ### globalThis Exports
182
+
183
+ When `rip-ui.min.js` loads, it registers these on `globalThis`:
184
+
185
+ | Function | Purpose |
186
+ |----------|---------|
187
+ | `rip(code)` | Console REPL — compile and execute Rip code |
188
+ | `importRip(url)` | Fetch, compile, and import a `.rip` file as an ES module |
189
+ | `compileToJS(code)` | Compile Rip source to JavaScript |
190
+ | `__rip` | Reactive runtime — `__state`, `__computed`, `__effect`, `__batch` |
191
+ | `__ripComponent` | Component runtime — `__Component`, `__clsx`, `__fragment` |
192
+ | `__ripExports` | All compiler exports — `compile`, `formatSExpr`, `VERSION`, etc. |
193
+
57
194
  ## The Stash
58
195
 
59
- Everything lives in one reactive tree:
196
+ App state lives in one reactive tree:
60
197
 
61
198
  ```
62
199
  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.)
200
+ ├── routes navigation state (path, params, query, hash)
201
+ └── data ← reactive app state (title, theme, user, etc.)
69
202
  ```
70
203
 
71
204
  Writing to `app.data.theme` updates any component reading it. The stash
@@ -74,13 +207,14 @@ uses Rip's built-in reactive primitives — the same signals that power
74
207
 
75
208
  ## The App Bundle
76
209
 
77
- The bundle is JSON served at `/{app}/bundle`. It populates the stash:
210
+ The bundle is JSON served at `/{app}/bundle`:
78
211
 
79
212
  ```json
80
213
  {
81
214
  "components": {
82
215
  "components/index.rip": "export Home = component...",
83
- "components/counter.rip": "export Counter = component..."
216
+ "components/counter.rip": "export Counter = component...",
217
+ "components/_lib/card.rip": "export Card = component..."
84
218
  },
85
219
  "data": {
86
220
  "title": "My App",
@@ -89,20 +223,26 @@ The bundle is JSON served at `/{app}/bundle`. It populates the stash:
89
223
  }
90
224
  ```
91
225
 
226
+ On disk you organize your app into `pages/` and `ui/`. The middleware
227
+ maps them into a flat `components/` namespace in the bundle — pages go
228
+ under `components/`, shared components under `components/_lib/`. The `_`
229
+ prefix tells the router to skip `_lib/` entries when generating routes.
230
+
92
231
  ## Server Middleware
93
232
 
94
233
  The `ripUI` middleware registers routes for the framework files, the app
95
234
  bundle, and optional SSE hot-reload:
96
235
 
97
236
  ```coffee
98
- use ripUI app: '/demo', dir: dir, title: 'My App'
237
+ use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
99
238
  ```
100
239
 
101
240
  | Option | Default | Description |
102
241
  |--------|---------|-------------|
103
242
  | `app` | `''` | URL mount point |
104
243
  | `dir` | `'.'` | App directory on disk |
105
- | `components` | `'components'` | Components subdirectory within `dir` |
244
+ | `components` | `'components'` | Directory for page components (file-based routing) |
245
+ | `includes` | `[]` | Directories for shared components (no routes) |
106
246
  | `watch` | `false` | Enable SSE hot-reload |
107
247
  | `debounce` | `250` | Milliseconds to batch file change events |
108
248
  | `state` | `null` | Initial app state |
@@ -111,11 +251,10 @@ use ripUI app: '/demo', dir: dir, title: 'My App'
111
251
  Routes registered:
112
252
 
113
253
  ```
114
- /rip/browser.js — Rip compiler
115
- /rip/ui.rip — UI framework
254
+ /rip/rip-ui.min.js — Rip compiler + pre-compiled UI framework
116
255
  /{app}/bundle — app bundle (components + data as JSON)
117
256
  /{app}/watch — SSE hot-reload stream (when watch: true)
118
- /{app}/components/* — individual component files (for hot-reload refetch)
257
+ /{app}/components/* — individual component files (for hot-reload refetch)
119
258
  ```
120
259
 
121
260
  ## State Preservation (Keep-Alive)
@@ -184,7 +323,6 @@ get '/', -> Response.redirect('/demo/', 302)
184
323
  start port: 3002
185
324
  ```
186
325
 
187
- Each app is a directory with `components/`, `css/`, `index.html`, and `index.rip`.
188
326
  The `/rip/` namespace is shared — all apps use the same compiler and framework.
189
327
 
190
328
  ## File Structure
@@ -193,16 +331,89 @@ The `/rip/` namespace is shared — all apps use the same compiler and framework
193
331
  my-app/
194
332
  ├── index.rip # Server
195
333
  ├── index.html # HTML page
196
- ├── components/
334
+ ├── pages/ # Page components (file-based routing)
197
335
  │ ├── _layout.rip # Root layout
198
336
  │ ├── index.rip # Home → /
199
337
  │ ├── about.rip # About → /about
200
338
  │ └── users/
201
339
  │ └── [id].rip # User profile → /users/:id
340
+ ├── ui/ # Shared components (no routes)
341
+ │ └── card.rip # Card → available as Card
202
342
  └── css/
203
343
  └── styles.css # Styles
204
344
  ```
205
345
 
346
+ Files starting with `_` don't generate routes (`_layout.rip` is a layout,
347
+ not a page). Directories starting with `_` are also excluded, which is how
348
+ shared components from `includes` stay out of the router.
349
+
350
+ ## Hash Routing
351
+
352
+ For static hosting (GitHub Pages, S3, etc.) where the server can't handle
353
+ SPA fallback routing, use hash-based URLs:
354
+
355
+ ```coffee
356
+ launch '/app', hash: true
357
+ ```
358
+
359
+ This switches from `/about` to `page.html#/about`. Back/forward navigation,
360
+ direct URL loading, and `href="#/path"` links all work correctly.
361
+
362
+ ## Static Deployment — `launch bundle:`
363
+
364
+ Inline all components in a single HTML file for zero-server deployment.
365
+ Use `rip-ui.min.js` (~52KB Brotli) — a combined bundle with the compiler
366
+ and pre-compiled UI framework. No extra network requests, no runtime
367
+ compilation of the framework:
368
+
369
+ ```html
370
+ <script type="module" src="dist/rip-ui.min.js"></script>
371
+ <script type="text/rip">
372
+ { launch } = importRip! 'ui.rip'
373
+
374
+ launch bundle:
375
+ '/': '''
376
+ export Home = component
377
+ render
378
+ h1 "Hello"
379
+ '''
380
+ '/about': '''
381
+ export About = component
382
+ render
383
+ h1 "About"
384
+ '''
385
+ , hash: true
386
+ </script>
387
+ ```
388
+
389
+ See `docs/demo.html` for a complete example — the full Rip UI Demo app
390
+ (6 components, router, reactive state, persistence) in 337 lines of
391
+ static HTML.
392
+
393
+ ## Tailwind CSS Autocompletion
394
+
395
+ To get Tailwind class autocompletion inside `.()` CLSX helpers in render
396
+ templates, install the
397
+ [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)
398
+ extension and add these to your VS Code / Cursor settings:
399
+
400
+ ```json
401
+ {
402
+ "tailwindCSS.includeLanguages": { "rip": "html" },
403
+ "tailwindCSS.experimental.classRegex": [
404
+ ["\\.\\(([\\s\\S]*?)\\)", "'([^']*)'"]
405
+ ]
406
+ }
407
+ ```
408
+
409
+ This gives you autocompletion, hover previews, and linting for Tailwind
410
+ classes in expressions like:
411
+
412
+ ```coffee
413
+ h1.('text-3xl font-semibold') "Hello"
414
+ button.('flex items-center px-4 py-2 rounded-full') "Click"
415
+ ```
416
+
206
417
  ## License
207
418
 
208
419
  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.3",
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
@@ -2,19 +2,18 @@
2
2
  # @rip-lang/ui/serve — Rip UI Server Middleware
3
3
  # ==============================================================================
4
4
  #
5
- # Serves the Rip UI runtime, auto-generated app bundles, and optional
6
- # SSE hot-reload.
5
+ # Serves the Rip UI runtime and auto-generated app bundles.
7
6
  #
8
7
  # Usage:
9
8
  # import { ripUI } from '@rip-lang/ui/serve'
10
- # use ripUI app: '/demo', dir: dir, title: 'My App'
9
+ # use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
11
10
  #
12
11
  # Options:
13
12
  # app: string — URL mount point (default: '')
14
13
  # dir: string — app directory on disk (default: '.')
15
- # components: string — components subdirectory name (default: 'components')
16
- # watch: boolean enable SSE hot-reload endpoint (default: false)
17
- # debounce: number ms to batch filesystem events (default: 250)
14
+ # components: string — directory for page components, relative to dir (default: 'components')
15
+ # includes: array directories for shared components, relative to dir (default: [])
16
+ # watch: boolean enable bundle cache invalidation on file changes (default: false)
18
17
  # state: object — initial app state passed via bundle
19
18
  # title: string — document title
20
19
  #
@@ -26,33 +25,31 @@ import { watch as fsWatch } from 'node:fs'
26
25
  export ripUI = (opts = {}) ->
27
26
  prefix = opts.app or ''
28
27
  appDir = opts.dir or '.'
29
- componentsDir = opts.components or "#{appDir}/components"
28
+ componentsDir = "#{appDir}/#{opts.components or 'components'}"
29
+ includeDirs = (opts.includes or []).map (d) -> "#{appDir}/#{d}"
30
30
  enableWatch = opts.watch or false
31
- debounceMs = opts.debounce or 250
32
31
  appState = opts.state or null
33
32
  appTitle = opts.title or null
34
33
  uiDir = import.meta.dir
35
34
 
36
- # Resolve compiler (rip.browser.js)
37
- compilerPath = null
35
+ # Resolve rip-ui.min.js (compiler + UI framework bundled)
36
+ bundlePath = null
38
37
  try
39
- compilerPath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip.browser.js'))
38
+ bundlePath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip-ui.min.js'))
40
39
  catch
41
- compilerPath = "#{uiDir}/../../docs/dist/rip.browser.js"
40
+ bundlePath = "#{uiDir}/../../docs/dist/rip-ui.min.js"
42
41
 
43
42
  # ----------------------------------------------------------------------------
44
- # Route: /rip/* — framework files (compiler + ui.rip), registered once
43
+ # Route: /rip/* — framework files, registered once
45
44
  # ----------------------------------------------------------------------------
46
45
 
47
46
  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'
47
+ get "/rip/rip-ui.min.js", (c) -> c.send bundlePath, 'application/javascript'
50
48
  ripUI._registered = true
51
49
 
52
50
  # ----------------------------------------------------------------------------
53
51
  # Route: {prefix}/components/* — individual .rip component files (for hot-reload)
54
52
  # Route: {prefix}/bundle — app bundle (components + data as JSON)
55
- # Route: {prefix}/watch — SSE hot-reload stream
56
53
  # ----------------------------------------------------------------------------
57
54
 
58
55
  get "#{prefix}/components/*", (c) ->
@@ -66,6 +63,9 @@ export ripUI = (opts = {}) ->
66
63
  if enableWatch
67
64
  fsWatch componentsDir, { recursive: true }, (event, filename) ->
68
65
  bundleDirty = true if filename?.endsWith('.rip')
66
+ for incDir in includeDirs
67
+ fsWatch incDir, { recursive: true }, (event, filename) ->
68
+ bundleDirty = true if filename?.endsWith('.rip')
69
69
 
70
70
  get "#{prefix}/bundle", (c) ->
71
71
  if bundleDirty or not bundleCache
@@ -75,6 +75,13 @@ export ripUI = (opts = {}) ->
75
75
  for path in paths
76
76
  components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
77
77
 
78
+ # Merge external include directories into components/_lib/
79
+ for dir in includeDirs
80
+ incPaths = Array.from(glob.scanSync(dir))
81
+ for path in incPaths
82
+ key = "components/_lib/#{path}"
83
+ components[key] = Bun.file("#{dir}/#{path}").text! unless components[key]
84
+
78
85
  data = {}
79
86
  data.title = appTitle if appTitle
80
87
  data.watch = enableWatch
@@ -86,55 +93,5 @@ export ripUI = (opts = {}) ->
86
93
 
87
94
  new Response bundleCache, headers: { 'Content-Type': 'application/json' }
88
95
 
89
- if enableWatch
90
- get "#{prefix}/watch", (c) ->
91
- encoder = new TextEncoder()
92
- pending = new Set()
93
- timer = null
94
- watcher = null
95
- heartbeat = null
96
-
97
- cleanup = ->
98
- watcher?.close()
99
- clearTimeout(timer) if timer
100
- clearInterval(heartbeat) if heartbeat
101
- watcher = heartbeat = timer = null
102
-
103
- new Response new ReadableStream(
104
- start: (controller) ->
105
- send = (event, data) ->
106
- try
107
- controller.enqueue encoder.encode("event: #{event}\ndata: #{JSON.stringify(data)}\n\n")
108
- catch
109
- cleanup()
110
-
111
- send 'connected', { time: Date.now() }
112
-
113
- heartbeat = setInterval ->
114
- try
115
- controller.enqueue encoder.encode(": heartbeat\n\n")
116
- catch
117
- cleanup()
118
- , 5000
119
-
120
- flush = ->
121
- paths = Array.from(pending)
122
- pending.clear()
123
- timer = null
124
- send('changed', { paths }) if paths.length > 0
125
-
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)
131
-
132
- cancel: -> cleanup()
133
- ),
134
- headers:
135
- 'Content-Type': 'text/event-stream'
136
- 'Cache-Control': 'no-cache'
137
- 'Connection': 'keep-alive'
138
-
139
96
  # Return pass-through middleware
140
97
  (c, next) -> next!()
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
@@ -877,7 +906,7 @@ export launch = (appBase = '', opts = {}) ->
877
906
 
878
907
  # Connect SSE watch if enabled
879
908
  if bundle.data?.watch
880
- connectWatch appComponents, router, renderer, "#{appBase}/watch", appBase
909
+ connectWatch '/watch'
881
910
 
882
911
  # Expose for console and dev tools
883
912
  if typeof window isnt 'undefined'
@@ -896,7 +925,7 @@ export launch = (appBase = '', opts = {}) ->
896
925
  # SSE Watch — hot-reload connection
897
926
  # ==============================================================================
898
927
 
899
- connectWatch = (components, router, renderer, url, base = '') ->
928
+ connectWatch = (url) ->
900
929
  retryDelay = 1000
901
930
  maxDelay = 30000
902
931
 
@@ -904,31 +933,15 @@ connectWatch = (components, router, renderer, url, base = '') ->
904
933
  es = new EventSource(url)
905
934
 
906
935
  es.addEventListener 'connected', ->
907
- retryDelay = 1000 # reset backoff on successful connection
936
+ retryDelay = 1000
908
937
  console.log '[Rip] Hot reload connected'
909
938
 
910
- es.addEventListener 'changed', (e) ->
911
- { paths } = JSON.parse(e.data)
912
- components.del(path) for path in paths
913
- router.rebuild()
914
-
915
- current = router.current
916
- toFetch = paths.filter (p) ->
917
- p is current.route?.file or current.layouts?.includes(p)
918
-
919
- if toFetch.length > 0
920
- results = await Promise.allSettled(toFetch.map (path) ->
921
- res = await fetch(base + '/' + path)
922
- content = res.text!
923
- components.write path, content
924
- )
925
- failed = results.filter (r) -> r.status is 'rejected'
926
- console.error '[Rip] Hot reload fetch error:', r.reason for r in failed
927
- renderer.remount()
939
+ es.addEventListener 'reload', ->
940
+ console.log '[Rip] Reloading...'
941
+ location.reload()
928
942
 
929
943
  es.onerror = ->
930
944
  es.close()
931
- console.log "[Rip] Hot reload reconnecting in #{retryDelay / 1000}s..."
932
945
  setTimeout connect, retryDelay
933
946
  retryDelay = Math.min(retryDelay * 2, maxDelay)
934
947