Home Documentation Templates Examples Showcase GitHub ↗
Theme

Architecture · Deep dive

How Nift works.

Nift is easiest to understand as a small dependency-aware build engine wrapped around ordinary files. It does not own your frontend runtime. It knows which outputs you care about, how each output is composed, which files and directories influence it, and enough previous-build state to decide whether that output needs to be produced again.

The whole model in one sentence.

Tracked item → content + template → parsed composition → dependencies → output + build metadata. On the next build, Nift compares those recorded inputs with the filesystem and only repeats the pipeline for affected items.

The architecture at a glance

                         .nift/config.json
                                │
                                ▼
                         project defaults
                                │
.nift/tracked.json ──────► tracked items ◄──── watched directories
                                │
                ┌───────────────┼────────────────┐
                │               │                │
                ▼               ▼                ▼
          content file      template file    *.deps.json
                │               │                │
                └───────┬───────┘                │
                        ▼                        │
                    Nift parser ◄────────────────┘
                        │
            ┌───────────┼────────────┐
            │           │            │
            ▼           ▼            ▼
        rendered     discovered    build error
         output      dependencies   + location
            │           │
            └─────┬─────┘
                  ▼
          page build metadata
                  │
                  ▼
       status / build-updated
                  │
          compare current state
                  │
             rebuild?
          yes ────┴──── no
           │             │
           ▼             ▼
        build page     skip page

That diagram is deliberately less impressive than the architecture diagrams of many web frameworks. That is a feature. Most of the web project remains normal HTML, CSS, JavaScript, TypeScript, images, data files, backend code and whatever other tools you choose.

The important boundaries

The architecture stays understandable because Nift keeps several responsibilities separate. Project state says what is tracked. Parsing says how an item becomes output and which dependencies were encountered. Incremental analysis says whether that work needs to happen again. Filesystem mutation commands update project state without turning rendering into a stateful template runtime.

BoundaryResponsibilityWhy it matters
Project / trackingResolve tracked identities, configured directories, extensions, templates and watched state.The parser does not need to invent project identity from arbitrary filenames.
Parser / rendererTransform template + content + immutable data into output while reporting dependencies and structured errors.Rendering can remain local and explainable instead of mutating global project state.
Dependency stateRemember which files/directories influenced a successful render and the state used to compare them later.Incremental decisions can happen before unnecessarily rendering every page.
Filesystem / CLITrack, copy, move, remove, watch and build while validating collisions and paths.Destructive operations and output ownership are handled deliberately rather than as parser side effects.

Build-time control flow is intentionally not a scripting runtime

@json, @for and @if add enough structure to render data-driven static output, but their architectural role is still parsing. JSON bindings are scoped data, loop variables are lexical bindings, and conditions select output. The model remains data + template → output; it does not require mutable program state, user-defined functions or a runtime shipped to the browser.

project JSON
     │
     ▼
immutable binding
     │
 ┌───┴──────────────┐
 │                  │
 ▼                  ▼
@for iteration    @if selection
 │                  │
 └───────┬──────────┘
         ▼
    rendered text
         │
         └── dependencies recorded for future builds

1. The project is ordinary files plus a small amount of state

A typical project might look like:

my-site/
├── .nift/
│   ├── config.json
│   ├── tracked.json
│   └── ...per-page build metadata...
├── content/
│   ├── index.html
│   ├── about.html
│   ├── dashboard.html
│   └── dashboard.deps.json
├── templates/
│   ├── template.html
│   └── partials/
│       ├── head.html
│       ├── header.html
│       └── footer.html
├── public/
│   ├── assets/
│   └── ...generated outputs...
├── src/
│   └── app.ts
└── package.json

Only a few of those files are Nift-specific. Your TypeScript compiler can still own src/. npm can still own package.json. A Go backend can live beside the frontend. Nift's job is not to replace those systems; it is to provide the composition and dependency-aware generation layer.

config.json: project defaults

{
  "content-dir": "content/",
  "content-ext": ".html",
  "output-dir": "public/",
  "output-ext": ".html",
  "default-template": "templates/template.html",
  "incremental-mode": "hybrid",
  "build-threads": -1
}

These values answer mundane but important questions once: where content lives, where output goes, which template is the default, and how aggressively incremental builds should detect changes.

tracked.json: the outputs Nift owns

{
  "tracked": [
    {
      "name": "/",
      "title": "Home",
      "template": "templates/template.html"
    },
    {
      "name": "about",
      "title": "About",
      "template": "templates/template.html"
    },
    {
      "name": "docs/getting-started",
      "title": "Getting started",
      "template": "templates/docs.html"
    }
  ]
}

The tracked name is the stable identity. Nift resolves that identity through the configured content/output directories and extensions. This is why commands can talk about docs/getting-started rather than forcing you to repeat concrete input and output filenames everywhere.

