0.2.0 • Published 9 months ago

htmx-router v0.2.0

Weekly downloads
-
License
MIT
Repository
github
Last release
9 months ago

htmX Router

A remix.js style file path router for htmX websites

This library attempts to be as unopinionated as possible allowing for multiple escape hatches in-case there are certain times you want a different style of behaviour.

This library does not rely on any heavy weight dependencies such as react, instead opting to be built on the lighter weight kitajs/html library for it's JSX rendering, and using csstype, just as a type interface to improve developer ergonomics.

You can also see an example site running this library here with source code as an extra helpful example. Please be mindful the slow loading of this site is actually due to Discord APIs, and the rendering is taking less than 2ms on a raspberry pi on my floor.

Routes

There are two requirements for this package behave correctly, you need a root.jsx/.tsx, in the same folder as a routes sub-folder. Plus any given route must have use the route name provided as the top element's id - which will be explained more in later.

URLs are resolved based on the file structure of yur routes folder, you can choose to use nested folders or .s to have all of your files in a single folder if you choose - all examples will use the . layout for simplicity.

If the url path /user/1234/history is requested, the router will create an outlet chain to your file tree (this chain is actually just an array of modules, and the library works based on stack operations to reduce recursion and increase response times).

root.tsx
routes
├── _index.tsx
├── user.tsx
├── user.static-path.tsx
└── user.$userID.tsx

Given the file tree above, when the root function calls it's Outlet function, that will trigger user to render, and when user calls it's Outlet function, that will trigger user.$userID.tsx to render as this sub route didn't match with any of the static options available, so instead it matched with the wild card route.

Since there is no user.$userID.history.tsx file, if user.$userID.tsx calls Outlet it will trigger a 404 error, which is actually generated by an internal hidden route placed at the end of an Outlet chain when it is not able to match the rest of a given URL.

If we request /user, the root route will render, and the user.tsx route will render, with user.tsx's Outlet function actually returning a blank string. If we instead want this request to throw an error we should add a user._index.tsx file which on render always throws a ErrorResponse.

Module Layout

The router will look for three functions when reading a module, Render, CatchError, and Auth. Any combination of all or none of these functions are allowed, with the only exception being your root.tsx which must have a Render and a CatchError function.

Auth Function

export async function Auth({shared}: RenderArgs) {
	if (!shared.auth?.isAdmin) throw new ErrorResponse(401, 'Unauthorised', "Unauthorised Access");
	return;
}

This function is ran on all routes resolved by this file, and it's child routes - no matter if the route itself is masked or not rendered. This function must return nothing, and instead signals failure via throwing an error. With the successful case being nothing was thrown.

Render Function

export async function Render(routeName: string, {}: RenderArgs): Promise<string>

The render function should follow the above function signature, the routeName string must be consumed if you're rendering a valid output, with the top most HTML element having the id assigned to this value. This helps the router dynamically insert new routes when using the Link component.

Note that a render function may not always run for a given request, and may sometimes be ommited when the router determines the client already has this information in their DOM, and instead only response with the new data and where it should be inserted.

For authorisation checks which must always run for a given URL, please use the Auth function

Optionally this function can also throw, in this case the thrown value will boil up until it hits a CatchError, unless it throws certain types from this library such as Redirect or Override which will boil all the way to the top without triggering any CatchError functions.

This allows a given route to return an arbitrary response without being nested within it's parent routes.

CatchError Function

export async function CatchError(rn: string, {}: RenderArgs, e: ErrorResponse): Promise<string>

This function behaves almost identically to Render in the way that it's results will be embed within it's parents unless an Override is thrown, and it takes a routeName which must be used in the root HTML element as the id. And it is given RenderArgs.

However this function must not call the Outlet function within the RenderArgs, as that will attempt to consume a failed child route as it's result.

Router

The router itself can be generated via two different ways through the CLI from this library, dynamically or statically.

npx htmx-router ./source/website --dynamic

When the command is ran it will generate a router based on the directory provided which should contain your root file and routes folder. This command will generate a router.ts which we recommend you git ignore from your project.

  • Static: The static build will read your directories and statically import all of your routes into itself, allowing for easy bundling with tools such as esbuild
  • Dynamic: Will instead generate a file will on startup will read your directory every time, and dynamically import your routes, which will make it unsuitable for use with webpackers, but allows for quick revisions and working well with tools such as nodemon.

Once your router is generated you can simply import it and use it like the example below:

const url = new URL(req.url || "/", "http://localhost");
const out = await Router.render(req, res, url);
if (out instanceof Redirect) {
	res.statusCode = 302;
	res.setHeader('Location', out.location);
	return res.end();
} else if (out instanceof Override) {
	res.end(out.data);
} else {
	res.setHeader('Content-Type', 'text/html; charset=UTF-8');
	res.end("<!DOCTYPE html>"+out);
}

