@zerotal/inertia 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ import { ServiceProvider, Router, ConfigError, ThrottleMiddleware } from "@zerotal/core";
2
+ import { registerDevBuildHook, pruneBuildOutput, DEV_RELOAD_CLIENT } from "@zerotal/core/dev";
3
+ import type { AppEnvironment } from "@zerotal/core";
4
+ import type { ConfigManager } from "@zerotal/core/config";
5
+ import { _setHtmlTemplate, _setPagesDir } from "../inertia.ts";
6
+ import { DEFAULT_PAGES_DIR } from "../config.ts";
7
+ import { setAssetVersion } from "../version.ts";
8
+ import { setHistoryEncryptionDefault } from "../historyState.ts";
9
+ import { generatePageRegistry } from "../PageRegistry.ts";
10
+ import { SsrHandler } from "../SsrHandler.ts";
11
+ import { detectCssPlugins } from "../css.ts";
12
+ import { detectVuePlugin, registerVueRuntimeLoader } from "../vuePlugin.ts";
13
+ import { InertiaMiddleware } from "../middleware/InertiaMiddleware.ts";
14
+ import { inertiaRoute } from "../route.ts";
15
+
16
+ export class InertiaProvider extends ServiceProvider {
17
+ static override environments: AppEnvironment[] = ["web", "console", "test"];
18
+
19
+ override onRegister(): void {
20
+ // Make Router.inertia() available before routes load.
21
+ Router.macro("inertia", inertiaRoute);
22
+ }
23
+
24
+ override async onBooting(): Promise<void> {
25
+ this.app.useOnce(InertiaMiddleware as never);
26
+
27
+ const config = this.app.container.makeSync("config") as ConfigManager;
28
+
29
+ // 1. Load HTML template into memory — one disk read at boot, never per-request
30
+ const templatePath = config.get<string>(
31
+ "inertia.htmlTemplate",
32
+ `${process.cwd()}/resources/app.html`,
33
+ );
34
+
35
+ try {
36
+ let html = await Bun.file(templatePath).text();
37
+
38
+ // In dev-worker mode bake the live-reload client into the cached template.
39
+ // DevReloadMiddleware would inject it for us, but only by buffering the
40
+ // response body — and Inertia.stream() exists precisely to avoid that. The
41
+ // middleware skips any page that already embeds a /__dev/ws client, so the
42
+ // two never double-inject.
43
+ if (process.argv.includes("--dev-worker")) {
44
+ html = html.replace("</body>", DEV_RELOAD_CLIENT + "\n</body>");
45
+ }
46
+
47
+ _setHtmlTemplate(html);
48
+ } catch {
49
+ // Template not found — warn in development, throw in production
50
+ const isDev = config.get<string>("app.env", "production") !== "production";
51
+ if (!isDev) {
52
+ throw new ConfigError(
53
+ `[Zerotal Inertia] HTML template not found at: ${templatePath}\n` +
54
+ `Create the template there, then run \`bun zt inertia:build\` to generate the page registry ` +
55
+ `(scaffold new pages with \`bun zt make:page\`).`,
56
+ );
57
+ }
58
+ console.warn(
59
+ `[Zerotal Inertia] HTML template not found at: ${templatePath}. ` +
60
+ `Using empty fallback for development.`,
61
+ );
62
+ _setHtmlTemplate(_defaultHtmlTemplate());
63
+ }
64
+
65
+ // 2. Set asset version (used for 409 cache-busting)
66
+ const version = config.get<string>("inertia.version", "1");
67
+ setAssetVersion(version);
68
+
69
+ // 2a. Resolve the pages directory (used by SSR / inertiaStream to load
70
+ // components from disk). Configurable via `inertia.pagesDir`.
71
+ const pagesDir = config.get<string>("inertia.pagesDir", DEFAULT_PAGES_DIR);
72
+ _setPagesDir(`${process.cwd()}/${pagesDir}`);
73
+
74
+ // 2b. History-encryption default (per-request overrides via Inertia.encryptHistory()).
75
+ setHistoryEncryptionDefault(config.get<boolean>("inertia.encryptHistory", false));
76
+
77
+ // 2c. Register the .vue runtime loader so server-side `import('*.vue')`
78
+ // works for SSR and Inertia.stream(). No-op unless @vue/compiler-sfc is
79
+ // installed, so React apps are unaffected.
80
+ await registerVueRuntimeLoader(process.cwd());
81
+
82
+ // 3. Register SSR endpoint when enabled
83
+ if (config.get<boolean>("inertia.ssr", false)) {
84
+ // Throttled as well as loopback-gated: rendering a page is real CPU, and this route
85
+ // sits outside every application guard.
86
+ Router.post(
87
+ "/__ssr",
88
+ SsrHandler as unknown as new (...args: unknown[]) => unknown,
89
+ "handle",
90
+ [ThrottleMiddleware.with({ maxAttempts: 120, windowSeconds: 60 })],
91
+ );
92
+ }
93
+ }
94
+
95
+ override async onBooted(): Promise<void> {
96
+ // Register the dev build hook so DevOrchestrator (in @zerotal/core) can
97
+ // trigger a full pages-manifest sync + asset rebuild without @zerotal/core
98
+ // importing @zerotal/inertia (which would create a circular dependency).
99
+ const cwd = process.cwd();
100
+ registerDevBuildHook("inertia", async () => {
101
+ await generatePageRegistry(cwd);
102
+ const plugins = [...(await detectCssPlugins(cwd)), ...(await detectVuePlugin(cwd))];
103
+ const outdir = `${cwd}/public/assets`;
104
+ const result = await Bun.build({
105
+ entrypoints: [`${cwd}/resources/js/app.tsx`],
106
+ outdir,
107
+ target: "browser",
108
+ splitting: true,
109
+ minify: false,
110
+ sourcemap: "external",
111
+ ...(plugins.length > 0 ? { plugins } : {}),
112
+ });
113
+
114
+ // Each rebuild renames every split chunk, so without this the directory
115
+ // collects a full set of dead chunks per edit for the whole session.
116
+ if (result.success) await pruneBuildOutput(outdir, result.outputs);
117
+ return result;
118
+ });
119
+
120
+ // Register CLI commands when in console mode
121
+ const runner = this.app.container.tryMake("commands");
122
+ if (!runner) return;
123
+
124
+ (runner as { registerLazy(name: string, thunk: () => Promise<unknown>): void }).registerLazy(
125
+ "inertia:build",
126
+ () =>
127
+ import("../commands/InertiaBuildCommand.ts").then(
128
+ (m) => (m as { InertiaBuildCommand: unknown }).InertiaBuildCommand,
129
+ ),
130
+ );
131
+ (runner as { registerLazy(name: string, thunk: () => Promise<unknown>): void }).registerLazy(
132
+ "make:page",
133
+ () =>
134
+ import("../commands/MakePageCommand.ts").then(
135
+ (m) => (m as { MakePageCommand: unknown }).MakePageCommand,
136
+ ),
137
+ );
138
+ }
139
+ }
140
+
141
+ // Used as fallback when resources/app.html does not exist yet
142
+ function _defaultHtmlTemplate(): string {
143
+ return `<!DOCTYPE html>
144
+ <html lang="en">
145
+ <head>
146
+ <meta charset="utf-8" />
147
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
148
+ <title>Zerotal App</title>
149
+ </head>
150
+ <body>
151
+ <!-- @inertia -->
152
+ </body>
153
+ </html>`;
154
+ }
package/src/route.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { Router } from "@zerotal/core";
2
+ import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
3
+ import { inertia } from "./inertia.ts";
4
+
5
+ /**
6
+ * Register a GET route that renders an Inertia page directly — no controller
7
+ * needed for controller-less pages.
8
+ *
9
+ * Useful for static/simple pages (about, dashboards) that need no controller logic —
10
+ * the generated handler just calls {@link inertia} with the given component and props.
11
+ *
12
+ * @param path - The URL path to register (GET).
13
+ * @param component - The page component name/path (relative to the pages dir, no extension).
14
+ * @param props - Static props object, **or** a middleware array as a shorthand (when it's an array it's treated as middleware, not props).
15
+ * @param middleware - Middleware to apply (ignored when `props` is used as the middleware-array shorthand).
16
+ * @returns The route registration from the underlying `Router.get`.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * Router.inertia('/about', 'About/Index');
21
+ * Router.inertia('/home', 'Home/Index', { greeting: 'Hello' });
22
+ * Router.inertia('/admin', 'Admin/Dashboard', [AuthMiddleware]);
23
+ * ```
24
+ */
25
+ export function inertiaRoute(
26
+ path: string,
27
+ component: string,
28
+ props?: Record<string, unknown> | MiddlewareClass[],
29
+ middleware: MiddlewareClass[] = [],
30
+ ): RouteRegistration {
31
+ let resolvedProps: Record<string, unknown> = {};
32
+ let resolvedMiddleware: MiddlewareClass[] = middleware;
33
+
34
+ if (Array.isArray(props)) {
35
+ resolvedMiddleware = props; // shorthand: Router.inertia('/x', 'X', [AuthMiddleware])
36
+ } else if (props) {
37
+ resolvedProps = props;
38
+ }
39
+
40
+ const handler = class InertiaRouteHandler {
41
+ async handle(): Promise<void> {
42
+ await inertia(component, resolvedProps);
43
+ }
44
+ };
45
+
46
+ return Router.get(path, handler, "handle", resolvedMiddleware);
47
+ }
package/src/share.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * App-registered shared props (`Inertia.share(...)`).
3
+ *
4
+ * Registered once (typically in a provider's boot or middleware) and merged into every page's
5
+ * props by {@link sharedProps}. Values may be plain values, factory functions (evaluated lazily per
6
+ * request), or prop wrappers — the resolver handles each.
7
+ */
8
+ const _shared = new Map<string, unknown>();
9
+
10
+ /** The always-present built-in shared keys. */
11
+ const BUILTIN_SHARED_KEYS = ["auth", "flash", "errors", "old"] as const;
12
+
13
+ /**
14
+ * Register shared prop(s) that Zerotal includes on **every** Inertia page, so page
15
+ * components can read them without each controller passing them explicitly.
16
+ *
17
+ * @remarks
18
+ * Call once at boot (typically in a service provider) — a later `share()` of the
19
+ * same key overwrites the previous value. Values may be plain data, a factory
20
+ * function (re-evaluated lazily per request, e.g. to read the current user), or a
21
+ * prop wrapper. The built-in shared keys (`auth`, `flash`, `errors`, `old`) are
22
+ * always present in addition to whatever you register here; see {@link sharedProps}.
23
+ *
24
+ * Two call styles: a single `key`/`value` pair, or an object of many at once.
25
+ *
26
+ * @param key - The shared prop name (single-key overload) — or, in the object overload, the map of names to values.
27
+ * @param value - The value for `key` (single-key overload only); a plain value, a per-request factory, or a prop wrapper.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * // In a service provider's boot() — available on every page (auth/flash/errors/old
32
+ * // are already built in; register your own extras here).
33
+ * Inertia.share({
34
+ * appName: 'Acme',
35
+ * permissions: () => Auth.user()?.permissions ?? [], // factory re-runs each request
36
+ * });
37
+ *
38
+ * // Or a single key:
39
+ * Inertia.share('year', () => new Date().getFullYear());
40
+ * ```
41
+ */
42
+ export function share(key: string, value: unknown): void;
43
+ export function share(values: Record<string, unknown>): void;
44
+ export function share(keyOrValues: string | Record<string, unknown>, value?: unknown): void {
45
+ if (typeof keyOrValues === "string") {
46
+ _shared.set(keyOrValues, value);
47
+ } else {
48
+ for (const [k, v] of Object.entries(keyOrValues)) _shared.set(k, v);
49
+ }
50
+ }
51
+
52
+ /** @internal The raw registered shared map (factories/wrappers left unresolved for the resolver). */
53
+ export function registeredShared(): Record<string, unknown> {
54
+ return Object.fromEntries(_shared);
55
+ }
56
+
57
+ /** @internal All shared prop keys (built-ins + registered), for the page object's `sharedProps`. */
58
+ export function allSharedKeys(): string[] {
59
+ return [...BUILTIN_SHARED_KEYS, ..._shared.keys()];
60
+ }
61
+
62
+ /** @internal Clear registered shared props (tests). */
63
+ export function flushShared(): void {
64
+ _shared.clear();
65
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Framework-aware server-side rendering for Inertia pages.
3
+ *
4
+ * Inertia's SSR contract is `{ component, props, url } → { head, body }`. Pages
5
+ * may be authored as React `.tsx` or Vue `.vue` components, so this module
6
+ * detects the framework from the page file on disk and renders with the matching
7
+ * runtime — React via `react-dom/server`, Vue via `@inertiajs/vue3`'s SSR mode +
8
+ * `vue/server-renderer`.
9
+ *
10
+ * Every framework runtime is resolved from the *app's* node_modules (not
11
+ * @zerotal/inertia's), so an app only needs the libraries for the framework it
12
+ * actually uses. Vue `.vue` files additionally require the `.vue` runtime loader
13
+ * registered by InertiaProvider (see `registerVueRuntimeLoader`).
14
+ */
15
+
16
+ export type Framework = "vue" | "react";
17
+
18
+ export interface SsrPage {
19
+ component: string;
20
+ props: Record<string, unknown>;
21
+ url: string;
22
+ }
23
+
24
+ export interface SsrResult {
25
+ head: string[];
26
+ body: string;
27
+ }
28
+
29
+ /**
30
+ * Resolve a page component's module path and which frontend framework it targets,
31
+ * preferring a `.vue` SFC when present and falling back to `.tsx`.
32
+ *
33
+ * @param pagesDir - Absolute path to the pages directory.
34
+ * @param component - Page component name/path (no extension).
35
+ * @returns The absolute module path and its detected {@link Framework}.
36
+ * @internal
37
+ */
38
+ export async function resolvePageModule(
39
+ pagesDir: string,
40
+ component: string,
41
+ ): Promise<{ modPath: string; framework: Framework }> {
42
+ const vuePath = `${pagesDir}/${component}.vue`;
43
+ if (await Bun.file(vuePath).exists()) {
44
+ return { modPath: vuePath, framework: "vue" };
45
+ }
46
+ return { modPath: `${pagesDir}/${component}.tsx`, framework: "react" };
47
+ }
48
+
49
+ /**
50
+ * Render an Inertia page to an HTML body string + head tags on the server.
51
+ * `modPath` is an absolute path to the page module; `framework` selects the
52
+ * rendering runtime (use {@link resolvePageModule} to derive both).
53
+ *
54
+ * @param page - The `{ component, props, url }` page to render.
55
+ * @param modPath - Absolute path to the page component module.
56
+ * @param framework - Which runtime to render with (`"vue"` or `"react"`).
57
+ * @returns The rendered `{ head, body }` HTML.
58
+ * @internal
59
+ */
60
+ export async function renderInertiaPage(
61
+ page: SsrPage,
62
+ modPath: string,
63
+ framework: Framework,
64
+ ): Promise<SsrResult> {
65
+ if (framework === "vue") return _renderVue(page, modPath);
66
+ return _renderReact(page, modPath);
67
+ }
68
+
69
+ async function _renderVue(page: SsrPage, modPath: string): Promise<SsrResult> {
70
+ const cwd = process.cwd();
71
+
72
+ // Resolve Vue + the Inertia Vue adapter from the app's install.
73
+ const [inertiaVue, serverRenderer, vue, pageMod] = await Promise.all([
74
+ import(Bun.resolveSync("@inertiajs/vue3", cwd)) as Promise<{
75
+ createInertiaApp: (options: Record<string, unknown>) => Promise<SsrResult>;
76
+ }>,
77
+ import(Bun.resolveSync("vue/server-renderer", cwd)) as Promise<{
78
+ renderToString: (app: unknown) => Promise<string>;
79
+ }>,
80
+ import(Bun.resolveSync("vue", cwd)) as Promise<{
81
+ createSSRApp: (options: unknown) => { use: (plugin: unknown) => unknown };
82
+ h: (type: unknown, props: unknown) => unknown;
83
+ }>,
84
+ import(modPath) as Promise<{ default: unknown }>,
85
+ ]);
86
+
87
+ const { createSSRApp, h } = vue;
88
+
89
+ const result = await inertiaVue.createInertiaApp({
90
+ page,
91
+ render: (app: unknown) => serverRenderer.renderToString(app),
92
+ resolve: () => pageMod.default,
93
+ setup({ App, props, plugin }: { App: unknown; props: unknown; plugin: unknown }) {
94
+ return createSSRApp({ render: () => h(App, props) }).use(plugin);
95
+ },
96
+ });
97
+
98
+ return { head: result.head ?? [], body: result.body };
99
+ }
100
+
101
+ async function _renderReact(page: SsrPage, modPath: string): Promise<SsrResult> {
102
+ const pageMod = (await import(modPath)) as { default: unknown };
103
+ if (typeof pageMod.default !== "function") {
104
+ throw new Error(`SSR component "${page.component}" has no default export`);
105
+ }
106
+
107
+ const [reactMod, serverMod] = await Promise.all([
108
+ import("react") as Promise<{ createElement: (type: unknown, props: unknown) => unknown }>,
109
+ import("react-dom/server") as Promise<{ renderToString: (element: unknown) => string }>,
110
+ ]);
111
+
112
+ const element = reactMod.createElement(pageMod.default, { ...page.props, url: page.url });
113
+ return { head: [], body: serverMod.renderToString(element) };
114
+ }
package/src/types.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The Inertia "page object" — the serialisable payload that describes a page.
3
+ *
4
+ * On a first load it is embedded into the HTML shell's `data-page` script; on
5
+ * subsequent visits it is the JSON response body. The Inertia client reads it to
6
+ * mount/swap the page component and to drive partial-reload, merge, defer, once, and
7
+ * history behaviour. Built by {@link buildPageObject}; the optional fields are only
8
+ * present when the corresponding prop feature is in use.
9
+ */
10
+ export interface PageObject {
11
+ /** Page component name/path (relative to the pages dir, no extension), e.g. `"Users/Index"`. */
12
+ component: string;
13
+ /** The resolved props for the page. */
14
+ props: Record<string, unknown>;
15
+ /** The request URL (pathname + query) this page was rendered for. */
16
+ url: string;
17
+ /** Asset version; a mismatch triggers a full reload so the client picks up new assets. */
18
+ version: string;
19
+ /** Encrypt this page's history state in the browser (only set when true). */
20
+ encryptHistory?: boolean;
21
+ /** Clear any previously encrypted history state (only set when true). */
22
+ clearHistory?: boolean;
23
+ /** Preserve the URL fragment across a redirect. */
24
+ preserveFragment?: boolean;
25
+ /** Prop keys (or `key.path`) the client should append-merge instead of replacing. */
26
+ mergeProps?: string[];
27
+ /** Prop keys (or `key.path`) the client should prepend-merge. */
28
+ prependProps?: string[];
29
+ /** Prop keys (or `key.path`) the client should deep-merge. */
30
+ deepMergeProps?: string[];
31
+ /** `key.path` entries whose last segment is the field used to match items when merging. */
32
+ matchPropsOn?: string[];
33
+ /** Infinite-scroll pagination config, keyed by prop name. */
34
+ scrollProps?: Record<string, unknown>;
35
+ /** Deferred props grouped by request group; the client fetches them after first render. */
36
+ deferredProps?: Record<string, string[]>;
37
+ /** Deferred prop keys that threw and were rescued server-side. */
38
+ rescuedProps?: string[];
39
+ /** Once props, keyed by once-key → { prop, expiresAt }. */
40
+ onceProps?: Record<string, { prop: string; expiresAt: number | null }>;
41
+ /** Top-level shared prop keys, for instant-visit carry-over. */
42
+ sharedProps?: string[];
43
+ }
44
+
45
+ /** Options passed to `InertiaProvider` to configure the adapter at boot. */
46
+ export interface InertiaProviderOptions {
47
+ /** Path to the HTML template. Default: 'resources/app.html' */
48
+ htmlTemplate?: string;
49
+ /** Current asset version string. Used for cache-busting (409 responses). */
50
+ version?: string;
51
+ /** Public URL prefix for built assets. Default: '/assets' */
52
+ assetsUrl?: string;
53
+ }
package/src/version.ts ADDED
@@ -0,0 +1,22 @@
1
+ let _version = "";
2
+
3
+ /**
4
+ * Set the current asset version. Called by InertiaProvider during onBooting().
5
+ *
6
+ * @param v - The asset version string (from `inertia.version` config).
7
+ * @internal
8
+ */
9
+ export function setAssetVersion(v: string): void {
10
+ _version = v;
11
+ }
12
+
13
+ /**
14
+ * Returns the current asset version.
15
+ * Used in every PageObject response so the client can detect
16
+ * asset changes and trigger a full reload (409 Conflict response).
17
+ *
18
+ * @returns The asset version string (empty until set at boot).
19
+ */
20
+ export function assetVersion(): string {
21
+ return _version;
22
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Vue Single-File Component (`.vue`) support for Inertia's Bun.build() pipeline.
3
+ *
4
+ * Bun's bundler has no native `.vue` loader, so this module compiles SFCs with
5
+ * `@vue/compiler-sfc` inside a Bun plugin. The compiler is resolved from the
6
+ * *app's* node_modules (not @zerotal/inertia's), exactly like the Tailwind CSS
7
+ * plugin — so React apps that never install it are completely unaffected.
8
+ *
9
+ * `detectVuePlugin()` returns an empty array when `@vue/compiler-sfc` is not
10
+ * installed, making `.vue` support fully opt-in.
11
+ */
12
+ import type { BunPlugin } from "bun";
13
+
14
+ // Minimal structural type for the slice of @vue/compiler-sfc we use. Imported
15
+ // dynamically from the app's install, so we avoid a hard dependency here.
16
+ interface SfcCompiler {
17
+ parse(
18
+ source: string,
19
+ options: { filename: string },
20
+ ): { descriptor: SfcDescriptor; errors: unknown[] };
21
+ compileScript(
22
+ descriptor: SfcDescriptor,
23
+ options: Record<string, unknown>,
24
+ ): { content: string; bindings?: unknown };
25
+ compileTemplate(options: Record<string, unknown>): { code: string; errors: unknown[] };
26
+ compileStyle(options: Record<string, unknown>): { code: string };
27
+ rewriteDefault(input: string, as: string): string;
28
+ }
29
+
30
+ interface SfcDescriptor {
31
+ scriptSetup: unknown | null;
32
+ script: { content: string } | null;
33
+ template: { content: string } | null;
34
+ styles: { content: string; scoped: boolean }[];
35
+ slotted: boolean;
36
+ }
37
+
38
+ /**
39
+ * Resolve `@vue/compiler-sfc` from `cwd` and return a Bun plugin that compiles
40
+ * `.vue` files. Returns `[]` when the compiler is not installed.
41
+ */
42
+ export async function detectVuePlugin(cwd: string): Promise<BunPlugin[]> {
43
+ let compilerPath: string;
44
+ try {
45
+ compilerPath = Bun.resolveSync("@vue/compiler-sfc", cwd);
46
+ } catch {
47
+ return [];
48
+ }
49
+ const sfc = (await import(compilerPath)) as unknown as SfcCompiler;
50
+ return [vueSfcPlugin(sfc)];
51
+ }
52
+
53
+ let _runtimeLoaderRegistered = false;
54
+
55
+ /**
56
+ * Register the `.vue` compiler as a *runtime* Bun loader so server-side
57
+ * `import('*.vue')` works (used by SSR and `Inertia.stream()`). Build-time
58
+ * plugins only affect `Bun.build`, not the runtime module loader.
59
+ *
60
+ * No-op (and harmless) when `@vue/compiler-sfc` is not installed, and safe to
61
+ * call more than once — only the first call registers.
62
+ */
63
+ export async function registerVueRuntimeLoader(cwd: string): Promise<void> {
64
+ if (_runtimeLoaderRegistered) return;
65
+ const plugins = await detectVuePlugin(cwd);
66
+ if (plugins.length === 0) return;
67
+ for (const plugin of plugins) Bun.plugin(plugin);
68
+ _runtimeLoaderRegistered = true;
69
+ }
70
+
71
+ function vueSfcPlugin(sfc: SfcCompiler): BunPlugin {
72
+ return {
73
+ name: "zerotal-vue-sfc",
74
+ setup(build) {
75
+ build.onLoad({ filter: /\.vue$/ }, async (args) => {
76
+ const source = await Bun.file(args.path).text();
77
+ const { descriptor, errors } = sfc.parse(source, { filename: args.path });
78
+ if (errors.length > 0) {
79
+ throw new Error(`[Zerotal Vue] Failed to parse ${args.path}:\n${errors.join("\n")}`);
80
+ }
81
+
82
+ // Stable per-file id used for scoped-style hashing (data-v-<id>).
83
+ const id = Bun.hash(args.path).toString(16).slice(0, 8);
84
+ const hasScoped = descriptor.styles.some((s) => s.scoped);
85
+
86
+ let code: string;
87
+
88
+ if (descriptor.scriptSetup) {
89
+ // `<script setup>`: inline the template render fn straight into setup().
90
+ const script = sfc.compileScript(descriptor, {
91
+ id,
92
+ inlineTemplate: true,
93
+ templateOptions: hasScoped ? { id, scoped: true } : { id },
94
+ });
95
+ code = script.content; // already contains `export default ...`
96
+ } else {
97
+ // Plain `<script>` (or none) + separate template compile.
98
+ const scriptContent = descriptor.script
99
+ ? sfc.compileScript(descriptor, { id }).content
100
+ : "export default {}";
101
+ code = sfc.rewriteDefault(scriptContent, "__sfc_main__");
102
+
103
+ if (descriptor.template) {
104
+ const tpl = sfc.compileTemplate({
105
+ source: descriptor.template.content,
106
+ filename: args.path,
107
+ id,
108
+ scoped: hasScoped,
109
+ slotted: descriptor.slotted,
110
+ compilerOptions: { bindingMetadata: sfc.compileScript(descriptor, { id }).bindings },
111
+ });
112
+ if (tpl.errors.length > 0) {
113
+ throw new Error(
114
+ `[Zerotal Vue] Template error in ${args.path}:\n${tpl.errors.join("\n")}`,
115
+ );
116
+ }
117
+ code += `\n${tpl.code}\n__sfc_main__.render = render;`;
118
+ }
119
+ code += "\nexport default __sfc_main__;";
120
+ }
121
+
122
+ if (hasScoped) {
123
+ code += `\nif (typeof __sfc_main__ !== 'undefined') __sfc_main__.__scopeId = ${JSON.stringify(`data-v-${id}`)};`;
124
+ }
125
+
126
+ // Compile and runtime-inject any <style> blocks (Tailwind apps have none).
127
+ if (descriptor.styles.length > 0) {
128
+ let css = "";
129
+ for (const style of descriptor.styles) {
130
+ css += sfc.compileStyle({
131
+ source: style.content,
132
+ filename: args.path,
133
+ id: `data-v-${id}`,
134
+ scoped: style.scoped,
135
+ }).code;
136
+ }
137
+ code += `\n(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.setAttribute('data-vue-id',${JSON.stringify(id)});s.textContent=${JSON.stringify(css)};document.head.appendChild(s);}})();`;
138
+ }
139
+
140
+ return { contents: code, loader: "ts" };
141
+ });
142
+ },
143
+ };
144
+ }