diff --git a/.changeset/petite-shrimps-relate.md b/.changeset/petite-shrimps-relate.md new file mode 100644 index 00000000..f0184f38 --- /dev/null +++ b/.changeset/petite-shrimps-relate.md @@ -0,0 +1,6 @@ +--- + +--- + + + \ No newline at end of file diff --git a/labs/README.md b/labs/README.md new file mode 100644 index 00000000..5e51d076 --- /dev/null +++ b/labs/README.md @@ -0,0 +1,14 @@ +# ๐Ÿงช Labs + +The `labs/` directory is a space for experimental, in-progress, or +proof-of-concept packages that are not yet production-ready. Think of this as +our "R&D" area inside the monorepo. + +## Purpose + +- ๐Ÿšง Prototype new ideas without affecting core packages +- ๐Ÿงฌ Explore design system concepts, architectural proposals, or new APIs +- ๐Ÿ›  Test integrations or isolated utilities before formalizing them +- ๐Ÿ”ฌ Collaborate on experimental features in a contained environment + +Happy hacking! ๐Ÿš€ diff --git a/labs/css-to-kotlin-utils/package.json b/labs/css-to-kotlin-utils/package.json new file mode 100644 index 00000000..ad4f22b0 --- /dev/null +++ b/labs/css-to-kotlin-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@lab-internals/css-to-kotlin-helpers", + "main": "src/index.ts", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vitest": "^3.1.4" + } +} diff --git a/labs/css-to-kotlin-utils/src/css-ast-to-modifier.test.ts b/labs/css-to-kotlin-utils/src/css-ast-to-modifier.test.ts new file mode 100644 index 00000000..07195738 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/css-ast-to-modifier.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "vitest"; +import { cssAstToModifier, sanitizeSelector } from "./css-ast-to-modifier.ts"; +import type { VNode } from "./types"; + +describe("cssAstToModifier", () => { + test("generates Modifier chain from a rule node", () => { + const vnode: VNode = { + type: "root", + children: [ + { + type: "rule", + selector: ".btn", + declarations: [ + { type: "declaration", prop: "padding", value: "16px" }, + { type: "declaration", prop: "width", value: "100px" }, + ], + }, + ], + }; + + const result = cssAstToModifier(vnode); + + expect(result).toContain("val btn = Modifier.padding(16.dp).width(100.dp)"); + }); + + test("generates modifiers for nested @media rules", () => { + const vnode: VNode = { + type: "root", + children: [ + { + type: "atrule", + name: "media", + params: "(max-width: 600px)", + children: [ + { + type: "rule", + selector: ".card", + declarations: [ + { type: "declaration", prop: "height", value: "200px" }, + ], + }, + ], + }, + ], + }; + + const result = cssAstToModifier(vnode); + + expect(result).toContain("// @media (max-width: 600px)"); + expect(result).toContain("val card = Modifier.height(200.dp)"); + }); + + test("supports border and border-radius", () => { + const vnode: VNode = { + type: "root", + children: [ + { + type: "rule", + selector: "#box", + declarations: [ + { type: "declaration", prop: "border", value: "1px solid #000" }, + { type: "declaration", prop: "border-radius", value: "4px" }, + ], + }, + ], + }; + + const result = cssAstToModifier(vnode); + + expect(result).toContain( + "val box = Modifier.border(1.dp, Color(0xFF000000)).clip(RoundedCornerShape(4.dp))", + ); + }); +}); + +describe("sanitizeSelector", () => { + test("replaces invalid characters", () => { + expect(sanitizeSelector(".btn-primary")).toBe("btn_primary"); + expect(sanitizeSelector("#main-section")).toBe("main_section"); + expect(sanitizeSelector("a:hover")).toBe("a_hover"); + expect(sanitizeSelector("123box")).toBe("123box"); + }); + + test("removes leading/trailing underscores", () => { + expect(sanitizeSelector(".--weird--.thing--")).toBe("weird_thing"); + }); +}); diff --git a/labs/css-to-kotlin-utils/src/css-ast-to-modifier.ts b/labs/css-to-kotlin-utils/src/css-ast-to-modifier.ts new file mode 100644 index 00000000..8d142c29 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/css-ast-to-modifier.ts @@ -0,0 +1,136 @@ +/* eslint-disable no-console */ +import { parseBackground, parseBorder, parseDp } from "./parsers.ts"; +import type { VNode } from "./types"; + +/** + * Converts a CSS AST (VNode) into a string of Jetpack Compose Modifier chains. + * + * @param vnode - The root VNode (type: "root"). + * @returns A Kotlin source snippet containing modifier definitions. + */ +export function cssAstToModifier(vnode: VNode): string { + const output: string[] = []; + + function walk(node: VNode): void { + if (node.type === "rule") { + const name = sanitizeSelector(node.selector); + const modifiers = generateModifierChain(node.declarations); + + output.push(`// Styles for ${node.selector}`); + output.push(`val ${name} = ${modifiers}\n`); + } + + if (node.type === "atrule") { + output.push(`// @${node.name} ${node.params}`); + node.children.forEach(walk); + } + + if (node.type === "root") { + node.children.forEach(walk); + } + } + + walk(vnode); + return output.join("\n"); +} + +/** + * Converts a list of CSS declarations into a chained Modifier expression. + * + * @param declarations - Array of declaration nodes. + * @returns A string representing Modifier chaining (e.g., "Modifier.padding(16.dp).width(100.dp)"). + */ +const generateModifierChain = (declarations: VNode[]): string => { + const mods: string[] = []; + + for (const decl of declarations) { + if (decl.type !== "declaration") continue; + + const mod = cssPropertyToModifier(decl); + + if (mod) { + mods.push(mod); + } else { + console.warn(`No conversion for CSS property: ${decl.prop}`); + } + } + + if (mods.length === 0) { + console.error("No Modifier chains were generated"); + } + + return ["Modifier", ...mods].join(""); +}; + +/** + * Converts a CSS property declaration into a Modifier chain segment. + * + * @param decl - A declaration node. + * @returns A Modifier segment string or undefined if unsupported. + */ +const cssPropertyToModifier = ( + decl: Extract, +): string | undefined => { + const { prop, value } = decl; + + switch (prop) { + case "padding": + return `.padding(${parseDp(value)})`; + + case "margin": + console.warn("margin not directly supported; using padding instead"); + return `.padding(${parseDp(value)})`; + + case "width": + return `.width(${parseDp(value)})`; + + case "height": + return `.height(${parseDp(value)})`; + + case "background-color": + return `.background(${parseBackground(value)})`; + + case "border-radius": + return `.clip(RoundedCornerShape(${parseDp(value)}))`; + + case "border": + return `.border(${parseBorder(value)})`; + + // case "color": + // return `.then(textColor(Color(${parseColor(value)})))`; + + default: + return undefined; + } +}; + +/** + * Converts a CSS selector into a safe Kotlin variable name. + * + * @param selector - A CSS selector (e.g., ".btn-primary"). + * @returns A sanitized string safe to use as a Kotlin identifier. + */ +export const sanitizeSelector = (selector: string): string => { + const chars: string[] = []; + let lastWasUnderscore = false; + + for (const c of selector) { + if (/[a-zA-Z0-9]/.test(c)) { + chars.push(c); + lastWasUnderscore = false; + } else if (!lastWasUnderscore) { + chars.push("_"); + lastWasUnderscore = true; + } + } + + // Trim leading/trailing underscores + let start = 0; + let end = chars.length; + + while (start < end && chars[start] === "_") start++; + while (end > start && chars[end - 1] === "_") end--; + + return chars.slice(start, end).join(""); +}; +/* eslint-enable no-console */ diff --git a/labs/css-to-kotlin-utils/src/css-to-css-ast.test.ts b/labs/css-to-kotlin-utils/src/css-to-css-ast.test.ts new file mode 100644 index 00000000..3cea1585 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/css-to-css-ast.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect */ +import { describe, expect, test } from "vitest"; +import { cssToCssAst, hasChildren } from "./css-to-css-ast.ts"; + +describe("cssToCssAst", () => { + test("parses a single CSS rule with declarations", () => { + const css = ` + .btn { + color: #000; + background: white; + } + `; + + const ast = cssToCssAst(css); + + expect(ast.type).toBe("root"); + + if (hasChildren(ast)) { + expect(ast.children.length).toBe(1); + const rule = ast.children[0]; + + expect(rule?.type).toBe("rule"); + if (rule?.type === "rule") { + expect(rule.selector).toBe(".btn"); + expect(rule.declarations).toEqual([ + { type: "declaration", prop: "color", value: "#000" }, + { type: "declaration", prop: "background", value: "white" }, + ]); + } + } else { + throw new Error("Expected root node to have children"); + } + }); + + test("parses an @media rule with nested declarations", () => { + const css = ` + @media (max-width: 600px) { + .card { + padding: 1rem; + } + } + `; + + const ast = cssToCssAst(css); + + expect(ast.type).toBe("root"); + + if (hasChildren(ast)) { + expect(ast.children).toHaveLength(1); + + const media = ast.children[0]; + + expect(media?.type).toBe("atrule"); + if (media?.type === "atrule") { + expect(media.name).toBe("media"); + expect(media.params).toBe("(max-width: 600px)"); + + if (hasChildren(media)) { + expect(media.children).toHaveLength(1); + const nestedRule = media.children[0]; + + expect(nestedRule?.type).toBe("rule"); + if (nestedRule?.type === "rule") { + expect(nestedRule.selector).toBe(".card"); + expect(nestedRule.declarations).toEqual([ + { type: "declaration", prop: "padding", value: "1rem" }, + ]); + } + } else { + throw new Error("Expected media node to have children"); + } + } + } else { + throw new Error("Expected root node to have children"); + } + }); + + test("ignores unsupported nodes like comments", () => { + const css = ` + /* This is a comment */ + .box { + margin: 0; + } + `; + + const ast = cssToCssAst(css); + + expect(ast.type).toBe("root"); + + if (hasChildren(ast)) { + expect(ast.children).toHaveLength(1); + const rule = ast.children[0]; + + expect(rule?.type).toBe("rule"); + if (rule?.type === "rule") { + expect(rule.selector).toBe(".box"); + } + } else { + throw new Error("Expected root node to have children"); + } + }); + + test("parses broken @media into fallback atrule", () => { + const css = `@media { .x { color: red } }`; + const ast = cssToCssAst(css); + + expect(ast.type).toBe("root"); + + if (hasChildren(ast)) { + const media = ast.children[0]; + + expect(media?.type).toBe("atrule"); + if (media?.type === "atrule" && hasChildren(media)) { + expect(media.name).toBe("media"); + expect(media.children[0]?.type).toBe("rule"); + } + } else { + throw new Error("Expected fallback atrule for malformed media"); + } + }); + + test("handles deeply nested @media with multiple rules", () => { + const css = ` + @media (min-width: 768px) { + .nav { + display: flex; + } + .nav-item { + padding: 0.5rem; + } + } + `; + + const ast = cssToCssAst(css); + + expect(ast.type).toBe("root"); + + if (hasChildren(ast)) { + const media = ast.children[0]; + + expect(media?.type).toBe("atrule"); + if (media?.type === "atrule" && hasChildren(media)) { + expect(media.children).toHaveLength(2); + const selectors = media.children.map( + c => c.type === "rule" && c.selector, + ); + + expect(selectors).toEqual([".nav", ".nav-item"]); + } else { + throw new Error("Expected media node to have children"); + } + } else { + throw new Error("Expected root node to have children"); + } + }); +}); + +/* eslint-enable playwright/no-conditional-in-test, playwright/no-conditional-expect */ diff --git a/labs/css-to-kotlin-utils/src/css-to-css-ast.ts b/labs/css-to-kotlin-utils/src/css-to-css-ast.ts new file mode 100644 index 00000000..f9da5779 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/css-to-css-ast.ts @@ -0,0 +1,93 @@ +import postcss, { type ChildNode } from "postcss"; +import safeParser from "postcss-safe-parser"; +import { type VNode } from "./types.ts"; + +/** + * Checks whether a VNode has a `children` array. + * + * @param node - The VNode to check. + * @returns True if the node is a `root` or `atrule`, both of which contain children. + */ +export const hasChildren = ( + node: VNode, +): node is Extract => { + return node.type === "root" || node.type === "atrule"; +}; + +/** + * Parses a PostCSS `ChildNode` (rule or at-rule) into a `VNode`. + * + * @param {ChildNode} node - A PostCSS node to convert. + * @returns {VNode | null} A `VNode` representation or `null` if the node type is unsupported. + */ +export const parseNode = (node: ChildNode): VNode | null => { + if (node.type === "rule") { + const rule = node; + + return { + type: "rule", + selector: rule.selector, + declarations: rule.nodes + .filter(n => n.type === "decl") + .map(decl => ({ + type: "declaration", + prop: decl.prop, + value: decl.value, + })), + }; + } + + if (node.type === "atrule") { + const atrule = node; + + if (!/^[a-zA-Z]+$/.test(atrule.name)) return null; + + return { + type: "atrule", + name: atrule.name, + params: atrule.params, + children: (atrule.nodes?.map(parseNode).filter(Boolean) as VNode[]) || [], + }; + } + + // Unsupported node types (e.g., comments, unknown) are ignored + return null; +}; + +/** + * Converts a raw CSS string into a simplified custom CSS AST (`VNode` structure). + * + * This function uses PostCSS with the `safeParser` to handle both valid and malformed CSS. + * It supports `rule`, `atrule`, and `declaration` node types, and safely skips over unknown nodes. + * + * @param {string} css - The raw CSS string to convert. + * @returns {VNode} A root-level `VNode` representing the parsed CSS AST. + * + * @example + * cssToCssAst(` + * .btn { + * color: #000; + * } + * @media (max-width: 600px) { + * .btn { + * color: red; + * } + * } + * `); + */ +export const cssToCssAst = (css: string): VNode => { + try { + const root = postcss().process(css, { parser: safeParser }).root; + + return { + type: "root", + children: root.nodes.map(parseNode).filter(Boolean) as VNode[], + }; + } catch (err) { + console.error("Failed to parse CSS to AST:", err); + return { + type: "root", + children: [], + }; + } +}; diff --git a/labs/css-to-kotlin-utils/src/index.ts b/labs/css-to-kotlin-utils/src/index.ts new file mode 100644 index 00000000..7258e9e9 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/index.ts @@ -0,0 +1,4 @@ +export * from "./css-ast-to-modifier.ts"; +export * from "./css-to-css-ast.ts"; +export * from "./parsers.ts"; +export * from "./types.ts"; diff --git a/labs/css-to-kotlin-utils/src/parsers.test.ts b/labs/css-to-kotlin-utils/src/parsers.test.ts new file mode 100644 index 00000000..cccb12db --- /dev/null +++ b/labs/css-to-kotlin-utils/src/parsers.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { + parseBackground, + parseBorder, + parseColor, + parseDp, + parseGradient, +} from "./parsers.ts"; + +describe("parseDp", () => { + test("converts px to dp", () => { + expect(parseDp("16px")).toBe("16.dp"); + }); + + test("converts rem to dp (assuming 1rem = 16px)", () => { + expect(parseDp("2rem")).toBe("32.dp"); + }); + + test("returns Dp.Unspecified for unknown units", () => { + expect(parseDp("auto")).toBe("Dp.Unspecified"); + }); +}); + +describe("parseColor", () => { + test("parses 6-digit hex colors", () => { + expect(parseColor("#123456")).toBe("0xFF123456"); + }); + + test("expands 3-digit hex colors", () => { + expect(parseColor("#abc")).toBe("0xFFAABBCC"); + }); +}); + +describe("parseGradient", () => { + test("parses linear-gradient with hex colors", () => { + const input = "linear-gradient(90deg, #ffffff, #000000)"; + const expected = + "Brush.horizontalGradient(listOf(Color(0xFFFFFFFF), Color(0xFF000000)))"; + + expect(parseGradient(input)).toBe(expected); + }); + + test("throws if not a linear-gradient", () => { + expect(() => parseGradient("radial-gradient(#fff, #000)")).toThrow(); + }); + + test("throws if no colors are found", () => { + expect(() => + parseGradient("linear-gradient(90deg, transparent, red)"), + ).toThrow(); + }); +}); + +describe("parseBackground", () => { + test("parses solid hex background", () => { + expect(parseBackground("#333333")).toBe("Color(0xFF333333)"); + }); + + test("parses linear-gradient background", () => { + const input = "linear-gradient(90deg, #fff, #000)"; + + expect(parseBackground(input)).toBe( + "Brush.horizontalGradient(listOf(Color(0xFFFFFFFF), Color(0xFF000000)))", + ); + }); +}); + +describe("parseBorder", () => { + test("parses full border string", () => { + expect(parseBorder("2px solid #ff0000")).toBe("2.dp, Color(0xFFFF0000)"); + }); + + test("handles color-first format", () => { + expect(parseBorder("#00ff00 1px solid")).toBe("1.dp, Color(0xFF00FF00)"); + }); + + test("returns default values if color or size is missing", () => { + expect(parseBorder("solid")).toBe("Dp.Unspecified, Color(0xFF000000)"); + }); +}); diff --git a/labs/css-to-kotlin-utils/src/parsers.ts b/labs/css-to-kotlin-utils/src/parsers.ts new file mode 100644 index 00000000..c18cbc64 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/parsers.ts @@ -0,0 +1,141 @@ +// TODO: handle css variables + +/** + * Converts a CSS length value to Jetpack Compose's `dp` unit. + * Supports `px` and `rem` units. Falls back to `Dp.Unspecified` for unsupported formats. + * + * @param {string} value - The CSS value to convert (e.g., "16px", "1.5rem"). + * @returns {string} The Jetpack Compose representation (e.g., "16.dp"). + */ +export const parseDp = (value: string): string => { + const trimmed = value.trim(); + + if (trimmed.endsWith("px")) { + return `${parseFloat(trimmed)}.dp`; + } + + if (trimmed.endsWith("rem")) { + const rem = parseFloat(trimmed); + const px = rem * 16; // 1rem = 16px (adjust if needed) + + return `${px}.dp`; + } + + return `Dp.Unspecified`; // Fallback for unsupported units +}; + +/** + * Converts a CSS hex color to Jetpack Compose color format. + * Expands 3-digit hex codes and adds alpha prefix. + * + * @param {string} value - The CSS hex color (e.g., "#fff", "#123456"). + * @returns {string} A Jetpack Compose color string (e.g., "0xFFFFFFFF"). + */ +export const parseColor = (value: string): string => { + let hex = value.trim().replace("#", ""); + + if (hex.length === 3) { + hex = hex + .split("") + .map(c => c + c) + .join(""); + } + + return `0xFF${hex.toUpperCase()}`; +}; + +/** + * Converts a CSS `linear-gradient` string into Jetpack Compose's horizontalGradient brush. + * Only supports hex colors (ignores angle/direction for now). + * + * @param {string} value - A CSS linear-gradient string. + * @returns {string} A Jetpack Compose `Brush.horizontalGradient(...)` call. + * @throws {Error} If the format is incorrect or contains no valid hex colors. + */ +export const parseGradient = (value: string): string => { + if (!value.startsWith("linear-gradient")) { + throw new Error("value should start with `linear-gradient`."); + } + + const hexColors = [...value.matchAll(/#([0-9a-fA-F]{3,8})/g)].map(match => { + const hex = match[0]; // full match with "#" + + return `Color(${parseColor(hex)})`; + }); + + if (hexColors.length === 0) { + throw new Error("No hex colors found in gradient."); + } + + return `Brush.horizontalGradient(listOf(${hexColors.join(", ")}))`; +}; + +/** + * Converts a CSS background value to Jetpack Compose color or brush. + * Supports solid hex color or linear-gradient. + * + * @param {string} value - A CSS background value. + * @returns {string} A Jetpack Compose expression. + */ +export const parseBackground = (value: string): string => { + if (value.trim().startsWith("linear-gradient")) { + return parseGradient(value); + } + + return `Color(${parseColor(value)})`; +}; + +/** + * Parses a CSS border declaration and converts it to Jetpack Compose border values. + * Only supports format like "1px solid #000" or "1px #000 solid". + * + * @param {string} value - A CSS border string. + * @returns {string} A Jetpack Compose border format (e.g., "1.dp, Color(...)"). + */ +export const parseBorder = (value: string): string => { + const parts = value.trim().split(/\s+/); + const width = parts.find(part => part.endsWith("px") || part.endsWith("rem")); + + const color = parts.find(part => part.startsWith("#")); + + const dp = width ? parseDp(width) : "Dp.Unspecified"; + const parsedColor = color + ? `Color(${parseColor(color)})` + : "Color(0xFF000000)"; + + return `${dp}, ${parsedColor}`; +}; + +export const parseTextStyle = (obj: { + font?: string; + size?: string; + height?: number; + weight?: number; +}): string => { + const parts: string[] = []; + + if (obj.size) { + const px = obj.size.endsWith("rem") + ? parseFloat(obj.size) * 16 + : parseFloat(obj.size); + + parts.push(`fontSize = ${px}.sp`); + } + + if (obj.height && obj.size) { + const px = obj.size.endsWith("rem") + ? parseFloat(obj.size) * 16 + : parseFloat(obj.size); + + const lineHeight = +(px * obj.height).toFixed(2); + + parts.push(`lineHeight = ${lineHeight}.sp`); + } + + if (obj.weight) { + // https://developer.android.com/reference/kotlin/androidx/compose/ui/text/font/FontWeight + parts.push(`fontWeight = FontWeight.W${obj.weight}`); + } + + return `TextStyle(${parts.join(", ")})`; +}; diff --git a/labs/css-to-kotlin-utils/src/types.ts b/labs/css-to-kotlin-utils/src/types.ts new file mode 100644 index 00000000..3ce20a20 --- /dev/null +++ b/labs/css-to-kotlin-utils/src/types.ts @@ -0,0 +1,21 @@ +export type VNode = + | { + type: "root"; + children: VNode[]; + } + | { + type: "rule"; + selector: string; + declarations: { + type: "declaration"; + prop: string; + value: string; + }[]; + } + | { type: "declaration"; prop: string; value: string } + | { + type: "atrule"; + name: string; + params: string; + children: VNode[]; + }; diff --git a/labs/css-to-kotlin-utils/tsconfig.build.json b/labs/css-to-kotlin-utils/tsconfig.build.json new file mode 100644 index 00000000..07edbc07 --- /dev/null +++ b/labs/css-to-kotlin-utils/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["dist"] +} diff --git a/labs/css-to-kotlin-utils/tsconfig.json b/labs/css-to-kotlin-utils/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/labs/css-to-kotlin-utils/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/labs/kotlin-theme/README.md b/labs/kotlin-theme/README.md new file mode 100644 index 00000000..5aff6520 --- /dev/null +++ b/labs/kotlin-theme/README.md @@ -0,0 +1,12 @@ +
+ +# `@tapsioss/lab-kotlin-theme` + +
+ +
+ +Theming package providing design tokens as CSS and JS variables for the Tapsi +Design System. + +
diff --git a/labs/kotlin-theme/package.json b/labs/kotlin-theme/package.json new file mode 100644 index 00000000..b3d1c98c --- /dev/null +++ b/labs/kotlin-theme/package.json @@ -0,0 +1,32 @@ +{ + "name": "@lab/kotlin-theme", + "description": "Tapsi Design System icons implemented as standard Web Components.", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "./dist", + "./README.md" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./*": { + "types": "./dist/*/index.d.ts", + "require": "./dist/*/index.js" + }, + "./*/element": "./dist/*/element.js" + }, + "scripts": { + "prebuild": "shx rm -rf dist", + "build": "tsx ./scripts/build.ts" + }, + "dependencies": { + "@tapsioss/theme": "workspace:*", + "@lab-internals/css-to-kotlin-helpers": "workspace:*" + } +} diff --git a/labs/kotlin-theme/scripts/build.ts b/labs/kotlin-theme/scripts/build.ts new file mode 100644 index 00000000..8f6812ad --- /dev/null +++ b/labs/kotlin-theme/scripts/build.ts @@ -0,0 +1,202 @@ +import { + parseColor, + parseDp, + parseGradient, + parseTextStyle, +} from "@lab-internals/css-to-kotlin-helpers"; +import tokens from "@tapsioss/theme/tokens"; +import fs from "fs"; +import path from "path"; + +import mustache from "mustache"; +import { fileURLToPath } from "url"; +import { ensureDirExists } from "../../../scripts/utils.ts"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PACKAGE_NAME = "tapsioss.designsystem.theme"; +const templateDir = path.join(__dirname, "../templates/GeneratedTheme.hbs"); +const outDir = path.join(__dirname, "../dist"); + +// TODO: move to a shared package +const logger = (message: string, scope: string = "kotlin-theme"): void => { + const completeMessage = `[LABS][${scope}]: ${message}`; + + console.log(completeMessage); +}; + +/** + * Checks whether a value is a plain object (non-null, non-array). + */ +const isObject = (val: unknown): val is Record => + typeof val === "object" && val !== null && !Array.isArray(val); + +/** + * Converts a key into a valid Kotlin property name. + */ +export const toKotlinKey = (key: string): string => { + if (/^[0-9-]*$/.test(key)) return `\`${key}\``; + return toCamelCase(key); +}; + +/** + * Converts a kebab-case or snake_case string to camelCase. + */ +const toCamelCase = (str: string): string => + str.replace(/[-_]+(.)/g, (_, chr: string) => chr.toUpperCase()); + +/** + * Capitalizes the first letter of a string. + */ +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +/** + * Indents lines based on nesting level. + */ +const indent = (level: number) => " ".repeat(level); + +/** + * Determines if a value is a TextStyle object with required fields. + */ +export function isTextStyleObject(obj: Record): boolean { + return "font" in obj && "size" in obj && "weight" in obj && "height" in obj; +} + +/** + * Converts a raw token value into Kotlin code. + */ +export function parseValue(value: unknown): string { + if (typeof value === "string") { + if (value.startsWith("linear-gradient")) return parseGradient(value); + if (value.startsWith("#")) return `Color(${parseColor(value)})`; + if (value.endsWith("px") || value.endsWith("rem")) return parseDp(value); + if (value.startsWith("var(--")) { + return resolveCssVarToKotlinPath(value); + } + + if (value === "0") return "0.dp"; + return `"${value}"`; + } + + if (typeof value === "number") return value.toString(); + return `"${String(value)}"`; // fallback for unknown types +} + +/** + * Converts a CSS variable like `--tapsi-palette-blue-400` + * into a Kotlin path like `Theme.Palette.Blue.\`400\`` + */ +export function resolveCssVarToKotlinPath(cssVar: string): string { + const varName = cssVar.slice(4, -1).trim(); + const raw = varName.replace(/^--/, ""); + const parts = raw.split("-"); + + // Skip brand prefix + const themeParts = parts.slice(1); + + const kotlinPath = themeParts + .map(part => { + if (/^[0-9-]+$/.test(part)) return `\`${part}\``; + return capitalize(toCamelCase(part)); + }) + .join("."); + + return kotlinPath; +} + +function detectImports(kotlinCode: string): string[] { + const importSet = new Set(); + + if (/Color\(/.test(kotlinCode)) + importSet.add("androidx.compose.ui.graphics.Color"); + if (/Brush\.horizontalGradient/.test(kotlinCode)) + importSet.add("androidx.compose.ui.graphics.Brush"); + + if (/TextStyle\(/.test(kotlinCode)) + importSet.add("androidx.compose.ui.text.TextStyle"); + if (/FontWeight/.test(kotlinCode)) + importSet.add("androidx.compose.ui.text.font.FontWeight"); + if (/FontFamily/.test(kotlinCode)) + importSet.add("androidx.compose.ui.text.font.FontFamily"); + + if (/\bdp\b/.test(kotlinCode) || /\.dp/.test(kotlinCode)) + importSet.add("androidx.compose.ui.unit.dp"); + if (/\bDp\b/.test(kotlinCode)) importSet.add("androidx.compose.ui.unit.Dp"); + if (/\bsp\b/.test(kotlinCode)) importSet.add("androidx.compose.ui.unit.sp"); + + return [...importSet].sort(); +} + +/** + * Recursively generates Kotlin object blocks from a nested token structure. + */ +export function generateKotlinBlock(obj: object, level = 0): string { + const lines: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + const safeKey = toKotlinKey(key); + + // TODO: fix this + if (safeKey === "fontFamily") continue; + + if (isObject(value)) { + if (isTextStyleObject(value)) { + lines.push( + `${indent(level + 1)}val ${safeKey} = ${parseTextStyle(value)}`, + ); + } else { + lines.push( + `${indent(level + 1)}object ${capitalize( + safeKey, + )} {\n${generateKotlinBlock(value, level + 1)}\n${indent( + level + 1, + )}}`, + ); + } + } else { + lines.push(`${indent(level + 1)}val ${safeKey} = ${parseValue(value)}`); + } + } + + return lines.join("\n"); +} + +export async function generateKotlinFiles(tokens: Record) { + const template = fs.readFileSync(templateDir, "utf-8"); + + await ensureDirExists(outDir); + console.log({ outDir }); + + for (const [section, value] of Object.entries(tokens)) { + if (!isObject(value)) { + logger(`skipping non-object token: "${section}"`); + continue; + } + + const block = `object ${capitalize(section)} {\n${generateKotlinBlock(value, 1)}\n}`; + const imports = detectImports(block); + + logger(`๐Ÿ‘พ generating Kotlin contents for theme's ${section}...`); + const output = mustache.render(template, { + packageName: PACKAGE_NAME, + imports, + blocks: [block], + }); + + const fileName = `Theme${capitalize(section)}.kt`; + const filePath = path.join(outDir, fileName); + + logger(`๐Ÿ“ writing generated file in \`${filePath}\`...`); + fs.writeFileSync(filePath, output); + + logger(`โœ… ${fileName} was generated successfully!`); + logger(`---`); + } +} + +void (async () => { + logger("๐Ÿงฉ generating Kotlin theme file from `@tapsioss/theme`..."); + logger(`---`); + await generateKotlinFiles(tokens); + logger("โœจ Theme was generated successfully!"); +})(); diff --git a/labs/kotlin-theme/templates/GeneratedTheme.hbs b/labs/kotlin-theme/templates/GeneratedTheme.hbs new file mode 100644 index 00000000..0ee8fdc8 --- /dev/null +++ b/labs/kotlin-theme/templates/GeneratedTheme.hbs @@ -0,0 +1,9 @@ +package {{packageName}} + +{{#imports}} +import {{.}} +{{/imports}} + +{{#blocks}} +{{&.}} +{{/blocks}} \ No newline at end of file diff --git a/labs/kotlin-theme/tsconfig.build.json b/labs/kotlin-theme/tsconfig.build.json new file mode 100644 index 00000000..07edbc07 --- /dev/null +++ b/labs/kotlin-theme/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["dist"] +} diff --git a/labs/kotlin-theme/tsconfig.json b/labs/kotlin-theme/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/labs/kotlin-theme/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/package.json b/package.json index 5e964097..322b6365 100644 --- a/package.json +++ b/package.json @@ -284,6 +284,7 @@ "@types/mustache": "^4.2.5", "@types/node": "^20.17.32", "@types/postcss-import": "^14.0.3", + "@types/postcss-safe-parser": "^5.0.4", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.7", "@types/stream-json": "^1.7.8", @@ -308,6 +309,7 @@ "postcss-cli": "^11.0.1", "postcss-combine-duplicated-selectors": "^10.0.3", "postcss-import": "^16.1.0", + "postcss-safe-parser": "^7.0.1", "prettier": "^3.5.3", "prettier-plugin-organize-imports": "^4.1.0", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb62e684..a96196c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@types/postcss-import': specifier: ^14.0.3 version: 14.0.3 + '@types/postcss-safe-parser': + specifier: ^5.0.4 + version: 5.0.4 '@types/react': specifier: ^18.3.20 version: 18.3.20 @@ -116,6 +119,9 @@ importers: postcss-import: specifier: ^16.1.0 version: 16.1.0(postcss@8.5.3) + postcss-safe-parser: + specifier: ^7.0.1 + version: 7.0.1(postcss@8.5.3) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -188,6 +194,21 @@ importers: internals/test-helpers: {} + labs/css-to-kotlin-utils: + devDependencies: + vitest: + specifier: ^3.1.4 + version: 3.1.4(@types/node@20.17.32) + + labs/kotlin-theme: + dependencies: + '@lab-internals/css-to-kotlin-helpers': + specifier: workspace:* + version: link:../css-to-kotlin-utils + '@tapsioss/theme': + specifier: workspace:* + version: link:../../packages/theme + packages/icons: dependencies: tslib: @@ -1222,6 +1243,9 @@ packages: '@types/postcss-import@14.0.3': resolution: {integrity: sha512-raZhRVTf6Vw5+QbmQ7LOHSDML71A5rj4+EqDzAbrZPfxfoGzFxMHRCq16VlddGIZpHELw0BG4G0YE2ANkdZiIQ==} + '@types/postcss-safe-parser@5.0.4': + resolution: {integrity: sha512-5zGTm1jsW3j4+omgND1SIDbrZOcigTuxa4ihppvKbLkg2INUGBHV/fWNRSRFibK084tU3fxqZ/kVoSIGqRHnrQ==} + '@types/prop-types@15.7.14': resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} @@ -1402,6 +1426,35 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@vitest/expect@3.1.4': + resolution: {integrity: sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==} + + '@vitest/mocker@3.1.4': + resolution: {integrity: sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.1.4': + resolution: {integrity: sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==} + + '@vitest/runner@3.1.4': + resolution: {integrity: sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==} + + '@vitest/snapshot@3.1.4': + resolution: {integrity: sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==} + + '@vitest/spy@3.1.4': + resolution: {integrity: sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==} + + '@vitest/utils@3.1.4': + resolution: {integrity: sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==} + '@vue/compiler-core@3.5.13': resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} @@ -1581,6 +1634,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1650,6 +1707,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1672,6 +1733,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1689,6 +1754,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chokidar@3.5.2: resolution: {integrity: sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==} engines: {node: '>= 8.10.0'} @@ -1831,6 +1900,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1929,6 +2002,9 @@ packages: es-module-lexer@0.9.3: resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2106,6 +2182,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2126,6 +2205,10 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + express-rate-limit@7.5.0: resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} engines: {node: '>= 16'} @@ -2763,6 +2846,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3062,6 +3148,13 @@ packages: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -3154,6 +3247,12 @@ packages: peerDependencies: postcss: ^8.1.0 + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -3452,6 +3551,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3499,10 +3601,16 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stream-chain@2.2.5: resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} @@ -3581,10 +3689,28 @@ packages: thenby@1.3.4: resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -3748,6 +3874,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.1.4: + resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -3799,6 +3930,34 @@ packages: postcss: optional: true + vitest@3.1.4: + resolution: {integrity: sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.4 + '@vitest/ui': 3.1.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vue@3.5.13: resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} peerDependencies: @@ -3838,6 +3997,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wireit@0.14.12: resolution: {integrity: sha512-gNSd+nZmMo6cuICezYXRIayu6TSOeCSCDzjSF0q6g8FKDsRbdqrONrSZYzdk/uBISmRcv4vZtsno6GyGvdXwGA==} engines: {node: '>=18.0.0'} @@ -4918,6 +5082,10 @@ snapshots: dependencies: postcss: 8.5.3 + '@types/postcss-safe-parser@5.0.4': + dependencies: + postcss: 8.5.3 + '@types/prop-types@15.7.14': {} '@types/react-dom@18.3.7(@types/react@18.3.20)': @@ -5098,6 +5266,46 @@ snapshots: vite: 5.4.19(@types/node@20.17.32) vue: 3.5.13(typescript@5.8.3) + '@vitest/expect@3.1.4': + dependencies: + '@vitest/spy': 3.1.4 + '@vitest/utils': 3.1.4 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.1.4(vite@5.4.19(@types/node@20.17.32))': + dependencies: + '@vitest/spy': 3.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.19(@types/node@20.17.32) + + '@vitest/pretty-format@3.1.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.1.4': + dependencies: + '@vitest/utils': 3.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@3.1.4': + dependencies: + '@vitest/pretty-format': 3.1.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.1.4': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.1.4': + dependencies: + '@vitest/pretty-format': 3.1.4 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + '@vue/compiler-core@3.5.13': dependencies: '@babel/parser': 7.27.1 @@ -5318,6 +5526,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} async-retry@1.2.3: @@ -5388,6 +5598,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5411,6 +5623,14 @@ snapshots: ccount@2.0.1: {} + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -5428,6 +5648,8 @@ snapshots: chardet@0.7.0: {} + check-error@2.1.1: {} + chokidar@3.5.2: dependencies: anymatch: 3.1.3 @@ -5607,6 +5829,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -5745,6 +5969,8 @@ snapshots: es-module-lexer@0.9.3: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6000,6 +6226,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + esutils@2.0.3: {} etag@1.8.1: {} @@ -6014,6 +6244,8 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 + expect-type@1.2.1: {} + express-rate-limit@7.5.0(express@5.1.0): dependencies: express: 5.1.0 @@ -6693,6 +6925,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.1.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -6971,6 +7205,10 @@ snapshots: path-type@6.0.0: {} + pathe@2.0.3: {} + + pathval@2.0.0: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -7045,6 +7283,10 @@ snapshots: postcss: 8.5.3 thenby: 1.3.4 + postcss-safe-parser@7.0.1(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -7397,6 +7639,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7434,8 +7678,12 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + statuses@2.0.1: {} + std-env@3.9.0: {} + stream-chain@2.2.5: {} stream-chain@3.4.0: {} @@ -7523,11 +7771,21 @@ snapshots: thenby@1.3.4: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinypool@1.0.2: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -7720,6 +7978,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite-node@3.1.4(@types/node@20.17.32): + dependencies: + cac: 6.7.14 + debug: 4.4.0 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.19(@types/node@20.17.32) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@20.17.32)): dependencies: debug: 4.4.0 @@ -7789,6 +8065,42 @@ snapshots: - typescript - universal-cookie + vitest@3.1.4(@types/node@20.17.32): + dependencies: + '@vitest/expect': 3.1.4 + '@vitest/mocker': 3.1.4(vite@5.4.19(@types/node@20.17.32)) + '@vitest/pretty-format': 3.1.4 + '@vitest/runner': 3.1.4 + '@vitest/snapshot': 3.1.4 + '@vitest/spy': 3.1.4 + '@vitest/utils': 3.1.4 + chai: 5.2.0 + debug: 4.4.0 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 5.4.19(@types/node@20.17.32) + vite-node: 3.1.4(@types/node@20.17.32) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.17.32 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vue@3.5.13(typescript@5.8.3): dependencies: '@vue/compiler-dom': 3.5.13 @@ -7855,6 +8167,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wireit@0.14.12: dependencies: brace-expansion: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b65d19e3..ae4a6550 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "packages/*" + - "labs/*" - "internals/test-helpers" - "internals/danger" - "playground"