A radically different approach to frontend development. Write complex application logic directly in HTML attributes with automatic error handling, promise chaining, and state management.
Very much in development. API of the supporting utilities may change (however the core Popstart/PopEvent API is stable and will not change). Documentation mostly generated by LLM and only lightly reviewed.
With that said, this is an awesome library that will change the way you think and code. It makes even complex UIs quite manageable and is better than reactive frameworks in nearly every respect (readability, security, performance) and far more powerful than AlpineJS or HTMX.
Have a look through the docs and see if you agree. There’s no Git repo yet, so if you want to discuss, please email the project name (without .js) at this domain.
Popstart converts your functions into insta-promises, so you can chain them together and they will execute, in order, and stop if there’s an error.
<!-- Chain multiple functions on a single click -->
<button
click="auth.login, ui.showDashboard, metrics.track"
hover="ui.showTooltip"
blur="ui.hideTooltip"
login-endpoint="/api/login"
track-event="login_success">
Login
</button>
You can even specify error handlers for each function, or just use the global default error handler.
<form
submit="api.submitForm"
submitForm-error="ui.showToast"
showToast-message="Failed to submit!">
</form>
Promise chaining allows sequential execution with data passing.
<div
click="fetch.getData, transform.process, storage.save"
getData-url="/api/data"
process-format="json"
save-writedatapath="lastSave">
Check out the auto-mapping of function parameters.
<input
change="validation.check"
check-minLength="3"
check-pattern="[A-Za-z]+"
check-message="Letters only!">
The validation.check()
function might look like this:
validation.check = function(data, minLength, pattern, message) {
// because `data` wasn't passed in from the HTML call,
// it will be set to `undefined`
// (you could also use scrape on the form as shown below.)
// do something with minLength, pattern, and message ...
}
Download one of the following and add it to your project:
<script src="popstart-full.min.js"></script>
In this example, the form will be validated, then the user will be registered (remotely via an API call), and finally, the user will be redirected to the dashboard. If any step fails, the error handler will be called.
For example, if the validation step fails, your custom ui.showError()
function might display “Invalid input” and processing would stop there,
meaning api.register()
and ui.redirect()
would not be called.
If that succeeded but the api.register step fails, the ui.showToast
function
might display “Registration failed”.
<form
submit="validate.user, api.register, ui.redirect"
validate-required="email,password"
validate-email="email@format"
validate-error="ui.showError"
register-endpoint="/api/users"
register-error="ui.showToast"
showError-class="error-state"
showToast-duration="5000"
redirect-url="/dashboard">
<input name="email" type="email">
<input name="password" type="password">
</form>
<div class="dashboard">
<button
click="data.fetch-1, ui.updateChart-1, ui.updateTable-1"
fetch-url="/api/stats"
fetch-writedatapath="currentStats"
updateChart-target="#mainChart"
updateTable-target="#dataTable"
updateChart-error="ui.showError-1"
showError-message="Chart update failed"
showError-class="chart-error">
Refresh
</button>
</div>
<select
change="api.getOptions, transform.format-1, ui.populate"
getOptions-url="/api/choices"
format-type="dropdown"
format-writedatapath="formattedChoices"
populate-target="#dependent-select"
populate-template="option-template"
getOptions-error="ui.disable">
</select>
<div
load="ws.connect, ui.ready"
connect-url="wss://api"
connect-error="ui.offline"
ws-message="handlers.process"
process-writedatapath="lastMessage"
process-error="ui.showError">
</div>
Function calls can be comma-separated, or longer ‘promise chains’ can be broken across multiple lines for readability.
<button
click="
auth.check
auth.refresh
auth.login
"
check-token="localStorage.token"
check-error="auth.logout"
refresh-writedatapath="newToken"
login-redirect="/dashboard"
login-error="ui.loginError"
loginError-message="Session expired">
Login
</button>
Functions that aren’t already defined in the global scope can be defined in any
appropriate namespace that you’ve defined, such as api
or ui
.
<div
mouseover="check.permission-1, ui.showTooltip-1"
mouseout="ui.hideTooltip-1"
click="check.permission-2, actions.perform-1"
permission-role="admin"
permission-error="ui.disable"
showTooltip-text="Admin only"
perform-action="delete"
perform-target="record-123">
</div>
Functions that aren’t already returning a promise will be executed synchronously, as you’d expect. (Behind the scenes, those functions are wrapped in a promise, so all functions and promises are formed into promise chains.)
<div
poll="sync.check, sync.download, sync.process"
check-interval="5000"
check-writedatapath="syncStatus"
download-condition="needsSync"
process-writedatapath="lastSync"
process-error="ui.syncError">
</div>
<form
submit="validate.form-1, api.submit-1, ui.success-1"
validate-fields="all"
validate-rules="required,email,length"
validate-error="ui.fieldError"
submit-method="POST"
submit-transform="json"
success-message="Saved!"
fieldError-class="invalid">
</form>
Popstart.js is a lightweight library that enables HTML-driven programming by mapping DOM events to function calls. It allows you to define complex application logic directly in HTML attributes, without writing JavaScript code.
Here’s how it works:
Define functions in HTML attributes: Use custom attributes like click
,
submit
, change
, etc., to specify which functions to call on DOM events.
Pass arguments via attributes: Use custom attributes like fetch-url
,
submit-method
, change-pattern
, etc., to pass arguments to functions.
Arguments named event
and element
are automatically populated with the
triggering event and DOM element, and this
from the calling UI DOM elemtn
is passed in if you use a normal (function()
) instead of a lambda (()=>
)
function (which don’t support this
). Attribute names are automatically
mapped to function parameters, so change-pattern=5 change-message=“Invalid
input” will be passed as function(5,"Invalid input")
. You can optionally
prefix all attributes with data-
or x-
to avoid conflicts with other
libraries.
Chain multiple functions: Separate function calls with commas to execute
them. They will be executed in sequence, with proper Promise handling, and
any non-promise-returning functions (regular functions) will be executed
synchronously as you’d expect. If a function returns a Promise, the next
function will be called only after the Promise is resolved. If there is an
error, the error handler for that function will be called
(functionName-error="errorHandlerName"
), or a generic error handler will
be called, and the chain will stop.
writedatapath
attribute or __.data
objectChain multiple functions with commas:
<button click="auth.login, ui.update, metrics.track">Login</button>
Functions execute in sequence, with proper Promise handling.
Arguments are automatically mapped from attributes:
<button
click="api.fetch"
fetch-url="/api/data"
fetch-method="POST"
fetch-body="json">
Load Data
</button>
Special argument names:
Each function can have its own error handler:
<form
submit="api.submit"
submit-error="ui.showError"
showError-message="Submit failed!">
</form>
Save function results for later use:
<div
click="api.getData"
getData-writedatapath="lastResponse">
</div>
Access stored data with:
debug(__.data.lastResponse)
Stop event propagation or prevent default:
<a
click="handler.process"
click-stop="true"
click-prevent="true"
href="#">
Click Me
</a>
Popstart is designed to have the power of Vue.js or React, but its syntax is probably closer to Alpine.js or HTMX. It also draws inspiration from jQuery, Angular, and lodash.
Here’s a quick comparison:
Feature | Popstart | HTMX | Alpine.js |
---|---|---|---|
Function Calling | click="fn1,fn2,fn3" |
hx-get="/api" |
@click="doThing()" |
Learning Curve | Low - just HTML attrs† | Medium - new syntax | Medium-High - custom directives |
Promise Chaining | Native ✓ | No (server side generation) | Manually (using x-init ) |
Error Handling | Per-function automatic | Global only | Try/catch blocks |
Server Dependency | None | Server must return HTML | None |
State Management | Built-in data store | None | Reactive system |
Event Control | Automatic | Manual | Manual |
Key Differentiator | Chain any function | Server generates HTML | Reactive data binding |
Minified Size (after gzip) | 6KB (tiny) to 14KB (full) | 16KB | 16KB |
† You can optionally prefix your attributes with data-
or x-
to avoid conflicts with other libraries.
Security features are critical, so let’s compare those. See also the FAQ and security section below. We prioritize security issues, so if you see any, please open an issue or send an email to security@.
Security Feature | Popstart | HTMX | Alpine.js |
---|---|---|---|
Full CSP Compliance | Yes | Yes | 2024 and later |
eval() or Embeds JS directly |
No | No | Embedded JS but sandboxed |
Dynamic functions (insecure) | No | No | No |
Arbitrary JS execution | No | No | Yes (sandboxed) |
Popstart’s attribute-based approach improves maintainability and reduces the surface area of code:
Additionally, the writedatapath
pattern is:
__.data
)Popstart doesn’t allow arbitrary dynamic functions to be created and called, or
arbitrary JS to be executed as in old-style HTML embeds
(onclick="alert('xss')"
), which are rightly blocked by CSP. All functions
have to already exist and also be identified in the HTML. In order to attack,
you’d have to already have another vulnerability to chain off of, as well as
the ability to inject arbitrary js func AND modify or inject html. This is a
much higher bar than the traditional onclick
or eval
attacks.
Popstart has a limited attack surface because functions must be pre-defined and arbitrary JS can’t be executed.
If an attacker can inject arbitrary JS or HTML into your code, they could just inject a script tag with malicious code, and the application would be completely compromised, with or without Popstart or any other library.
Dynamic functions are specifically disallowed by CSP. (If you’re not using CSP, you should be!)
XSS remains a concern for all libraries of this nature, from jQuery to React. The only way to prevent XSS is to sanitize user input and properly escape output. Popstart provides a built-in sanitizer for attributes and it will automatically use DOMPurify if it’s available, but you should always sanitize user input and escape output.
In the meantime, you get:
<!-- Popstart -->
<button
click="getData, updateUI"
getData-url="/api"
getData-error="showError">
</button>
<!-- HTMX -->
<button
hx-get="/api"
hx-target="#result"
hx-swap="innerHTML">
</button>
<!-- Alpine -->
<button
x-data="{ getData() {...} }"
@click="getData().then(updateUI)">
</button>
Even though you can just natively use fetch or axios, you can also use the more powerful AJAX library that’s built in to full editions of Popstart. have native support to pass data between promises.
PopStart’s AJAX functions enable complex API interactions directly from HTML attributes, with automatic JSON parsing and data storage.
<!-- Simple GET with auto-storage -->
<button
click="__.get"
get-url="/api/users"
get-writedatapath="users">
Load Users
</button>
<!-- Chained API calls -->
<div
load="__.get, __.post"
get-url="/api/token"
get-writedatapath="auth"
post-url="/api/validate"
post-readdatapath="auth">
</div>
The get-querydatapath
attribute allows you to specify a path in the data
store where the query parameters are stored, and they will be automatically
encoded and appended to the URL.
<!-- GET with query parameters -->
<button
click="__.get"
get-url="/api/search"
get-querydatapath="searchParams"
get-writedatapath="results">
Search
</button>
Form fields scraped from the form are automatically stored in
__.data.form
by default in JSON format, and then passed to the server, all
automatically.
Errors are automatically handled or you can manually specify a custom error handler.
<!-- POST with stored form data -->
<form
submit="__.scrape,__.post, ui.showResponse"
post-url="/api/submit"
post-readdatapath="form"
post-writedatapath="response"
submit-prevent="true"
showResponse-target="#response"
showResponse-message="Submitted!">
</form>
If you have thoroughly sanitized your HTML on the server-side, you can also use direct HTML injection, similar to HTMX.
SECURITY WARNING: Direct HTML injection is a security risk and should be avoided if possible. Always sanitize user input and use proper security headers.
<!-- Direct HTML injection (Warning, XSS risks) -->
<div
click="__.getto"
getto-url="/api/template"
getto-selector="#target">
Load Template
</div>
As noted above, the AJAX functions are optional and only available in the full or custom builds.
Method | Description | Parameters |
---|---|---|
__.get |
GET request | url , opt , writedatapath , notjson , querydatapath |
__.post |
POST request | url , data , opt , readdatapath , writedatapath , querydatapath |
__.put |
PUT request | url , data , opt , readdatapath , querydatapath |
__.delete |
DELETE request | url , opt , querydatapath |
__.patch |
PATCH request | url , data , opt , readdatapath , querydatapath |
__.getto |
GET + innerHTML | url , opt , writedatapath , notjson , querydatapath , selector |
{
json: true, // Send/receive JSON
parseJSON: true, // Parse response as JSON
headers: {}, // Custom headers
withCredentials: false, // CORS credentials
auth: { // Basic auth
username: "user",
password: "pass"
},
query: {}, // Query parameters
followRedirects: false, // Follow 3xx redirects
onProgress: (evt) => {} // Progress handler
}
As noted above, the AJAX functions are optional and only available in the full or custom builds.
As with all Popstart functions, you can chain multiple AJAX or non-promise-returning calls together,
and you can pass data between many using the writedatapath
attribute or the __.data
object.
For all functions in __
, you can always drop the __.
namespace prefix if you
prefer (as shown below), which can make your HTML more readable, but beware of
naming conflicts, such as a method named post
in the global scope;
In the event of a conflict, the __. prefix will always take precedence.
<button
click="post"
post-url="/api/secure"
post-opt='{"auth":{"username":"admin","password":"secret"},"headers":{"X-Custom":"value"}}'>
Secure Post
</button>
<div
click="get"
get-url="/api/large-file"
get-opt='{"onProgress":"ui.updateProgress"}'
get-notjson="true">
Download
</div>
<form
submit="post, ui.showSuccess"
post-url="/api/submit"
post-readdatapath="formData"
post-writedatapath="response"
post-querydatapath="queryParams"
showSuccess-message="Saved!">
</form>
<button
click="get, ui.update"
get-url="/api/data"
get-writedatapath="apiData"
get-error="ui.showError"
showError-message="API Error">
Load
</button>
The optional utility functions include a function called incrHTML that increments the innerHTML of an element from a start value to an end value. (It’s really designed for a select dropdown for things like years and months, but it’s just an incrementer and could be used for other things.)
Notice how it can be used twice in the same startup tag, and the selectors are different. Just append -1, -2, etc to the function and its attributes to differentiate them. Because - is not a valid character in a function name or variable name in Javascript, it’s safe to use as a separator and works naturally in this context.
<div startup=__.incrHTML-1,__.incrHTML-2
incrHTML-selector-1=.yearsource
incrHTML-replaceValue-1=1900
incrHTML-start-1=2016
incrHTML-end-1=1900
incrHTML-step-1=-1
incrHTML-selector-2=.daysource
incrHTML-replaceValue-2=1
incrHTML-start-2=1
incrHTML-end-2=31
incrHTML-step-2=1>
<select class="daysource form-select form-control-alt rnd wauto iblock" name=day placeholder=Day aria-label="Please select day of birth">
<option>1</option>
</select>
<select class="yearsource form-select form-control-alt rnd wauto iblock" name=year placeholder=Year aria-label="Please select year of birth">
<option>1900</option>
</select>
</div>
Any function can be called from HTML attributes - no registration needed. Functions are automatically Promise-wrapped for proper sequencing.
Unlike traditional JS frameworks, Popstart doesn’t require you to define functions in a specific way or register them with the library. You can call any function that’s in scope, whether it’s a global function, a function in a namespace, or a function defined in the same or different file (as long as it’s loaded before the DOM event is triggered).
SECURITY NOTE: Popstart doesn’t allow arbitrary dynamic functions to be
created and called, or arbitrary JS to be executed as in old-style HTML embeds
(onclick="alert('xss')"
). All functions have to already exist and also be
identified in the calling html. In order to attack, you’d have to already have
another vulnerability to chain off of, as well as the ability to inject
arbitrary js func AND modify or inject html. This is a much higher bar than the
traditional onclick
or eval
attacks. Under CSP, you can’t use inline JS, so
you’re already protected from that. If someone could inject arbitrary JS or
modify your html, you’ve already been fully compromised and it’s game over, and
no library can protect you.
// 'this' is automatically set to the triggering DOM element
const updateElement = function(text, color) {
this.innerHTML = text
this.style.color = color
}
Calling that function from HTML:
<div
click="updateElement"
updateElement-text="Hello!"
updateElement-color="red">
Click me
</div>
Arrow Functions don’t support this
, but can otherwise
be used in the same way as regular functions.
As with regular functions, if they return a Promise, the next function in the chain will be called only after the Promise is resolved. If they do not return a promise, then the function will be wrapped in a promise automatically so they can be called in the correct order.
window.updateElement = (text, color) => {
document.getElementById('div').innerHTML = text
document.getElementById('div').style.color = color
}
Here’s a toy example that would throw away the JSON that was fetched but, if a successful HTTP status code was returned, the updateElement function would be called and update the element with the text “Done!”:
Here’s the function that returns a Promise:
fetchJSON = (url, method = 'GET') => fetch(url, { method }).then(r => r.json());
Here’s an updateElement function (it will be wrapped in a Promise automatically):
updateElement = function(text){this.innerHTML = text}
Here’s the HTML to call those functions:
<button
click="fetchJSON, updateElement"
fetchJSON-url="/api/data"
updateElement-text="Done!">
Load
</button>
If there was an error in the fetchJSON function, the global default error handler (error()
)
would be called.
Functions can live anywhere in the global scope or namespaces - Popstart will find them automatically.
window.api = {
getData: (endpoint) => fetch(endpoint),
process: function(data) {
this.textContent = data // 'this' works in regular functions
}
}
If you create a namespace in the global window
object named api
, you can
call functions from that namespace by prefixing the function name with the
namespace followed by a period, like api.getData
in the example above:
<div
load="api.getData, api.process"
getData-endpoint="/api/status">
</div>
Configure additional attribute prefixes:
__.config.AttrPrefixes.push('data-')
Add custom argument names that receive the event:
__.config.eventNameArgs.push('evt')
Add custom argument names that receive errors:
__.config.errorNameArgs.push('err')
Access last operation data:
__.state.lastOp.last // Last function result
__.state.lastOp.ev // Last event
__.state.lastOp.this // Last element
Q. How does this compare to traditional JS frameworks?
A. Popstart is a different approach to frontend development:
Compared to React or Vue.js, Popstart is a lightweight, HTML-driven library that works directly in the DOM, which is much faster and more flexible and allows you to write complex application logic directly in HTML attributes.
Popstart also lets you modify the DOM outside of Popstart’s control. It’s not all or nothing! Popstart plays nicely with other libraries and frameworks. You can use Popstart with jQuery, HTMX, Alpine.js, or any other library. (In the full build or in your custom build, Popstart includes an optional DOM mutation observer to detect changes to the DOM and re-apply Popstart to new elements.)
Please see Comparison Table above for more details compared to Alpine.js and HTMX.
Attribute | Description | Example |
---|---|---|
click | Mouse click | click=“auth.login, ui.update” |
submit | Form submission | submit=“validate.form, api.send” |
change | Value change | change=“data.update” |
load | Element loaded | load=“init.setup” |
startup | Document loaded | startup=“app.init” |
Suffix | Purpose | Example |
---|---|---|
-error | Error handler | login-error=“ui.showError” |
-prevent | Prevent default | click-prevent=“true” |
-stop | Stop propagation | click-stop=“true” |
-writedatapath | Store result | fetch-writedatapath=“userData” |
The double-underscore __
object contains all the shared data, configuration settings,
state information, and utility functions.
__.data // Shared data store
__.state.lastOp // Last operation details
__.config // Configuration settings
__.config = {
AttrPrefixes: ['data-', 'x-'], // Optional attribute prefixes
eventNameArgs: ['event', 'e'], // Event argument names
errorNameArgs: ['error', 'err'], // Error argument names
debug: false // Enable debug logging
}
Function | Purpose |
---|---|
debug() | Development details |
info() | General information |
warn() | Non-fatal warnings |
error() | Errors |
danger() | Critical errors |
Function | Purpose | Example |
---|---|---|
__.incrHTML | Generate incremental HTML | incrHTML-start=“1” incrHTML-end=“10” |
__.getEl | Element selector | getEl(‘.myClass’) |
__.clone | Deep clone objects | clone(myObject) |
The build process (if desired) is a simple shell script. If you want to exclude
a file from the build, just add that filename to the file exclude.txt
before
running ./build.sh
.
The build.sh simply walks through the files in the src
directory, concatenates
them, and minifies them with minify
(sudo apt-get install minify
)
MIT Licensed, Copyright © by Popchat Inc.
PRs welcome! Popstart is feature complete and the core library is not likely to
receive any additional features, but bug fixes and performance improvements are
always welcome. Please add features to the higher-numbered files in src/
as
appropriate, or create new files as needed.
The examples in this README were partly written by AI, so please open an issue if you see any errors.