asteroid/docs/PARENSCRIPT-EXPERIMENT.org

218 lines
7.0 KiB
Org Mode

#+TITLE: ParenScript Conversion Experiment
#+AUTHOR: Glenn
#+DATE: 2025-11-06
* Overview
This branch experiments with converting all JavaScript files to ParenScript, allowing us to write client-side code in Common Lisp that compiles to JavaScript.
* Goals
- Replace all =.js= files with ParenScript equivalents
- Maintain same functionality
- Improve code maintainability by using one language (Lisp) for both frontend and backend
- Take advantage of Lisp macros for client-side code generation
* Current JavaScript Files
- =static/js/admin.js= - Admin dashboard functionality
- =static/js/auth-ui.js= - Authentication UI
- =static/js/front-page.js= - Front page interactions
- =static/js/player.js= - Audio player controls
- =static/js/profile.js= - User profile page
- =static/js/users.js= - User management
* Implementation Plan
** Phase 1: Setup [DONE]
- [X] Add ParenScript dependency to =asteroid.asd=
- [X] Create =parenscript-utils.lisp= with helper functions
- [X] Create experimental branch
** Phase 2: Convert Simple Files First
- [X] Convert =auth-ui.js= (smallest, simplest) - COMPLETE ✅
- [X] Convert =front-page.js= (stream quality, now playing, pop-out, frameset) - COMPLETE ✅
- [X] Convert =profile.js= (user profile, stats, history) - COMPLETE ✅
- [X] Convert =users.js= (user management, admin) - COMPLETE ✅
** Phase 3: Convert Complex Files
- [ ] Convert =player.js= (audio player logic)
- [ ] Convert =admin.js= (queue management, track controls)
** Phase 4: Testing & Refinement
- [ ] Test all functionality
- [ ] Optimize generated JavaScript
- [ ] Document ParenScript patterns used
* Benefits
** Code Reuse
- Share utility functions between frontend and backend
- Use same data structures and validation logic
** Macros
- Create domain-specific macros for common UI patterns
- Generate repetitive JavaScript code programmatically
** Type Safety
- Catch more errors at compile time
- Better IDE support with Lisp tooling
** Maintainability
- Single language for entire stack
- Easier refactoring across frontend/backend boundary
* ParenScript Resources
- [[https://parenscript.common-lisp.dev/][ParenScript Documentation]]
- [[https://github.com/vsedach/Parenscript][ParenScript GitHub]]
* Lessons Learned
** auth-ui.js Conversion (2025-11-06)
*** Challenge 1: Route Precedence
*Problem:* Radiance routes are matched in load order, not definition order. The general static file route (=/static/(.*)=) was intercepting our specific ParenScript route.
*Solution:* Intercept the static file route and check if path is =js/auth-ui.js=. If yes, serve ParenScript; otherwise serve regular file.
*** Challenge 2: Async/Await Syntax
*Problem:* ParenScript doesn't support =async/await= syntax. Using =(async lambda ...)= generated invalid JavaScript.
*Solution:* Use promise chains with =.then()= instead of async/await.
*** Challenge 3: Compile Time vs Runtime
*Problem:* ParenScript compiler (=ps:ps*=) isn't available in saved binary at runtime.
*Solution:* Compile JavaScript at load time and store in a parameter. The function just returns the pre-compiled string.
*** Success Metrics
- JavaScript compiles correctly (1386 characters)
- No browser console errors
- Auth UI works perfectly (show/hide elements based on login status)
- Generated code is readable and maintainable
*** Key Patterns
*Compile at load time:*
#+BEGIN_EXAMPLE
(defparameter *my-js*
(ps:ps* '(progn ...)))
(defun generate-my-js ()
*my-js*)
#+END_EXAMPLE
*Promise chains instead of async/await:*
#+BEGIN_EXAMPLE
(ps:chain (fetch url)
(then (lambda (response) (ps:chain response (json))))
(then (lambda (data) (process data)))
(catch (lambda (error) (handle error))))
#+END_EXAMPLE
*Intercept static route:*
#+BEGIN_EXAMPLE
(define-page static #@"/static/(.*)" (:uri-groups (path))
(if (string= path "js/my-file.js")
(serve-parenscript)
(serve-static-file)))
#+END_EXAMPLE
* Notes
This is an EXPERIMENTAL branch. The goal is to evaluate ParenScript for this project, not to immediately replace all JavaScript.
If successful, we can merge incrementally, one file at a time.
** Conversion Progress
- *auth-ui.js* (2025-11-06): Successfully converted. 1386 chars. All functionality working.
- *front-page.js* (2025-11-06): Successfully converted. 6900 chars. Stream quality, now playing, pop-out player, frameset mode all working.
- *profile.js* (2025-11-06): Successfully converted. Profile data, listening stats, recent tracks, top artists, password change all working.
- *users.js* (2025-11-06): Successfully converted. User stats, user list, role changes, activate/deactivate, create user all working.
** front-page.js Conversion Notes
This was a more complex file with multiple features. Key learnings:
*** Global Variables
ParenScript uses =(defvar *variable-name* value)= for global variables:
#+BEGIN_EXAMPLE
(defvar *popout-window* nil)
#+END_EXAMPLE
*** String Concatenation
Use =+= operator for string concatenation:
#+BEGIN_EXAMPLE
(+ "width=" width ",height=" height)
#+END_EXAMPLE
*** Conditional Logic
Use =cond= for multiple conditions in route interception:
#+BEGIN_EXAMPLE
(cond
((string= path "js/auth-ui.js") ...)
((string= path "js/front-page.js") ...)
(t ...))
#+END_EXAMPLE
*** Object Property Access
Use =ps:getprop= for dynamic property access:
#+BEGIN_EXAMPLE
(ps:getprop config encoding) ; config[encoding]
#+END_EXAMPLE
All features tested and working:
- Stream quality selector changes stream correctly
- Now playing updates every 10 seconds
- Pop-out player functionality works
- Frameset mode toggle works
- Auto-reconnect on stream errors works
** profile.js and users.js Conversion Notes
*** Modulo Operator
ParenScript doesn't support =%= for modulo. Use =rem= (remainder) instead:
#+BEGIN_EXAMPLE
;; WRONG:
(% seconds 3600)
;; CORRECT:
(rem seconds 3600)
#+END_EXAMPLE
*** Property Access with Hyphens
For properties with hyphens (like ="last-login"=), use =ps:getprop=:
#+BEGIN_EXAMPLE
(ps:getprop user "last-login")
;; Instead of (ps:@ user last-login)
#+END_EXAMPLE
*** Template Literals in HTML Generation
Build HTML strings with =+= concatenation:
#+BEGIN_EXAMPLE
(+ "<td>" (ps:@ user username) "</td>"
"<td>" (ps:@ user email) "</td>")
#+END_EXAMPLE
*** Conditional Attributes
Use =if= expressions inline for conditional HTML attributes:
#+BEGIN_EXAMPLE
(+ "<option value=\"listener\" "
(if (= (ps:@ user role) "listener") "selected" "")
">Listener</option>")
#+END_EXAMPLE
** Summary of Key ParenScript Patterns
1. *Async/Await*: Use promise chains with =.then()= instead
2. *Modulo*: Use =rem= instead of =%=
3. *Global Variables*: Use =defvar= with asterisks: =*variable-name*=
4. *String Concatenation*: Use =+= operator
5. *Property Access*: Use =ps:getprop= for dynamic/hyphenated properties
6. *Object Creation*: Use =ps:create= with keyword arguments
7. *Array Methods*: Use =ps:chain= for method chaining
8. *Route Interception*: Use =cond= in static route handler
9. *Compile at Load Time*: Store compiled JS in =defparameter=
10. *Return Pre-compiled String*: Function just returns the parameter value