mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio): export the header logo, button sizes and a stable styles.css subpath (#4197)
A host mounting Studio's header reuses HyperframesLogo and buttonSizes for matching chrome, and imports the compiled stylesheet as a fixed-name package export instead of a hash that changes on every rebuild.
This commit is contained in:
@@ -19,6 +19,12 @@
|
||||
"types": null,
|
||||
"environments": ["browser"]
|
||||
},
|
||||
"./styles.css": {
|
||||
"source": "./src/styles/studio.css",
|
||||
"runtime": "./dist/styles.css",
|
||||
"types": null,
|
||||
"environments": ["browser"]
|
||||
},
|
||||
"./package.json": {
|
||||
"source": "./package.json",
|
||||
"runtime": "./package.json",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"types": "./src/styles/tailwind-preset.ts"
|
||||
},
|
||||
"./theme.css": "./src/styles/theme.css",
|
||||
"./styles.css": "./src/styles/studio.css",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -41,6 +42,7 @@
|
||||
"types": "./dist/styles/tailwind-preset.d.ts"
|
||||
},
|
||||
"./theme.css": "./src/styles/theme.css",
|
||||
"./styles.css": "./dist/styles.css",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface StudioHeaderProps {
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
function HyperframesLogo() {
|
||||
export function HyperframesLogo() {
|
||||
// Full logo from logo-dark.svg (263×79): heygen label + gradient mark + hyperframes wordmark.
|
||||
// All fill="black" paths inverted to white for the dark header.
|
||||
const height = 28;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Imports the header logo and button size classes the way a host app does: by package name.
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HyperframesLogo, buttonSizes } from "@hyperframes/studio";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe("header package exports", () => {
|
||||
it("mounts the logo mark", async () => {
|
||||
const el = document.createElement("div");
|
||||
document.body.append(el);
|
||||
const root = createRoot(el);
|
||||
await act(async () => root.render(<HyperframesLogo />));
|
||||
expect(el.querySelector("svg")).not.toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("exposes the button size classes a host reuses for matching chrome", () => {
|
||||
expect(buttonSizes.md).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
// UI primitives
|
||||
export { Button, buttonBase, buttonVariants } from "./components/ui/Button";
|
||||
export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
|
||||
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
|
||||
export { IconButton } from "./components/ui/IconButton";
|
||||
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
|
||||
export { Tooltip } from "./components/ui/Tooltip";
|
||||
export { HyperframesLogo } from "./components/StudioHeader";
|
||||
export { cn } from "./components/ui/cn";
|
||||
export {
|
||||
ContextMenu,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import config, { stableStylesCssPlugin } from "./vite.config";
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8")) as {
|
||||
publishConfig: { exports: Record<string, string> };
|
||||
};
|
||||
const subpaths = JSON.parse(readFileSync(resolve(__dirname, "package-subpaths.json"), "utf8")) as {
|
||||
subpaths: Record<string, { runtime: string }>;
|
||||
};
|
||||
|
||||
// Rollup's own asset/chunk types aren't imported here; the plugin only reads
|
||||
// `type` and `fileName`, so a minimal shape is enough to drive it.
|
||||
function asset(fileName: string): { type: "asset"; fileName: string } {
|
||||
return { type: "asset", fileName };
|
||||
}
|
||||
function chunk(fileName: string): { type: "chunk"; fileName: string } {
|
||||
return { type: "chunk", fileName };
|
||||
}
|
||||
|
||||
function callWriteBundle(dir: string, bundle: Record<string, unknown>): void {
|
||||
const writeBundle = stableStylesCssPlugin().writeBundle as (
|
||||
options: { dir: string },
|
||||
bundle: Record<string, unknown>,
|
||||
) => void;
|
||||
writeBundle({ dir }, bundle);
|
||||
}
|
||||
|
||||
describe("build.rollupOptions", () => {
|
||||
it("does not override assetFileNames, so every asset stays content-hashed", () => {
|
||||
// Regression for the cache bug this PR fixed: /assets/* is served with a
|
||||
// one-year immutable Cache-Control by filename convention alone
|
||||
// (packages/cli/src/server/studioServer.ts), so an unhashed name there
|
||||
// sticks to every CLI user's cache for a year.
|
||||
expect(config.build?.rollupOptions?.output).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("./styles.css export path", () => {
|
||||
it("resolves outside dist/assets, so it never inherits the immutable cache header", () => {
|
||||
const publishedPath = packageJson.publishConfig.exports["./styles.css"];
|
||||
const runtimePath = subpaths.subpaths["./styles.css"]?.runtime;
|
||||
expect(publishedPath).toBe("./dist/styles.css");
|
||||
expect(runtimePath).toBe("./dist/styles.css");
|
||||
for (const path of [publishedPath, runtimePath]) {
|
||||
expect(path?.startsWith("./dist/assets/")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stableStylesCssPlugin", () => {
|
||||
function tmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "styles-css-plugin-"));
|
||||
}
|
||||
|
||||
it("copies the single CSS asset, unhashed, to dist root, ignoring non-asset bundle entries", () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeFileSync(join(dir, "app-abc123.css"), "body{color:red}");
|
||||
callWriteBundle(dir, {
|
||||
"app-abc123.css": asset("app-abc123.css"),
|
||||
"app-def456.js": chunk("app-def456.js"),
|
||||
// A chunk can't really carry a `.css` name, but proving the `type`
|
||||
// guard (not just the filename suffix) does the filtering keeps the
|
||||
// count right if a future asset kind ever ends in `.css` too.
|
||||
"fake.css": chunk("fake.css"),
|
||||
});
|
||||
expect(readFileSync(join(dir, "styles.css"), "utf8")).toBe("body{color:red}");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws instead of guessing when the build emits zero or several CSS assets", () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
expect(() => callWriteBundle(dir, { "app.js": chunk("app.js") })).toThrow(/found 0/);
|
||||
writeFileSync(join(dir, "a.css"), "a");
|
||||
writeFileSync(join(dir, "b.css"), "b");
|
||||
expect(() =>
|
||||
callWriteBundle(dir, { "a.css": asset("a.css"), "b.css": asset("b.css") }),
|
||||
).toThrow(/found 2/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
|
||||
import {
|
||||
copyFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||
import { watch } from "chokidar";
|
||||
@@ -27,6 +34,30 @@ async function loadRuntimeSourceForDev(
|
||||
|
||||
const studioPkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
|
||||
|
||||
/**
|
||||
* Copies the build's one CSS asset, unhashed, to `dist/styles.css` for the
|
||||
* `./styles.css` export. Throws if the build ever emits more than one.
|
||||
*/
|
||||
export function stableStylesCssPlugin(): Plugin {
|
||||
return {
|
||||
name: "studio-stable-styles-css",
|
||||
writeBundle(options, bundle) {
|
||||
const cssAssets = Object.values(bundle).filter(
|
||||
(item) => item.type === "asset" && item.fileName.endsWith(".css"),
|
||||
);
|
||||
if (cssAssets.length !== 1) {
|
||||
throw new Error(
|
||||
`stableStylesCssPlugin: expected exactly one CSS asset for the ./styles.css ` +
|
||||
`export, found ${cssAssets.length} (${cssAssets.map((a) => a.fileName).join(", ") || "none"}). ` +
|
||||
`Scope this plugin to the entry stylesheet instead of assuming a single emit.`,
|
||||
);
|
||||
}
|
||||
const outDir = options.dir ?? "dist";
|
||||
copyFileSync(join(outDir, cssAssets[0]!.fileName), join(outDir, "styles.css"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
|
||||
|
||||
async function bridgeHonoResponse(
|
||||
@@ -262,6 +293,12 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
// /assets/* caches by filename alone, immutably, for a year
|
||||
// (studioServer.ts). Keep every hash; copy one CSS file, unhashed,
|
||||
// to the dist ROOT instead for the ./styles.css export.
|
||||
plugins: [stableStylesCssPlugin()],
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["bpm-detective"],
|
||||
|
||||
Reference in New Issue
Block a user