chore: migrate package sources from Aphrody

This commit is contained in:
aphrody-dev committed 2026-10-12 08:23:47 +02:00
1 parent 6fce4748ce
commit 45d0e7090b
9 files changed
+768 -2

No files matched your search

+24 -2
View File
@@ -1,2 +1,24 @@
# aphrody-m3-mcp
Aphrody package @aphrody/m3-mcp
# @aphrody/m3-mcp
The design/UI tool host of aphrody-m3: packages, M3 tokens, docs, research links, templates,
Material Symbols and desktop icons, served from this repository's files. It is not an MCP server:
the single `aphrody-mcp` hosts these tools (`aphrody mcp-server`, alias `m3-mcp`).
```sh
bun src/tool-host.ts list # tool definitions (JSON)
bun src/tool-host.ts call m3_token_lookup '{"query":"primary"}' # one MCP-shaped result
```
| Tool | Does |
| --- | --- |
| `m3_list_packages` | workspace packages with versions |
| `m3_token_lookup`, `m3_token_validate` | look up and validate M3 tokens |
| `m3_os_themes_list`, `m3_os_themes_generate` | OS theme targets of `@aphrody/m3-os-themes` (dry run) |
| `m3_docs_search`, `m3_research_links` | search `docs/`, list research links |
| `m3_list_templates` | `templates/` |
| `m3_symbol_svg`, `m3_symbol_search` | Material Symbols SVG and catalogue search (`@aphrody/material-design-icons`) |
| `m3_svg_to_ico` | SVG to Tauri desktop icons |
Sources: `src/tools.ts` (tool table), `src/tool-host.ts` (`list` / `call`), `src/catalog.ts` (file-backed lookups). Tests:
`bun test`; typecheck: `bun run typecheck`. Registered in the aphrody-labs plugins marketplace as
the `aphrody-m3` plugin.
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@aphrody/m3-mcp",
"version": "1.4.4",
"description": "Design/UI tool host of aphrody-m3 (served through the single aphrody-mcp): packages, M3 tokens (lookup, validate), docs search, research links, templates, Material Symbols SVG, SVG to Tauri icons.",
"license": "Apache-2.0",
"files": [
"README.md",
"src"
],
"type": "module",
"exports": {
".": "./src/tools.ts",
"./tool-host": "./src/tool-host.ts"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"start": "bun src/tool-host.ts list",
"test": "bun test",
"typecheck": "bun check --noEmit -p tsconfig.json"
},
"dependencies": {
"@aphrody/m3-os-themes": "workspace:*",
"@aphrody/material-design-icons": "workspace:*",
"zod": "catalog:"
},
"engines": {
"bun": ">=1.4.4"
}
}
+151
View File
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: Apache-2.0
import { afterAll, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { loadTokens, lookupTokens, validateTokens } from "./catalog.ts";
const scratch = mkdtempSync(join(tmpdir(), "m3-token-catalog-"));
let fixtures = 0;
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
function fixture(exports: Record<string, unknown>, files: Record<string, string>): string {
const root = join(scratch, String(fixtures++));
const pkg = join(root, "packages/m3-tokens");
mkdirSync(pkg, { recursive: true });
writeFileSync(join(pkg, "package.json"), JSON.stringify({ exports }));
for (const [path, text] of Object.entries(files)) {
const file = join(pkg, path);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text);
}
return root;
}
describe("the repository token catalogue", () => {
test("Expressive typography, motion and directional shapes can be looked up and validated", () => {
const names = [
"--md-sys-typescale-display-large-emphasized-font",
"--md-sys-motion-spring-expressive-fast-spatial-damping",
"--md-sys-shape-corner-large-start",
];
for (const name of names) {
const tokens = lookupTokens(name);
expect(tokens).toHaveLength(1);
expect(tokens[0]?.source).toBe("packages/m3-tokens/src/expressive.css");
}
expect(lookupTokens(names[1]!)[0]?.value).toBe("0.6");
const input = names.map((name) => `var(${name})`).join(" ");
expect(validateTokens(input)).toEqual({ checked: names, unknown: [] });
expect(
validateTokens(`${input} var(--md-sys-motion-spring-expressive-fast-spatial-dampng)`).unknown,
).toEqual(["--md-sys-motion-spring-expressive-fast-spatial-dampng"]);
});
test("base tokens, breakpoints and dynamic colour roles remain available without duplicates", () => {
expect(lookupTokens("corner-full")[0]?.value).toBe("9999px");
expect(lookupTokens("--md-breakpoint-expanded")[0]?.value).toBe("840px");
expect(lookupTokens("--md-sys-color-on-primary-container")[0]?.source).toBe(
"packages/m3-tokens/src/dynamic-color.ts",
);
const tokens = loadTokens();
expect(new Set(tokens.map((token) => token.name)).size).toBe(tokens.length);
});
});
describe("exported token sources", () => {
test("reads exported CSS rather than unrelated files and retains the first declaration's source", () => {
const root = fixture(
{
"./base.css": { default: "./src/base.css" },
"./alias.css": "./src/base.css",
"./extended.css": ["./src/extended.css"],
"./helpers": "./src/helpers.ts",
},
{
"src/base.css": `/* --md-commented-out: nope; */
:root { --md-sys-shape-corner-large: 16px; --md-sys-color-primary: #6750a4; }
.dark { --md-sys-shape-corner-large: 20px; }`,
"src/extended.css":
":root { --md-sys-shape-corner-large: 32px; --md-sys-shape-corner-large-start: 16px 0 0 16px; }",
"src/unexported.css": ":root { --md-unexported: hidden; }",
"src/helpers.ts": "// --md-not-css: hidden;",
"src/dynamic-color.ts": `export const ROLES = ["primary", 'surfaceBright', "primary", /* "hiddenRole" */];`,
},
);
const tokens = loadTokens(root);
expect(tokens).toHaveLength(4);
expect(lookupTokens("--md-sys-shape-corner-large", 1, root)).toEqual([
{
name: "--md-sys-shape-corner-large",
value: "16px",
source: "packages/m3-tokens/src/base.css",
},
]);
expect(lookupTokens("--md-sys-color-primary", 50, root)).toEqual([
{
name: "--md-sys-color-primary",
value: "#6750a4",
source: "packages/m3-tokens/src/base.css",
},
]);
expect(lookupTokens("surface-bright", 50, root)).toEqual([
{
name: "--md-sys-color-surface-bright",
value: "(dynamic colour role)",
source: "packages/m3-tokens/src/dynamic-color.ts",
},
]);
expect(
validateTokens(
"var(--md-sys-shape-corner-large-start) var(--md-sys-color-surface-bright)",
root,
).unknown,
).toEqual([]);
expect(
validateTokens(
"--md-unexported --md-commented-out --md-not-css --md-sys-color-hidden-role",
root,
).unknown,
).toEqual([
"--md-unexported",
"--md-commented-out",
"--md-not-css",
"--md-sys-color-hidden-role",
]);
});
test("missing exported sheets do not hide present sheets or dynamic roles", () => {
const root = fixture(
{
"./missing.css": "./src/missing.css",
"./present.css": "./src/present.css",
"./outside.css": "../outside.css",
},
{
"src/present.css": ":root { --md-present: 1px; }",
"src/dynamic-color.ts": 'export const ROLES = ["onPrimaryContainer"];',
},
);
expect(loadTokens(root).map((token) => token.name)).toEqual([
"--md-present",
"--md-sys-color-on-primary-container",
]);
expect(
validateTokens("--md-present --md-sys-color-on-primary-container --md-missing", root),
).toEqual({
checked: ["--md-present", "--md-sys-color-on-primary-container", "--md-missing"],
unknown: ["--md-missing"],
});
});
test("an absent token checkout gives an empty catalogue and reports unknown references", () => {
const root = join(scratch, "absent");
expect(loadTokens(root)).toEqual([]);
expect(lookupTokens("primary", 50, root)).toEqual([]);
expect(validateTokens("var(--md-sys-color-primary)", root)).toEqual({
checked: ["--md-sys-color-primary"],
unknown: ["--md-sys-color-primary"],
});
});
});
+200
View File
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: Apache-2.0
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
export const ROOT = join(import.meta.dir, "../../..");
const read = (p: string) => (existsSync(p) ? readFileSync(p, "utf8") : "");
export function listPackages(root = ROOT): string[] {
const dir = join(root, "packages");
return readdirSync(dir).flatMap((d) => {
const p = join(dir, d, "package.json");
if (!existsSync(p)) return [];
const j = JSON.parse(readFileSync(p, "utf8"));
return [`${j.name}@${j.version ?? "0.0.0"} packages/${d}`];
});
}
export interface Token {
name: string;
value: string;
source: string;
}
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
/** Local CSS targets of the token package's exports, including conditional exports and aliases. */
function exportedSheets(exports: unknown): string[] {
if (typeof exports === "string")
return exports.startsWith("./") && exports.endsWith(".css") && !exports.includes("*")
? [exports]
: [];
if (Array.isArray(exports)) return exports.flatMap(exportedSheets);
if (exports && typeof exports === "object") return Object.values(exports).flatMap(exportedSheets);
return [];
}
/** Every exported `--md-*` CSS property and dynamic colour role, once per name. */
export function loadTokens(root = ROOT): Token[] {
const pkg = join(root, "packages/m3-tokens");
const manifest = read(join(pkg, "package.json"));
const exports: unknown = manifest
? (JSON.parse(manifest) as { exports?: unknown }).exports
: undefined;
const out = new Map<string, Token>();
const add = (token: Token) => {
// The first declaration is representative; theme/direction variants do not create new names.
if (!out.has(token.name)) out.set(token.name, token);
};
for (const target of new Set(exportedSheets(exports))) {
const file = resolve(pkg, target);
const path = relative(pkg, file).replaceAll("\\", "/");
if (
isAbsolute(path) ||
path === ".." ||
path.startsWith("../") ||
path.split("/").includes("node_modules")
)
continue;
const css = read(file).replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of css.matchAll(/(--md-[\w-]+)\s*:\s*([^;]+);/g)) {
add({ name: m[1]!, value: m[2]!.trim(), source: `packages/m3-tokens/${path}` });
}
}
// `dynamic-color.ts` is the public module of the roles; it re-exports the list from `native-theme.ts`.
const list = ["src/dynamic-color.ts", "src/native-theme.ts"]
.map((file) => /export const ROLES = \[([\s\S]*?)\]/.exec(read(join(pkg, file)))?.[1])
.find((roles) => roles !== undefined);
const roles = (list ?? "").replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "");
for (const m of roles.matchAll(/["'](\w+)["']/g)) {
add({
name: `--md-sys-color-${kebab(m[1]!)}`,
value: "(dynamic colour role)",
source: "packages/m3-tokens/src/dynamic-color.ts",
});
}
return [...out.values()];
}
export function lookupTokens(query: string, limit = 50, root = ROOT): Token[] {
const q = query.toLowerCase();
return loadTokens(root)
.filter((t) => t.name.includes(q) || t.value.toLowerCase().includes(q))
.slice(0, limit);
}
/** Validate token names or every `var(--md-*)` / `--md-*` reference found in a CSS snippet. */
export function validateTokens(
input: string,
root = ROOT,
): { checked: string[]; unknown: string[] } {
const known = new Set(loadTokens(root).map((t) => t.name));
const checked = [...new Set([...input.matchAll(/--md-[\w-]+/g)].map((m) => m[0]))];
return { checked, unknown: checked.filter((n) => !known.has(n)) };
}
function* walk(dir: string, includeJson = false): Generator<string> {
for (const e of readdirSync(dir, { withFileTypes: true }).toSorted((a, b) =>
a.name.localeCompare(b.name),
)) {
if (e.name === "node_modules" || e.name.startsWith(".git") || e.isSymbolicLink()) continue;
const p = join(dir, e.name);
if (e.isDirectory()) yield* walk(p, includeJson);
else if ((includeJson ? /\.(md|mdx|json)$/ : /\.(md|mdx)$/).test(e.name)) yield p;
}
}
/** The single native Web acquisition corpus, explicitly configurable for qualified hosts. */
export function designCorpusRoot(
root = ROOT,
configured = process.env.APHRODY_DESIGN_CORPUS,
): string | undefined {
const corpus = resolve(configured ?? join(root, "../var/mirrors/design-sync"));
return existsSync(join(corpus, "AI_READY.md")) && statSync(corpus).isDirectory()
? corpus
: undefined;
}
export function searchDocs(
query: string,
limit = 20,
root = ROOT,
corpus = designCorpusRoot(root),
): string[] {
const q = query.toLowerCase();
const hits: string[] = [];
const docs = join(root, "docs");
const sources = [
...(corpus
? [...walk(corpus, true)].map((file) => ({
file,
source: `design-sync/${relative(corpus, file).replaceAll("\\", "/")}`,
}))
: []),
...(existsSync(docs)
? [...walk(docs)].map((file) => ({
file,
source: relative(root, file).replaceAll("\\", "/"),
}))
: []),
];
for (const { file, source } of sources) {
const lines = read(file).split("\n");
for (let i = 0; i < lines.length && hits.length < limit; i++) {
if (lines[i]!.toLowerCase().includes(q))
hits.push(`${source}:${i + 1}: ${lines[i]!.trim().slice(0, 200)}`);
}
if (hits.length >= limit) break;
}
return hits;
}
export function researchLinks(
root = ROOT,
corpus = designCorpusRoot(root),
): { files: string[]; links: string[] } {
if (corpus) {
const documents = [...walk(corpus, true)];
const links = [
...new Set(
documents.flatMap((file) =>
[...read(file).matchAll(/https?:\/\/[^\s<>"'\])]+/g)].map((match) =>
match[0].replace(/[.,;]+$/, ""),
),
),
),
].toSorted();
return {
files: documents.map((file) => `design-sync/${relative(corpus, file).replaceAll("\\", "/")}`),
links,
};
}
const dir = join(root, "docs/research");
const files = existsSync(dir) ? [...walk(dir)].map((f) => relative(root, f)) : [];
// The link index moved with the M3 integrations to the Bun fork checkout next to this repository.
const index = [
dir,
join(root, "../../bun/packages/bun-aphrody-web/integrations/m3/docs/research"),
]
.map((research) => join(research, "GOOGLE-LINKS.md"))
.find((file) => existsSync(file));
if (index && !index.startsWith(dir)) files.push(relative(root, index));
const links = [
...read(index ?? "").matchAll(/^\|\s*([^|]+?)\s*\|\s*(https?:\/\/\S+)\s*\|/gm),
].map((m) => `${m[1]} ${m[2]}`);
return { files, links };
}
export function listTemplates(root = ROOT): string[] {
const rows: string[] = [];
for (const base of ["templates", "examples", "examples/tauri"]) {
const dir = join(root, base);
if (!existsSync(dir)) continue;
for (const e of readdirSync(dir)) {
if (statSync(join(dir, e)).isDirectory() && !(base === "examples" && e === "tauri"))
rows.push(`${base}/${e}`);
}
}
return rows;
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bun
// SPDX-License-Identifier: Apache-2.0
// Tool host protocol of the single Aphrody MCP server: `list` prints the tool definitions as JSON,
// `call <name> '<json>'` prints one MCP-shaped result (`{content, isError?}`) and exits 0.
import { callM3Tool, listM3Tools } from "./tools";
const [command, name, json] = Bun.argv.slice(2);
if (command === "list") {
console.log(JSON.stringify(listM3Tools()));
} else if (command === "call" && name) {
let args: unknown;
try {
args = JSON.parse(json ?? "{}");
} catch {
console.log(
JSON.stringify({
content: [{ type: "text", text: "arguments are not JSON" }],
isError: true,
}),
);
process.exit(0);
}
console.log(JSON.stringify(await callM3Tool(name, args)));
} else {
console.error("Usage: tool-host.ts list | call <tool-name> '<json-object>'");
process.exitCode = 2;
}
+227
View File
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: Apache-2.0
// The M3 design tool table. It is hosted by the single Aphrody MCP server (`aphrody-mcp`) through
// `tool-host.ts` (`list` / `call`); this package no longer speaks MCP itself.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
import type { Target } from "@aphrody/m3-os-themes";
import {
ROOT,
listPackages,
listTemplates,
lookupTokens,
researchLinks,
searchDocs,
validateTokens,
} from "./catalog";
// The theme generators (16 ms with m3-tokens) and the symbol catalogue are loaded by the tools that use them, not at
// start-up: listing the tools never waits for them.
const osThemes = () => import("@aphrody/m3-os-themes");
const symbols = () => import("@aphrody/material-design-icons");
export interface ToolResult {
content: { type: "text"; text: string }[];
isError?: boolean;
}
export interface M3Tool {
name: string;
description: string;
shape: z.ZodRawShape;
run(args: never, signal?: AbortSignal): Promise<ToolResult> | ToolResult;
}
const text = (t: string): ToolResult => ({ content: [{ type: "text", text: t || "(no result)" }] });
const themeInput = {
seed: z.string().describe("Seed colour #rrggbb"),
target: z.enum(["windows", "gnome", "cosmic", "chromium", "all"]).default("all"),
name: z.string().default("Aphrody"),
};
const limit = (fallback: number) => z.number().int().min(1).max(500).default(fallback);
function tool<S extends z.ZodRawShape>(
name: string,
description: string,
shape: S,
run: (args: z.infer<z.ZodObject<S>>, signal?: AbortSignal) => Promise<ToolResult> | ToolResult,
): M3Tool {
return { name, description, shape, run: run as M3Tool["run"] };
}
export const M3_TOOLS: M3Tool[] = [
tool("m3_list_packages", "List the aphrody-m3 workspace packages with versions.", {}, () =>
text(listPackages().join("\n")),
),
tool(
"m3_token_lookup",
"Look up Material 3 design tokens (--md-*) by name or value fragment in m3-tokens.",
{ query: z.string(), limit: limit(50) },
({ query, limit: maximum }) =>
text(
lookupTokens(query, maximum)
.map((t) => `${t.name}: ${t.value} [${t.source}]`)
.join("\n"),
),
),
tool(
"m3_token_validate",
"Validate that every --md-* custom property in a CSS snippet or name list exists in m3-tokens.",
{ css: z.string() },
({ css }) => {
const r = validateTokens(css);
return text(
`checked ${r.checked.length}, unknown ${r.unknown.length}${r.unknown.length ? `\n${r.unknown.join("\n")}` : ""}`,
);
},
),
tool(
"m3_os_themes_list",
"List the files m3-os-themes would generate for a seed colour and target (nothing is written).",
themeInput,
async ({ seed, target, name }) => {
const { TARGETS, generate } = await osThemes();
const targets: readonly Target[] = target === "all" ? TARGETS : [target];
return text(
generate({ name, seed, mode: "auto" }, targets)
.map((f) => f.path)
.join("\n"),
);
},
),
tool(
"m3_os_themes_generate",
"Return the text content of one generated OS theme file (path from m3_os_themes_list). Never writes to disk or the system.",
{ ...themeInput, path: z.string() },
async ({ seed, target, name, path }) => {
const { TARGETS, generate } = await osThemes();
const targets: readonly Target[] = target === "all" ? TARGETS : [target];
const file = generate({ name, seed, mode: "auto" }, targets).find((f) => f.path === path);
if (!file) return text(`unknown path ${path}`);
return text(
typeof file.content === "string" ? file.content : `(binary, ${file.content.length} bytes)`,
);
},
),
tool(
"m3_docs_search",
"Search the canonical native Web design-sync corpus (AI-ready Markdown, specs and asset JSON), followed by local M3 guides; returns source path:line hits.",
{ query: z.string(), limit: limit(20) },
({ query, limit: maximum }) => text(searchDocs(query, maximum).join("\n")),
),
tool(
"m3_research_links",
"List the canonical native Web design-sync corpus files and source URLs; fall back to the dated local research index when no corpus has been acquired.",
{},
() => {
const r = researchLinks();
return text(`${r.files.join("\n")}\n\n${r.links.join("\n")}`);
},
),
tool("m3_list_templates", "List UI templates, examples and Tauri example apps.", {}, () =>
text(listTemplates().join("\n")),
),
tool(
"m3_symbol_svg",
"Return path and SVG source of a Material Symbol from the forked catalogue.",
{
name: z.string().regex(/^[a-z0-9_]+$/),
style: z.enum(["outlined", "rounded", "sharp"]).default("outlined"),
weight: z
.union([
z.literal(100),
z.literal(200),
z.literal(300),
z.literal(400),
z.literal(500),
z.literal(600),
z.literal(700),
])
.default(400),
filled: z.boolean().default(false),
},
async ({ name, style, weight, filled }) => {
const { symbolSvgPath } = await symbols();
const p = symbolSvgPath(name, style, weight, filled);
return existsSync(p) ? text(`${p}\n${readFileSync(p, "utf8")}`) : text(`not found: ${p}`);
},
),
tool(
"m3_symbol_search",
"Search the full Material Symbols catalogue (4150 names) by substring; returns matching symbol names.",
{ query: z.string(), limit: limit(50) },
async ({ query, limit: maximum }) => {
const { searchSymbols, symbolNames } = await symbols();
const hits = searchSymbols(query, maximum);
return text(
hits.length > 0 ? hits.join("\n") : `no match among ${symbolNames().length} symbols`,
);
},
),
tool(
"m3_svg_to_ico",
"Generate Tauri desktop icons (ico, icns, png) from a symbol name or SVG file.",
{
input: z.string(),
out: z.string().default("src-tauri/icons"),
style: z.enum(["outlined", "rounded", "sharp"]).default("outlined"),
fill: z.string().optional(),
},
async ({ input, out, style, fill }, signal) => {
const args = [
"bun",
join(ROOT, "packages/material-design-icons/scripts/svg-to-ico.ts"),
input,
"--out",
out,
"--style",
style,
];
if (fill) args.push("--fill", fill);
const child = Bun.spawn({
windowsHide: true,
cmd: args,
stdout: "pipe",
stderr: "pipe",
signal,
timeout: 30_000,
killSignal: "SIGKILL",
maxBuffer: 1024 * 1024,
});
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
return { ...text(`${code}\n${stdout}${stderr}`), ...(code !== 0 ? { isError: true } : {}) };
},
),
];
/** JSON Schema definitions in the shape every MCP client expects from `tools/list`. */
export function listM3Tools(): { name: string; description: string; inputSchema: unknown }[] {
return M3_TOOLS.map((t) => ({
name: t.name,
description: t.description,
inputSchema: z.toJSONSchema(z.object(t.shape)),
}));
}
/** Validate the arguments with the tool's schema, then run it; failures are `isError` results. */
export async function callM3Tool(
name: string,
args: unknown,
signal?: AbortSignal,
): Promise<ToolResult> {
const found = M3_TOOLS.find((t) => t.name === name);
if (!found) return { ...text(`unknown tool ${name}`), isError: true };
const parsed = z.object(found.shape).safeParse(args ?? {});
if (!parsed.success) return { ...text(parsed.error.message), isError: true };
try {
return await found.run(parsed.data as never, signal);
} catch (error) {
return { ...text(error instanceof Error ? error.message : String(error)), isError: true };
}
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
listPackages,
listTemplates,
lookupTokens,
researchLinks,
searchDocs,
designCorpusRoot,
validateTokens,
} from "../src/catalog";
test("packages include m3-mcp", () => {
const p = listPackages().join("\n");
expect(p).toContain("@aphrody/m3-mcp");
});
test("token lookup and validation", () => {
expect(lookupTokens("corner-full")[0]?.value).toBe("9999px");
expect(lookupTokens("on-primary-container").length).toBeGreaterThan(0);
expect(validateTokens("a{color:var(--md-sys-color-primary);b:var(--md-nope)}").unknown).toEqual([
"--md-nope",
]);
});
test("docs, research and templates", () => {
expect(searchDocs("Material", 3).length).toBeGreaterThan(0);
expect(researchLinks().links.length).toBeGreaterThan(0);
expect(listTemplates()).toContain("templates/react");
});
test("native design corpus takes priority and exposes Markdown, JSON provenance and URL exports", () => {
const root = mkdtempSync(join(tmpdir(), "m3-design-corpus-"));
try {
const framework = join(root, "m3");
const corpus = join(root, "var/mirrors/design-sync");
mkdirSync(join(framework, "docs"), { recursive: true });
mkdirSync(join(corpus, "material-3/documents"), { recursive: true });
writeFileSync(join(framework, "docs/old.md"), "Material historical guide");
expect(designCorpusRoot(framework)).toBeUndefined();
writeFileSync(join(corpus, "AI_READY.md"), "Material primary source https://m3.material.io/\n");
writeFileSync(
join(corpus, "material-3/documents/buttons.md"),
"Material expressive buttons https://m3.material.io/components/buttons/overview\n",
);
writeFileSync(
join(corpus, "assets.json"),
'{"Material": "https://design.google/assets/pet.svg"}',
);
expect(designCorpusRoot(framework)).toBe(corpus);
expect(searchDocs("Material", 1, framework)).toEqual([
"design-sync/AI_READY.md:1: Material primary source https://m3.material.io/",
]);
expect(searchDocs("pet.svg", 3, framework)[0]).toContain("design-sync/assets.json:1:");
expect(researchLinks(framework).links).toContain("https://design.google/assets/pet.svg");
expect(researchLinks(framework).files).toContain("design-sync/material-3/documents/buttons.md");
expect(searchDocs("Material", 10, framework).at(-1)).toContain("docs/old.md");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: Apache-2.0
import { expect, test } from "bun:test";
import { callM3Tool, listM3Tools } from "../src/tools.ts";
test("the table lists the design tools with JSON schemas", () => {
const tools = listM3Tools();
const names = tools.map((tool) => tool.name);
expect(names).toContain("m3_token_lookup");
expect(names).toContain("m3_os_themes_generate");
expect(tools.length).toBeGreaterThan(10);
expect(JSON.stringify(tools[0]?.inputSchema)).toContain("object");
});
test("a call validates its arguments and returns an MCP-shaped result", async () => {
const ok = await callM3Tool("m3_token_lookup", { query: "corner-full", limit: 1 });
expect(ok.isError).not.toBe(true);
expect(JSON.stringify(ok.content)).toContain("9999px");
expect((await callM3Tool("m3_symbol_svg", { name: "../../private" })).isError).toBe(true);
expect((await callM3Tool("m3_docs_search", { query: "Material", limit: 501 })).isError).toBe(
true,
);
expect((await callM3Tool("nope", {})).isError).toBe(true);
});
test("the executable host prints the list and one call result", () => {
const host = Bun.fileURLToPath(new URL("../src/tool-host.ts", import.meta.url));
const list = Bun.spawnSync([process.execPath, host, "list"], { windowsHide: true });
expect(JSON.parse(list.stdout.toString()).length).toBeGreaterThan(10);
const call = Bun.spawnSync([process.execPath, host, "call", "m3_list_packages", "{}"], {
windowsHide: true,
});
expect(JSON.parse(call.stdout.toString()).content[0].type).toBe("text");
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun"],
"allowImportingTsExtensions": true
},
"include": ["src/**/*", "test/**/*"]
}