mirror of
https://github.com/aphrody-labs/aphrody-m3-config.git
synced 2026-10-13 19:54:16 +00:00
chore: migrate package sources from Aphrody
This commit is contained in:
1 parent
a97691eec2
commit
8a60e6518c
11 files changed
+754
-2
No files matched your search
@@ -1,2 +1,17 @@
|
|||||||
# aphrody-m3-config
|
# @aphrody/m3-config
|
||||||
Aphrody package @aphrody/m3-config
|
|
||||||
|
Shared presets for the first-party Material Design 3 packages (`packages/m3-*`, `packages/m3-react`,
|
||||||
|
`packages/material-web`). Private; consumed as a `workspace:*` devDependency. Contract and status:
|
||||||
|
[`docs/design/M3-PLAN.md`](../../docs/plans/design/M3-PLAN.md), enforced by `bun run bun:check` (rule `m3`).
|
||||||
|
|
||||||
|
| Export | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `@aphrody/m3-config/tsconfig.json` | `"extends"` for `tsconfig.json`; the package keeps only `include` and its own overrides. |
|
||||||
|
| `@aphrody/m3-config/tsconfig.build.json` | Second entry of `"extends": ["./tsconfig.json", "@aphrody/m3-config/tsconfig.build.json"]` (declaration-only emit); the package sets `outDir`, `rootDir`, `include`. |
|
||||||
|
| `@aphrody/m3-config/bun-build` | The one build preset, on `Bun.build`: `m3Package({ entry, target, splitting, external, useClient })` types a package's `build.config.ts`; `scripts/build.ts` at the repository root runs every config (dependency order, content-hash cache, declarations through `tsc`). `m3BunBuild(...)` is the imperative form; `M3_EXTERNAL` and `prependUseClient` (`'use client';` through `Bun.file` / `Bun.write`) are defined once here. |
|
||||||
|
| `@aphrody/m3-config/cache-db` | `M3UiCache` / `getSharedUiCache()`: embedded SQLite cache on `bun:sqlite` (WAL mode) for themes, icons, tokens, sessions. |
|
||||||
|
| `@aphrody/m3-config/test/dom` | `bun test` preload: happy-dom + ElementInternals polyfill. |
|
||||||
|
|
||||||
|
Workspace packages resolve to their sources under Bun (`bun` / `import` conditions); `dist/` is built by
|
||||||
|
`bun scripts/build.ts` for consumers that need the `default` condition and the declarations. Bun resolves bare
|
||||||
|
package specifiers natively before any runtime plugin sees them, so a preload cannot redirect them to sources.
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
/**
|
||||||
|
* The one build preset of the first-party Material Design 3 packages, on `Bun.build`.
|
||||||
|
*
|
||||||
|
* Declarative, per package (`build.config.ts`):
|
||||||
|
*
|
||||||
|
* import { m3Package } from "@aphrody/m3-config/bun-build";
|
||||||
|
* export default m3Package({ entry: ["src/index.ts", "src/react.tsx"], useClient: ["react.js"] });
|
||||||
|
*
|
||||||
|
* `scripts/build.ts` at the repository root reads every `build.config.ts`, builds the packages in dependency
|
||||||
|
* order with a content-hash cache and emits the declarations. Imperative, for one-off use:
|
||||||
|
*
|
||||||
|
* import { m3BunBuild } from "@aphrody/m3-config/bun-build";
|
||||||
|
* await m3BunBuild({ entry: ["src/index.ts"] }); // or: bun packages/m3-config/bun-build.ts --entry src/index.ts
|
||||||
|
*
|
||||||
|
* Provides native ESM bundling with code splitting, the React client directive (`'use client';`)
|
||||||
|
* through `Bun.file` / `Bun.write`, external sourcemaps and the shared external list.
|
||||||
|
*/
|
||||||
|
import { Glob, type BuildArtifact, type BunPlugin } from "bun";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
export const USE_CLIENT = "'use client';\n";
|
||||||
|
|
||||||
|
/** Everything the M3 packages never bundle: React, Lit, motion, Tailwind and the workspace scope. */
|
||||||
|
export const M3_EXTERNAL: string[] = [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"lit",
|
||||||
|
"lit/*",
|
||||||
|
"@lit/*",
|
||||||
|
"motion",
|
||||||
|
"motion/*",
|
||||||
|
"tailwindcss",
|
||||||
|
"@aphrody/*",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type M3Target = "browser" | "bun" | "node";
|
||||||
|
|
||||||
|
/** What a package declares in `build.config.ts`. Entry paths may be globs, relative to the package. */
|
||||||
|
export interface M3PackageConfig {
|
||||||
|
/** Entrypoint(s). Default `["src/index.ts"]`. */
|
||||||
|
entry?: string | readonly string[];
|
||||||
|
/** Runtime target (default `browser`). */
|
||||||
|
target?: M3Target;
|
||||||
|
/** Code splitting across entries (default true). */
|
||||||
|
splitting?: boolean;
|
||||||
|
/** Packages left unbundled in addition to {@link M3_EXTERNAL} and the package's own dependencies and peers; wildcards allowed. */
|
||||||
|
external?: readonly string[];
|
||||||
|
/**
|
||||||
|
* `true` (default) marks every emitted JS file with `'use client';`, a list limits that to the
|
||||||
|
* named files (`react.js`, `shims/link.js`), `false` skips it (packages without React code).
|
||||||
|
*/
|
||||||
|
useClient?: boolean | readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Types a `build.config.ts`; the identity otherwise. */
|
||||||
|
export function m3Package(config: M3PackageConfig = {}): M3PackageConfig {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3BunBuildOptions extends M3PackageConfig {
|
||||||
|
/** Output directory. Defaults to `"dist"`. */
|
||||||
|
outDir?: string;
|
||||||
|
/** Module format: `"esm"` (default) or `"cjs"`. */
|
||||||
|
format?: "esm" | "cjs";
|
||||||
|
/** Sourcemap mode. Defaults to `"external"`. */
|
||||||
|
sourcemap?: "external" | "inline" | "none";
|
||||||
|
/** Minification. Defaults to false. */
|
||||||
|
minify?: boolean | { whitespace?: boolean; syntax?: boolean; identifiers?: boolean };
|
||||||
|
/** Additional Bun plugins. */
|
||||||
|
plugins?: BunPlugin[];
|
||||||
|
/** Compile-time constant defines. */
|
||||||
|
define?: Record<string, string>;
|
||||||
|
/** Working directory. Defaults to `process.cwd()`. */
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M3BunBuildResult {
|
||||||
|
success: boolean;
|
||||||
|
outDir: string;
|
||||||
|
outputs: BuildArtifact[];
|
||||||
|
logs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The packages a build leaves unbundled: {@link M3_EXTERNAL}, `node:*`, the `dependencies` and `peerDependencies` of the
|
||||||
|
* package at `cwd` (consumers install them) and `extra`. Anything else a package imports is bundled in.
|
||||||
|
*/
|
||||||
|
export function externals(cwd: string, extra: readonly string[] = []): string[] {
|
||||||
|
const manifest = join(cwd, "package.json");
|
||||||
|
const names = new Set<string>();
|
||||||
|
if (existsSync(manifest)) {
|
||||||
|
const { dependencies, peerDependencies } = JSON.parse(readFileSync(manifest, "utf8")) as {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
peerDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
for (const name of [
|
||||||
|
...Object.keys(dependencies ?? {}),
|
||||||
|
...Object.keys(peerDependencies ?? {}),
|
||||||
|
]) {
|
||||||
|
names.add(name);
|
||||||
|
names.add(`${name}/*`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `node:*` stays an import in every target: the browser target would otherwise inline Node polyfills.
|
||||||
|
return [...new Set([...M3_EXTERNAL, "node:*", ...names, ...extra])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand glob entries (`wrappers/*.ts`) against `cwd`; plain paths pass through. */
|
||||||
|
export function expandEntries(entries: string | readonly string[], cwd: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of typeof entries === "string" ? [entries] : entries) {
|
||||||
|
if (/[*?{]/.test(entry)) {
|
||||||
|
// eslint-disable-next-line unicorn/no-array-sort -- Sorting a fresh array keeps the shared ES2022 type contract.
|
||||||
|
const matches = [...new Glob(entry).scanSync({ cwd, onlyFiles: true })].sort();
|
||||||
|
if (matches.length === 0) throw new Error(`m3-config: entry glob matches nothing: ${entry}`);
|
||||||
|
out.push(...matches.map((m) => resolve(cwd, m)));
|
||||||
|
} else out.push(resolve(cwd, entry));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deepest directory that contains every entry: the base of the output layout (`src/a.ts` -> `dist/a.js`). */
|
||||||
|
export function commonDir(files: readonly string[]): string {
|
||||||
|
const [first, ...rest] = files.map((f) => dirname(f).replaceAll("\\", "/").split("/"));
|
||||||
|
let common = first ?? [];
|
||||||
|
for (const parts of rest) {
|
||||||
|
let i = 0;
|
||||||
|
while (i < common.length && i < parts.length && common[i] === parts[i]) i++;
|
||||||
|
common = common.slice(0, i);
|
||||||
|
}
|
||||||
|
return common.join("/") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend the React client directive to emitted `.js` files using Bun's native Glob and I/O.
|
||||||
|
* Returns the number of files changed; a second run changes none.
|
||||||
|
*/
|
||||||
|
export async function prependUseClient(dir: string, only?: readonly string[]): Promise<number> {
|
||||||
|
const files: string[] = [];
|
||||||
|
for await (const found of new Glob("**/*.js").scan({ cwd: dir, onlyFiles: true })) {
|
||||||
|
const path = found.replaceAll("\\", "/");
|
||||||
|
if (!only || only.includes(path.split("/").pop() ?? path) || only.includes(path))
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
const changed = await Promise.all(
|
||||||
|
files.map(async (path) => {
|
||||||
|
const file = join(dir, path);
|
||||||
|
const content = await Bun.file(file).text();
|
||||||
|
if (content.startsWith(USE_CLIENT)) return 0;
|
||||||
|
await Bun.write(file, USE_CLIENT + content);
|
||||||
|
return 1;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return changed.reduce<number>((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes a native Bun build for an M3 package. */
|
||||||
|
export async function m3BunBuild(options: M3BunBuildOptions = {}): Promise<M3BunBuildResult> {
|
||||||
|
const cwd = options.cwd ?? process.cwd();
|
||||||
|
const entrypoints = expandEntries(options.entry ?? ["src/index.ts"], cwd);
|
||||||
|
const outdir = resolve(cwd, options.outDir ?? "dist");
|
||||||
|
const useClient = options.useClient ?? true;
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints,
|
||||||
|
root: commonDir(entrypoints),
|
||||||
|
outdir,
|
||||||
|
target: options.target ?? "browser",
|
||||||
|
format: options.format ?? "esm",
|
||||||
|
splitting: options.splitting ?? true,
|
||||||
|
sourcemap: options.sourcemap ?? "external",
|
||||||
|
minify: options.minify ?? false,
|
||||||
|
external: externals(cwd, options.external),
|
||||||
|
plugins: options.plugins ?? [],
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),
|
||||||
|
...options.define,
|
||||||
|
},
|
||||||
|
throw: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
outDir: outdir,
|
||||||
|
outputs: result.outputs,
|
||||||
|
logs: result.logs.map((log) => String(log)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (useClient !== false)
|
||||||
|
await prependUseClient(
|
||||||
|
outdir,
|
||||||
|
Array.isArray(useClient) ? (useClient as readonly string[]) : undefined,
|
||||||
|
);
|
||||||
|
return { success: true, outDir: outdir, outputs: result.outputs, logs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct CLI invocation: `bun packages/m3-config/bun-build.ts --entry src/index.ts`
|
||||||
|
if (import.meta.main) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options: M3BunBuildOptions = {};
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === "--entry" || arg === "-e") options.entry = args[++i]!;
|
||||||
|
else if (arg === "--outdir" || arg === "-o") options.outDir = args[++i]!;
|
||||||
|
else if (arg === "--target" || arg === "-t") options.target = args[++i] as M3Target;
|
||||||
|
else if (arg === "--minify" || arg === "-m") options.minify = true;
|
||||||
|
}
|
||||||
|
const res = await m3BunBuild(options);
|
||||||
|
if (!res.success) {
|
||||||
|
console.error("[m3-bun-build] Failed to build:", res.logs.join("\n"));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[m3-bun-build] Successfully built ${res.outputs.length} artifact(s) to ${res.outDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
/**
|
||||||
|
* Native Bun SQLite cache for Aphrody UI runtime and tooling.
|
||||||
|
*
|
||||||
|
* Backed by Bun's built-in `bun:sqlite` with WAL mode:
|
||||||
|
* - Ultra-low latency (<100µs)
|
||||||
|
* - Zero external dependencies
|
||||||
|
* - High-concurrency safe via WAL journal mode
|
||||||
|
* - Used for caching theme palettes, icon SVGs, CSS tokens, and UI session state.
|
||||||
|
*/
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
export interface CacheEntry<T = unknown> {
|
||||||
|
key: string;
|
||||||
|
namespace: string;
|
||||||
|
value: T;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheStats {
|
||||||
|
totalEntries: number;
|
||||||
|
namespaces: Record<string, number>;
|
||||||
|
databasePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class M3UiCache {
|
||||||
|
private db: Database;
|
||||||
|
private path: string;
|
||||||
|
|
||||||
|
constructor(dbPath: string = ":memory:") {
|
||||||
|
this.path = dbPath;
|
||||||
|
if (dbPath !== ":memory:") {
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db = new Database(dbPath, { create: true });
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(): void {
|
||||||
|
this.db.run("PRAGMA journal_mode = WAL;");
|
||||||
|
this.db.run("PRAGMA synchronous = NORMAL;");
|
||||||
|
this.db.run("PRAGMA temp_store = MEMORY;");
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ui_cache (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (namespace, key)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ui_cache_ns ON ui_cache (namespace);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a cached value, or returns null if not found.
|
||||||
|
*/
|
||||||
|
get<T = unknown>(namespace: string, key: string): T | null {
|
||||||
|
const row = this.db
|
||||||
|
.query<{ value: string }, [string, string]>(
|
||||||
|
"SELECT value FROM ui_cache WHERE namespace = ? AND key = ? LIMIT 1",
|
||||||
|
)
|
||||||
|
.get(namespace, key);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value) as T;
|
||||||
|
} catch {
|
||||||
|
return row.value as unknown as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value in the cache under namespace and key.
|
||||||
|
*/
|
||||||
|
set<T = unknown>(namespace: string, key: string, value: T): void {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
if (serialized === undefined) throw new TypeError("M3UiCache: value must be JSON serializable");
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.query(
|
||||||
|
`INSERT INTO ui_cache (key, namespace, value, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (namespace, key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run(key, namespace, serialized, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a specific key from the cache.
|
||||||
|
*/
|
||||||
|
delete(namespace: string, key: string): boolean {
|
||||||
|
const result = this.db
|
||||||
|
.query("DELETE FROM ui_cache WHERE namespace = ? AND key = ?")
|
||||||
|
.run(namespace, key);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears an entire namespace, or all entries if no namespace specified.
|
||||||
|
*/
|
||||||
|
clear(namespace?: string): number {
|
||||||
|
if (namespace !== undefined) {
|
||||||
|
const result = this.db.query("DELETE FROM ui_cache WHERE namespace = ?").run(namespace);
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
const result = this.db.query("DELETE FROM ui_cache").run();
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns cache metrics and entry counts.
|
||||||
|
*/
|
||||||
|
stats(): CacheStats {
|
||||||
|
const totalRow = this.db
|
||||||
|
.query<{ total: number }, []>("SELECT COUNT(*) AS total FROM ui_cache")
|
||||||
|
.get();
|
||||||
|
const total = totalRow?.total ?? 0;
|
||||||
|
|
||||||
|
const nsRows = this.db
|
||||||
|
.query<{ namespace: string; count: number }, []>(
|
||||||
|
"SELECT namespace, COUNT(*) AS count FROM ui_cache GROUP BY namespace",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const namespaces: Record<string, number> = Object.create(null);
|
||||||
|
for (const r of nsRows) {
|
||||||
|
namespaces[r.namespace] = r.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEntries: total,
|
||||||
|
namespaces,
|
||||||
|
databasePath: this.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the underlying SQLite database connection.
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global shared instance for in-process UI caching */
|
||||||
|
let defaultInstance: M3UiCache | null = null;
|
||||||
|
|
||||||
|
export function getSharedUiCache(path?: string): M3UiCache {
|
||||||
|
if (!defaultInstance) {
|
||||||
|
const defaultPath =
|
||||||
|
path ??
|
||||||
|
process.env.APHRODY_UI_CACHE_PATH ??
|
||||||
|
(process.env.HOME ? `${process.env.HOME}/.aphrody/cache/ui.sqlite` : ":memory:");
|
||||||
|
defaultInstance = new M3UiCache(defaultPath);
|
||||||
|
}
|
||||||
|
return defaultInstance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@aphrody/m3-config",
|
||||||
|
"version": "1.4.4",
|
||||||
|
"description": "Shared build, typecheck and test presets for the @aphrody Material Design 3 packages: tsconfig, the Bun.build preset and the happy-dom test preload.",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"author": "aphrody-code",
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"bun-build.ts",
|
||||||
|
"cache-db.ts",
|
||||||
|
"tsconfig.build.json",
|
||||||
|
"tsconfig.check.json",
|
||||||
|
"tsconfig.json",
|
||||||
|
"test/dom.ts"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./tsconfig.json": "./tsconfig.json",
|
||||||
|
"./tsconfig.build.json": "./tsconfig.build.json",
|
||||||
|
"./bun-build": "./bun-build.ts",
|
||||||
|
"./cache-db": "./cache-db.ts",
|
||||||
|
"./test/dom": "./test/dom.ts"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test",
|
||||||
|
"typecheck": "bun check -p tsconfig.check.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@happy-dom/global-registrator": "catalog:",
|
||||||
|
"element-internals-polyfill": "catalog:"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { M3_EXTERNAL, commonDir, expandEntries, externals, m3BunBuild, m3Package, prependUseClient } from "../bun-build.ts";
|
||||||
|
|
||||||
|
const DIRECTIVE = "'use client';\n";
|
||||||
|
let dir = "";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), "m3-bun-build-"));
|
||||||
|
await mkdir(join(dir, "src"), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (dir) await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("m3BunBuild native preset", () => {
|
||||||
|
test("prepends 'use client' using native Bun I/O", async () => {
|
||||||
|
const dist = join(dir, "dist");
|
||||||
|
await mkdir(join(dist, "sub"), { recursive: true });
|
||||||
|
await Bun.write(join(dist, "index.js"), "export const a = 1;\n");
|
||||||
|
await Bun.write(join(dist, "sub", "btn.js"), "export const b = 2;\n");
|
||||||
|
|
||||||
|
const modified = await prependUseClient(dist);
|
||||||
|
expect(modified).toBe(2);
|
||||||
|
|
||||||
|
const content1 = await Bun.file(join(dist, "index.js")).text();
|
||||||
|
const content2 = await Bun.file(join(dist, "sub", "btn.js")).text();
|
||||||
|
expect(content1).toBe(`${DIRECTIVE}export const a = 1;\n`);
|
||||||
|
expect(content2).toBe(`${DIRECTIVE}export const b = 2;\n`);
|
||||||
|
|
||||||
|
// Second run should be idempotent
|
||||||
|
const modifiedSecond = await prependUseClient(dist);
|
||||||
|
expect(modifiedSecond).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bundles TypeScript code into dist using Bun.build", async () => {
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "src", "index.ts"),
|
||||||
|
`export const greet = (name: string): string => 'Hello ' + name;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await m3BunBuild({
|
||||||
|
entry: "src/index.ts",
|
||||||
|
cwd: dir,
|
||||||
|
target: "browser",
|
||||||
|
useClient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.outputs.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const outFile = join(dir, "dist", "index.js");
|
||||||
|
expect(await Bun.file(outFile).exists()).toBe(true);
|
||||||
|
|
||||||
|
const text = await Bun.file(outFile).text();
|
||||||
|
expect(text).toContain(DIRECTIVE);
|
||||||
|
expect(text).toContain("greet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves M3_EXTERNAL dependencies", () => {
|
||||||
|
expect(M3_EXTERNAL).toContain("react");
|
||||||
|
expect(M3_EXTERNAL).toContain("lit");
|
||||||
|
expect(M3_EXTERNAL).toContain("@aphrody/*");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit list marks only those files", async () => {
|
||||||
|
const dist = join(dir, "dist");
|
||||||
|
await mkdir(join(dist, "sub"), { recursive: true });
|
||||||
|
await Bun.write(join(dist, "index.js"), "export {};\n");
|
||||||
|
await Bun.write(join(dist, "react.js"), "export {};\n");
|
||||||
|
await Bun.write(join(dist, "sub", "link.js"), "export {};\n");
|
||||||
|
await Bun.write(join(dist, "index.js.map"), "{}");
|
||||||
|
expect(await prependUseClient(dist, ["react.js", "sub/link.js"])).toBe(2);
|
||||||
|
expect(await Bun.file(join(dist, "index.js")).text()).toBe("export {};\n");
|
||||||
|
expect(await Bun.file(join(dist, "react.js")).text()).toStartWith(DIRECTIVE);
|
||||||
|
expect(await Bun.file(join(dist, "sub", "link.js")).text()).toStartWith(DIRECTIVE);
|
||||||
|
expect(await Bun.file(join(dist, "index.js.map")).text()).toBe("{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entry globs expand against the package and plain paths pass through", async () => {
|
||||||
|
await Bun.write(join(dir, "wrappers", "a.ts"), "export {};\n");
|
||||||
|
await Bun.write(join(dir, "wrappers", "b.ts"), "export {};\n");
|
||||||
|
expect(expandEntries(["index.ts", "wrappers/*.ts"], dir)).toEqual([
|
||||||
|
join(dir, "index.ts"),
|
||||||
|
join(dir, "wrappers", "a.ts"),
|
||||||
|
join(dir, "wrappers", "b.ts"),
|
||||||
|
]);
|
||||||
|
expect(() => expandEntries("nothing/*.ts", dir)).toThrow(/matches nothing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the output layout is based on the deepest common directory of the entries", () => {
|
||||||
|
expect(commonDir(["/p/src/index.ts", "/p/src/shims/link.ts"])).toBe("/p/src");
|
||||||
|
expect(commonDir(["/p/index.ts", "/p/wrappers/a.ts"])).toBe("/p");
|
||||||
|
expect(commonDir(["/p/a/x.ts", "/q/b/y.ts"])).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("m3Package is a typed identity", () => {
|
||||||
|
const config = { entry: ["src/a.ts"], target: "node", useClient: false } as const;
|
||||||
|
expect(m3Package({ ...config })).toEqual(config);
|
||||||
|
expect(m3Package()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a build of several entries keeps the src-relative layout, splits shared code and marks the listed entries", async () => {
|
||||||
|
await Bun.write(join(dir, "src", "shared.ts"), "export const shared = () => 'shared';\n");
|
||||||
|
await Bun.write(join(dir, "src", "a.ts"), "import { shared } from './shared'; export const a = shared() + 'a';\n");
|
||||||
|
await Bun.write(join(dir, "src", "react.tsx"), "import { shared } from './shared'; export const r = shared() + 'r';\n");
|
||||||
|
const result = await m3BunBuild({ entry: ["src/a.ts", "src/react.tsx"], cwd: dir, useClient: ["react.js"] });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(await Bun.file(join(dir, "dist", "a.js")).text()).not.toStartWith(DIRECTIVE);
|
||||||
|
expect(await Bun.file(join(dir, "dist", "react.js")).text()).toStartWith(DIRECTIVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a build error is returned, not thrown", async () => {
|
||||||
|
await Bun.write(join(dir, "src", "bad.ts"), "import './nope';\n");
|
||||||
|
const result = await m3BunBuild({ entry: "src/bad.ts", cwd: dir });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.logs.join("\n")).toContain("nope");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("externals", () => {
|
||||||
|
test("shared list, then the package's dependencies and peers with their subpaths, then extras", async () => {
|
||||||
|
await Bun.write(
|
||||||
|
join(dir, "package.json"),
|
||||||
|
JSON.stringify({ dependencies: { swiper: "1" }, peerDependencies: { "@scope/x": "1" }, devDependencies: { bundled: "1" } }),
|
||||||
|
);
|
||||||
|
const list = externals(dir, ["bun:*"]);
|
||||||
|
expect(list).toEqual(expect.arrayContaining([...M3_EXTERNAL, "node:*", "swiper", "swiper/*", "@scope/x", "@scope/x/*", "bun:*"]));
|
||||||
|
expect(list).not.toContain("bundled");
|
||||||
|
expect(new Set(list).size).toBe(list.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dependency stays an import and a dev-only package is bundled in", async () => {
|
||||||
|
await Bun.write(join(dir, "package.json"), JSON.stringify({ dependencies: { kept: "1" } }));
|
||||||
|
await Bun.write(join(dir, "node_modules", "kept", "package.json"), '{"name":"kept","main":"index.js"}');
|
||||||
|
await Bun.write(join(dir, "node_modules", "kept", "index.js"), "export const kept = 'KEPT-BODY';\n");
|
||||||
|
await Bun.write(join(dir, "node_modules", "inlined", "package.json"), '{"name":"inlined","main":"index.js"}');
|
||||||
|
await Bun.write(join(dir, "node_modules", "inlined", "index.js"), "export const inlined = 'INLINED-BODY';\n");
|
||||||
|
await Bun.write(join(dir, "src", "index.ts"), "import { kept } from 'kept'; import { inlined } from 'inlined'; export const both = kept + inlined;\n");
|
||||||
|
const result = await m3BunBuild({ entry: "src/index.ts", cwd: dir, useClient: false });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const out = await Bun.file(join(dir, "dist", "index.js")).text();
|
||||||
|
expect(out).toContain('from "kept"');
|
||||||
|
expect(out).not.toContain("KEPT-BODY");
|
||||||
|
expect(out).toContain("INLINED-BODY");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { M3UiCache } from "../cache-db.ts";
|
||||||
|
|
||||||
|
describe("M3UiCache (native bun:sqlite)", () => {
|
||||||
|
test("stores, retrieves and deletes cached entries with typed objects", () => {
|
||||||
|
const cache = new M3UiCache(":memory:");
|
||||||
|
|
||||||
|
// Theme palette cache test
|
||||||
|
const palette = { primary: "#6750a4", secondary: "#625b71" };
|
||||||
|
cache.set("theme", "seed:#6750a4", palette);
|
||||||
|
|
||||||
|
const retrieved = cache.get<typeof palette>("theme", "seed:#6750a4");
|
||||||
|
expect(retrieved).toEqual(palette);
|
||||||
|
|
||||||
|
// Non-existent key
|
||||||
|
expect(cache.get("theme", "missing")).toBeNull();
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = cache.stats();
|
||||||
|
expect(stats.totalEntries).toBe(1);
|
||||||
|
expect(stats.namespaces["theme"]).toBe(1);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
expect(cache.delete("theme", "seed:#6750a4")).toBe(true);
|
||||||
|
expect(cache.get("theme", "seed:#6750a4")).toBeNull();
|
||||||
|
|
||||||
|
cache.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears namespaces selectively or entirely", () => {
|
||||||
|
const cache = new M3UiCache(":memory:");
|
||||||
|
|
||||||
|
cache.set("icons", "home", "<svg>home</svg>");
|
||||||
|
cache.set("icons", "settings", "<svg>settings</svg>");
|
||||||
|
cache.set("tokens", "sys-color", ":root { --color: red; }");
|
||||||
|
|
||||||
|
expect(cache.stats().totalEntries).toBe(3);
|
||||||
|
|
||||||
|
// Clear icons only
|
||||||
|
const clearedIcons = cache.clear("icons");
|
||||||
|
expect(clearedIcons).toBe(2);
|
||||||
|
expect(cache.get("icons", "home")).toBeNull();
|
||||||
|
expect(cache.get("tokens", "sys-color")).not.toBeNull();
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.stats().totalEntries).toBe(0);
|
||||||
|
|
||||||
|
cache.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { M3UiCache } from "../cache-db.ts";
|
||||||
|
|
||||||
|
test("cache preserves JSON-looking strings as strings", () => {
|
||||||
|
const cache = new M3UiCache();
|
||||||
|
try {
|
||||||
|
for (const value of ["null", "true", "false", "123", '"quoted"', '{"a":1}', "[1,2]", ""]) {
|
||||||
|
cache.set("values", value, value);
|
||||||
|
expect(cache.get<string>("values", value)).toBe(value);
|
||||||
|
}
|
||||||
|
cache.set("values", "object", { a: [1, null, true] });
|
||||||
|
expect(cache.get<{ a: (number | boolean | null)[] }>("values", "object")).toEqual({
|
||||||
|
a: [1, null, true],
|
||||||
|
});
|
||||||
|
expect(() => cache.set("values", "undefined", undefined)).toThrow("JSON serializable");
|
||||||
|
expect(cache.get("values", "undefined")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
cache.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing the empty namespace preserves all other data", () => {
|
||||||
|
const cache = new M3UiCache();
|
||||||
|
try {
|
||||||
|
cache.set("", "key", "empty namespace");
|
||||||
|
cache.set("other", "key", "protected");
|
||||||
|
cache.set("__proto__", "key", "literal namespace");
|
||||||
|
expect(cache.stats().namespaces["__proto__"]).toBe(1);
|
||||||
|
expect(cache.clear("")).toBe(1);
|
||||||
|
expect(cache.get<string>("other", "key")).toBe("protected");
|
||||||
|
expect(cache.get<string>("__proto__", "key")).toBe("literal namespace");
|
||||||
|
} finally {
|
||||||
|
cache.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// Shared `bun test` preload: one happy-dom for the whole run (react-dom decides whether it can use
|
||||||
|
// the DOM when it is first loaded, so the DOM must exist before any test module imports it), plus
|
||||||
|
// ElementInternals, which happy-dom lacks and the form-associated md-* elements need.
|
||||||
|
//
|
||||||
|
// [test]
|
||||||
|
// preload = ["@aphrody/m3-config/test/dom"]
|
||||||
|
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||||
|
|
||||||
|
const nativeTS = globalThis.TransformStream;
|
||||||
|
const nativeRS = globalThis.ReadableStream;
|
||||||
|
const nativeWS = globalThis.WritableStream;
|
||||||
|
|
||||||
|
if (!GlobalRegistrator.isRegistered) {
|
||||||
|
GlobalRegistrator.register();
|
||||||
|
}
|
||||||
|
if (nativeTS) globalThis.TransformStream = nativeTS;
|
||||||
|
if (nativeRS) globalThis.ReadableStream = nativeRS;
|
||||||
|
if (nativeWS) globalThis.WritableStream = nativeWS;
|
||||||
|
try {
|
||||||
|
await import("element-internals-polyfill");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Rendering is driven with `flushSync`, not React Testing Library: silence the act() warning.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"bun-build.ts",
|
||||||
|
"cache-db.ts",
|
||||||
|
"test/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noUncheckedIndexedAccess": false,
|
||||||
|
"noImplicitOverride": false,
|
||||||
|
"types": [
|
||||||
|
"bun",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"declaration": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user