How it all fits together.

index97 is a small set of cooperating systems. Each one does one thing.

File-based routing

At startup, index97 scans your pages directory recursively and builds a route table. Every file with a recognized extension becomes a route.

pages/ _layout.html ← layout wrapper (not a route) _layout.js ← layout data provider (not a route) index.phtml → GET / about.phtml → GET /about contact.html → GET /contact (served as-is) blog/ index.js → GET,POST /blog index.phtml ← template for blog/index.js [slug].js → GET,DELETE /blog/:slug [slug].phtml ← template for [slug].js public/ ← static files, served directly style.css

Route types

ExtensionTypeBehavior
.phtml page Server-rendered template. Optionally paired with a .js sibling for data. Wrapped in the nearest _layout.html.
.html document Served as a full HTML document. No layout applied. Good for self-contained pages like error pages.
.js handler Exports named HTTP method functions. Paired with a .phtml or .html sibling for rendering. Wins over .html at the same pattern.
.md markdown Rendered via Bun's built-in markdown renderer. YAML front matter maps keys to layout slots.

Naming conventions

PatternMeaning
_filenamePrivate — not a route. Used for layouts, auth helpers, DB modules, partials.
[param]Dynamic segment — matches any URL segment, available as req.params.param.
indexMaps to the directory root (/blog/index.phtml/blog).

Request lifecycle

For a handler route (.js with a .phtml template):

  1. Request arrives at Bun.serve()
  2. resolveMethod() checks for _method form field and rewrites the method
  3. The exported handler function (GET, POST, etc.) is called with the request
  4. If it returns a plain object, the object is merged with the .phtml template via render(), and the data is retained
  5. resolveIncludes() processes any <include> tags in the rendered HTML, with the handler's data available for @key attribute references
  6. findLayout() walks up directories to find the nearest _layout.html
  7. applyLayout() extracts named slots and injects content into the layout
  8. If a _layout.js exists, its data(req) is called and the result renders layout-level variables
  9. rewriteFormMethods() converts non-standard form methods to POST + hidden _method field
  10. In dev mode, the HMR script is injected
  11. The final HTML is returned as a Response

Layout system

_layout.html acts as a wrapper for all pages in its directory and subdirectories. index97 walks up from the current page's directory until it finds one (or reaches the pages root).

Pages populate layout slots using <template data-slot="name"> elements. The layout references them with default value.

A sibling _layout.js can export data(req) to provide request-aware data to the layout itself — used for things like session-aware navigation.

Partials system

The <include src="partial.phtml"> tag includes server-side partials. Data is passed explicitly via attributes. The parent's data context — whether from a .phtml page handler or a .js route handler — is always available for @key resolution:

SyntaxMeaning
attr="@key"Pass data.key from the parent context (the @ sigil resolves from parent data)
attr="literal"Pass the string "literal" as a value
(no attrs)The partial inherits the full parent data context

Error pages

index97 looks for error pages by walking up from the failing route's directory:

  • 404 errors → _404.html
  • All other errors → _error.html

This means a /blog/_404.html handles 404s within the blog section, while the root /_404.html catches everything else. Error pages receive {{status}}, {{title}}, and {{message}} variables.

Hot reload

In dev mode, index97 watches the pages directory with Bun's file watcher. Changes are broadcast to connected browsers via SSE at /__index97_hmr. The injected client script handles two cases:

  • CSS changes — re-stamps the <link> href with a ?t=timestamp query string, forcing the browser to re-fetch the stylesheet with no page reload and no scroll reset.
  • All other changes — fetches the updated page HTML, parses it with DOMParser, then morphs the live DOM in place. Attributes, text nodes, and child elements are patched recursively. No full page reload, no lost state.

Form method override

HTML forms only natively support GET and POST. index97 provides two-way method override:

  • Server rendering: <form method="DELETE"> is rewritten to method="POST" with a hidden <input name="_method" value="DELETE">
  • Request handling: resolveMethod() reads the _method field and constructs a new Request with the correct HTTP method before dispatching to the handler