2. A build starts from a tracked item

Suppose Nift is building:

name:      docs/getting-started
title:     Getting started
content:   content/docs/getting-started.html
template:  templates/docs.html
output:    public/docs/getting-started.html

The template might be:

<!doctype html>
<html lang="en">
<head>
  @input('templates/partials/head.html')
  <title>$[title]</title>
</head>
<body>
  @input('templates/partials/header.html')
  <main class="docs">
    @content
  </main>
  @input('templates/partials/footer.html')
</body>
</html>

The content can remain completely ordinary:

<h1>Getting started</h1>
<p>Install Nift, initialise a project and start building.</p>

The parser walks the template, emits literal text directly, expands the small set of Nift expressions, and accumulates dependencies as it goes. @content processes the current content through the same parser, so Nift expressions inside content work naturally and the content itself participates in the dependency graph.

3. Composition discovers the dependency graph naturally

This is one of the most important parts of Nift's design. Dependencies are not a separate graph you normally have to maintain by hand. They fall out of composition.

templates/docs.html
  │
  ├── @input(head.html) ─────────────► templates/partials/head.html
  │
  ├── @input(header.html) ───────────► templates/partials/header.html
  │                                      │
  │                                      └── @input(nav.html)
  │                                             │
  │                                             ▼
  │                                      templates/partials/nav.html
  │
  ├── @content ──────────────────────► content/docs/getting-started.html
  │
  └── @input(footer.html) ───────────► templates/partials/footer.html

If nav.html changes, every page whose composition reached that file can be identified as affected. If only content/docs/getting-started.html changes, unrelated pages can remain untouched.

Inputs are parsed, not pasted blindly

<!-- templates/partials/header.html -->
<header>
  @input('navigation.html')
</header>

Relative inputs can resolve from the file currently being processed, so a partial can own its own nearby sub-partials. Nift also tracks the input stack and rejects recursive input loops rather than recursing forever.

4. Explicit dependencies cover things that are not inserted

Composition finds the common case automatically, but sometimes a page depends on something whose contents should not appear in the output.

Declare it where it matters with @dep(...)

@dep('data/products.json')

<script type="module" src="@pathto('public/assets/catalog.js')"></script>

The dependency becomes part of the page's build state, while @dep itself emits no content.

Or declare page-level dependencies with *.deps.json

{
  "dependencies": [
    "data/products.json",
    "generated/search-index.json",
    "public/assets/generated/"
  ]
}

For content/dashboard.html, the sidecar is content/dashboard.deps.json. This is particularly useful when another tool generates the dependency list, or when the relationship is build metadata rather than something you want embedded in authored HTML.

Dependency sourceWhat it means
@contentThis page depends on its tracked content.
@input(...)This page depends on a file it processes and inserts.
@dep(...)This page depends on a file/directory that is not inserted.
*.deps.jsonThis page has additional dependency metadata outside the template.

5. Paths are resolved in project context

Path requirements preserve existence without over-invalidating

When @pathto(...) resolves a local file or tracked output, the render result also records that resolved target as a requirement. Requirements are persisted separately from dependencies as reqs. On later incremental/status checks Nift asks only whether the path still exists; it does not hash/stat-compare it for changes.

That distinction matters architecturally: a dependency says “changing this input can change the generated bytes,” while a req says “this generated page assumes this project-local target continues to exist.” If a req is missing, incremental analysis schedules the page for rebuilding—it does not fail the page before rendering. The normal render then either repairs the metadata because changed source no longer references the target, or reports the ordinary @pathto error if the broken reference remains.

A shared template should not have to know how deeply nested the current output happens to be.

<a href="@pathto('/')">Home</a>
<a href="@pathto('docs/getting-started')">Getting started</a>
<link rel="stylesheet" href="@pathto('public/assets/css/site.css')">

When the argument is a tracked name, Nift resolves its output relative to the output currently being generated. When it is a concrete project path, Nift verifies that the target exists and then calculates the appropriate relative path.

Current output:
public/docs/guides/install.html

Target:
public/assets/css/site.css

Generated relative reference:
../../assets/css/site.css

This is more than syntactic sugar. It moves a useful class of broken-link and missing-asset errors from the browser/deployment stage into the build.

6. Rendering returns structured information, not just a string

Conceptually, a page render produces something like:

RenderResult {
    ok,
    output,
    dependencies,
    reqs,
    content_used,
    error
}

That distinction matters. The generated text is only one result of parsing. The dependency set is what makes future builds incremental, and the structured error is what lets Nift produce useful diagnostics instead of a generic “parse failed”.

Errors retain source context

error: while building docs/getting-started
  templates/partials/sidebar.html:18:31
  input: expected 1 parameter
      @input('one.html', 'two.html')
                              ^

