diff --git a/apps/lapikit.dev/src/routes/+page.svelte b/apps/lapikit.dev/src/routes/+page.svelte index 854486e..3f117ed 100644 --- a/apps/lapikit.dev/src/routes/+page.svelte +++ b/apps/lapikit.dev/src/routes/+page.svelte @@ -1,5 +1,5 @@

Welcome to SvelteKit

@@ -15,4 +15,10 @@ demo

Labs merge Class and Styles Core

-Demo +Demo diff --git a/packages/lapikit/CHANGELOG.md b/packages/lapikit/CHANGELOG.md index 29dd946..6c76a7b 100644 --- a/packages/lapikit/CHANGELOG.md +++ b/packages/lapikit/CHANGELOG.md @@ -19,6 +19,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.8] - 2026-02-01 + +### Updated + +- Optmize preprocess lili with new function +- Rewrite all mapping code +- Add test for critical function in lili core + ## [0.3.7] - 2026-01-25 ### Added diff --git a/packages/lapikit/package.json b/packages/lapikit/package.json index ffabe1d..67ea46c 100644 --- a/packages/lapikit/package.json +++ b/packages/lapikit/package.json @@ -1,6 +1,6 @@ { "name": "lapikit", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "publishConfig": { "access": "public" diff --git a/packages/lapikit/src/lib/entry-bundler.ts b/packages/lapikit/src/lib/entry-bundler.ts index 8341dd4..f99b0f9 100644 --- a/packages/lapikit/src/lib/entry-bundler.ts +++ b/packages/lapikit/src/lib/entry-bundler.ts @@ -1,135 +1,8 @@ -const components: readonly string[] = ['btn'] as const; - -function componentName(shortName: string): string { - return 'Kit' + shortName.charAt(0).toUpperCase() + shortName.slice(1); -} - -// plugins lapikit -const lapikitPlugins = { - repl: { - components: ['repl'], - ref: '@lapikit/repl' - } -} as const; - -type LapikitPreprocessOptions = { - plugins?: string[]; -}; +import { liliCore } from '$lib/labs/compiler/preprocess/index.js'; +import type { LapikitPreprocessOptions } from '$lib/labs/compiler/types/options.js'; function lapikitPreprocess(options?: LapikitPreprocessOptions) { - return { - markup({ content }: { content: string; filename?: string }) { - const allComponents = [...components]; - const componentToRef = new Map(); - - components.forEach((comp) => { - componentToRef.set(comp, 'lapikit/labs/components'); - }); - - // plugins - if (options?.plugins) { - options.plugins.forEach((pluginKey) => { - const plugin = lapikitPlugins[pluginKey as keyof typeof lapikitPlugins]; - if (plugin) { - plugin.components.forEach((comp) => { - if (!allComponents.includes(comp)) { - allComponents.push(comp); - } - componentToRef.set(comp, plugin.ref); - }); - } - }); - } - - const hasComponent = allComponents.some((comp) => content.includes(`(); - - let hasChanges = true; - while (hasChanges) { - hasChanges = false; - - for (const shortName of allComponents) { - const componentNameStr = componentName(shortName); - - const attrPattern = `(?:[^>"']|"[^"]*"|'[^']*')*?`; - - const selfClosingRegex = new RegExp(``, 'g'); - - const pairRegex = new RegExp( - `([\\s\\S]*?)<\\/kit:${shortName}\\s*>`, - 'g' - ); - - let newContent = processedContent.replace(selfClosingRegex, (fullMatch, attrs) => { - hasChanges = true; - const ref = componentToRef.get(shortName) || 'lapikit/labs/components'; - importedComponents.set(componentNameStr, ref); - return `<${componentNameStr}${attrs} />`; - }); - - newContent = newContent.replace(pairRegex, (fullMatch, attrs, children) => { - hasChanges = true; - const ref = componentToRef.get(shortName) || 'lapikit/labs/components'; - importedComponents.set(componentNameStr, ref); - return `<${componentNameStr}${attrs}>${children}`; - }); - - if (newContent !== processedContent) { - processedContent = newContent; - } - } - } - - if (processedContent === content) return; - - if (importedComponents.size > 0) { - const importsByRef = new Map(); - importedComponents.forEach((ref, component) => { - if (!importsByRef.has(ref)) { - importsByRef.set(ref, []); - } - importsByRef.get(ref)!.push(component); - }); - - const importLines = Array.from(importsByRef.entries()) - .map(([ref, components]) => { - const imports = components.join(', '); - return `\n\timport { ${imports} } from '${ref}';`; - }) - .join(''); - - const scriptRegex = /]*\bmodule\b)([^>]*)>/; - const scriptMatch = processedContent.match(scriptRegex); - - if (scriptMatch && scriptMatch.index !== undefined) { - const insertPos = scriptMatch.index + scriptMatch[0].length; - processedContent = - processedContent.slice(0, insertPos) + importLines + processedContent.slice(insertPos); - } else { - const moduleScriptMatch = processedContent.match(/]*\bmodule\b[^>]*>/); - - if (moduleScriptMatch && moduleScriptMatch.index !== undefined) { - const moduleScriptEnd = - processedContent.indexOf('', moduleScriptMatch.index) + ''.length; - processedContent = - processedContent.slice(0, moduleScriptEnd) + - `\n\n` + - processedContent.slice(moduleScriptEnd); - } else { - processedContent = `\n\n` + processedContent; - } - } - } - - return { - code: processedContent - }; - } - }; + return liliCore(options); } export { lapikitPreprocess }; diff --git a/packages/lapikit/src/lib/labs/compiler/__test__/compute-classes.test.ts b/packages/lapikit/src/lib/labs/compiler/__test__/compute-classes.test.ts new file mode 100644 index 0000000..c5379cf --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/__test__/compute-classes.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import { computeSClasses } from '../mapped-code.js'; +import type { SClassProp } from '../types/index.js'; + +describe('computeSClasses', () => { + describe('sClass as string', () => { + it('should return the string when sClass is a non-empty string', () => { + const result = computeSClasses('btn btn-primary', {}); + expect(result).toBe('btn btn-primary'); + }); + + it('should return empty string when sClass is an empty string', () => { + const result = computeSClasses('', {}); + expect(result).toBe(''); + }); + }); + + describe('sClass as array', () => { + it('should join array elements with spaces', () => { + const result = computeSClasses(['btn', 'btn-primary'], {}); + expect(result).toBe('btn btn-primary'); + }); + + it('should filter out empty strings from array', () => { + const result = computeSClasses(['btn', '', 'btn-primary'], {}); + expect(result).toBe('btn btn-primary'); + }); + + it('should filter out non-string values from array', () => { + const result = computeSClasses( + ['btn', null, undefined, 'btn-primary'] as unknown as SClassProp, + {} + ); + expect(result).toBe('btn btn-primary'); + }); + + it('should return empty string for empty array', () => { + const result = computeSClasses([], {}); + expect(result).toBe(''); + }); + }); + + describe('sClass as object', () => { + it('should add keys where value is true', () => { + const result = computeSClasses( + { + btn: true, + 'btn-primary': true, + 'btn-disabled': false + }, + {} + ); + expect(result).toBe('btn btn-primary'); + }); + + it('should use string values as class names', () => { + const result = computeSClasses( + { + btn: 'custom-button', + size: 'large-size' + }, + {} + ); + expect(result).toBe('custom-button large-size'); + }); + + it('should handle mixed boolean and string values', () => { + const result = computeSClasses( + { + btn: true, + color: 'text-primary', + disabled: false + }, + {} + ); + expect(result).toBe('btn text-primary'); + }); + + it('should filter out empty string values', () => { + const result = computeSClasses( + { + btn: true, + color: '' + }, + {} + ); + expect(result).toBe('btn'); + }); + + it('should return empty string for empty object', () => { + const result = computeSClasses({}, {}); + expect(result).toBe(''); + }); + }); + + describe('classDirectiveProps (s-class_xxx)', () => { + it('should add class name when value is true', () => { + const result = computeSClasses('', { + 's-class_btn': true, + 's-class_primary': true + }); + expect(result).toBe('btn primary'); + }); + + it('should concatenate base with string value', () => { + const result = computeSClasses('', { + 's-class_btn': '-primary', + 's-class_size': '-large' + }); + expect(result).toBe('btn-primary size-large'); + }); + + it('should ignore non-true and non-string values', () => { + const result = computeSClasses('', { + 's-class_btn': true, + 's-class_disabled': false, + 's-class_color': '' + }); + expect(result).toBe('btn'); + }); + + it('should handle mixed boolean and string values', () => { + const result = computeSClasses('', { + 's-class_btn': true, + 's-class_variant': '-outlined' + }); + expect(result).toBe('btn variant-outlined'); + }); + }); + + describe('combined scenarios', () => { + it('should combine sClass string and classDirectiveProps', () => { + const result = computeSClasses('base-class', { + 's-class_modifier': '-active' + }); + expect(result).toBe('base-class modifier-active'); + }); + + it('should combine sClass array and classDirectiveProps', () => { + const result = computeSClasses(['btn', 'rounded'], { + 's-class_variant': '-primary' + }); + expect(result).toBe('btn rounded variant-primary'); + }); + + it('should combine sClass object and classDirectiveProps', () => { + const result = computeSClasses( + { + btn: true, + outlined: 'border-solid' + }, + { + 's-class_color': '-blue' + } + ); + expect(result).toBe('btn border-solid color-blue'); + }); + + it('should handle all types combined', () => { + const result = computeSClasses( + { + btn: true, + size: 'text-large' + }, + { + 's-class_variant': '-outlined', + 's-class_active': true + } + ); + expect(result).toBe('btn text-large variant-outlined active'); + }); + + it('should return empty string when everything is empty', () => { + const result = computeSClasses('', {}); + expect(result).toBe(''); + }); + + it('should handle null and undefined sClass', () => { + const result = computeSClasses(undefined, {}); + expect(result).toBe(''); + }); + }); + + describe('edge cases', () => { + it('should handle multiple spaces in string sClass', () => { + const result = computeSClasses('btn btn-primary', {}); + expect(result).toBe('btn btn-primary'); + }); + + it('should preserve order of classes', () => { + const result = computeSClasses(['z-class', 'a-class'], { + 's-class_m': true + }); + expect(result).toBe('z-class a-class m'); + }); + + it('should handle special characters in class names', () => { + const result = computeSClasses( + { + 'btn--primary': true, + btn__icon: 'icon--hover' + }, + { + 's-class_state': ':active' + } + ); + expect(result).toBe('btn--primary icon--hover state:active'); + }); + }); +}); diff --git a/packages/lapikit/src/lib/labs/compiler/__test__/compute-styles.test.ts b/packages/lapikit/src/lib/labs/compiler/__test__/compute-styles.test.ts new file mode 100644 index 0000000..6144ffb --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/__test__/compute-styles.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { computeSStyles } from '../mapped-code.js'; + +describe('computeSStyles', () => { + describe('sStyle as object', () => { + it('should convert object to CSS style string', () => { + const result = computeSStyles( + { + color: 'red', + 'font-size': '16px' + }, + {} + ); + expect(result).toBe('color: red; font-size: 16px'); + }); + + it('should filter out falsy values', () => { + const result = computeSStyles( + { + color: 'red', + display: false, + margin: '' + }, + {} + ); + expect(result).toBe('color: red'); + }); + + it('should handle boolean true values', () => { + const result = computeSStyles( + { + color: 'blue', + visible: true + }, + {} + ); + expect(result).toBe('color: blue; visible: true'); + }); + + it('should return empty string for empty object', () => { + const result = computeSStyles({}, {}); + expect(result).toBe(''); + }); + + it('should handle undefined sStyle', () => { + const result = computeSStyles(undefined, {}); + expect(result).toBe(''); + }); + + it('should handle CSS properties with units', () => { + const result = computeSStyles( + { + width: '100px', + height: '50%', + padding: '1rem' + }, + {} + ); + expect(result).toBe('width: 100px; height: 50%; padding: 1rem'); + }); + }); + + describe('styleDirectiveProps (s-style_xxx)', () => { + it('should add styles from directives', () => { + const result = computeSStyles( + {}, + { + 's-style_color': 'red', + 's-style_background': 'blue' + } + ); + expect(result).toBe('color: red; background: blue'); + }); + + it('should filter out falsy directive values', () => { + const result = computeSStyles( + {}, + { + 's-style_color': 'red', + 's-style_display': false, + 's-style_margin': '' + } + ); + expect(result).toBe('color: red'); + }); + + it('should handle boolean true values in directives', () => { + const result = computeSStyles( + {}, + { + 's-style_visible': true, + 's-style_color': 'green' + } + ); + expect(result).toBe('visible: true; color: green'); + }); + + it('should handle hyphenated CSS properties', () => { + const result = computeSStyles( + {}, + { + 's-style_font-size': '14px', + 's-style_text-align': 'center' + } + ); + expect(result).toBe('font-size: 14px; text-align: center'); + }); + }); + + describe('combined scenarios', () => { + it('should combine sStyle object and styleDirectiveProps', () => { + const result = computeSStyles( + { + color: 'red', + 'font-size': '16px' + }, + { + 's-style_margin': '10px', + 's-style_padding': '5px' + } + ); + expect(result).toBe('color: red; font-size: 16px; margin: 10px; padding: 5px'); + }); + + it('should filter falsy values from both sources', () => { + const result = computeSStyles( + { + color: 'red', + display: false + }, + { + 's-style_margin': '10px', + 's-style_padding': '' + } + ); + expect(result).toBe('color: red; margin: 10px'); + }); + + it('should return empty string when everything is empty', () => { + const result = computeSStyles({}, {}); + expect(result).toBe(''); + }); + + it('should handle mixed boolean and string values', () => { + const result = computeSStyles( + { + color: 'blue', + visible: true + }, + { + 's-style_display': 'block', + 's-style_active': true + } + ); + expect(result).toBe('color: blue; visible: true; display: block; active: true'); + }); + }); + + describe('edge cases', () => { + it('should preserve order of styles', () => { + const result = computeSStyles( + { + 'z-index': '999', + 'a-property': 'value' + }, + { + 's-style_m-property': 'test' + } + ); + expect(result).toBe('z-index: 999; a-property: value; m-property: test'); + }); + + it('should handle CSS custom properties (variables)', () => { + const result = computeSStyles( + { + '--primary-color': '#ff0000', + '--spacing': '1rem' + }, + {} + ); + expect(result).toBe('--primary-color: #ff0000; --spacing: 1rem'); + }); + + it('should handle calc and other CSS functions', () => { + const result = computeSStyles( + { + width: 'calc(100% - 20px)', + transform: 'rotate(45deg)' + }, + {} + ); + expect(result).toBe('width: calc(100% - 20px); transform: rotate(45deg)'); + }); + + it('should handle numeric values', () => { + const result = computeSStyles( + { + opacity: '0.5', + 'z-index': '10' + }, + {} + ); + expect(result).toBe('opacity: 0.5; z-index: 10'); + }); + + it('should handle color formats', () => { + const result = computeSStyles( + { + color: 'rgb(255, 0, 0)', + background: '#ff0000', + border: 'rgba(0, 0, 0, 0.5)' + }, + {} + ); + expect(result).toBe('color: rgb(255, 0, 0); background: #ff0000; border: rgba(0, 0, 0, 0.5)'); + }); + }); +}); diff --git a/packages/lapikit/src/lib/labs/compiler/__test__/make-component-props.test.ts b/packages/lapikit/src/lib/labs/compiler/__test__/make-component-props.test.ts new file mode 100644 index 0000000..8567578 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/__test__/make-component-props.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from 'vitest'; +import { makeComponentProps } from '../mapped-code.js'; + +describe('makeComponentProps', () => { + describe('basic separation', () => { + it('should separate s-class_ directives into classProps', () => { + const props = { + 's-class_btn': true, + 's-class_variant': '-primary', + id: 'test', + disabled: false + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({ + 's-class_btn': true, + 's-class_variant': '-primary' + }); + }); + + it('should separate s-style_ directives into styleProps', () => { + const props = { + 's-style_color': 'red', + 's-style_font-size': '16px', + id: 'test', + disabled: false + }; + + const result = makeComponentProps(props); + + expect(result.styleProps).toEqual({ + 's-style_color': 'red', + 's-style_font-size': '16px' + }); + }); + + it('should separate remaining props into restProps', () => { + const props = { + 's-class_btn': true, + 's-style_color': 'red', + id: 'test', + disabled: false, + onClick: () => {} + }; + + const result = makeComponentProps(props); + + expect(result.restProps).toEqual({ + id: 'test', + disabled: false, + onClick: expect.any(Function) + }); + }); + }); + + describe('s-class and s-style base properties', () => { + it('should exclude s-class base property from all groups', () => { + const props = { + 's-class': 'btn btn-primary', + 's-class_active': true, + id: 'test' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({ + 's-class_active': true + }); + expect(result.styleProps).toEqual({}); + expect(result.restProps).toEqual({ + id: 'test' + }); + }); + + it('should exclude s-style base property from all groups', () => { + const props = { + 's-style': { color: 'red' }, + 's-style_margin': '10px', + id: 'test' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({}); + expect(result.styleProps).toEqual({ + 's-style_margin': '10px' + }); + expect(result.restProps).toEqual({ + id: 'test' + }); + }); + }); + + describe('all types combined', () => { + it('should properly separate all three types of props', () => { + const props = { + 's-class': 'base-class', + 's-class_btn': true, + 's-class_variant': '-outlined', + 's-style': { display: 'flex' }, + 's-style_color': 'blue', + 's-style_padding': '20px', + id: 'component', + disabled: false, + onClick: () => {}, + 'data-testid': 'test' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({ + 's-class_btn': true, + 's-class_variant': '-outlined' + }); + + expect(result.styleProps).toEqual({ + 's-style_color': 'blue', + 's-style_padding': '20px' + }); + + expect(result.restProps).toEqual({ + id: 'component', + disabled: false, + onClick: expect.any(Function), + 'data-testid': 'test' + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty props object', () => { + const result = makeComponentProps({}); + + expect(result.classProps).toEqual({}); + expect(result.styleProps).toEqual({}); + expect(result.restProps).toEqual({}); + }); + + it('should handle props with only s-class directives', () => { + const props = { + 's-class_btn': true, + 's-class_size': '-large' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({ + 's-class_btn': true, + 's-class_size': '-large' + }); + expect(result.styleProps).toEqual({}); + expect(result.restProps).toEqual({}); + }); + + it('should handle props with only s-style directives', () => { + const props = { + 's-style_color': 'red', + 's-style_margin': '10px' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({}); + expect(result.styleProps).toEqual({ + 's-style_color': 'red', + 's-style_margin': '10px' + }); + expect(result.restProps).toEqual({}); + }); + + it('should handle props with only regular props', () => { + const props = { + id: 'test', + disabled: true, + 'aria-label': 'Button' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({}); + expect(result.styleProps).toEqual({}); + expect(result.restProps).toEqual({ + id: 'test', + disabled: true, + 'aria-label': 'Button' + }); + }); + + it('should handle hyphenated s-class directives', () => { + const props = { + 's-class_btn-primary': true, + 's-class_font-size': '-large' + }; + + const result = makeComponentProps(props); + + expect(result.classProps).toEqual({ + 's-class_btn-primary': true, + 's-class_font-size': '-large' + }); + }); + + it('should handle hyphenated s-style directives', () => { + const props = { + 's-style_font-size': '14px', + 's-style_background-color': 'white' + }; + + const result = makeComponentProps(props); + + expect(result.styleProps).toEqual({ + 's-style_font-size': '14px', + 's-style_background-color': 'white' + }); + }); + + it('should preserve values of different types', () => { + const fn = () => {}; + const obj = { nested: 'value' }; + + const props = { + 's-class_active': true, + 's-style_display': 'block', + string: 'text', + number: 42, + boolean: true, + null: null, + undefined: undefined, + function: fn, + object: obj, + array: [1, 2, 3] + }; + + const result = makeComponentProps(props); + + expect(result.restProps).toEqual({ + string: 'text', + number: 42, + boolean: true, + null: null, + undefined: undefined, + function: fn, + object: obj, + array: [1, 2, 3] + }); + }); + }); + + describe('return object structure', () => { + it('should always return an object with classProps, styleProps, and restProps keys', () => { + const result = makeComponentProps({ test: 'value' }); + + expect(result).toHaveProperty('classProps'); + expect(result).toHaveProperty('styleProps'); + expect(result).toHaveProperty('restProps'); + }); + + it('should return separate objects that do not reference the original props', () => { + const props = { + 's-class_btn': true, + 's-style_color': 'red', + id: 'test' + }; + + const result = makeComponentProps(props); + + // Modify result objects + result.classProps['s-class_new'] = true; + result.styleProps['s-style_new'] = 'blue'; + result.restProps['newProp'] = 'value'; + + // Original should be unchanged + expect(props).not.toHaveProperty('s-class_new'); + expect(props).not.toHaveProperty('s-style_new'); + expect(props).not.toHaveProperty('newProp'); + }); + }); +}); diff --git a/packages/lapikit/src/lib/labs/compiler/components.ts b/packages/lapikit/src/lib/labs/compiler/components.ts new file mode 100644 index 0000000..90e2eb9 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/components.ts @@ -0,0 +1,3 @@ +const lapikitComponents: readonly string[] = ['sheet'] as const; + +export default lapikitComponents; diff --git a/packages/lapikit/src/lib/labs/compiler/index.ts b/packages/lapikit/src/lib/labs/compiler/index.ts new file mode 100644 index 0000000..7a5df06 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/index.ts @@ -0,0 +1 @@ +export * from './preprocess/index.js'; diff --git a/packages/lapikit/src/lib/labs/compiler/mapped-code.ts b/packages/lapikit/src/lib/labs/compiler/mapped-code.ts new file mode 100644 index 0000000..a9ce106 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/mapped-code.ts @@ -0,0 +1,125 @@ +import type { SClassProp, SStyleProp } from '$lib/labs/compiler/types/index.js'; +import type { PropValue } from '$lib/labs/utils/types/index.js'; + +/** + * Computes a string of class names based on the provided sClass and classDirectiveProps. + * @param sClass - The s-class property which can be a string, array, or object. + * @param classDirectiveProps - An object containing s-class_xxx directives. + * @returns A string of class names. + */ +export function computeSClasses( + sClass: SClassProp, + classDirectiveProps: Record +): string { + const classes: string[] = []; + + // s-class string + if (typeof sClass === 'string' && sClass) { + classes.push(sClass); + } + + // s-class array + if (Array.isArray(sClass)) { + for (const value of sClass) { + if (typeof value === 'string' && value) { + classes.push(value); + } + } + } + + // s-class object + if (sClass && typeof sClass === 'object' && !Array.isArray(sClass)) { + const entries = Object.entries(sClass); + if (entries.length > 0) { + for (const [key, value] of entries) { + if (value === true) { + classes.push(key); + } else if (typeof value === 'string' && value) { + classes.push(value); + } + } + } + } + + // s-class_xxx + const classEntries = Object.entries(classDirectiveProps); + if (classEntries.length > 0) { + for (const [key, value] of classEntries) { + // Use slice instead of replace for better performance (8 = 's-class_'.length) + const base = key.slice(8); + + if (value === true) { + classes.push(base); + } else if (typeof value === 'string' && value) { + classes.push(`${base}${value}`); + } + } + } + + return classes.join(' '); +} + +/** + * Computes a string of style declarations based on the provided sStyle and styleDirectiveProps. + * @param sStyle - The s-style property which is an object of style key-value pairs. + * @param styleDirectiveProps - An object containing s-style_xxx directives. + * @returns A string of style declarations. + */ +export function computeSStyles( + sStyle: SStyleProp, + styleDirectiveProps: Record +): string { + const styles: string[] = []; + + if (sStyle && typeof sStyle === 'object') { + const entries = Object.entries(sStyle); + if (entries.length > 0) { + for (const [key, value] of entries) { + if (value) { + styles.push(`${key}: ${value}`); + } + } + } + } + + const styleEntries = Object.entries(styleDirectiveProps); + if (styleEntries.length > 0) { + for (const [key, value] of styleEntries) { + // Use slice instead of replace for better performance (8 = 's-style_'.length) + const base = key.slice(8); + if (value) { + styles.push(`${base}: ${value}`); + } + } + } + + return styles.join('; '); +} + +/** + * Makes component props by separating s-class and s-style directives from other props. + * Optimized to use a single pass instead of three separate iterations. + * @param props The original props object containing all props. + * @returns An object containing separated classProps, styleProps, and restProps. + */ +export function makeComponentProps(props: Record): { + classProps: Record; + styleProps: Record; + restProps: Record; +} { + const classProps: Record = {}; + const styleProps: Record = {}; + const restProps: Record = {}; + + for (const [key, value] of Object.entries(props)) { + if (key.startsWith('s-class_')) { + classProps[key] = value as PropValue; + } else if (key.startsWith('s-style_')) { + styleProps[key] = value as PropValue; + } else if (!key.startsWith('s-class') && !key.startsWith('s-style')) { + restProps[key] = value; + } + } + + return { classProps, styleProps, restProps }; +} diff --git a/packages/lapikit/src/lib/labs/compiler/plugins.ts b/packages/lapikit/src/lib/labs/compiler/plugins.ts new file mode 100644 index 0000000..9b5e1d0 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/plugins.ts @@ -0,0 +1,8 @@ +const lapikitPlugins = { + repl: { + components: ['repl'], + ref: '@lapikit/repl' + } +} as const; + +export default lapikitPlugins; diff --git a/packages/lapikit/src/lib/labs/compiler/preprocess/__test__/lili.test.ts b/packages/lapikit/src/lib/labs/compiler/preprocess/__test__/lili.test.ts new file mode 100644 index 0000000..be81e14 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/preprocess/__test__/lili.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { liliCore } from '../lili.js'; +import { componentName, lapikitImportsRef } from '../source-import.js'; + +describe('liliCore', () => { + it('converts kit tags with multiline attributes and injects imports', () => { + const preprocess = liliCore(); + const sheetName = componentName('sheet'); + const input = `\n\n a > b}\n/>`; + + const result = preprocess.markup({ content: input }); + + expect(result?.code).toContain(`import { ${sheetName} } from '${lapikitImportsRef}';`); + expect(result?.code).toContain(`<${sheetName}`); + expect(result?.code).toContain('class="gnome"'); + expect(result?.code).toContain('on:click={() => a > b}'); + expect(result?.code).not.toContain(' { + const preprocess = liliCore(); + const sheetName = componentName('sheet'); + const input = `\n\n\n\ncontent`; + + const result = preprocess.markup({ content: input }); + + expect(result?.code).toContain("const template = '';"); + expect(result?.code).toContain('content: "";'); + expect(result?.code).toContain(`<${sheetName}>content`); + expect(result?.code).toContain(`import { ${sheetName} } from '${lapikitImportsRef}';`); + }); + + it('does nothing when kit tags exist only inside script or style', () => { + const preprocess = liliCore(); + const input = `\n\n`; + + const result = preprocess.markup({ content: input }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/lapikit/src/lib/labs/compiler/preprocess/decode-sourcemap.ts b/packages/lapikit/src/lib/labs/compiler/preprocess/decode-sourcemap.ts new file mode 100644 index 0000000..e558cb1 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/preprocess/decode-sourcemap.ts @@ -0,0 +1,128 @@ +import { findTagEnd } from '$lib/labs/compiler/preprocess/finding.js'; +import type { ComponentInfo, KitComponentScan } from '$lib/labs/compiler/types/index.js'; + +export const decodeSourceMap = ( + content: string, + componentInfo: Map +): KitComponentScan => { + const importedComponents = new Map(); + const parts: string[] = []; + let changed = false; + let index = 0; + const length = content.length; + + while (index < length) { + const nextLt = content.indexOf('<', index); + if (nextLt === -1) { + parts.push(content.slice(index)); + break; + } + + if (nextLt > index) { + parts.push(content.slice(index, nextLt)); + } + + const ch = content[nextLt + 1]; + const isClosing = ch === '/'; + + if (content.startsWith('/]/.test(afterName)) { + parts.push('<'); + index = nextLt + 1; + continue; + } + + const openEnd = findTagEnd(content, nameEnd); + if (openEnd === -1) { + parts.push(content.slice(nextLt)); + break; + } + + const closeTag = ``; + const closeStart = content.indexOf(closeTag, openEnd + 1); + if (closeStart === -1) { + parts.push(content.slice(nextLt)); + break; + } + + const closeEnd = closeStart + closeTag.length; + parts.push(content.slice(nextLt, closeEnd)); + index = closeEnd; + continue; + } + + const isKitTag = + (!isClosing && content.startsWith('' || c === '/' || c === ' ' || c === '\n' || c === '\t' || c === '\r') { + break; + } + nameEnd++; + } + + const shortName = content.slice(nameStart, nameEnd); + if (!shortName) { + parts.push('<'); + index = nextLt + 1; + continue; + } + + const info = componentInfo.get(shortName); + const isKnown = info !== undefined; + if (isClosing) { + const tagEnd = content.indexOf('>', nameEnd); + if (tagEnd === -1) { + parts.push(content.slice(nextLt)); + break; + } + + if (isKnown) { + importedComponents.set(info!.name, info!.ref); + parts.push(``); + changed = true; + } else { + parts.push(content.slice(nextLt, tagEnd + 1)); + } + + index = tagEnd + 1; + continue; + } + + const tagEnd = findTagEnd(content, nameEnd); + if (tagEnd === -1) { + parts.push(content.slice(nextLt)); + break; + } + + if (isKnown) { + importedComponents.set(info!.name, info!.ref); + parts.push(`<${info!.name}${content.slice(nameEnd, tagEnd + 1)}`); + changed = true; + } else { + parts.push(content.slice(nextLt, tagEnd + 1)); + } + + index = tagEnd + 1; + } + + return { + code: changed ? parts.join('') : content, + changed, + importedComponents + }; +}; diff --git a/packages/lapikit/src/lib/labs/compiler/preprocess/finding.ts b/packages/lapikit/src/lib/labs/compiler/preprocess/finding.ts new file mode 100644 index 0000000..502b897 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/preprocess/finding.ts @@ -0,0 +1,57 @@ +export const findTagEnd = (content: string, start: number): number => { + let inSingle = false; + let inDouble = false; + let inTemplate = false; + let braceDepth = 0; + + for (let i = start; i < content.length; i++) { + const ch = content[i]; + const prev = i > 0 ? content[i - 1] : ''; + + if (inSingle) { + if (ch === "'" && prev !== '\\') inSingle = false; + continue; + } + + if (inDouble) { + if (ch === '"' && prev !== '\\') inDouble = false; + continue; + } + + if (inTemplate) { + if (ch === '`' && prev !== '\\') inTemplate = false; + continue; + } + + if (ch === "'") { + inSingle = true; + continue; + } + + if (ch === '"') { + inDouble = true; + continue; + } + + if (ch === '`') { + inTemplate = true; + continue; + } + + if (ch === '{') { + braceDepth++; + continue; + } + + if (ch === '}' && braceDepth > 0) { + braceDepth--; + continue; + } + + if (braceDepth === 0 && ch === '>') { + return i; + } + } + + return -1; +}; diff --git a/packages/lapikit/src/lib/labs/compiler/preprocess/index.ts b/packages/lapikit/src/lib/labs/compiler/preprocess/index.ts new file mode 100644 index 0000000..91e8097 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/preprocess/index.ts @@ -0,0 +1 @@ +export * from './lili.js'; diff --git a/packages/lapikit/src/lib/labs/compiler/preprocess/lili.ts b/packages/lapikit/src/lib/labs/compiler/preprocess/lili.ts new file mode 100644 index 0000000..54c29f5 --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/preprocess/lili.ts @@ -0,0 +1,95 @@ +import type { ComponentInfo, LapikitPreprocessOptions } from '$lib/labs/compiler/types/index.js'; +import { componentName, lapikitImportsRef } from '$lib/labs/compiler/preprocess/source-import.js'; +import { decodeSourceMap } from '$lib/labs/compiler/preprocess/decode-sourcemap.js'; + +// imports components and plugins +import lapikitComponents from '$lib/labs/compiler/components.js'; +import lapikitPlugins from '$lib/labs/compiler/plugins.js'; + +export function liliCore(options?: LapikitPreprocessOptions) { + return { + markup({ content }: { content: string; filename?: string }) { + const allComponents = [...lapikitComponents]; + const componentToRef = new Map(); + + lapikitComponents.forEach((comp) => { + componentToRef.set(comp, lapikitImportsRef); + }); + + // plugins + if (options?.plugins) { + options.plugins.forEach((pluginKey) => { + const plugin = lapikitPlugins[pluginKey as keyof typeof lapikitPlugins]; + if (plugin) { + plugin.components.forEach((comp) => { + if (!allComponents.includes(comp)) { + allComponents.push(comp); + } + componentToRef.set(comp, plugin.ref); + }); + } + }); + } + + if (!content.includes('(); + for (const shortName of allComponents) { + componentInfo.set(shortName, { + name: componentName(shortName), + ref: componentToRef.get(shortName) || lapikitImportsRef + }); + } + + const scanResult = decodeSourceMap(content, componentInfo); + + if (!scanResult.changed) return; + + let processedContent = scanResult.code; + const importedComponents = scanResult.importedComponents; + + if (importedComponents.size > 0) { + const importsByRef = new Map(); + importedComponents.forEach((ref, component) => { + if (!importsByRef.has(ref)) { + importsByRef.set(ref, []); + } + importsByRef.get(ref)!.push(component); + }); + + const importLines = Array.from(importsByRef.entries()) + .map(([ref, components]) => { + const imports = components.join(', '); + return `\n\timport { ${imports} } from '${ref}';`; + }) + .join(''); + + const scriptRegex = /]*\bmodule\b)([^>]*)>/; + const scriptMatch = processedContent.match(scriptRegex); + + if (scriptMatch && scriptMatch.index !== undefined) { + const insertPos = scriptMatch.index + scriptMatch[0].length; + processedContent = + processedContent.slice(0, insertPos) + importLines + processedContent.slice(insertPos); + } else { + const moduleScriptMatch = processedContent.match(/]*\bmodule\b[^>]*>/); + + if (moduleScriptMatch && moduleScriptMatch.index !== undefined) { + const moduleScriptEnd = + processedContent.indexOf('', moduleScriptMatch.index) + ''.length; + processedContent = + processedContent.slice(0, moduleScriptEnd) + + `\n\n` + + processedContent.slice(moduleScriptEnd); + } else { + processedContent = `\n\n` + processedContent; + } + } + } + + return { + code: processedContent + }; + } + }; +} diff --git a/packages/lapikit/src/lib/labs/compiler/preprocess/source-import.ts b/packages/lapikit/src/lib/labs/compiler/preprocess/source-import.ts new file mode 100644 index 0000000..204150a --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/preprocess/source-import.ts @@ -0,0 +1,10 @@ +/** + * componentName generates the component name used in imports + * @param shortName The short name of the component + * @returns The component name with "Kit" prefix and the first letter capitalized + */ +export function componentName(shortName: string): string { + return 'Kit' + shortName.charAt(0).toUpperCase() + shortName.slice(1); +} + +export const lapikitImportsRef = 'lapikit/labs/components'; diff --git a/packages/lapikit/src/lib/labs/compiler/types/dom.ts b/packages/lapikit/src/lib/labs/compiler/types/dom.ts new file mode 100644 index 0000000..3676b7f --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/types/dom.ts @@ -0,0 +1,2 @@ +export type SClassProp = string | string[] | Record | undefined; +export type SStyleProp = Record | undefined; diff --git a/packages/lapikit/src/lib/labs/compiler/types/index.ts b/packages/lapikit/src/lib/labs/compiler/types/index.ts new file mode 100644 index 0000000..f9436cd --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/types/index.ts @@ -0,0 +1,2 @@ +export type * from './dom.js'; +export * from './options.js'; diff --git a/packages/lapikit/src/lib/labs/compiler/types/options.ts b/packages/lapikit/src/lib/labs/compiler/types/options.ts new file mode 100644 index 0000000..8d70dff --- /dev/null +++ b/packages/lapikit/src/lib/labs/compiler/types/options.ts @@ -0,0 +1,14 @@ +export type LapikitPreprocessOptions = { + plugins?: string[]; +}; + +export type KitComponentScan = { + code: string; + changed: boolean; + importedComponents: Map; +}; + +export type ComponentInfo = { + name: string; + ref: string; +}; diff --git a/packages/lapikit/src/lib/labs/components/btn/btn.svelte b/packages/lapikit/src/lib/labs/components/btn/btn.svelte deleted file mode 100644 index 3319b5e..0000000 --- a/packages/lapikit/src/lib/labs/components/btn/btn.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - - diff --git a/packages/lapikit/src/lib/labs/components/btn/btn.svelte.ts b/packages/lapikit/src/lib/labs/components/btn/btn.svelte.ts deleted file mode 100644 index 00f70a0..0000000 --- a/packages/lapikit/src/lib/labs/components/btn/btn.svelte.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { SClassProp, SStyleProp } from '../../utils/index.js'; - -export function useClassName({ - baseClass = '', - className, - sClass, - classDirectiveProps -}: { - baseClass?: string; - className?: string; - sClass?: SClassProp; - classDirectiveProps?: Record; -} = {}) { - return { - get value() { - const classes: string[] = []; - - if (baseClass) { - classes.push(baseClass); - } - - if (typeof sClass === 'string' && sClass) { - classes.push(sClass); - } - - if (Array.isArray(sClass)) { - for (const value of sClass) { - if (typeof value === 'string' && value) { - classes.push(value); - } - } - } - - if (sClass && typeof sClass === 'object' && !Array.isArray(sClass)) { - Object.entries(sClass).forEach(([key, value]) => { - if (value === true) { - classes.push(key); - } else if (typeof value === 'string' && value) { - classes.push(value); - } - }); - } - - if (classDirectiveProps) { - Object.entries(classDirectiveProps).forEach(([key, value]) => { - const base = key.replace('s-class_', ''); - - if (value === true) { - classes.push(base); - } else if (typeof value === 'string' && value) { - classes.push(`${base}${value}`); - } - }); - } - - if (className) { - classes.push(className); - } - - return classes.filter(Boolean).join(' '); - } - }; -} - -export function useStyles({ - styleAttr, - sStyle, - styleDirectiveProps -}: { - styleAttr?: string; - sStyle?: SStyleProp; - styleDirectiveProps?: Record; -} = {}) { - return { - get value() { - const styles: string[] = []; - - if (sStyle && typeof sStyle === 'object') { - Object.entries(sStyle).forEach(([key, value]) => { - if (value) { - styles.push(`${key}: ${value}`); - } - }); - } - - if (styleDirectiveProps) { - Object.entries(styleDirectiveProps).forEach(([key, value]) => { - const base = key.replace('s-style_', ''); - if (value) { - styles.push(`${base}: ${value}`); - } - }); - } - - if (styleAttr) { - styles.push(styleAttr); - } - - return styles.filter(Boolean).join('; '); - } - }; -} diff --git a/packages/lapikit/src/lib/labs/components/index.ts b/packages/lapikit/src/lib/labs/components/index.ts index 5139ff9..f82ea47 100644 --- a/packages/lapikit/src/lib/labs/components/index.ts +++ b/packages/lapikit/src/lib/labs/components/index.ts @@ -1,2 +1,2 @@ // components -export { default as KitBtn } from './btn/btn.svelte'; +export { default as KitSheet } from './sheet/sheet.svelte'; diff --git a/packages/lapikit/src/lib/labs/components/sheet/sheet.svelte b/packages/lapikit/src/lib/labs/components/sheet/sheet.svelte new file mode 100644 index 0000000..298bad5 --- /dev/null +++ b/packages/lapikit/src/lib/labs/components/sheet/sheet.svelte @@ -0,0 +1,49 @@ + + +
+ {@render children()} +
+ + diff --git a/packages/lapikit/src/lib/labs/utils/__test__/use-class-name.test.ts b/packages/lapikit/src/lib/labs/utils/__test__/use-class-name.test.ts new file mode 100644 index 0000000..9b3f1bf --- /dev/null +++ b/packages/lapikit/src/lib/labs/utils/__test__/use-class-name.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; +import { useClassName } from '../components.js'; + +describe('useClassName', () => { + describe('baseClass', () => { + it('should return baseClass when only baseClass is provided', () => { + const result = useClassName({ baseClass: 'btn' }); + expect(result).toBe('btn'); + }); + + it('should return empty string when no arguments are provided', () => { + const result = useClassName(); + expect(result).toBe(''); + }); + + it('should ignore empty baseClass', () => { + const result = useClassName({ baseClass: '' }); + expect(result).toBe(''); + }); + }); + + describe('className', () => { + it('should return className when only className is provided', () => { + const result = useClassName({ className: 'custom-class' }); + expect(result).toBe('custom-class'); + }); + + it('should combine baseClass and className', () => { + const result = useClassName({ baseClass: 'btn', className: 'custom' }); + expect(result).toBe('btn custom'); + }); + + it('should ignore empty className', () => { + const result = useClassName({ baseClass: 'btn', className: '' }); + expect(result).toBe('btn'); + }); + }); + + describe('sClass - string', () => { + it('should handle sClass as a string', () => { + const result = useClassName({ sClass: 'primary' }); + expect(result).toBe('primary'); + }); + + it('should combine baseClass and sClass string', () => { + const result = useClassName({ baseClass: 'btn', sClass: 'primary' }); + expect(result).toBe('btn primary'); + }); + + it('should ignore empty sClass string', () => { + const result = useClassName({ baseClass: 'btn', sClass: '' }); + expect(result).toBe('btn'); + }); + }); + + describe('sClass - array', () => { + it('should handle sClass as an array of strings', () => { + const result = useClassName({ sClass: ['primary', 'large'] }); + expect(result).toBe('primary large'); + }); + + it('should combine baseClass and sClass array', () => { + const result = useClassName({ baseClass: 'btn', sClass: ['primary', 'large'] }); + expect(result).toBe('btn primary large'); + }); + + it('should filter out empty strings in array', () => { + const result = useClassName({ sClass: ['primary', '', 'large', ''] }); + expect(result).toBe('primary large'); + }); + + it('should handle empty array', () => { + const result = useClassName({ baseClass: 'btn', sClass: [] }); + expect(result).toBe('btn'); + }); + + it('should ignore non-string values in array', () => { + // Testing runtime behavior with invalid types + const result = useClassName({ + sClass: ['primary', null, undefined, 123, true] as unknown as string[] + }); + expect(result).toBe('primary'); + }); + }); + + describe('sClass - object', () => { + it('should handle sClass object with boolean true values', () => { + const result = useClassName({ sClass: { primary: true, disabled: true } }); + expect(result).toBe('primary disabled'); + }); + + it('should ignore sClass object with boolean false values', () => { + const result = useClassName({ sClass: { primary: true, disabled: false } }); + expect(result).toBe('primary'); + }); + + it('should handle sClass object with string values', () => { + const result = useClassName({ sClass: { variant: 'primary', size: 'lg' } }); + expect(result).toBe('primary lg'); + }); + + it('should ignore empty string values in object', () => { + const result = useClassName({ sClass: { variant: 'primary', size: '' } }); + expect(result).toBe('primary'); + }); + + it('should combine baseClass and sClass object', () => { + const result = useClassName({ baseClass: 'btn', sClass: { primary: true, large: true } }); + expect(result).toBe('btn primary large'); + }); + + it('should handle empty object', () => { + const result = useClassName({ baseClass: 'btn', sClass: {} }); + expect(result).toBe('btn'); + }); + + it('should handle mixed boolean and string values in object', () => { + const result = useClassName({ + sClass: { primary: true, variant: 'outlined', disabled: false, size: 'md' } + }); + expect(result).toBe('primary outlined md'); + }); + }); + + describe('classProps (s-class_xxx directives)', () => { + it('should handle classProps with boolean true', () => { + const result = useClassName({ classProps: { 's-class_active': true } }); + expect(result).toBe('active'); + }); + + it('should handle classProps with string values', () => { + const result = useClassName({ classProps: { 's-class_variant': '-primary' } }); + expect(result).toBe('variant-primary'); + }); + + it('should ignore classProps with boolean false', () => { + const result = useClassName({ + classProps: { 's-class_active': true, 's-class_disabled': false } + }); + expect(result).toBe('active'); + }); + + it('should handle multiple classProps', () => { + const result = useClassName({ + classProps: { + 's-class_active': true, + 's-class_variant': '-primary', + 's-class_size': '-lg' + } + }); + expect(result).toBe('active variant-primary size-lg'); + }); + + it('should handle empty classProps object', () => { + const result = useClassName({ baseClass: 'btn', classProps: {} }); + expect(result).toBe('btn'); + }); + + it('should ignore empty string values in classProps', () => { + const result = useClassName({ + classProps: { 's-class_variant': '-primary', 's-class_size': '' } + }); + expect(result).toBe('variant-primary'); + }); + }); + + describe('combination of all props', () => { + it('should combine all props in correct order', () => { + const result = useClassName({ + baseClass: 'btn', + sClass: 'primary', + className: 'custom', + classProps: { 's-class_active': true } + }); + expect(result).toBe('btn primary active custom'); + }); + + it('should handle complex combination with sClass array', () => { + const result = useClassName({ + baseClass: 'btn', + sClass: ['primary', 'large'], + className: 'my-btn', + classProps: { 's-class_rounded': true, 's-class_shadow': '-md' } + }); + expect(result).toBe('btn primary large rounded shadow-md my-btn'); + }); + + it('should handle complex combination with sClass object', () => { + const result = useClassName({ + baseClass: 'btn', + sClass: { primary: true, disabled: false, variant: 'outlined' }, + className: 'custom', + classProps: { 's-class_active': true } + }); + expect(result).toBe('btn primary outlined active custom'); + }); + + it('should filter out all falsy values', () => { + const result = useClassName({ + baseClass: '', + sClass: { primary: false, active: true, size: '' }, + className: '', + classProps: { 's-class_disabled': false, 's-class_rounded': true } + }); + expect(result).toBe('active rounded'); + }); + }); + + describe('edge cases', () => { + it('should handle undefined values gracefully', () => { + const result = useClassName({ + baseClass: undefined, + sClass: undefined, + className: undefined, + classProps: undefined + }); + expect(result).toBe(''); + }); + + it('should handle null values in sClass object', () => { + // Testing runtime behavior with invalid types + const result = useClassName({ + sClass: { primary: null } as unknown as Record + }); + expect(result).toBe(''); + }); + + it('should handle number values in classProps', () => { + // Testing runtime behavior with invalid types + const result = useClassName({ + classProps: { 's-class_count': 5 } as unknown as Record + }); + expect(result).toBe(''); + }); + + it('should preserve whitespace in class names', () => { + const result = useClassName({ baseClass: 'btn', className: 'my custom' }); + expect(result).toBe('btn my custom'); + }); + }); +}); diff --git a/packages/lapikit/src/lib/labs/utils/__test__/use-styles.test.ts b/packages/lapikit/src/lib/labs/utils/__test__/use-styles.test.ts new file mode 100644 index 0000000..aa0461d --- /dev/null +++ b/packages/lapikit/src/lib/labs/utils/__test__/use-styles.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import { useStyles } from '../components.js'; + +describe('useStyles', () => { + describe('no arguments', () => { + it('should return empty string when no arguments are provided', () => { + const result = useStyles(); + expect(result).toBe(''); + }); + + it('should return empty string when all arguments are undefined', () => { + const result = useStyles({ + styleAttr: undefined, + sStyle: undefined, + styleProps: undefined + }); + expect(result).toBe(''); + }); + }); + + describe('styleAttr', () => { + it('should return styleAttr when only styleAttr is provided', () => { + const result = useStyles({ styleAttr: 'color: red' }); + expect(result).toBe('color: red'); + }); + + it('should ignore empty styleAttr', () => { + const result = useStyles({ styleAttr: '' }); + expect(result).toBe(''); + }); + + it('should handle multiple style declarations in styleAttr', () => { + const result = useStyles({ styleAttr: 'color: red; font-size: 16px' }); + expect(result).toBe('color: red; font-size: 16px'); + }); + }); + + describe('sStyle - object', () => { + it('should handle sStyle object with string values', () => { + const result = useStyles({ sStyle: { color: 'red', fontSize: '16px' } }); + expect(result).toBe('color: red; fontSize: 16px'); + }); + + it('should handle sStyle object with single property', () => { + const result = useStyles({ sStyle: { color: 'blue' } }); + expect(result).toBe('color: blue'); + }); + + it('should ignore falsy values in sStyle object', () => { + const result = useStyles({ + sStyle: { color: 'red', fontSize: '', display: false as unknown as string } + }); + expect(result).toBe('color: red'); + }); + + it('should handle empty sStyle object', () => { + const result = useStyles({ sStyle: {} }); + expect(result).toBe(''); + }); + + it('should handle sStyle with boolean true values', () => { + const result = useStyles({ + sStyle: { color: 'red', display: true as unknown as string } + }); + expect(result).toBe('color: red; display: true'); + }); + + it('should handle CSS custom properties (variables)', () => { + const result = useStyles({ + sStyle: { '--primary-color': '#3b82f6', '--spacing': '1rem' } + }); + expect(result).toBe('--primary-color: #3b82f6; --spacing: 1rem'); + }); + + it('should handle numeric string values', () => { + const result = useStyles({ sStyle: { width: '100px', height: '50px' } }); + expect(result).toBe('width: 100px; height: 50px'); + }); + }); + + describe('styleProps (s-style_xxx directives)', () => { + it('should handle styleProps with string values', () => { + const result = useStyles({ styleProps: { 's-style_color': 'red' } }); + expect(result).toBe('color: red'); + }); + + it('should handle multiple styleProps', () => { + const result = useStyles({ + styleProps: { + 's-style_color': 'red', + 's-style_fontSize': '16px', + 's-style_display': 'flex' + } + }); + expect(result).toBe('color: red; fontSize: 16px; display: flex'); + }); + + it('should ignore falsy values in styleProps', () => { + const result = useStyles({ + styleProps: { + 's-style_color': 'red', + 's-style_fontSize': '', + 's-style_display': false as unknown as string + } + }); + expect(result).toBe('color: red'); + }); + + it('should handle empty styleProps object', () => { + const result = useStyles({ styleProps: {} }); + expect(result).toBe(''); + }); + + it('should handle styleProps with CSS custom properties', () => { + const result = useStyles({ + styleProps: { + 's-style_--primary': '#3b82f6', + 's-style_--spacing': '1rem' + } + }); + expect(result).toBe('--primary: #3b82f6; --spacing: 1rem'); + }); + + it('should handle styleProps with boolean true', () => { + const result = useStyles({ + styleProps: { 's-style_display': true as unknown as string } + }); + expect(result).toBe('display: true'); + }); + }); + + describe('combination of props', () => { + it('should combine sStyle and styleAttr', () => { + const result = useStyles({ + sStyle: { color: 'red' }, + styleAttr: 'font-size: 16px' + }); + expect(result).toBe('color: red; font-size: 16px'); + }); + + it('should combine styleProps and styleAttr', () => { + const result = useStyles({ + styleProps: { 's-style_color': 'red' }, + styleAttr: 'font-size: 16px' + }); + expect(result).toBe('color: red; font-size: 16px'); + }); + + it('should combine all props in correct order (sStyle, styleProps, styleAttr)', () => { + const result = useStyles({ + sStyle: { color: 'red' }, + styleProps: { 's-style_fontSize': '16px' }, + styleAttr: 'display: flex' + }); + expect(result).toBe('color: red; fontSize: 16px; display: flex'); + }); + + it('should handle complex combination with multiple properties', () => { + const result = useStyles({ + sStyle: { color: 'red', backgroundColor: 'white' }, + styleProps: { + 's-style_fontSize': '16px', + 's-style_padding': '10px' + }, + styleAttr: 'display: flex; align-items: center' + }); + expect(result).toBe( + 'color: red; backgroundColor: white; fontSize: 16px; padding: 10px; display: flex; align-items: center' + ); + }); + + it('should filter out all falsy values in combination', () => { + const result = useStyles({ + sStyle: { color: 'red', fontSize: '' }, + styleProps: { 's-style_display': 'flex', 's-style_margin': '' }, + styleAttr: '' + }); + expect(result).toBe('color: red; display: flex'); + }); + }); + + describe('edge cases', () => { + it('should handle null sStyle', () => { + const result = useStyles({ sStyle: null as unknown as Record }); + expect(result).toBe(''); + }); + + it('should handle undefined styleProps', () => { + const result = useStyles({ styleProps: undefined }); + expect(result).toBe(''); + }); + + it('should handle number values in sStyle', () => { + // Testing runtime behavior with invalid types + const result = useStyles({ + sStyle: { width: 100 as unknown as string, color: 'red' } + }); + expect(result).toBe('width: 100; color: red'); + }); + + it('should handle null values in sStyle object', () => { + // Testing runtime behavior with invalid types + const result = useStyles({ + sStyle: { color: null as unknown as string, fontSize: '16px' } + }); + expect(result).toBe('fontSize: 16px'); + }); + + it('should preserve semicolons in styleAttr', () => { + const result = useStyles({ + sStyle: { color: 'red' }, + styleAttr: 'font-size: 16px;' + }); + expect(result).toBe('color: red; font-size: 16px;'); + }); + + it('should handle styleAttr with leading/trailing spaces', () => { + const result = useStyles({ styleAttr: ' color: red ' }); + expect(result).toBe(' color: red '); + }); + + it('should handle empty string values correctly', () => { + const result = useStyles({ + sStyle: { color: '', fontSize: '16px' }, + styleProps: { 's-style_display': '' } + }); + expect(result).toBe('fontSize: 16px'); + }); + + it('should handle zero values in sStyle', () => { + const result = useStyles({ + sStyle: { margin: '0', padding: '0px', opacity: '0' } + }); + expect(result).toBe('margin: 0; padding: 0px; opacity: 0'); + }); + }); + + describe('CSS properties formatting', () => { + it('should handle kebab-case properties', () => { + const result = useStyles({ + sStyle: { 'background-color': 'red', 'font-size': '16px' } + }); + expect(result).toBe('background-color: red; font-size: 16px'); + }); + + it('should handle camelCase properties', () => { + const result = useStyles({ + sStyle: { backgroundColor: 'red', fontSize: '16px' } + }); + expect(result).toBe('backgroundColor: red; fontSize: 16px'); + }); + + it('should handle mixed kebab-case and camelCase', () => { + const result = useStyles({ + sStyle: { 'background-color': 'red', fontSize: '16px' } + }); + expect(result).toBe('background-color: red; fontSize: 16px'); + }); + }); +}); diff --git a/packages/lapikit/src/lib/labs/utils/components.ts b/packages/lapikit/src/lib/labs/utils/components.ts new file mode 100644 index 0000000..a4cbefe --- /dev/null +++ b/packages/lapikit/src/lib/labs/utils/components.ts @@ -0,0 +1,110 @@ +import type { useClassNameProps, useStylesProps } from '$lib/labs/utils/types/index.js'; + +/** + * useClassName - Utility to compute class names for a component. + * @param baseClass - The base class name for the component. + * @param className - Additional class names as a string. + * @param sClass - The s-class property which can be a string, array, or object. + * @param classProps - An object containing s-class_xxx directives. + * @returns A computed class string. + */ +export function useClassName({ + baseClass = '', + className, + sClass, + classProps +}: useClassNameProps = {}): string { + const classes: string[] = []; + + if (baseClass) { + classes.push(baseClass); + } + + if (typeof sClass === 'string' && sClass) { + classes.push(sClass); + } + + if (Array.isArray(sClass)) { + for (const value of sClass) { + if (typeof value === 'string' && value) { + classes.push(value); + } + } + } + + if (sClass && typeof sClass === 'object' && !Array.isArray(sClass)) { + const entries = Object.entries(sClass); + if (entries.length > 0) { + for (const [key, value] of entries) { + if (value === true) { + classes.push(key); + } else if (typeof value === 'string' && value) { + classes.push(value); + } + } + } + } + + if (classProps) { + const entries = Object.entries(classProps); + if (entries.length > 0) { + for (const [key, value] of entries) { + // Use slice instead of replace for better performance (8 = 's-class_'.length) + const base = key.slice(8); + + if (value === true) { + classes.push(base); + } else if (typeof value === 'string' && value) { + classes.push(`${base}${value}`); + } + } + } + } + + if (className) { + classes.push(className); + } + + return classes.filter(Boolean).join(' '); +} + +/** + * useStyles - Utility to compute style declarations for a component (optimized pure function). + * @param styleAttr - Inline style attribute as a string. + * @param sStyle - The s-style property which is an object of style key-value pairs. + * @param styleProps - An object containing s-style_xxx directives. + * @returns A computed style string. + */ +export function useStyles({ styleAttr, sStyle, styleProps }: useStylesProps = {}): string { + const styles: string[] = []; + + if (sStyle && typeof sStyle === 'object') { + const entries = Object.entries(sStyle); + if (entries.length > 0) { + for (const [key, value] of entries) { + if (value) { + styles.push(`${key}: ${value}`); + } + } + } + } + + if (styleProps) { + const entries = Object.entries(styleProps); + if (entries.length > 0) { + for (const [key, value] of entries) { + // Use slice instead of replace for better performance (8 = 's-style_'.length) + const base = key.slice(8); + if (value) { + styles.push(`${base}: ${value}`); + } + } + } + } + + if (styleAttr) { + styles.push(styleAttr); + } + + return styles.filter(Boolean).join('; '); +} diff --git a/packages/lapikit/src/lib/labs/utils/index.ts b/packages/lapikit/src/lib/labs/utils/index.ts index 1b692d7..dcd948a 100644 --- a/packages/lapikit/src/lib/labs/utils/index.ts +++ b/packages/lapikit/src/lib/labs/utils/index.ts @@ -1 +1 @@ -export * from './props.js'; +export * from './components.js'; diff --git a/packages/lapikit/src/lib/labs/utils/props.ts b/packages/lapikit/src/lib/labs/utils/props.ts deleted file mode 100644 index c3cf22a..0000000 --- a/packages/lapikit/src/lib/labs/utils/props.ts +++ /dev/null @@ -1,89 +0,0 @@ -export type SClassProp = string | string[] | Record | undefined; -export type SStyleProp = Record | undefined; - -export function splitSyntheticProps(allProps: Record) { - const classDirectiveProps = Object.fromEntries( - Object.entries(allProps).filter(([key]) => key.startsWith('s-class_')) - ); - - const styleDirectiveProps = Object.fromEntries( - Object.entries(allProps).filter(([key]) => key.startsWith('s-style_')) - ); - - const regularProps = Object.fromEntries( - Object.entries(allProps).filter( - ([key]) => !key.startsWith('s-class') && !key.startsWith('s-style') - ) - ); - - return { classDirectiveProps, styleDirectiveProps, regularProps }; -} - -export function computeSClasses( - sClass: SClassProp, - classDirectiveProps: Record -): string { - const classes: string[] = []; - - // s-class string - if (typeof sClass === 'string' && sClass) { - classes.push(sClass); - } - - // s-class array - if (Array.isArray(sClass)) { - for (const value of sClass) { - if (typeof value === 'string' && value) { - classes.push(value); - } - } - } - - // s-class object - if (sClass && typeof sClass === 'object' && !Array.isArray(sClass)) { - Object.entries(sClass).forEach(([key, value]) => { - if (value === true) { - classes.push(key); - } else if (typeof value === 'string' && value) { - classes.push(value); - } - }); - } - - // s-class_xxx - Object.entries(classDirectiveProps).forEach(([key, value]) => { - const base = key.replace('s-class_', ''); - - if (value === true) { - classes.push(base); - } else if (typeof value === 'string' && value) { - classes.push(`${base}${value}`); - } - }); - - return classes.join(' '); -} - -export function computeSStyles( - sStyle: SStyleProp, - styleDirectiveProps: Record -): string { - const styles: string[] = []; - - if (sStyle && typeof sStyle === 'object') { - Object.entries(sStyle).forEach(([key, value]) => { - if (value) { - styles.push(`${key}: ${value}`); - } - }); - } - - Object.entries(styleDirectiveProps).forEach(([key, value]) => { - const base = key.replace('s-style_', ''); - if (value) { - styles.push(`${base}: ${value}`); - } - }); - - return styles.join('; '); -} diff --git a/packages/lapikit/src/lib/labs/utils/types/index.ts b/packages/lapikit/src/lib/labs/utils/types/index.ts new file mode 100644 index 0000000..bee8e51 --- /dev/null +++ b/packages/lapikit/src/lib/labs/utils/types/index.ts @@ -0,0 +1,16 @@ +import type { SClassProp, SStyleProp } from '$lib/labs/compiler/types/index.js'; + +export type PropValue = string | boolean | number | null | undefined; + +export interface useClassNameProps { + baseClass?: string; + className?: string; + sClass?: SClassProp; + classProps?: Record; +} + +export interface useStylesProps { + styleAttr?: string; + sStyle?: SStyleProp; + styleProps?: Record; +} diff --git a/packages/lapikit/test-debug.js b/packages/lapikit/test-debug.js new file mode 100644 index 0000000..a48c01e --- /dev/null +++ b/packages/lapikit/test-debug.js @@ -0,0 +1,7 @@ +import { liliCore } from './src/lib/labs/compiler/preprocess/lili.ts'; + +const input = '
'; +const result = liliCore().markup({ content: input }); + +console.log('Result:', result); +console.log('Code:', result?.code);