diff --git a/.github/workflows/ui-test-vue.yml b/.github/workflows/ui-test-vue.yml index 8b29f7fb68..869d69062b 100644 --- a/.github/workflows/ui-test-vue.yml +++ b/.github/workflows/ui-test-vue.yml @@ -86,3 +86,6 @@ jobs: # fails if tokens.css has drifted from tokens.ts bun run tokens:check bun run test + # build the distributable and check its contract + bun run build + bun run verify:dist diff --git a/plugins/ui/bun.lock b/plugins/ui/bun.lock index 6db1ea1b67..5a3912803b 100644 --- a/plugins/ui/bun.lock +++ b/plugins/ui/bun.lock @@ -550,6 +550,7 @@ "vite": "^6.4.2", "vitest": "^4.0.18", "vue": "^3.5.17", + "vue-tsc": "^2.2.12", "vuetify": "3.12.0", }, "peerDependencies": { diff --git a/plugins/ui/libs/d2e-ui/.gitignore b/plugins/ui/libs/d2e-ui/.gitignore index 0c3679981e..eb16768191 100644 --- a/plugins/ui/libs/d2e-ui/.gitignore +++ b/plugins/ui/libs/d2e-ui/.gitignore @@ -1,2 +1,3 @@ node_modules/ .histoire/ +dist/ diff --git a/plugins/ui/libs/d2e-ui/README.md b/plugins/ui/libs/d2e-ui/README.md index 108a441e5c..02be85da8d 100644 --- a/plugins/ui/libs/d2e-ui/README.md +++ b/plugins/ui/libs/d2e-ui/README.md @@ -1,12 +1,34 @@ # `@d2e/ui` Vue 3 and Vuetify components for D2E, with the design tokens from the D2E -design system. The application uses the source directly. There is no build -step. +design system. The application reads the source directly through vite aliases. +Every other consumer uses the built artifact — see below. + +## Consuming the built package + +The application consumes this package as **source**, through aliases in +`apps/vue-mri-ui-lib/vite.config*.ts`. Any other consumer should use the built +artifact: + +```ts +import { D2eButton, D2eDialog } from "@d2e/ui"; +import "@d2e/ui/tokens.css"; +import "@d2e/ui/style.css"; // component styles — required for the built package +``` + +`style.css` is new with the build. Scoped SFC styles used to be compiled into +each consumer's own bundle; the built package emits them as one file instead. +The application does not need it while it reads source. + +`vue` and `vuetify` are peer dependencies and are never bundled. Components +import the Vuetify pieces they use, so a consumer does **not** need +`vite-plugin-vuetify`. ## Commands ```bash +bun run build # dist/index.js, dist/index.css, dist/types +bun run verify:dist # checks exports and that peers stayed external bun run test # unit tests bun run tokens:build # write src/tokens/tokens.css from src/tokens/tokens.ts bun run tokens:check # fail if tokens.css is not current diff --git a/plugins/ui/libs/d2e-ui/package.json b/plugins/ui/libs/d2e-ui/package.json index 6fd60fbc4c..78c1faafa8 100644 --- a/plugins/ui/libs/d2e-ui/package.json +++ b/plugins/ui/libs/d2e-ui/package.json @@ -5,17 +5,25 @@ "type": "module", "description": "D2E Vue 3 + Vuetify component library", "exports": { - ".": "./src/index.ts", + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/index.css", "./tokens.css": "./src/tokens/tokens.css" }, "files": [ + "dist", "src" ], "scripts": { "tokens:build": "tsx scripts/build-tokens.ts", "tokens:check": "tsx scripts/build-tokens.ts && git diff --exit-code src/tokens/tokens.css", "test": "vitest run", - "lint": "prettier --write ." + "lint": "prettier --write .", + "build": "vite build --config vite.config.lib.ts && bun run build:types", + "build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types -p tsconfig.build.json", + "verify:dist": "node scripts/verify-dist.mjs" }, "peerDependencies": { "vue": "^3.5.0", @@ -28,6 +36,10 @@ "vite": "^6.4.2", "vitest": "^4.0.18", "vue": "^3.5.17", + "vue-tsc": "^2.2.12", "vuetify": "3.12.0" - } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/types/index.d.ts" } diff --git a/plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs b/plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs new file mode 100644 index 0000000000..029d4f4e46 --- /dev/null +++ b/plugins/ui/libs/d2e-ui/scripts/verify-dist.mjs @@ -0,0 +1,99 @@ +// Verifies the built artifact without executing it. Plain node cannot import +// dist/index.js: vuetify's ESM pulls in .css files, which only a bundler +// resolves. These static checks catch the failures that actually matter — +// a missing export, or a peer dependency accidentally bundled in. +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const js = path.join(root, "dist/index.js"); +const css = path.join(root, "dist/index.css"); +const types = path.join(root, "dist/types/index.d.ts"); + +const problems = []; + +for (const f of [js, css, types]) { + if (!existsSync(f)) + problems.push(`missing artifact: ${path.relative(root, f)}`); +} +if (problems.length) { + console.error(problems.join("\n")); + process.exit(1); +} + +const bundle = readFileSync(js, "utf8"); + +const EXPECTED = [ + "D2eButton", + "D2eCard", + "D2eDialog", + "D2eExplorationCard", + "D2eIconButton", + "D2eMenu", + "D2eStatusChip", + "D2eTextField", + "D2eToolbar", + "DIALOG_SIZE_MAP", + "EXPLORATION_STATUS_MAP", + "ICON_BUTTON_SIZE_MAP", + "SIZE_MAP", + "STATUS_CHIP_VARIANT_MAP", + "VARIANT_MAP", + "buildD2eVuetifyOptions", + "tokens", +]; +// Pull the names out of the final `export { ... }` block. +const exportBlock = bundle.match(/export\s*\{([\s\S]*?)\}/); +if (!exportBlock) { + console.error("no export block found in dist/index.js"); + process.exit(1); +} +const exported = new Set( + exportBlock[1] + .split(",") + .map((part) => + part + .trim() + .split(/\s+as\s+/) + .pop(), + ) + .filter(Boolean), +); +const missing = EXPECTED.filter((n) => !exported.has(n)); +if (missing.length) problems.push(`missing exports: ${missing.join(", ")}`); + +// vue and vuetify are peers; they must appear only as import specifiers. +const specifiers = new Set( + [...bundle.matchAll(/from ?"([^"]+)"/g)].map((m) => m[1]), +); +const unexpected = [...specifiers].filter( + (s) => s !== "vue" && !s.startsWith("vuetify"), +); +if (unexpected.length) + problems.push(`unexpected runtime imports: ${unexpected.join(", ")}`); + +// Positive assertions. Checking only for *unexpected* specifiers misses the +// case that matters: if a peer is bundled it stops appearing as an import at +// all, so its absence is the symptom. +if (!specifiers.has("vue")) + problems.push("vue is not imported — it may be bundled"); +if (![...specifiers].some((s) => s.startsWith("vuetify"))) + problems.push("vuetify is not imported — it may be bundled"); + +// Backstop: the library is small once the peers are external. Bundling +// vuetify inflates it by an order of magnitude. +const MAX_BYTES = 150_000; +const size = readFileSync(js).length; +if (size > MAX_BYTES) + problems.push( + `dist/index.js is ${size} bytes (limit ${MAX_BYTES}) — a peer is probably bundled`, + ); + +if (problems.length) { + console.error(problems.join("\n")); + process.exit(1); +} +console.log( + `dist ok — ${EXPECTED.length} exports, peers external (${[...specifiers].join(", ")})`, +); diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts index 96f86d49aa..e3fa749fcd 100644 --- a/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts +++ b/plugins/ui/libs/d2e-ui/src/__tests__/d2e-button.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SIZE_MAP, VARIANT_MAP } from "../components/D2eButton.vue"; +import { SIZE_MAP, VARIANT_MAP } from "../components/buttonVariants"; describe("D2eButton lookup tables", () => { it("maps every variant to the Vuetify variant/color pair", () => { diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts index 260246cb2e..b61e7e6afe 100644 --- a/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts +++ b/plugins/ui/libs/d2e-ui/src/__tests__/explorer-parity.test.ts @@ -19,7 +19,11 @@ const explorer = readManifest("explorer/package.json"); const app = readManifest("../../apps/vue-mri-ui-lib/package.json"); const minorOf = (range: string): string => - range.replace(/^[^\d]*/, "").split(".").slice(0, 2).join("."); + range + .replace(/^[^\d]*/, "") + .split(".") + .slice(0, 2) + .join("."); describe("explorer stays outside the workspace", () => { it("keeps the workspace globs one level deep", () => { @@ -46,7 +50,7 @@ describe("explorer matches the application", () => { it("uses the same vue minor version as the application", () => { expect(minorOf(explorer.devDependencies.vue)).toBe( - minorOf(app.dependencies.vue) + minorOf(app.dependencies.vue), ); }); diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts index 5bf2df421b..2174eb6df4 100644 --- a/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts +++ b/plugins/ui/libs/d2e-ui/src/__tests__/icon-button.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ICON_BUTTON_SIZE_MAP } from "../components/D2eIconButton.vue"; +import { ICON_BUTTON_SIZE_MAP } from "../components/iconButtonSizes"; describe("D2eIconButton size map", () => { it("maps sizes to container and icon dimensions", () => { diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts index 15130e5ecd..1ff06d2cd2 100644 --- a/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts +++ b/plugins/ui/libs/d2e-ui/src/__tests__/status-chip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { STATUS_CHIP_VARIANT_MAP } from "../components/D2eStatusChip.vue"; +import { STATUS_CHIP_VARIANT_MAP } from "../components/statusChipVariants"; describe("D2eStatusChip variant map", () => { it("maps every variant to the Figma background/text pair", () => { diff --git a/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts b/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts index 52bf14d5cd..b703665387 100644 --- a/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts +++ b/plugins/ui/libs/d2e-ui/src/__tests__/tokens.test.ts @@ -6,8 +6,8 @@ describe("tokens.css generator", () => { const css = generateTokensCss(); expect( css.startsWith( - "/* GENERATED — DO NOT EDIT.\n * Source: src/tokens/tokens.ts." - ) + "/* GENERATED — DO NOT EDIT.\n * Source: src/tokens/tokens.ts.", + ), ).toBe(true); }); diff --git a/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue b/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue index edf6518d99..f3dea55d75 100644 --- a/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue +++ b/plugins/ui/libs/d2e-ui/src/components/D2eButton.vue @@ -15,30 +15,10 @@ - - - - -