xpine
v0.0.62
Published
Combines JSX with Alpine.js for a simpler, easier development experience. Includes a static site generator.
Readme
XPine - Alpine.js + JSX + Tailwind framework
Combines JSX with Alpine.js for a simpler, easier development experience. Includes a static site generator.
Quick start
Scaffold a new app with pages, demo content, and an API endpoint already set up:
npx create-xpine-app my-app
cd my-app
npm install
npm run devInstall
npm install xpine
Routing, page setup, and using Alpine.js
XPine uses page based routing. Render an HTML page using JSX components, for example the path /src/page/about.tsx will route to /about and /src/page/index.tsx will route to /
import { WrapperProps } from 'xpine/dist/types';
import Base from '../components/Base';
export const config = {
data() {
return {
title: 'Home page',
description: 'The description',
}
},
wrapper({ req, children, data, routePath }: WrapperProps) {
return (
<Base
title={data?.title || 'My awesome website'}
description={data?.description}
req={req}
>
<h1>Home page wrapper</h1>
{children}
</Base>
)
},
}
export default function Home() {
return (
<div x-data="HomePageData" x-on:click="logMessage">
Hello world
</div>
);
}
<script />
export function HomePageData() {
return {
logMessage() {
console.log('Hello world');
}
};
}- src/components/Base.tsx
import { JsxElement } from 'typescript';
import { ServerRequest } from 'xpine/dist/types';
type BaseProps = {
head?: JsxElement;
title: string;
description?: string;
req?: ServerRequest;
children?: JsxElement;
}
export default async function Base({
head,
title,
description,
children,
}: BaseProps) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="index,follow" />
<meta name="description" content={description || `${title} page`} />
<title>{title || 'My Website'}</title>
<link rel="stylesheet" href="/styles/global.css" />
<script defer src="/scripts/app.js"></script>
{head}
</head>
<body>
<div id="xpine-root">
{children}
</div>
</body>
</html>
);
}Client side Javascript and CSS files
In the above code, we have a /styles/global.css file and a /scripts/app.js file import.
CSS
Create a file called /src/public/styles/global.css and import Tailwind:
@import "tailwindcss";Note you must install tailwindcss yourself: npm install tailwindcss
Javascript
Create a file called /src/public/scripts/index.ts:
import Alpine from 'alpinejs';
Alpine.start();Note you must install alpinejs yourself: npm install alpinejs
Dynamic routing
Create dynamic routes with paths similar to this /src/pages/[pathA]/[pathB]
Express API endpoints
Create regular Express routes by using .ts file extensions in the /src/pages folder. Specify the HTTP method by naming the file something like /src/pages/api/my-endpoint.POST.ts
import { PageProps } from 'xpine/dist/types';
export default async function myEndpoint({ res }: PageProps) {
res.status(200).json({
message: 'Hello World!',
});
}Catch all routes
You can create catch all routes by naming the file _all_.(jsx|tsx|js|ts). You can make static catch all route pages by using the param 0 in the staticPaths config function:
export const config = {
staticPaths() {
return [
{
0: 'hello/world',
}
]
},
}You can get the route param in your function with req.params[0], such as how express handles catch all routes.
Multi-segment dynamic routes ([...slug])
Catch all (_all_) routes register a single Express wildcard, which means they match every path under their prefix — including paths that should not exist. For deeply nested but known dynamic routes (e.g. a blog post at /blog/technology/devops/my-blog-post), use a multi-segment dynamic param instead. Name the file [...slug].(jsx|tsx|js|ts); the captured value (which may contain slashes) is available as req.params.slug.
Instead of a wildcard, XPine registers an explicit Express route for each slug returned by the staticPaths config function, so unknown paths safely fall through to your 404 page:
// /src/pages/blog/+config.ts
export default {
staticPaths() {
// Slugs can come from a CMS/database
return [
{ slug: 'technology/devops/my-blog-post' },
];
},
};// /src/pages/blog/[...slug].tsx
import { PageProps } from 'xpine/dist/types';
export default function BlogPost({ req }: PageProps) {
return <div>{req.params.slug}</div>;
}Each slug from staticPaths is also statically generated at build time. To allow slugs that are not known at build time (without falling back to an unsafe catch-all), add an isValid function to the config. It receives the requested slug and runs at request time; return false (the default for unknown slugs) to fall through to the 404 handler:
export default {
staticPaths() {
return [{ slug: 'technology/devops/my-blog-post' }];
},
// Resolve newly-published slugs without a rebuild
async isValid(slug, req) {
return await postExists(slug);
},
};Route specific middleware
If you need route specific middleware, e.g. for file uploads, you can specify a routeMiddleware function in a config variable in the endpoint file:
export const config = {
routeMiddleware(req, res, next) {
console.log('route middleware');
next();
}
}CSRF protection
Because authentication is cookie based, state-changing requests (POST/PUT/PATCH/DELETE) are exposed to cross-site request forgery. Enable the built-in guard by setting csrf in your xpine.config.mjs:
export default {
csrf: true,
}It uses a stateless signed double-submit cookie. On safe requests (GET/HEAD/OPTIONS) it sets a csrfToken cookie (readable by client JS). On state-changing requests it requires that same token to be echoed back in an x-csrf-token header (or a _csrf form field) — a cross-site attacker can make the browser send the cookie but cannot read it or set the header, so forged requests are rejected with a 403. The token is HMAC-signed so it can't be forged even by an attacker who can plant a cookie.
Set a secret via the CSRF_SECRET env var (it falls back to JWT_PRIVATE_KEY). The server fails to start if neither is set.
For fetch/Alpine requests, read the cookie and send it back:
const token = document.cookie.split('; ').find(c => c.startsWith('csrfToken='))?.split('=')[1];
await fetch('/api/thing', {
method: 'POST',
headers: { 'x-csrf-token': decodeURIComponent(token) },
});For server-rendered HTML forms, the token is available on res.locals.csrfToken (pages receive res); render it as a hidden <input name="_csrf">.
You can customize the cookie/header/field names, cookie attributes, and skip specific path prefixes (e.g. signed webhooks):
export default {
csrf: {
headerName: 'x-csrf-token',
cookie: { sameSite: 'strict', secure: true },
ignorePaths: ['/api/webhooks/'],
},
}HTML escaping (XSS protection)
Values interpolated into JSX are HTML-escaped by default, so rendering user input is safe:
// req.params.slug = '<img src=x onerror=alert(1)>'
<div>{req.params.slug}</div> // renders <img src=x onerror=alert(1)>Nested components and elements are not re-escaped, and attribute values are escaped too. Text inside <script>/<style> is left raw (it isn't HTML) — never interpolate untrusted data there.
If you have trusted HTML that should render as-is, opt out explicitly with raw():
import { raw } from 'xpine';
<div>{raw(trustedHtmlString)}</div>Only use raw() with HTML you control — never with user input.
Static Site Generation
Generate path specific static pages by specifying in the config of either the page's file, such as /src/pages/about.tsx with a config export:
export const config = {
staticPaths: true,
data() {
return {
title: 'My title'
}
}
}or a +config.ts file.
Configs
Configs can be nested. Create a +config.ts file in a directory and all subfolders will inherit that config unless overridden by their own +config.ts files. Want to apply static paths to an entire folder except for a single folder? In that folder you can create a +config.ts file like this:
export default {
staticPaths: false,
}Dynamic Static Pages
You can also create dynamic static pages by using a function in the staticPaths folder. For example, a directory named /src/[pathA]/[pathB]/[pathC]/[pathD].tsx might have a configuration file like this:
import { ServerRequest } from 'xpine/dist/types';
import axios from 'axios';
export const config = {
staticPaths() {
return [
{
pathA: 'my-path-a2',
pathB: 'my-path-b2',
pathC: 'my-path-c2',
pathD: '2'
}
]
},
async data(req: ServerRequest) {
const url = `https://jsonplaceholder.typicode.com/posts/${req.params.pathD}`;
try {
const { data } = await axios.get(url);
return {
...data,
...req.params,
};
} catch (err) {
console.error('could not fetch', url);
return {
...req.params,
data: {},
}
}
}
}Context
Create app context, useful for things like Navbars. In /src/context.tsx:
import { createContext } from 'xpine';
export function NavbarContext() {
const navbar = createContext([]);
return {
navbar,
}
}then in a page, say /src/pages/about.tsx, you can add to the NavbarContext like this:
export function xpineOnLoad() {
context.addToArray('navbar', 'My awesome context 1', 2);
context.addToArray('navbar', 'My awesome context 2', new Date('January 11, 2024'));
context.addToArray('navbar', 'My awesome context 3', new Date('January 10, 2024'));
context.addToArray('navbar', 'My awesome context 4', new Date('January 30, 2024'));
}Context is sorted by array position then by date. You can then use context in your component like this:
import { context } from 'xpine';
export default function Navbar() {
const navbar = context.get('navbar');
return (
<div>
{navbar.map(item => {
return <div>{item}</div>
})}
</div>
);
}Server set up
- src/server/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import logger from 'morgan';
import http from 'http';
import { createXPineRouter, setupEnv } from 'xpine';
await setupEnv();
export default async function startServer() {
const port = process.env.PORT || 8080;
const app = express();
app.set('view engine', 'html');
app.enable('trust proxy');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true, }));
app.use(cookieParser());
await createXPineRouter(app);
app.set('port', port);
const server = http.createServer(app);
server.listen(port, () => {
console.info(`Server listening on port ${port}`);
});
return {
app,
server,
};
}Add a directory in /src/server called run, and create two files: /src/server/dev.ts and /src/server/prod.ts.
dev.ts should look like this:
import { runDevServer } from 'xpine';
runDevServer();and can be run with an npm command like this in your package.json scripts: "dev": "PORT=8888 LOCALHOST=1 xpine-dev".
The prod.ts should look like this:
import startServer from '../app';
await startServer();and can be run with an npm command in your package.json scripts like this, after the app has been built with xpine-build:
"start": "PORT=8888 node ./dist/server/run/prod.js"
xpine.config.mjs file
Add an xpine.config.mjs file to your root directory. This is used primarily for configuring/changing file paths. Configs can be imported with import { config } from "xpine".
export default {}These are the following default paths:
rootDir: process.cwd
srcDir: rootDir + ./src
distDir: rootDir + ./dist
packageJsonPath: rootDir + ./package.json
distPublicDir: distDir + ./public
distPublicScriptsDir: distPublicDir + ./scripts
distTempFolder: distDir + ./temp
clientJSBundlePath: distPublicScriptsDir + ./app.js
alpineDataPath: distTempFolder + ./alpine-data.ts
serverDistDir: distDir + ./server
serverDistAppPath: serverDistDir + ./app.js
pagesDir: srcDir + ./pages
distPagesDir: distDir + ./pages
publicDir: srcDir + ./public
serverDir: srcDir + ./server
runDir: serverDir + ./run
serverAppPath: serverDir + ./app.ts
globalCSSFile: publicDir + ./styles/global.cssSPA interactivity
- data-spa="true"
- transforms link into client side URL update
- data-persistent="id"
- makes element persistent across pages that include the same persistent data-persistent tag
- data-spa-crossorigin="true"
- enables cross-origin spa navigation requests (untested)
API
Build command
xpine-build
Dev server command
xpine-dev
Auth
import { signUser, verifyUser } from 'xpine';
Config
import { config } from 'xpine';
Env
import { setupEnv } from 'xpine';
await setupEnv();
setupEnv also supports AWS secrets manager. Simply add SECRETS_NAME=your_aws_secret_name to your .env.{stage} file
Custom events
- spa-update-page-content
- sent when the page content has update
- spa-update-page-url
- sent when the page URL has update
- spa-link-click
- sent when link initially gets clicked and when link is done updating content
- the "state" value in the event detail will be "start" or "end"
- breakpoint-change
- sent when a breakpoint is changed via your Tailwind css file's breakpoints in the @theme directive, e.g.
@theme { --breakpoint-xl: 1184px; --breakpoint-sm: 640px; --breakpoint-md: 768px; --breakpoint-lg: 1024px; } - this will send a custom event with the detail being the breakpoint, such as
{ breakpoint: 'xl' } - note this will only send an event based on the values of the breakpoints in your @theme configuration
- sent when a breakpoint is changed via your Tailwind css file's breakpoints in the @theme directive, e.g.
Custom scripts for certain pages
- Add script to src/public/scripts/pages/your_script.ts
- Import script into page HTML (e.g.
<script src="/scripts/pages/your_script.ts">) - To unload event listeners, use
window.addEventListener('spa-update-page-url', () => { remove event listeners here})in the code
Separate client side bundles
You can create separate client side bundles in xpine.config.mjs like this:
export default {
bundles: [
{
id: 'site',
excludePaths: [
// Excludes SecretPageData
'/**/pages/secret/**/*.{js,ts,tsx,jsx}',
'/**/pages/secret/*.{js,ts,tsx,jsx}',
],
},
{
id: 'secret-page',
includePaths: [
// Only uses Alpine data coming from paths in this directory
'/**/pages/secret/**/*.{js,ts,tsx,jsx}',
],
requireAuthentication: true, // Only authenticated users can access this .js file
}
]
}Pages public scripts
If you need to add a one-off public script, add a folder in the /public/scripts directory called pages.
For example: /public/scripts/pages/my-awesome-page/sw.js
Which will result in the public path of /scripts/pages/my-awesome-page/sw.js
Any .js or .ts file in this directory will be added as-is to the public folder and can be used in a script tag:
<script src="/scripts/pages/my-awesome-page/sw.js" />Standalone public files
If you need to add a file that needs direct routing, like a service worker file, add a folder in the /public directory called standalone.
For example : /public/standalone/my-page/sw.js
Which will result in the public path of /my-page/sw.js
