Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/petite-shrimps-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---

---



14 changes: 14 additions & 0 deletions labs/README.md
Original file line number Diff line number Diff line change
@@ -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! 🚀
13 changes: 13 additions & 0 deletions labs/css-to-kotlin-utils/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
87 changes: 87 additions & 0 deletions labs/css-to-kotlin-utils/src/css-ast-to-modifier.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
136 changes: 136 additions & 0 deletions labs/css-to-kotlin-utils/src/css-ast-to-modifier.ts
Original file line number Diff line number Diff line change
@@ -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<VNode, { type: "declaration" }>,
): 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 */
Loading
Loading