The Router.render function may output three possible types Redirect, Override, or a simple string. These two non-string types are to allow the boil up of overrides within routes, and allows you do handle them specific to your server environment.

Types

RenderArgs

This class has been designed to work well with object unpacking for ease of use, typically it's used in a style like this for routes that only need information about the req and res objects.

export async function Render(rn: string, {req, res}: RenderArgs) {
  return "Hello World";
}

However it also includes a bunch of functions to help with higher order features.

Outlet

The outlet function will call the child route to render, this function is asynchronous and will always return a string, or else it will throw. If there is nothing left in the outlet chain, it will return an empty string.

setTitle

This function will update the title that will be generated by the renderHeadHTML function, as well as the trigger value for the title updater when using the Link component.

You should consider when you call this function in conjunction to your Outlet function, because if you run setTitle, after the outlet has been called it will override the title set by the child.

export async function Render(rn: string, {setTitle, Outlet}: RenderArgs) {
	setTitle("Admin Panel");

	return <div id={rn}>
		<h1><Link to="/admin" style="color: inherit">
			Admin Panel
		</Link></h1>
		{await Outlet()}
	</div>;
}

addMeta

This function allows you to add meta tags which will be rendered by the renderHeadHTML function.

addMeta([
	{ property: "og:title", content: `${guild.name} - Predictions` },
	{ property: "og:image", content: banner }
], true);

If the second argument of this function is set to true this function will override any meta tags currently set, replacing them with the inputted tags instead.

addLinks

This function behaves identically to addMeta but instead designed for link tags.

renderHeadHTML

This renders out the set meta and link tags for use in the root.tsx module, it also includes an embed script for updating the title for dynamic loads from the Link component.

shared

There is also a blank object attached to all RenderArgs for sharing information between routes.

This can be used for various purposes, but one example is to hold onto decoded cookie values so that each session doesn't need to recompute them if they already have.

Such an example would look like this

import type { IncomingMessage } from "node:http";
import * as cookie from "cookie";

export function GetCookies(req: IncomingMessage, shared: any): Record<string, string> {
	if (shared.cookie) return shared.cookie;

	shared.cookies = cookie.parse(req.headers.cookie || "");
	return shared.cookies;
}
import type { GetCookies } from "../shared/cookie.ts";

export function GetCookies(rn: string, {shared}: RenderArgs) {
	const cookies = GetCookies(shared);
  // do stuff....
}

ErrorResponse

This class is a way of HTTP-ifying error objects, and other error states, if an error is thrown by a Render or an Auth function that isn't already wrapped by this type, the error will then become wrapped by this type.

export class ErrorResponse {
	code   : number;
	status : string;
	data   : any;
}

Override

If a render function throws a value of this type, it will boil all the way up to the original render call allowing it to bypass any parent manipulation.

export class Override {
	data : string | Buffer | Uint8Array;
}

Redirect

This type behaves identically to override by is intended for boiling up specifically http redirect responses.

export class Redirect {
	location: string;
}

Components

Link

export function Link(props: {
	to: string,
	target?: string,
	style?: string
}, contents: string[])
<Link to={`/user/${id}`}>View Profile</Link>

This component overrides a normal <a>, adding extra headers telling the server route it is coming from, based on this information the server can determine the minimal route that needs to be rendered to send back to the client and calculates just that sub route.

Sending it back to the client telling htmX where to insert the new content.

This element will encode as a standard <a> with some extra html attributes, meaning it won't affect SEO and bots attempting to scrape your website.

StyleCSS

DEPRECATED: If you utilize @kitajs/html instead of typed-html this function is no longer needed

This is a helper function allowing you to give it a CSS.Properties type, and render it into a string for kitajs/html to use.

<div style={StyleCSS({
  height: "100%",
  width: "100%",
  fontWeight: "bold",
  fontSize: "5em",
})}>
  I AM BIG
</div>
0.2.0

9 months ago

0.1.3

9 months ago

0.1.2

9 months ago

0.1.1

9 months ago

0.1.0

10 months ago

0.0.14

10 months ago

0.0.13

10 months ago

0.0.12

10 months ago

0.0.11

10 months ago

0.0.10

10 months ago

0.0.9

10 months ago

0.0.8

10 months ago

0.0.7

10 months ago

0.0.6

10 months ago

0.0.5

10 months ago

0.0.4

10 months ago

0.0.3

10 months ago

0.0.2

10 months ago

0.0.1

10 months ago