The error carries the tracked item being built, source file, line, column, message and source line. This is especially important with nested partials: the page being built and the file containing the mistake are not necessarily the same file.

7. Successful builds write output and remember what produced it

Once rendering succeeds, Nift writes the generated output and per-page build metadata. Conceptually that metadata answers:

What tracked item was this?
Which template produced it?
Which content/output extensions applied?
Which files/directories did rendering depend on?
Which @pathto targets must still exist (reqs)?
What hashes/state were recorded for dependencies?

This metadata is internal build state rather than content you should edit manually. Its purpose is to make the next invocation cheap and explainable.

8. Incremental builds ask “why?” before doing work

nift build-updated does not begin by parsing every page. It first computes build reasons for tracked items. nift status uses the same idea but stops before writing anything.

$ nift status

Changes detected

docs/getting-started
  dependency modified: templates/partials/docs-sidebar.html

docs/commands
  dependency modified: templates/partials/docs-sidebar.html

2 pages need building

Reasons can include changed content, changed templates/dependencies, a missing output, missing or stale build metadata, changed user-defined dependencies and other state that means the previous output can no longer be trusted.

status is essentially an incremental-build dry run.

That is useful to humans, CI and AI coding assistants because the build system can explain its decision before changing the project.

9. Modified, hash and hybrid modes answer “changed?” differently

modified

Uses filesystem modification times. It is cheap and works well when normal editing/build tools preserve sensible mtimes.

dependency mtime > previous page build metadata mtime
                    │
                    └──► changed

hash

Compares stored hashes with current content. This catches a content change even if a tool deliberately restores the old modification time.

stored hash:   392812...
current hash:  901447...
               │
               └──► changed

hybrid

Uses modification time or hash evidence. This is the defensive choice when you want ordinary timestamp changes detected while retaining protection against preserved-mtime content changes.

mtime says changed ──────────────┐
                                 ├──► rebuild
hash says changed ───────────────┘

10. Directory dependencies are real recursive dependencies

A dependency does not have to be one file. Suppose a page depends on generated search data:

{
  "dependencies": [
    "generated/search/"
  ]
}
generated/search/
├── pages/
│   ├── 001.json
│   └── 002.json
└── index.json

In hash-aware modes Nift hashes directory structure and contents deterministically. File names participate as well as child hashes, and nested directories are processed recursively. That means all of these can invalidate the dependent page:

  • changing index.json;
  • changing pages/001.json;
  • adding pages/003.json;
  • adding a nested directory/file;
  • removing or renaming a child.

This makes directory dependencies useful for generated asset trees and data pipelines without requiring the dependency list to enumerate every child.

11. Nift parallelises at the page level

Tracked pages are naturally independent build jobs once project state has been loaded. Nift uses a worker pool for page builds and also parallelises the incremental build-reason analysis used by build-updated and status.

                  tracked pages
                       │
             build / reason queue
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
     worker 1       worker 2       worker N
        │              │              │
        ▼              ▼              ▼
      page A          page B          page C
        │              │              │
        └──────────────┼──────────────┘
                       ▼
                summarised result

build-threads controls the worker count. Positive values are explicit thread counts, 0 uses hardware concurrency, and negative values are multipliers of hardware concurrency. The default -1 means one worker per reported hardware thread.

Shared reads can be reused safely

Many pages may read the same template or partial. Nift can retain immutable shared source text so workers do not repeatedly load identical shared files from disk. Importantly, this is a source read optimisation—not a rendered-partial cache. A partial is still parsed in the context of each page, because metadata such as $[title], @pathto(...) results and other page context can differ.

<!-- Same source file, different result per page -->
<title>$[title]</title>
<a href="@pathto('/')">Home</a>

12. Fast builds should not mean noisy builds

Most Nift builds are short enough that a progress display would be visual noise. The current progress system waits 200 ms before showing interactive progress. That delay is a single implementation constant, so it can be tuned without changing build logic.

Successful build output is also bounded. A handful of rebuilt pages can be explained individually; a huge dependency fan-out is summarised rather than dumping thousands of nearly identical lines. Use -p when full per-page detail is genuinely useful.

13. build-auto separates watching from terminal spam

nift build-auto repeatedly runs the incremental workflow. In an interactive terminal, q stops it. Instead of continuously printing unchanged status, the latest meaningful build output is kept in:

.nift/build-auto.log

The log is rewritten only when its content changes and terminal colour escape sequences are stripped before writing. This keeps both the console and log useful during long development sessions.

14. Watched directories turn files into tracked items

Tracking is explicit, but Nift can also watch directories using extension/template/output rules.

nift watch content/blog .html templates/post.html .html

Reconciliation compares the watched filesystem with Nift's tracking state. Matching files can become tracked items using the watch rule, while the watch metadata remains separate from the actual per-page build graph.

