@accup/vite-plugin-hogen
v0.1.0
Published
Vite plugin for emitting assets from TypeScript files matched by user-defined rules.
Maintainers
Readme
@accup/vite-plugin-hogen
Vite plugin for emitting assets from TypeScript files matched by user-defined rules.
For each matching file, the plugin evaluates the source with Vite's ModuleRunner, lets the rule emit assets and produce the final exports, and writes the result back as the file's compiled JS.
Installation
npm install --save-dev @accup/vite-plugin-hogenPlugin setup
Register the plugin in vite.config.ts with the rules to apply.
import { hogen } from "@accup/vite-plugin-hogen/config";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
hogen({
rules: [
/* see below */
],
}),
],
});Plugin options
rulesRules registered with the plugin.
assetLeakPolicyPolicy for the check that detects a Vite asset placeholder appearing in an emitted text asset's content. Values are
"error","warn", and"ignore". Defaults to"error".
Rule
A rule selects evaluation entries by id and decides how the matching file emits assets and produces exports.
import type { HogenRule } from "@accup/vite-plugin-hogen/config";
const rule: HogenRule = {
name: "my-rule",
testEntry: (id) => /\.my\.ts(\?.*)?$/u.test(id),
// Adapter hook, emits during evaluation.
adapterKey,
createAdapter,
// Evaluated-module hook, emits after evaluation.
processModule,
};Both hooks are optional. adapterKey and createAdapter are set together when the adapter hook is used.
Evaluation-only rule
A rule may declare no hook at all. The plugin then evaluates the source with ModuleRunner and serializes the resulting exports as static JS. A matching file becomes a build-time computation whose result is compiled into the output bundle without any asset emit.
import type { HogenRule } from "@accup/vite-plugin-hogen/config";
export function createConstRule(): HogenRule {
return {
name: "const",
testEntry: (id) => /\.const\.ts(\?.*)?$/u.test(id),
};
}A matching file uses Node APIs at build time and produces plain values.
// version.const.ts
import { readFileSync } from "node:fs";
const pkg: { version: string } = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
);
export const version = pkg.version;The compiled module is plain JS with the resolved value.
export const version = "1.2.3";Adapter-based rule
Use the adapter hook when each emit happens during the file's evaluation. The file imports helpers that read the adapter and call adapter.emit per call site. Each helper returns the URL accessors of the asset it emitted.
Declare the adapter interface and a key.
import type { HogenAdapter } from "@accup/vite-plugin-hogen/config";
import type { HogenEmittedAsset } from "@accup/vite-plugin-hogen/config";
import { createAdapterKey } from "@accup/vite-plugin-hogen/config";
export interface SnapshotAdapter extends HogenAdapter {
/** Emit a text snapshot and return its URL accessors */
readonly snapshot: (text: string) => HogenEmittedAsset;
}
export const ADAPTER_KEY = createAdapterKey<SnapshotAdapter>("__snapshot_adapter__");Build the rule.
import type { HogenRule } from "@accup/vite-plugin-hogen/config";
export function createSnapshotRule(): HogenRule<SnapshotAdapter> {
return {
name: "snapshot",
testEntry: (id) => /\.snapshot\.ts(\?.*)?$/u.test(id),
adapterKey: ADAPTER_KEY,
createAdapter: (context) => ({
snapshot(text) {
return context.emit({
type: "asset",
name: "snapshot",
source: text,
mimeType: "text/plain",
});
},
}),
};
}User code reaches the adapter through readAdapter.
import { readAdapter } from "@accup/vite-plugin-hogen";
import type { HogenEmittedAsset } from "@accup/vite-plugin-hogen";
import { ADAPTER_KEY } from "./adapter";
export function snapshot(text: string): HogenEmittedAsset {
return readAdapter(ADAPTER_KEY).snapshot(text);
}A matching file emits one asset per call. Each helper return value carries the URL accessors of the emitted asset, so each named export has the HogenEmittedAsset type at both the source and consumer side.
// notes.snapshot.ts
import { snapshot } from "./snapshot";
export const intro = snapshot("Hello, world.");
export const detail = snapshot("Longer payload.");A consumer picks the accessor that matches the embedding context.
import { intro } from "./notes.snapshot";
const href = intro.absolutePath;Evaluated-module-based rule
Use processModule when the rule reads the file's evaluated exports and decides what to emit. Helpers the source code imports brand each value; processModule detects the brand after evaluation and replaces every branded value with a URL.
Each named export keeps the same name across the source and the compiled output, so the source-side and consumer-side types stay aligned. The branding helper declares the return type that consumers receive, while its runtime value carries the data the rule needs.
Declare the value shape, the brand, and the helpers.
const REPORT_KEY = "__report__";
export interface Report {
readonly title: string;
readonly total: number;
}
/**
* Brand a report value so the rule can find it among the evaluated exports.
*
* The runtime value carries the report data, while the declared return type matches the URL the rule writes into the compiled exports.
*
* @param report report data
* @returns URL of the emitted report
*/
export function defineReport(report: Report): string {
Object.defineProperty(report, REPORT_KEY, {
value: true,
enumerable: false,
configurable: false,
writable: false,
});
// The runtime value is replaced by a URL in the rule's processModule.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
return report as unknown as string;
}
export function isReport(value: unknown): value is Report {
if (typeof value !== "object" || value == null) {
return false;
}
return REPORT_KEY in value;
}Build the rule.
import type { HogenRule } from "@accup/vite-plugin-hogen/config";
import { isReport } from "./report";
export function createReportRule(): HogenRule {
return {
name: "report",
testEntry: (id) => /\.report\.ts(\?.*)?$/u.test(id),
processModule(module) {
const replaced = Object.entries(module.exports).map(([name, value]) => {
if (!isReport(value)) {
return [name, value] as const;
}
const url = module.emit({
type: "asset",
name,
source: JSON.stringify(value),
mimeType: "application/json",
}).absolutePath;
return [name, url] as const;
});
return Object.fromEntries(replaced);
},
};
}A matching file calls the helper.
// sales.report.ts
import { defineReport } from "./report";
export const sales = defineReport({
title: "Sales 2024",
total: 12345,
});Consumers see the declared return type of the helper, which matches the URL the rule writes into the compiled exports.
import { sales } from "./sales.report";
// sales has type `string` and is the URL of the emitted JSON at runtimeprocessModule may also return exports without calling module.emit. The rule then only transforms values; no asset reaches the output.
Mixed rule
A rule can declare both hooks. createAdapter exposes per-call helpers that emit during evaluation; processModule reads the resulting exports and emits or rewrites values that need post-evaluation handling. The two styles compose inside one file, and every export keeps the declared return type at the source and consumer side as long as each helper aligns its return type with the value the rule writes into the compiled exports.
import type { HogenRule } from "@accup/vite-plugin-hogen/config";
import { isReport } from "./report";
export function createCatalogRule(): HogenRule<CatalogAdapter> {
return {
name: "catalog",
testEntry: (id) => /\.catalog\.ts(\?.*)?$/u.test(id),
adapterKey: ADAPTER_KEY,
createAdapter: (context) => ({
attach(input) {
return context.emit({
type: "asset",
name: input.name,
source: input.source,
mimeType: input.mimeType,
});
},
}),
processModule(module) {
const replaced = Object.entries(module.exports).map(([name, value]) => {
if (!isReport(value)) {
return [name, value] as const;
}
const url = module.emit({
type: "asset",
name,
source: JSON.stringify(value),
mimeType: "application/json",
}).absolutePath;
return [name, url] as const;
});
return Object.fromEntries(replaced);
},
};
}A matching file mixes both emit styles. attach emits during evaluation and returns the URL accessors directly; defineReport brands the value, and processModule rewrites it into a URL after evaluation.
// store.catalog.ts
import { attach } from "./attach";
import { defineReport } from "./report";
export const logo = attach({
name: "logo",
source: "<svg>...</svg>",
mimeType: "image/svg+xml",
});
export const sales = defineReport({
title: "Sales 2024",
total: 12345,
});Rule nesting
A file matched by one rule can import a file matched by another rule. The inner ModuleRunner that evaluates the entry hands a cross-rule id back to the main plugin so the main plugin processes it with the outer Rollup context, and the entry's evaluation receives the imported module's compiled exports.
// welcome.snap.ts
import { snapshot } from "./snapshot";
export const greeting = snapshot("Welcome.");// page.bundle.ts
import { defineBundle } from "./bundle";
import * as welcome from "./welcome.snap.ts";
export const home = defineBundle({
title: "Home",
items: [{ label: "greeting", href: welcome.greeting.absolutePath }],
});Module evaluation
The plugin evaluates each id at most once per session. After the first transform of a matched file, subsequent imports of the same id reuse the cached transform result, so emits and processModule run exactly once per session.
In dev mode, modifying a matched file invalidates its cached entry and every cached entry that reached it through a cross-rule import. Every id reached through the cross-rule delegate path is added to Vite's watcher, so a change to a file imported only by another evaluated file still triggers HMR.
Emit input
emit accepts two input shapes.
type: "file"Fixed-path asset. The asset is written under the given
fileName. Setrule.testFileto the same path predicate so the dev server serves the file at that path and forces a full reload when the source module changes.type: "asset"Dynamic-path asset. The output path is derived from
nameand a content hash.
source is a string or a Uint8Array. mimeType is read by the dev middleware to set the response Content-Type and is ignored at build time.
Emit result
emit returns a HogenEmittedAsset carrying three URL accessors.
absolutePathAbsolute path including Vite's
baseprefix. Use this when embedding the URL inside another asset's content.relativePathPath relative to the bundle output root.
assetRefValue safe to embed in a JS chunk. At build time this is a Vite asset placeholder that Vite resolves to the final URL during bundling.
Embedding assetRef inside another asset's content leaks the unresolved placeholder into the output. The plugin checks every text-typed type: "asset" emit for this pattern and reports a match according to the assetLeakPolicy option.
