@indago/hyper-json
v1.2.0
Published
JSON Schema → strict validation + auto-generated TypeScript types + ambient module declarations for typed JSON content imports.
Maintainers
Readme
HyperJson
What is HyperJson?
HyperJson is the structured-data counterpart to HyperDown. Where HyperDown handles Markdown/MDX prose, HyperJson handles JSON content — playlists, photo albums, profiles, skills, project lists, and any other collection that is better described as data than as documents.
It does three things:
- Validates every JSON content file against the
schema.jsonthat sits next to it, at build time, using Ajv. - Generates TypeScript types from each schema (via
json-schema-to-typescript) so your content is typed end-to-end. - Emits ambient module declarations so importing a content file — e.g.
import data from "@content/music/en/playlists.json"— yields a fully-typed value with no manual casting.
A small set of client-side React hooks (filter / search / sort / paginate / compose) rounds it out for building content explorers and gallery UIs. HyperJson has no dependency on front-matter — all front-matter logic lives in HyperDown.
Architecture at a glance
flowchart LR
subgraph Repo["Your repo"]
S["schema.json"]
J["*.json content<br/>(en/ · pt-BR/)"]
end
subgraph Build["Build time · hyperjsonValidationPlugin"]
AJV["Ajv validation<br/>strict · failOnError"]
GEN["json-schema-to-typescript<br/>codegen · worker pool"]
S --> AJV
J --> AJV
S --> GEN
GEN --> T["Generated types +<br/>ambient module declarations"]
end
subgraph App["Your app"]
IMP["typed import<br/>@content/**/*.json"]
HOOKS["headless hooks<br/>filter · search · sort · paginate"]
T --> IMP --> HOOKS
end
AJV -->|"invalid ⇒ build exits non-zero"| FAIL["❌ build fails"]Everything happens at build time: invalid content fails the build, valid content arrives fully typed at every import, and nothing runs at request time.
Feature highlights
- ✅ Build-time validation of JSON content against per-folder
schema.json. - 🧬 Generated types for every content type, named after each schema's
title. - 📦 Ambient module declarations for typed
@content/**/*.jsonimports. - ⚡ Parallel codegen — a bounded worker pool (configurable via
HYPERJSON_CONCURRENCY). - ⚙️ Vite plugin that validates + generates on build and serves
virtual:hyperjson-config. - 🗺️ Sitemap plugin that turns JSON collections into
sitemap.xmlURLs, with anappendmode that merges into a sitemap other tools also write to. - 🪝 Headless React hooks for filtering, searching, sorting, and paginating data.
- 🧰 Full CLI (
hyperjson) and an MCP server (hyperjson-mcp).
Installation
# bun (recommended)
bun add @indago/hyper-json
# npm / pnpm / yarn
npm install @indago/hyper-jsonPeer dependencies
| Peer | Range | Notes |
| ----------- | --------- | ----------------------- |
| react | ^19.2.6 | required for the hooks |
| react-dom | ^19.2.6 | required for the hooks |
| vite | ^8.0.14 | required for the plugin |
node >= 20is required for the CLI and codegen. Codegen uses the in-processjson-schema-to-typescriptAPI — no extra tooling needed at generation time.
Content layout
HyperJson scans a content directory (default src/content). Each content category is
a folder containing a schema.json and one or more JSON data files, optionally grouped by
locale (en/, pt-BR/):
src/content/
└── music/
├── schema.json # JSON Schema describing the data shape
├── en/
│ ├── playlists.json
│ └── favorites.json
└── pt-BR/
└── playlists.jsonThe schema.json title becomes the generated TypeScript type name. Validation scans the
en/ and pt-BR/ locale subdirectories as well as JSON files at the category root
(excluding schema.json).
Quick start
1. Scaffold the config
bunx @indago/hyper-json initCreates hyperjson.config.json:
{
"$schema": "./node_modules/@indago/hyper-json/schemas/hyperjson.config.schema.json",
"contentDir": "src/content",
"validation": { "strict": true, "failOnError": true },
}2. Add the Vite plugin
// vite.config.ts
import { hyperjsonValidationPlugin } from "@indago/hyper-json";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [hyperjsonValidationPlugin()],
});3. Create a content type
bunx @indago/hyper-json create-content-type \
--name music \
--locales "en,pt-BR" \
--fields "title:string:required;genre:string;url:string"4. Validate & generate
bunx @indago/hyper-json validate
bunx @indago/hyper-json generate5. Import content with full typing
import enPlaylists from "@content/music/en/playlists.json";
import type { MusicContentSchema } from "@indago/hyper-json";
// `enPlaylists` is typed via the generated ambient module declaration.The
@content/*import alias must be declared in yourtsconfig.jsoncompilerOptions.pathspointing at./src/content/*. HyperJson reads that alias to know which prefix to declare the ambient modules under.
CLI reference
The hyperjson binary is installed with the package. Run it via bunx @indago/hyper-json <command>.
hyperjson <command> [target] [options]| Command | Summary |
| ------------------------------------------------------- | ------------------------------------------- |
| init | Scaffold a default hyperjson.config.json. |
| validate [target] | Validate config and/or JSON content. |
| generate (alias gen) | Generate TypeScript types from schemas. |
| create-content-type | Scaffold a new JSON content type. |
Scaffolds hyperjson.config.json in the current directory. Skips with a warning if the
file already exists.
bunx @indago/hyper-json initValidates the config and/or all JSON content files against their schemas. target is
config, content, or both (default: both). Exits non-zero when any file fails.
The path flag overrides the default location for the chosen target: the config file for
config, the content directory for content. It is ignored when target is both.
| Option | Default | Description |
| ------------------- | -------------- | ----------------------------------------------------------------------------------- |
| -p, --path <path> | target default | Path to the file/dir matching target (config or content). Ignored for both. |
bunx @indago/hyper-json validate
bunx @indago/hyper-json validate content
bunx @indago/hyper-json validate config --path ./apps/web/hyperjson.config.jsonGenerates TypeScript types for every content schema and writes the ambient module declarations. Runs the codegen with a parallel worker pool (see Codegen).
bunx @indago/hyper-json generate
bunx @indago/hyper-json gen # alias
HYPERJSON_CONCURRENCY=2 bunx @indago/hyper-json generateScaffolds a new JSON content type: a schema.json and empty { "<wrapper>": [] } data
files per locale. With neither --fields nor --fields-json, it launches an interactive
builder that supports nested objects, arrays, and recursion.
| Option | Default | Description |
| ---------------------- | --------------------- | ------------------------------------------------------------ |
| --name <name> | — | Content folder name. |
| --title <title> | <Name>ContentSchema | Schema title (becomes the generated type name). |
| --locales <locales> | en | Comma-separated locale codes. |
| --fields <fields> | — | Semicolon-separated flat name:type[:required] definitions. |
| --fields-json <json> | — | JSON FieldSpec[] — nested objects, arrays, recursion. |
| --content-dir <dir> | src/content | Content directory. |
| --wrapper <prop> | items | Top-level array property name. |
Flat field types: string, number, integer, boolean, string[], enum, date
(string with a YYYY-MM-DD pattern). A field is required when the third segment is the
literal required.
bunx @indago/hyper-json create-content-type \
--name skill \
--locales en \
--fields "name:string:required;level:string:required"For nesting and recursion, pass a FieldSpec[] to --fields-json. Each spec is
{ name, type, required?, enumValues?, format?, fields?, items?, def?, ref? } — object
nests fields, array nests an items element spec, and a named def can be reused with
ref (including by itself, for trees/menus):
bunx @indago/hyper-json create-content-type --name menu \
--fields-json '[
{"name":"label","type":"string","required":true},
{"name":"children","type":"array","items":{"name":"","type":"ref","ref":"MenuItem"}}
]'MCP server
hyperjson-mcp (declared in bin) is an MCP stdio server that wraps the same CLI as MCP
tools for AI agents.
bunx --package @indago/hyper-json hyperjson-mcpTools: hyperjson_init, hyperjson_validate, hyperjson_generate,
hyperjson_create_content_type. The creation tool requires name + fields —
interactive prompts are disabled under MCP.
Vite plugin & virtual module
hyperjsonValidationPlugin(contentDir?)
On buildStart the plugin validates every JSON content file. If validation.failOnError
is in effect and any file is invalid, the build exits with a non-zero code. Otherwise
it runs codegen (types + ambient modules).
It also serves the virtual:hyperjson-config module — the parsed hyperjson.config.json
as a default export, available in both dev and build:
import config from "virtual:hyperjson-config";
// → { contentDir: "src/content", validation: { strict, failOnError } }contentDir (the plugin argument) overrides the config's contentDir when provided.
hyperjsonSitemapPlugin({ appRootDir? })
A build-only plugin (apply: "build") that turns JSON collections into sitemap.xml
entries. On closeBundle it reads the optional sitemap block of hyperjson.config.json,
walks each declared collection, and emits one <url> per item — locale-prefixed when an
i18n block is present. If there is no sitemap block it no-ops (it never throws). Import
it from @indago/hyper-json/plugins:
// vite.config.ts
import { hyperjsonValidationPlugin } from "@indago/hyper-json";
import { hyperjsonSitemapPlugin } from "@indago/hyper-json/plugins";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
hyperjsonValidationPlugin(),
// …any other sitemap-writing plugin (router, @indago/hyper-down)…
hyperjsonSitemapPlugin(), // append mode: must run LAST among sitemap writers
],
});For each collections[] entry the plugin reads <contentDir>/<file> (per-locale when
i18n.strategy === "folder"), drills into itemsPath when given, and maps every item to
${siteUrl}${localePrefix}${basePath}/${item[idField]}. lastmod comes from the JSON
file's modification time (JSON has no front-matter date to read). staticRoutes are emitted
verbatim, just like HyperDown's plugin.
mode mirrors @indago/hyper-down's sitemap plugin, but the default is "append"
(not "override") — this plugin's whole purpose is to layer JSON-content URLs onto a sitemap
the router and/or HyperDown already wrote:
"append"(default) — preserves any<url>already present atoutputPathand overlays this run's URLs, deduplicating by<loc>(this run wins). Because it preserves whatever is already on disk, this plugin must run after every other tool that writes the same file in the build."override"— always writes a fresh sitemap from this config alone.
Dedup normalizes locs first (trailing slash, fragment, scheme/host case ignored), then keeps
the last entry on a tie — both within this run's own staticRoutes + collection URLs, and
against whatever was already on disk.
Ordering contract. In
appendmodehyperjsonSitemapPluginreads the existingsitemap.xmlfrom disk and merges into it, so it has to be the last sitemap writer in the pipeline. A typical site puts the router/static sitemap first, thenhyperdownSitemapPlugin({ mode: "append" })for MDX content, thenhyperjsonSitemapPlugin()for JSON content — each layer preserving the previous one.
Programmatic API
The package exposes three entry points:
| Import | Provides |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @indago/hyper-json | loadHyperJsonConfig, validateHyperJsonConfig, validateContentSchemas, hyperjsonValidationPlugin, loggers, and the generated content/config types. |
| @indago/hyper-json/hooks | Client-side React hooks: useFilter, useSearch, useSort, usePaginate, useComposed. |
| @indago/hyper-json/plugins | Vite plugins: hyperjsonValidationPlugin, hyperjsonSitemapPlugin. |
Validation & config
import {
loadHyperJsonConfig, // (appRootDir) => HyperJsonConfiguration (validated)
validateHyperJsonConfig, // (config, path?) => boolean (type guard)
validateContentSchemas, // (contentDir?) => { passed, failed, results, schemaDirs }
} from "@indago/hyper-json";Codegen
HyperJsonCodegen drives the type generation. Each content schema compiles through the
in-process json-schema-to-typescript API, run through a bounded promise pool. It writes
only into the consuming app's .hyper-json/ tree.
import { HyperJsonCodegen } from "@indago/hyper-json"; // via the package's codegen export
const codegen = new HyperJsonCodegen({
appRootDir: process.cwd(),
concurrency: 4, // optional
});
await codegen.generate();The concurrency limit is resolved in priority order:
- the explicit
concurrencyoption, then - the
HYPERJSON_CONCURRENCYenvironment variable, then cpus().length - 1(minimum1) — leaving a core free to keep the build responsive.
Ambient module declarations are written after all per-schema types settle, since they aggregate every content file.
Hooks
Headless, in-memory React hooks for shaping arrays of content. All are pure and
useMemo-backed.
import {
useFilter, // (data, FilterConfig[]) => T[]
useSearch, // (data, query, fields) => T[]
useSort, // (data, SortConfig | null) => T[]
usePaginate, // (data, page, perPage) => { items, page, totalPages, total }
useComposed, // filter → search → sort → paginate, all in one
} from "@indago/hyper-json/hooks";const { paginated, filtered } = useComposed(playlists, {
filters: [{ key: "genre", value: selectedGenre }], // "All"/undefined ⇒ skipped
searchQuery,
searchFields: ["title", "artist"],
sort: { key: "title", dir: "asc" },
page,
perPage: 12,
});
// paginated.items, paginated.totalPages, paginated.total, …Configuration reference
hyperjson.config.json
Validated against schemas/hyperjson.config.schema.json.
| Key | Type | Default | Description |
| ------------------------ | --------- | ------------- | ------------------------------------------------------------------------ |
| contentDir | string | src/content | Base directory for JSON content, relative to the app root. Required. |
| validation.strict | boolean | true | Reject properties not declared in the schema. |
| validation.failOnError | boolean | true | Exit non-zero on any validation error. |
| sitemap | object | — | Optional. Configures hyperjsonSitemapPlugin (see below). |
| i18n | object | — | Optional locale config; drives locale-prefixed sitemap URLs (see below). |
sitemap (optional — hyperjsonSitemapPlugin)
{
"sitemap": {
"siteUrl": "https://example.com", // no trailing slash
"outputPath": "./public/sitemap.xml",
"mode": "append", // "append" (default) | "override"
"staticRoutes": [{ "path": "/music", "priority": "0.6", "changefreq": "monthly" }],
"collections": [
{
"name": "music-favorites",
"file": "music/favorites.json", // relative to contentDir (and the locale folder)
"itemsPath": "items", // wrapper key holding the array (omit for a top-level array)
"basePath": "/music", // URL prefix for each item
"idField": "id", // item property used as the URL segment (default "id")
"priority": "0.6",
"changefreq": "monthly",
},
],
},
}| Key | Type | Default | Description |
| ------------------------- | ---------- | ------------ | ------------------------------------------------------------------------ |
| siteUrl | string | — | Origin without a trailing slash. Required when sitemap is present. |
| outputPath | string | — | Where to write the sitemap, relative to the app root. Required. |
| mode | string | "append" | "append" merges into an existing file; "override" rewrites it. |
| staticRoutes[] | object[] | [] | { path, priority, changefreq } — emitted verbatim under siteUrl. |
| collections[] | object[] | [] | JSON collections expanded into one URL per item. |
| collections[].file | string | — | JSON file relative to contentDir (and the locale folder under i18n). |
| collections[].itemsPath | string | (whole file) | Dotted path drilled into before treating the value as an array. |
| collections[].idField | string | "id" | Item property used as the final URL segment. |
| collections[].basePath | string | — | URL prefix; item URL = ${siteUrl}${localePrefix}${basePath}/${id}. |
With an i18n block whose strategy is "folder", each collection file is resolved
per-locale as <contentDir>/<dir>/<locale>/<name>.json and non-default locales are prefixed
(/pt, …), matching HyperDown's behavior. lastmod is taken from each file's modification
time. In append mode (the default), place this plugin after every other sitemap writer
— it preserves whatever is already on disk and layers its URLs on top.
Auto-generated files — do not edit: the per-schema
.hyper-json/src/content/*/types.tsand the aggregate.hyper-json/src/content/generated.d.tsare produced by codegen and will be overwritten — re-runbunx @indago/hyper-json generateafter changing aschema.json. (Inside this repo, the package's ownsrc/lib/types.tsis dev-time codegen:bun run gen:types, run automatically on prebuild.)
License
MIT © Zaú Júlio