This gives Nift two useful modes of project organisation:

Explicit trackingDirectory watching
You deliberately name each generated item.Files matching a directory rule are reconciled automatically.
Excellent for hand-curated pages/routes.Excellent for larger homogeneous content trees.
Metadata is explicit in tracked.json.Rules describe how matching files become tracked items.

15. The JSON layer is intentionally part of the core

Nift's project state is JSON, but the executable does not need a large JSON/runtime dependency to manage it. Its JSON implementation has its own document/value representation for nulls, booleans, numbers, strings, arrays and objects, parsing/serialization, and object access through operator[].

The practical architectural benefit is not “Nift invented JSON”. It is that configuration and persistent state remain human-readable and portable while the native executable stays self-contained.

16. The CLI is a thin interface over the same project model

Commands are not separate mini-applications with independent interpretations of the project.

nift status ─────────────┐
nift build-updated ──────┤
nift build-all ──────────┤
nift build-names ────────┼──► ProjectInfo / tracked state
nift info* ──────────────┤       │
nift track/untrack ──────┤       ├── parser
nift watch/unwatch ──────┘       ├── dependencies
                                 ├── filesystem
                                 └── build metadata

That shared model is why status can agree with build-updated, why inspection commands can expose the same resolved paths the builder uses, and why fixes to dependency semantics do not need to be independently recreated in every command.

17. Inspection is designed for humans and tools

Interactive info* output can use headings and syntax colouring. Redirect it or pipe it, and Nift emits plain JSON instead.

nift info docs/getting-started

nift info-all | jq '.tracked[] | .name'

nift info-watching > watching.json

This small detail matters for interoperability. Nift does not need a bespoke API server for another tool to inspect project state.

18. What Nift deliberately does not own

The boundary around the architecture is as important as what sits inside it.

Nift ownsYour stack can own
Tracked generated outputsBrowser runtime and application state
Template/content compositionTypeScript compilation and bundling
Project-aware local pathsReact, Vue, Svelte or vanilla JS
Build dependenciesCSS preprocessors / Tailwind
Incremental rebuild decisionsImage/video pipelines
Page build metadataBackend APIs and databases
Watched content trackingDeployment platform and infrastructure

This is why the same Nift architecture can make sense for a plain brochure site and for the generated frontend shell of a larger application. Nift's complexity does not need to grow merely because the rest of the project becomes more sophisticated.

19. A complete example: change one shared partial

Consider a 10,000-page documentation site. Every documentation page ultimately includes:

templates/partials/docs-sidebar.html

You edit one navigation label and run:

nift status

The incremental analysis checks the recorded dependency sets in parallel and discovers that thousands of pages depend on the changed sidebar. Instead of flooding the terminal with thousands of copies of the same reason, Nift can summarise the fan-out. Then:

nift build-updated

creates page build jobs only for the affected set, workers render those pages concurrently, and the final output reports the successful page count and elapsed build time.

Now edit only:

content/docs/one-obscure-page.html

The same architecture discovers a dependency set of essentially one page. The size of the repository does not force Nift to rebuild all 10,000 outputs just because one leaf changed.

20. Another example: Nift beside TypeScript and a backend

project/
├── content/
│   └── dashboard.html
├── templates/
│   └── app-shell.html
├── frontend/
│   ├── src/
│   │   └── dashboard.tsx
│   └── package.json
├── public/
│   ├── dashboard.html
│   └── assets/
│       └── dashboard.js
├── server/
│   └── main.go
└── .nift/

A frontend bundler can produce public/assets/dashboard.js. Nift can generate the surrounding HTML and reference that asset with @pathto. A dashboard.deps.json sidecar can declare generated frontend artifacts or data as dependencies if the page itself should be rebuilt when they change. The Go server remains completely independent.

<div id="dashboard-root"></div>
<script
  type="module"
  src="@pathto('public/assets/dashboard.js')">
</script>

There is no “Nift way” to write the dashboard component or API. That absence is intentional.

21. Why this architecture stays understandable

There are a few recurring principles behind the implementation:

  • Explicit identity: tracked names identify generated items.
  • Ordinary files: content, templates and dependencies remain inspectable on disk.
  • Composition discovers dependencies: normal reuse automatically informs incremental builds.
  • Escape hatches stay small: @dep and *.deps.json cover dependencies Nift cannot infer.
  • Build decisions are explainable: the same reason analysis powers status and incremental builds.
  • Parallelise independent work: pages and reason checks can fan out across workers.
  • Do not cache the wrong abstraction: shared source text can be reused; rendered partials remain page-context dependent.
  • Keep the boundary sharp: Nift does not need to become npm, React, a backend framework or a deployment platform.
Nift is small not because web projects are small, but because most of a web project does not need to be Nift-specific.

Where to go next