commit 2f1137b1d569b8a7523cb5fff8704e01a3dd92be Author: Michal Date: Fri Jan 16 10:27:30 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f90cdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Quarc specific +/dist/ +/out-tsc/ +/tmp/ +/coverage/ +/e2e/test-output/ +/.quarc/ +.quarc/ + +# Node modules and dependency files +node_modules +package-lock.json +yarn.lock + +# Environment files +/.env + +# Quarc CLI and build artefacts +/.quarc-cli.json +/.qu/ + +# TypeScript cache +*.tsbuildinfo + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/DEPENDENCY_REGISTRATION.md b/DEPENDENCY_REGISTRATION.md new file mode 100644 index 0000000..694a5b9 --- /dev/null +++ b/DEPENDENCY_REGISTRATION.md @@ -0,0 +1,301 @@ +# Automatic Component Dependency Registration + +## Overview + +The framework now automatically registers all component dependencies before rendering. When you bootstrap or create a component, all components listed in its `imports` array are recursively registered as native Web Components. + +## How It Works + +### Registration Flow + +```typescript +@Component({ + selector: 'app-root', + template: '', + imports: [DashboardComponent], +}) +export class AppComponent {} + +// When bootstrapping: +Core.bootstrap(AppComponent); +``` + +**Internal Process:** + +1. `Core.bootstrap(AppComponent)` is called +2. `WebComponentFactory.createFromElement()` is invoked +3. `registerWithDependencies(appComponentInstance)` is called +4. Framework checks `imports: [DashboardComponent]` +5. Creates `DashboardComponent` instance via Injector +6. Recursively calls `registerWithDependencies(dashboardComponentInstance)` +7. Registers `DashboardComponent` as `` custom element +8. Registers `AppComponent` as `` custom element +9. Renders the template with all dependencies ready + +### Code Implementation + +```typescript +// In WebComponentFactory +static registerWithDependencies(component: IComponent): boolean { + const imports = component._quarcComponent[0].imports || []; + const injector = Injector.get(); + + // Register all imported components first + for (const importItem of imports) { + if (this.isComponentType(importItem)) { + let componentInstance = injector.createInstance( + importItem as Type + ); + + if (!componentInstance._quarcComponent) { + componentInstance = importItem as unknown as IComponent; + } + + if (componentInstance._quarcComponent) { + // Recursive call for nested dependencies + this.registerWithDependencies(componentInstance); + } + } + } + + // Finally register the parent component + return this.tryRegister(component); +} +``` + +## Example: Multi-Level Dependencies + +```typescript +// Level 3: Icon Component (no dependencies) +@Component({ + selector: 'app-icon', + template: '', +}) +export class IconComponent {} + +// Level 2: Button Component (depends on Icon) +@Component({ + selector: 'app-button', + template: ` + + `, + imports: [IconComponent], +}) +export class ButtonComponent {} + +// Level 1: Dashboard Component (depends on Button) +@Component({ + selector: 'dashboard', + template: ` +
+

Dashboard

+ +
+ `, + imports: [ButtonComponent], +}) +export class DashboardComponent {} + +// Level 0: Root Component (depends on Dashboard) +@Component({ + selector: 'app-root', + template: '', + imports: [DashboardComponent], +}) +export class AppComponent {} +``` + +**Registration Order:** +1. `IconComponent` → `` +2. `ButtonComponent` → `` +3. `DashboardComponent` → `` +4. `AppComponent` → `` + +## Benefits + +### ✅ No Manual Registration + +Before: +```typescript +// Manual registration required +WebComponentFactory.tryRegister(iconComponent); +WebComponentFactory.tryRegister(buttonComponent); +WebComponentFactory.tryRegister(dashboardComponent); +Core.bootstrap(AppComponent); +``` + +After: +```typescript +// Automatic registration +Core.bootstrap(AppComponent); // All dependencies registered automatically +``` + +### ✅ Correct Order Guaranteed + +The framework ensures components are registered in the correct dependency order, preventing errors where a parent tries to use an unregistered child component. + +### ✅ Prevents Duplicate Registration + +The `tryRegister()` method checks if a component is already registered and returns `false` if it is, preventing duplicate registration errors. + +### ✅ Supports Circular Dependencies + +If two components import each other (not recommended but possible), the registration system handles it gracefully by checking if a component is already registered before attempting to register it again. + +### ✅ Works with Lazy Loading + +Components are only registered when they're actually needed, supporting lazy loading patterns: + +```typescript +// Only registers when loaded +const lazyComponent = await import('./lazy.component'); +WebComponentFactory.create(lazyComponent.LazyComponent); +``` + +## Type Checking + +The `isComponentType()` helper ensures only valid components are processed: + +```typescript +private static isComponentType(item: any): boolean { + // Check if it's a class constructor + if (typeof item === 'function') { + return true; + } + // Check if it's already an instance with metadata + if (item && typeof item === 'object' && item._quarcComponent) { + return true; + } + return false; +} +``` + +## Integration with Injector + +The framework uses the `Injector` to create component instances, which: +- Resolves constructor dependencies +- Caches instances for reuse +- Supports dependency injection patterns + +```typescript +const injector = Injector.get(); +let componentInstance = injector.createInstance( + importItem as Type +); +``` + +## Error Handling + +Registration errors are caught and logged without breaking the application: + +```typescript +try { + customElements.define(tagName, WebComponentClass); + this.registeredComponents.set(tagName, WebComponentClass); + this.componentInstances.set(tagName, component); + return true; +} catch (error) { + console.warn(`Failed to register component ${tagName}:`, error); + return false; +} +``` + +Common errors: +- **Duplicate registration**: Component already registered (handled gracefully) +- **Invalid tag name**: Tag name doesn't contain a hyphen (auto-converted) +- **Constructor errors**: Component constructor throws an error (logged) + +## Best Practices + +### 1. Always Declare Imports + +```typescript +@Component({ + selector: 'parent', + template: '', + imports: [ChildComponent], // ✅ Declared +}) +export class ParentComponent {} +``` + +### 2. Avoid Circular Dependencies + +```typescript +// ❌ Avoid this +@Component({ + selector: 'comp-a', + imports: [ComponentB], +}) +export class ComponentA {} + +@Component({ + selector: 'comp-b', + imports: [ComponentA], // Circular! +}) +export class ComponentB {} +``` + +### 3. Use Lazy Loading for Large Dependencies + +```typescript +@Component({ + selector: 'app-root', + template: '', + // Don't import heavy components here +}) +export class AppComponent { + async loadHeavyComponent() { + const { HeavyComponent } = await import('./heavy.component'); + WebComponentFactory.create(HeavyComponent); + } +} +``` + +## Debugging + +Enable console logging to see the registration flow: + +```typescript +// In WebComponentFactory.registerWithDependencies +console.log(`Registering dependencies for: ${component._quarcComponent[0].selector}`); + +// In WebComponentFactory.tryRegister +console.log(`Registering component: ${tagName}`); +``` + +## Migration from Manual Registration + +If you have existing code with manual registration: + +**Before:** +```typescript +WebComponentFactory.tryRegister(childComponent); +WebComponentFactory.tryRegister(parentComponent); +``` + +**After:** +```typescript +// Just use the parent, children are registered automatically +WebComponentFactory.create(parentComponent); +``` + +Or simply: +```typescript +Core.bootstrap(ParentComponent); +``` + +## Summary + +The automatic dependency registration system: +- ✅ Registers components in correct order +- ✅ Handles nested dependencies recursively +- ✅ Prevents duplicate registrations +- ✅ Integrates with dependency injection +- ✅ Provides error handling and logging +- ✅ Supports lazy loading patterns +- ✅ Simplifies component usage + +No manual registration needed - just declare your imports and bootstrap your app! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b772fc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 quarc-hub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NATIVE_WEB_COMPONENTS.md b/NATIVE_WEB_COMPONENTS.md new file mode 100644 index 0000000..6353526 --- /dev/null +++ b/NATIVE_WEB_COMPONENTS.md @@ -0,0 +1,304 @@ +# Native Web Components Implementation + +This framework now uses **native browser Web Components API** with custom elements registration. + +## Key Features + +- ✅ Uses `customElements.define()` for component registration +- ✅ Components extend native `HTMLElement` +- ✅ Automatic lifecycle management with `connectedCallback` and `disconnectedCallback` +- ✅ Safe registration with `tryRegister()` method that prevents duplicate registration errors +- ✅ Support for Shadow DOM, Emulated, and None encapsulation modes + +## How It Works + +### 1. Component Definition + +Components are defined using the `@Component` decorator, just like before: + +```typescript +import { Component } from '@quarc/core'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.scss', +}) +export class AppComponent { + constructor() { + // Your component logic + } +} +``` + +### 2. Native Custom Element Registration + +The `WebComponentFactory.tryRegister()` method: + +- Converts the selector (e.g., `'app-root'`) to a valid custom element tag name (e.g., `'app-root'`) +- Creates a custom element class that extends `HTMLElement` +- Registers it using `customElements.define()` +- Returns `true` if registration succeeds, `false` if already registered +- Catches and logs any registration errors + +```typescript +const success = WebComponentFactory.tryRegister(componentInstance); +// Returns false if already registered, preventing errors +``` + +### 3. Component Instantiation + +Components can be created in three ways: + +#### a) Auto-create and append to body +```typescript +const webComponent = WebComponentFactory.create(componentInstance); +``` + +#### b) Create inside a parent element +```typescript +const parent = document.getElementById('container'); +const webComponent = WebComponentFactory.createInElement(componentInstance, parent); +``` + +#### c) Replace an existing element +```typescript +const existingElement = document.querySelector('.my-element'); +const webComponent = WebComponentFactory.createFromElement(componentInstance, existingElement); +``` + +### 4. Native Custom Elements in HTML + +Once registered, components can be used directly in HTML: + +```html + + + + + + + + + +``` + +## API Reference + +### WebComponentFactory + +#### `registerWithDependencies(component: IComponent): boolean` + +Recursively registers a component and all its imported dependencies as native custom elements. This ensures that all child components are registered before the parent component renders. + +```typescript +@Component({ + selector: 'parent-component', + template: '', + imports: [ChildComponent], // Automatically registered before parent +}) +export class ParentComponent {} + +// All imports are registered recursively +WebComponentFactory.registerWithDependencies(parentComponentInstance); +``` + +**How it works:** +1. Iterates through the `imports` array in component metadata +2. Creates instances of imported components using the Injector +3. Recursively calls `registerWithDependencies` on each imported component +4. Finally registers the parent component + +This method is automatically called by `create()`, `createInElement()`, and `createFromElement()`. + +#### `tryRegister(component: IComponent): boolean` + +Safely registers a single component as a native custom element without checking dependencies. Returns `false` if already registered. + +```typescript +const isNewRegistration = WebComponentFactory.tryRegister(myComponent); +``` + +**Note:** Use `registerWithDependencies()` instead if your component has imports. + +#### `create(component: IComponent, selector?: string): WebComponent` + +Creates and returns a web component instance. If no element with the selector exists, creates one and appends to body. + +#### `createInElement(component: IComponent, parent: HTMLElement): WebComponent` + +Creates a web component and appends it to the specified parent element. + +#### `createFromElement(component: IComponent, element: HTMLElement): WebComponent` + +Converts an existing HTML element to a web component or replaces it with the component's custom element. + +#### `isRegistered(selector: string): boolean` + +Checks if a component with the given selector is already registered. + +#### `getRegisteredTagName(selector: string): string | undefined` + +Returns the registered tag name for a selector, or `undefined` if not registered. + +## WebComponent Class + +The `WebComponent` class now extends `HTMLElement` and implements: + +### Lifecycle Callbacks + +- `connectedCallback()` - Called when element is added to DOM (protected against multiple initialization) +- `disconnectedCallback()` - Called when element is removed from DOM + +**Note:** The component uses an internal `_initialized` flag to prevent multiple renderings. Even if `initialize()` is called multiple times (e.g., from both `setComponentInstance()` and `connectedCallback()`), the component will only render once. + +### Methods + +- `setComponentInstance(component: IComponent, componentType: ComponentType)` - Sets the component instance and component type, then initializes +- `getComponentOptions()` - Returns the component options from the static `_quarcComponent` property +- `renderComponent()` - Renders the component template and styles +- `getHostElement()` - Returns the element itself (since it IS the host) +- `getShadowRoot()` - Returns the shadow root if using Shadow DOM encapsulation +- `getAttributes()` - Returns all attributes as an array +- `getChildElements()` - Returns all child elements with metadata +- `querySelector(selector)` - Queries within the component's render target +- `querySelectorAll(selector)` - Queries all matching elements +- `destroy()` - Removes all child nodes + +## Component Dependencies & Import Registration + +The framework automatically handles component dependencies through the `imports` property. When a component is registered, all its imported components are recursively registered first. + +### Example: Parent-Child Components + +```typescript +// Child component +@Component({ + selector: 'user-card', + template: '
User Info
', + style: '.card { border: 1px solid #ccc; }', +}) +export class UserCardComponent {} + +// Parent component +@Component({ + selector: 'user-list', + template: ` +
+ + +
+ `, + imports: [UserCardComponent], // Child component will be registered first +}) +export class UserListComponent {} + +// Bootstrap +Core.bootstrap(UserListComponent); +``` + +### Registration Flow + +1. **UserListComponent** is bootstrapped +2. `registerWithDependencies()` is called +3. Framework finds `UserCardComponent` in imports +4. **UserCardComponent** is registered as `` custom element +5. **UserListComponent** is registered as `` custom element +6. Template renders with `` elements working natively + +### Nested Dependencies + +Dependencies can be nested multiple levels deep: + +```typescript +@Component({ + selector: 'icon', + template: '...', +}) +export class IconComponent {} + +@Component({ + selector: 'button', + template: '', + imports: [IconComponent], +}) +export class ButtonComponent {} + +@Component({ + selector: 'form', + template: '
', + imports: [ButtonComponent], // IconComponent is also registered automatically +}) +export class FormComponent {} +``` + +**Registration order:** `IconComponent` → `ButtonComponent` → `FormComponent` + +### Benefits + +✅ **Automatic dependency resolution** - No manual registration needed +✅ **Prevents registration errors** - Components registered in correct order +✅ **Supports deep nesting** - Recursive registration handles any depth +✅ **Lazy loading ready** - Only registers components that are actually used +✅ **Type-safe** - TypeScript ensures imports are valid component types + +## Encapsulation Modes + +### ViewEncapsulation.ShadowDom + +Uses native Shadow DOM for true style encapsulation: + +```typescript +@Component({ + selector: 'my-component', + template: '
Content
', + style: 'div { color: red; }', + encapsulation: ViewEncapsulation.ShadowDom, +}) +``` + +### ViewEncapsulation.Emulated + +Angular-style scoped attributes (`_nghost-*`, `_ngcontent-*`): + +```typescript +@Component({ + selector: 'my-component', + template: '
Content
', + style: 'div { color: red; }', + encapsulation: ViewEncapsulation.Emulated, // Default +}) +``` + +### ViewEncapsulation.None + +No encapsulation - styles are global: + +```typescript +@Component({ + selector: 'my-component', + template: '
Content
', + style: 'div { color: red; }', + encapsulation: ViewEncapsulation.None, +}) +``` + +## Migration from Old System + +The old system created wrapper `
` elements with `data-component` attributes. The new system: + +1. ✅ Registers actual custom elements with the browser +2. ✅ Uses native lifecycle callbacks +3. ✅ Allows direct HTML usage without JavaScript +4. ✅ Better performance and browser integration +5. ✅ Proper custom element naming (must contain hyphen) + +## Browser Compatibility + +Native Web Components are supported in all modern browsers: +- Chrome 54+ +- Firefox 63+ +- Safari 10.1+ +- Edge 79+ + +For older browsers, polyfills may be required. diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md new file mode 100644 index 0000000..bec7343 --- /dev/null +++ b/cli/ARCHITECTURE.md @@ -0,0 +1,286 @@ +# Architektura CLI + +## Przegląd + +System CLI składa się z dwóch głównych komponentów: +- **Processors** - Transformują kod źródłowy (template, style, DI) +- **Helpers** - Przetwarzają szczegółowe aspekty szablonów (atrybuty Angular) + +## Diagram Architektury + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Build Process │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Lite Transformer │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Processor Pipeline │ │ +│ │ │ │ +│ │ 1. ClassDecoratorProcessor │ │ +│ │ └─ Process class decorators │ │ +│ │ │ │ +│ │ 2. SignalTransformerProcessor │ │ +│ │ ├─ Transform signal calls (input, output, etc.) │ │ +│ │ ├─ Add 'this' as first argument │ │ +│ │ └─ Ensure _nativeElement in constructor │ │ +│ │ │ │ +│ │ 3. TemplateProcessor │ │ +│ │ ├─ Read template file │ │ +│ │ ├─ Transform control flow (@if → *ngIf) │ │ +│ │ ├─ Parse HTML (TemplateParser) │ │ +│ │ ├─ Process attributes (AttributeHelpers) │ │ +│ │ ├─ Reconstruct HTML │ │ +│ │ └─ Escape template string │ │ +│ │ │ │ +│ │ 4. StyleProcessor │ │ +│ │ ├─ Read style files │ │ +│ │ └─ Inline styles │ │ +│ │ │ │ +│ │ 5. DIProcessor │ │ +│ │ └─ Add __di_params__ metadata │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Transformed Source │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Szczegółowy Przepływ TemplateProcessor + +``` +Template File (HTML) + │ + ▼ +┌──────────────────────┐ +│ transformControlFlow │ @if/@else → *ngIf +└──────────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ TemplateParser │ HTML → Element Tree +└──────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ Attribute Processing Loop │ +│ │ +│ For each element: │ +│ For each attribute: │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Find matching AttributeHelper │ │ +│ │ │ │ +│ │ • StructuralDirectiveHelper (*ngIf) │ │ +│ │ • InputBindingHelper ([property]) │ │ +│ │ • OutputBindingHelper ((event)) │ │ +│ │ • TwoWayBindingHelper ([(model)]) │ │ +│ │ • TemplateReferenceHelper (#ref) │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ Process attribute → Update element tree │ +└──────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ reconstructTemplate │ Element Tree → HTML +└──────────────────────┘ + │ + ▼ +┌──────────────────────┐ +│ escapeTemplateString │ Escape \, `, $ +└──────────────────────┘ + │ + ▼ + Inline Template String +``` + +## Hierarchia Klas + +### Processors + +``` +BaseProcessor (abstract) +├── ClassDecoratorProcessor +├── SignalTransformerProcessor +├── TemplateProcessor +│ ├── parser: TemplateParser +│ └── attributeHelpers: BaseAttributeHelper[] +├── StyleProcessor +└── DIProcessor +``` + +### Helpers + +``` +BaseAttributeHelper (abstract) +├── StructuralDirectiveHelper +│ └── Handles: *ngIf, *ngFor, *ngSwitch +├── InputBindingHelper +│ └── Handles: [property] +├── OutputBindingHelper +│ └── Handles: (event) +├── TwoWayBindingHelper +│ └── Handles: [(model)] +└── TemplateReferenceHelper + └── Handles: #ref +``` + +## Interfejsy Danych + +### ProcessorContext +```typescript +{ + filePath: string; // Ścieżka do pliku źródłowego + fileDir: string; // Katalog pliku + source: string; // Zawartość pliku +} +``` + +### ParsedElement +```typescript +{ + tagName: string; // Nazwa tagu HTML + attributes: ParsedAttribute[]; // Lista atrybutów + children: (ParsedElement | ParsedTextNode)[]; // Elementy potomne i text nodes + textContent?: string; // Zawartość tekstowa (legacy) +} +``` + +### ParsedTextNode +```typescript +{ + type: 'text'; // Identyfikator typu + content: string; // Zawartość tekstowa z białymi znakami +} +``` + +### ParsedAttribute +```typescript +{ + name: string; // Nazwa atrybutu + value: string; // Wartość atrybutu + type: AttributeType; // Typ atrybutu +} +``` + +### AttributeType (enum) +```typescript +STRUCTURAL_DIRECTIVE // *ngIf, *ngFor +INPUT_BINDING // [property] +OUTPUT_BINDING // (event) +TWO_WAY_BINDING // [(model)] +TEMPLATE_REFERENCE // #ref +REGULAR // class, id, etc. +``` + +## Rozszerzalność + +### Dodawanie Nowego Processora + +```typescript +export class CustomProcessor extends BaseProcessor { + get name(): string { + return 'custom-processor'; + } + + async process(context: ProcessorContext): Promise { + // Implementacja transformacji + return { source: context.source, modified: false }; + } +} +``` + +### Dodawanie Nowego Helpera + +```typescript +export class CustomAttributeHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'custom'; + } + + canHandle(attribute: ParsedAttribute): boolean { + // Logika wykrywania + return attribute.name.startsWith('custom-'); + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + // Logika przetwarzania + return { transformed: true }; + } +} +``` + +## Wzorce Projektowe + +### Strategy Pattern +- **BaseAttributeHelper** - Interfejs strategii +- **Concrete Helpers** - Konkretne strategie dla różnych typów atrybutów +- **TemplateProcessor** - Kontekst używający strategii + +### Visitor Pattern +- **TemplateParser.traverseElements()** - Odwiedzanie elementów drzewa +- **Callback function** - Visitor wykonujący operacje na elementach + +### Pipeline Pattern +- **Processor chain** - Sekwencyjne przetwarzanie przez kolejne procesory +- Każdy processor otrzymuje wynik poprzedniego + +### Factory Pattern +- **Constructor TemplateProcessor** - Tworzy instancje wszystkich helperów +- Centralizacja tworzenia zależności + +## Wydajność + +### Optymalizacje +1. **Single-pass parsing** - Jeden przebieg przez szablon +2. **Lazy evaluation** - Przetwarzanie tylko zmodyfikowanych plików +3. **Reverse iteration** - Unikanie problemów z offsetami przy zamianie + +### Złożoność +- **Parsing**: O(n) gdzie n = długość szablonu +- **Attribute processing**: O(m × h) gdzie m = liczba atrybutów, h = liczba helperów +- **Reconstruction**: O(e + t) gdzie e = liczba elementów, t = liczba text nodes + +### Obsługa Text Nodes +Parser automatycznie wykrywa i zachowuje text nodes jako osobne węzły: +- Text nodes mogą występować na poziomie root lub jako dzieci elementów +- Zachowują wszystkie białe znaki (spacje, nowe linie, tabulatory) +- Są pomijane przez `traverseElements()` (tylko elementy HTML) +- Są prawidłowo rekonstruowane podczas budowania HTML +- Używają type guard `isTextNode()` do rozróżnienia od elementów + +## Testowanie + +### Unit Tests +```typescript +// Test parsera +const parser = new TemplateParser(); +const result = parser.parse('
'); +expect(result[0].attributes[0].type).toBe(AttributeType.INPUT_BINDING); + +// Test helpera +const helper = new InputBindingHelper(); +expect(helper.canHandle({ name: '[title]', value: 'x', type: AttributeType.INPUT_BINDING })).toBe(true); +``` + +### Integration Tests +```typescript +// Test całego procesora +const processor = new TemplateProcessor(); +const result = await processor.process({ + filePath: '/test/component.ts', + fileDir: '/test', + source: 'templateUrl = "./template.html"' +}); +expect(result.modified).toBe(true); +``` + +## Dokumentacja + +- **[processors/README.md](./processors/README.md)** - Dokumentacja procesorów +- **[helpers/README.md](./helpers/README.md)** - Dokumentacja helperów +- **[helpers/example.md](./helpers/example.md)** - Przykłady użycia diff --git a/cli/bin/qu.js b/cli/bin/qu.js new file mode 100755 index 0000000..7e6ccd3 --- /dev/null +++ b/cli/bin/qu.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +const path = require('path'); +const { execSync } = require('child_process'); + +const command = process.argv[2]; +const args = process.argv.slice(3); + +if (!command) { + console.log('Usage: qu [options]'); + console.log('\nAvailable commands:'); + console.log(' build Build the application'); + console.log(' serve [options] Watch and rebuild on file changes'); + console.log(' --port, -p Specify port (default: 4300)'); + console.log(' help Show this help message'); + process.exit(0); +} + +if (command === 'help' || command === '--help' || command === '-h') { + console.log('Usage: qu [options]'); + console.log('\nAvailable commands:'); + console.log(' build Build the application'); + console.log(' serve [options] Watch and rebuild on file changes'); + console.log(' --port, -p Specify port (default: 4300)'); + console.log(' help Show this help message'); + console.log('\nExamples:'); + console.log(' qu serve'); + console.log(' qu serve --port 3000'); + console.log(' qu serve -p 8080'); + process.exit(0); +} + +function findQuarcCliPath(cwd) { + // Try local quarc/cli first + const localPath = path.join(cwd, 'quarc', 'cli'); + if (require('fs').existsSync(localPath)) { + return localPath; + } + // Try dependencies/quarc/cli (relative to project root) + const depsPath = path.join(cwd, '..', '..', 'dependencies', 'quarc', 'cli'); + if (require('fs').existsSync(depsPath)) { + return depsPath; + } + // Fallback to CLI's own directory + return path.join(__dirname, '..'); +} + +if (command === 'build') { + try { + const cwd = process.cwd(); + const cliPath = findQuarcCliPath(cwd); + const buildScript = path.join(cliPath, 'build.ts'); + const tsNodePath = path.join(cwd, 'node_modules', '.bin', 'ts-node'); + const buildArgs = args.join(' '); + execSync(`${tsNodePath} ${buildScript} ${buildArgs}`, { stdio: 'inherit', cwd }); + } catch (error) { + process.exit(1); + } +} else if (command === 'serve') { + try { + const cwd = process.cwd(); + const cliPath = findQuarcCliPath(cwd); + const serveScript = path.join(cliPath, 'serve.ts'); + const tsNodePath = path.join(cwd, 'node_modules', '.bin', 'ts-node'); + execSync(`${tsNodePath} ${serveScript}`, { stdio: 'inherit', cwd }); + } catch (error) { + process.exit(1); + } +} else { + console.error(`Unknown command: ${command}`); + console.error('Run "qu help" for usage information'); + process.exit(1); +} diff --git a/cli/build.ts b/cli/build.ts new file mode 100644 index 0000000..8598fdf --- /dev/null +++ b/cli/build.ts @@ -0,0 +1,710 @@ +#!/usr/bin/env node + +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import { execSync } from 'child_process'; +import * as esbuild from 'esbuild'; +import { minify } from 'terser'; +import Table from 'cli-table3'; +import * as sass from 'sass'; +import { liteTransformer } from './lite-transformer'; +import { consoleTransformer } from './build/transformers/console-transformer'; + +const projectRoot = process.cwd(); +const srcDir = path.join(projectRoot, 'src'); +const publicDir = path.join(srcDir, 'public'); +const distDir = path.join(projectRoot, 'dist'); +const configPath = path.join(projectRoot, 'quarc.json'); + +interface SizeThreshold { + warning: string; + error: string; +} + +interface EnvironmentConfig { + treatWarningsAsErrors: boolean; + minifyNames: boolean; + generateSourceMaps: boolean; + compressed?: boolean; +} + +interface ActionsConfig { + prebuild?: string[]; + postbuild?: string[]; +} + +interface LiteConfig { + environment: string; + build: { + actions?: ActionsConfig; + minifyNames: boolean; + scripts?: string[]; + externalEntryPoints?: string[]; + styles?: string[]; + externalStyles?: string[]; + limits: { + total: SizeThreshold; + main: SizeThreshold; + sourceMaps: SizeThreshold; + components?: SizeThreshold; + }; + }; + environments: { + [key: string]: EnvironmentConfig; + }; +} + +interface ValidationResult { + status: 'success' | 'warning' | 'error'; + message: string; + actual: number; + limit: number; +} + +function parseSizeString(sizeStr: string): number { + const match = sizeStr.match(/^([\d.]+)\s*(B|KB|MB|GB)$/i); + if (!match) throw new Error(`Invalid size format: ${sizeStr}`); + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + + const multipliers: { [key: string]: number } = { + B: 1, + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, + }; + + return value * (multipliers[unit] || 1); +} + +function getCliEnvironment(): string | undefined { + const args = process.argv.slice(2); + const envIndex = args.findIndex(arg => arg === '--environment' || arg === '-e'); + if (envIndex !== -1 && args[envIndex + 1]) { + return args[envIndex + 1]; + } + return undefined; +} + +function loadConfig(): LiteConfig { + const cliEnv = getCliEnvironment(); + + if (!fs.existsSync(configPath)) { + return { + environment: cliEnv ?? 'development', + build: { + minifyNames: false, + limits: { + total: { warning: '50 KB', error: '60 KB' }, + main: { warning: '15 KB', error: '20 KB' }, + sourceMaps: { warning: '10 KB', error: '20 KB' }, + }, + }, + environments: { + development: { + treatWarningsAsErrors: false, + minifyNames: false, + generateSourceMaps: true, + }, + production: { + treatWarningsAsErrors: true, + minifyNames: true, + generateSourceMaps: false, + }, + }, + }; + } + + const content = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(content) as LiteConfig; + + if (cliEnv) { + config.environment = cliEnv; + } + + return config; +} + +function getEnvironmentConfig(config: LiteConfig): EnvironmentConfig { + const envConfig = config.environments[config.environment]; + if (!envConfig) { + console.warn(`Environment '${config.environment}' not found in config, using defaults`); + return { + treatWarningsAsErrors: false, + minifyNames: false, + generateSourceMaps: true, + }; + } + return envConfig; +} + +function ensureDirectoryExists(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function copyDirectory(src: string, dest: string): void { + if (!fs.existsSync(src)) { + console.warn(`Source directory not found: ${src}`); + return; + } + + ensureDirectoryExists(dest); + + const files = fs.readdirSync(src); + files.forEach(file => { + const srcPath = path.join(src, file); + const destPath = path.join(dest, file); + const stat = fs.statSync(srcPath); + + if (stat.isDirectory()) { + copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + }); +} + +async function bundleTypeScript(): Promise { + try { + console.log('Bundling TypeScript with esbuild...'); + const config = loadConfig(); + const envConfig = getEnvironmentConfig(config); + const mainTsPath = path.join(srcDir, 'main.ts'); + + await esbuild.build({ + entryPoints: [mainTsPath], + bundle: true, + minify: false, + sourcemap: envConfig.generateSourceMaps, + outdir: distDir, + format: 'esm', + target: 'ES2020', + splitting: true, + chunkNames: 'chunks/[name]-[hash]', + external: [], + plugins: [liteTransformer(), consoleTransformer()], + tsconfig: path.join(projectRoot, 'tsconfig.json'), + treeShaking: true, + logLevel: 'info', + define: { + 'process.env.NODE_ENV': config.environment === 'production' ? '"production"' : '"development"', + }, + drop: config.environment === 'production' ? ['console', 'debugger'] : ['debugger'], + pure: config.environment === 'production' ? ['console.log', 'console.error', 'console.warn', 'console.info', 'console.debug'] : [], + globalName: undefined, + }); + + console.log('TypeScript bundling completed.'); + await bundleExternalEntryPoints(); + await obfuscateAndMinifyBundles(); + } catch (error) { + console.error('TypeScript bundling failed:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +async function bundleExternalEntryPoints(): Promise { + const config = loadConfig(); + const envConfig = getEnvironmentConfig(config); + const externalEntryPoints = config.build.externalEntryPoints || []; + + if (externalEntryPoints.length === 0) { + return; + } + + console.log('Bundling external entry points...'); + const externalDistDir = path.join(distDir, 'external'); + ensureDirectoryExists(externalDistDir); + + for (const entryPoint of externalEntryPoints) { + const entryPath = path.join(projectRoot, entryPoint); + + if (!fs.existsSync(entryPath)) { + console.warn(`External entry point not found: ${entryPath}`); + continue; + } + + const basename = path.basename(entryPoint, '.ts'); + + await esbuild.build({ + entryPoints: [entryPath], + bundle: true, + minify: false, + sourcemap: envConfig.generateSourceMaps, + outfile: path.join(externalDistDir, `${basename}.js`), + format: 'esm', + target: 'ES2020', + splitting: false, + external: [], + plugins: [liteTransformer(), consoleTransformer()], + tsconfig: path.join(projectRoot, 'tsconfig.json'), + treeShaking: true, + logLevel: 'info', + define: { + 'process.env.NODE_ENV': config.environment === 'production' ? '"production"' : '"development"', + }, + drop: config.environment === 'production' ? ['console', 'debugger'] : ['debugger'], + pure: config.environment === 'production' ? ['console.log', 'console.error', 'console.warn', 'console.info', 'console.debug'] : [], + }); + + console.log(`✓ Bundled external: ${basename}.js`); + } +} + +async function obfuscateAndMinifyBundles(): Promise { + try { + console.log('Applying advanced obfuscation and minification...'); + const config = loadConfig(); + const envConfig = getEnvironmentConfig(config); + + const collectJsFiles = (dir: string, prefix = ''): { file: string; filePath: string }[] => { + const results: { file: string; filePath: string }[] = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + results.push(...collectJsFiles(fullPath, relativePath)); + } else if (entry.name.endsWith('.js') && !entry.name.endsWith('.map')) { + results.push({ file: relativePath, filePath: fullPath }); + } + } + return results; + }; + + const jsFiles = collectJsFiles(distDir); + + for (const { file, filePath } of jsFiles) { + const code = fs.readFileSync(filePath, 'utf-8'); + + const result = await minify(code, { + compress: { + passes: 3, + unsafe: true, + unsafe_methods: true, + unsafe_proto: true, + drop_console: config.environment === 'production', + drop_debugger: true, + inline: 3, + reduce_vars: true, + reduce_funcs: true, + collapse_vars: true, + dead_code: true, + evaluate: true, + hoist_funs: true, + hoist_vars: true, + if_return: true, + join_vars: true, + loops: true, + properties: false, + sequences: true, + side_effects: true, + switches: true, + typeofs: true, + unused: true, + }, + mangle: envConfig.minifyNames ? { + toplevel: true, + keep_classnames: false, + keep_fnames: false, + properties: false, + } : false, + output: { + comments: false, + beautify: false, + max_line_len: 1000, + }, + }); + + if (result.code) { + fs.writeFileSync(filePath, result.code, 'utf-8'); + const originalSize = code.length; + const newSize = result.code.length; + const reduction = ((1 - newSize / originalSize) * 100).toFixed(2); + console.log(`✓ ${file}: ${originalSize} → ${newSize} bytes (${reduction}% reduction)`); + } + } + + console.log('Obfuscation and minification completed.'); + } catch (error) { + console.error('Obfuscation failed:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +async function compileStyleFile(stylePath: string, outputDir: string): Promise { + const fullPath = path.join(projectRoot, stylePath); + + if (!fs.existsSync(fullPath)) { + console.warn(`Style file not found: ${fullPath}`); + return; + } + + const ext = path.extname(fullPath); + const basename = path.basename(fullPath, ext); + const outputPath = path.join(outputDir, `${basename}.css`); + + ensureDirectoryExists(outputDir); + + if (ext === '.scss' || ext === '.sass') { + try { + const result = sass.compile(fullPath, { + style: 'compressed', + sourceMap: false, + }); + fs.writeFileSync(outputPath, result.css, 'utf-8'); + console.log(`✓ Compiled ${stylePath} → ${basename}.css`); + } catch (error) { + console.error(`Failed to compile ${stylePath}:`, error instanceof Error ? error.message : String(error)); + throw error; + } + } else if (ext === '.css') { + fs.copyFileSync(fullPath, outputPath); + console.log(`✓ Copied ${stylePath} → ${basename}.css`); + } +} + +async function compileSCSS(): Promise { + const config = loadConfig(); + const styles = config.build.styles || []; + const externalStyles = config.build.externalStyles || []; + + if (styles.length === 0 && externalStyles.length === 0) { + return; + } + + console.log('Compiling SCSS files...'); + + for (const stylePath of styles) { + await compileStyleFile(stylePath, distDir); + } + + const externalDistDir = path.join(distDir, 'external'); + for (const stylePath of externalStyles) { + await compileStyleFile(stylePath, externalDistDir); + } +} + +function injectScriptsAndStyles(indexPath: string): void { + if (!fs.existsSync(indexPath)) { + console.warn(`Index file not found: ${indexPath}`); + return; + } + + const config = loadConfig(); + let html = fs.readFileSync(indexPath, 'utf-8'); + + const styles = config.build.styles || []; + const scripts = config.build.scripts || []; + + let styleInjections = ''; + for (const stylePath of styles) { + const basename = path.basename(stylePath, path.extname(stylePath)); + const cssFile = `${basename}.css`; + if (!html.includes(cssFile)) { + styleInjections += ` \n`; + } + } + + if (styleInjections) { + html = html.replace('', `${styleInjections}`); + } + + let scriptInjections = ''; + for (const scriptPath of scripts) { + const basename = path.basename(scriptPath); + if (!html.includes(basename)) { + scriptInjections += ` \n`; + } + } + + const mainScript = ` \n`; + if (!html.includes('main.js')) { + scriptInjections += mainScript; + } + + if (scriptInjections) { + html = html.replace('', `${scriptInjections}`); + } + + fs.writeFileSync(indexPath, html, 'utf-8'); + console.log('Injected scripts and styles into index.html'); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function getGzipSize(content: Buffer | string): number { + const buffer = typeof content === 'string' ? Buffer.from(content) : content; + return zlib.gzipSync(buffer, { level: 9 }).length; +} + +function displayBuildStats(): void { + const files: { name: string; size: number; gzipSize: number; path: string }[] = []; + let totalSize = 0; + let totalGzipSize = 0; + let mainSize = 0; + let mapSize = 0; + let externalSize = 0; + + const config = loadConfig(); + const envConfig = getEnvironmentConfig(config); + const showGzip = envConfig.compressed ?? false; + + const isExternalFile = (relativePath: string): boolean => relativePath.startsWith('external/'); + const isMapFile = (name: string): boolean => name.endsWith('.map'); + + const walkDir = (dir: string, prefix = ''): void => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + entries.forEach(entry => { + const fullPath = path.join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + walkDir(fullPath, relativePath); + } else if (!entry.name.endsWith('.gz')) { + const content = fs.readFileSync(fullPath); + const size = content.length; + const gzipSize = getGzipSize(content); + + files.push({ name: entry.name, size, gzipSize, path: relativePath }); + + if (isMapFile(entry.name)) { + mapSize += size; + } else if (isExternalFile(relativePath)) { + externalSize += size; + } else { + totalSize += size; + totalGzipSize += gzipSize; + } + + if (entry.name === 'main.js') mainSize = size; + } + }); + }; + + walkDir(distDir); + files.sort((a, b) => b.size - a.size); + + const validationResults: { [key: string]: ValidationResult } = {}; + + // Validate limits + const totalWarning = parseSizeString(config.build.limits.total.warning); + const totalError = parseSizeString(config.build.limits.total.error); + const mainWarning = parseSizeString(config.build.limits.main.warning); + const mainError = parseSizeString(config.build.limits.main.error); + const mapWarning = parseSizeString(config.build.limits.sourceMaps.warning); + const mapError = parseSizeString(config.build.limits.sourceMaps.error); + + validationResults.total = validateSizeWithThresholds('Total Size', totalSize, totalWarning, totalError); + validationResults.main = validateSizeWithThresholds('Main Bundle', mainSize, mainWarning, mainError); + validationResults.sourceMaps = validateSizeWithThresholds('Source Maps', mapSize, mapWarning, mapError); + + console.log(`\n📊 Size breakdown:`); + console.log(` App total: ${formatBytes(totalSize)}`); + console.log(` External: ${formatBytes(externalSize)}`); + console.log(` Maps: ${formatBytes(mapSize)}`); + + // Display table + const tableHead = showGzip + ? ['📄 File', '💾 Size', '📦 Gzip', '✓ Status'] + : ['📄 File', '💾 Size', '✓ Status']; + const colWidths = showGzip ? [32, 12, 12, 10] : [40, 15, 12]; + + const table = new Table({ + head: tableHead, + style: { head: [], border: ['cyan'] }, + wordWrap: true, + colWidths, + }); + + files.forEach(file => { + const sizeStr = formatBytes(file.size); + const gzipStr = formatBytes(file.gzipSize); + const fileName = file.path.length > 28 ? file.path.substring(0, 25) + '...' : file.path; + + if (showGzip) { + table.push([fileName, sizeStr, gzipStr, '✓']); + } else { + table.push([fileName, sizeStr, '✓']); + } + }); + + if (showGzip) { + table.push([ + '\x1b[1mTOTAL\x1b[0m', + '\x1b[1m' + formatBytes(totalSize) + '\x1b[0m', + '\x1b[1m' + formatBytes(totalGzipSize) + '\x1b[0m', + '\x1b[1m✓\x1b[0m', + ]); + } else { + table.push(['\x1b[1mTOTAL\x1b[0m', '\x1b[1m' + formatBytes(totalSize) + '\x1b[0m', '\x1b[1m✓\x1b[0m']); + } + + console.log('\n' + table.toString()); + + // Display validation results + + + // Check if build should fail + const hasErrors = Object.values(validationResults).some(r => r.status === 'error'); + const hasWarnings = Object.values(validationResults).some(r => r.status === 'warning'); + const treatWarningsAsErrors = envConfig.treatWarningsAsErrors; + + if (hasErrors || (hasWarnings && treatWarningsAsErrors)) { + console.error('\n❌ Build validation failed!'); + Object.entries(validationResults).forEach(([key, result]) => { + if (result.status === 'error' || (result.status === 'warning' && treatWarningsAsErrors)) { + console.error(` ${result.message}`); + } + }); + process.exit(1); + } + + if (hasWarnings) { + console.warn('\n⚠️ Build completed with warnings:'); + Object.entries(validationResults).forEach(([key, result]) => { + if (result.status === 'warning') { + console.warn(` ${result.message}`); + } + }); + } +} + +function validateSizeWithThresholds(name: string, actual: number, warningLimit: number, errorLimit: number): ValidationResult { + if (actual > errorLimit) { + return { + status: 'error', + message: `${name}: ${formatBytes(actual)} exceeds error limit of ${formatBytes(errorLimit)}`, + actual, + limit: warningLimit, + }; + } + + if (actual > warningLimit) { + return { + status: 'warning', + message: `${name}: ${formatBytes(actual)} exceeds warning limit of ${formatBytes(warningLimit)}`, + actual, + limit: warningLimit, + }; + } + + return { + status: 'success', + message: `${name}: ${formatBytes(actual)} is within limits`, + actual, + limit: warningLimit, + }; +} + +function generateCompressedFiles(): void { + const config = loadConfig(); + const envConfig = getEnvironmentConfig(config); + + if (!envConfig.compressed) { + return; + } + + console.log('Generating compressed files...'); + + const compressibleExtensions = ['.js', '.css', '.html', '.json', '.svg', '.xml']; + + const walkAndCompress = (dir: string): void => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + entries.forEach(entry => { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + walkAndCompress(fullPath); + } else { + const ext = path.extname(entry.name).toLowerCase(); + if (compressibleExtensions.includes(ext)) { + const content = fs.readFileSync(fullPath); + const compressed = zlib.gzipSync(content, { level: 9 }); + const gzPath = fullPath + '.gz'; + fs.writeFileSync(gzPath, compressed); + } + } + }); + }; + + walkAndCompress(distDir); + console.log('✓ Compressed files generated (.gz)'); +} + +function runBuildActions(phase: 'prebuild' | 'postbuild'): void { + const config = loadConfig(); + const actions = config.build.actions?.[phase] || []; + + if (actions.length === 0) return; + + console.log(`🔧 Running ${phase} actions...`); + + for (const action of actions) { + console.log(` ▶ ${action}`); + try { + execSync(action, { + cwd: projectRoot, + stdio: 'inherit', + }); + } catch (err) { + console.error(` ❌ Action failed: ${action}`); + throw err; + } + } +} + +async function build(): Promise { + try { + console.log('Starting build process...'); + + runBuildActions('prebuild'); + + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); + } + ensureDirectoryExists(distDir); + + console.log('Copying public files...'); + copyDirectory(publicDir, distDir); + + await compileSCSS(); + + await bundleTypeScript(); + + const indexPath = path.join(distDir, 'index.html'); + injectScriptsAndStyles(indexPath); + + generateCompressedFiles(); + + displayBuildStats(); + + runBuildActions('postbuild'); + + console.log('Build completed successfully!'); + console.log(`Output directory: ${distDir}`); + } catch (error) { + console.error('Build failed:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +build().catch(error => { + console.error('Build process failed:', error); + process.exit(1); +}); diff --git a/cli/build/transformers/console-transformer.ts b/cli/build/transformers/console-transformer.ts new file mode 100644 index 0000000..5dba77a --- /dev/null +++ b/cli/build/transformers/console-transformer.ts @@ -0,0 +1,39 @@ +import { Plugin } from 'esbuild'; + +export function consoleTransformer(): Plugin { + return { + name: 'console-transformer', + setup(build) { + build.onLoad({ filter: /\.ts$/ }, async (args) => { + const fs = await import('fs'); + const contents = fs.readFileSync(args.path, 'utf8'); + + // Zastąp console.* z krótszymi zmiennymi + let transformed = contents + .replace(/console\.log/g, '_log') + .replace(/console\.error/g, '_error') + .replace(/console\.warn/g, '_warn') + .replace(/console\.info/g, '_info') + .replace(/console\.debug/g, '_debug'); + + // Dodaj deklaracje na początku pliku jeśli są używane + if (transformed !== contents) { + const declarations = ` +// Console shortcuts for size optimization +const _log = console.log; +const _error = console.error; +const _warn = console.warn; +const _info = console.info; +const _debug = console.debug; +`; + transformed = declarations + transformed; + } + + return { + contents: transformed, + loader: 'ts' + }; + }); + } + }; +} diff --git a/cli/di-plugin.ts b/cli/di-plugin.ts new file mode 100644 index 0000000..da2d4f6 --- /dev/null +++ b/cli/di-plugin.ts @@ -0,0 +1,75 @@ +import * as esbuild from 'esbuild'; +import * as fs from 'fs'; + +export function diPlugin(): esbuild.Plugin { + return { + name: 'di-metadata', + setup(build) { + build.onLoad({ filter: /\.(ts)$/ }, async (args) => { + if (args.path.includes('node_modules')) { + return { contents: await fs.promises.readFile(args.path, 'utf8'), loader: 'ts' }; + } + + let source = await fs.promises.readFile(args.path, 'utf8'); + + // Nie modyfikuj plików z import type lub interface + if (source.includes('import type') || source.includes('export interface')) { + return { contents: source, loader: 'ts' }; + } + + const classRegex = /export\s+class\s+(\w+)(?:\s+implements\s+[\w,\s]+)?\s*\{/g; + const constructorRegex = /constructor\s*\([^)]*\)/; + + let match; + const modifications: Array<{ index: number; className: string; params: string[] }> = []; + + while ((match = classRegex.exec(source)) !== null) { + const className = match[1]; + const classStart = match.index; + const openBraceIndex = match.index + match[0].length; + + const afterClass = source.substring(openBraceIndex); + const constructorMatch = afterClass.match(constructorRegex); + + if (constructorMatch) { + const constructorStr = constructorMatch[0]; + const paramsMatch = constructorStr.match(/constructor\s*\(([^)]*)\)/); + + if (paramsMatch && paramsMatch[1].trim()) { + const paramsStr = paramsMatch[1]; + const params = paramsStr + .split(',') + .map(p => { + const typeMatch = p.match(/:\s*(\w+)/); + return typeMatch ? typeMatch[1] : null; + }) + .filter(Boolean) as string[]; + + if (params.length > 0) { + modifications.push({ + index: openBraceIndex, + className, + params + }); + } + } + } + } + + if (modifications.length > 0) { + modifications.reverse().forEach(mod => { + const metadata = `\n static __di_params__ = ['${mod.params.join("', '")}'];`; + source = source.slice(0, mod.index) + metadata + source.slice(mod.index); + }); + + return { + contents: source, + loader: 'ts', + }; + } + + return { contents: source, loader: 'ts' }; + }); + }, + }; +} diff --git a/cli/helpers/FRAGMENT_RERENDERING.md b/cli/helpers/FRAGMENT_RERENDERING.md new file mode 100644 index 0000000..c29a2cc --- /dev/null +++ b/cli/helpers/FRAGMENT_RERENDERING.md @@ -0,0 +1,175 @@ +# Fragment Re-rendering + +System fragment re-rendering pozwala na selektywne przeładowywanie części DOM bez konieczności re-renderowania całego komponentu. + +## Jak to działa + +Każdy `ng-container` w template jest zastępowany parą komentarzy-markerów: + +```html + + +
Szczegóły
+
+ + + +
Szczegóły
+ +``` + +## Zalety + +1. **Widoczność w DevTools** - możesz zobaczyć w DOM gdzie był `ng-container` +2. **Selektywne re-renderowanie** - możesz przeładować tylko fragment zamiast całego komponentu +3. **Zachowanie struktury** - markery pokazują dokładne granice fragmentu +4. **Debugowanie** - łatwiej śledzić które fragmenty są renderowane + +## API + +### Pobranie TemplateFragment + +```typescript +const appRoot = document.querySelector('app-root') as any; +const templateFragment = appRoot.templateFragment; +``` + +### Pobranie informacji o markerach + +```typescript +const markers = templateFragment.getFragmentMarkers(); + +// Zwraca tablicę obiektów: +// { +// startMarker: Comment, // Komentarz początkowy +// endMarker: Comment, // Komentarz końcowy +// condition?: string, // Warunek *ngIf (jeśli istnieje) +// originalTemplate: string // Oryginalny HTML fragmentu +// } +``` + +### Re-renderowanie konkretnego fragmentu + +```typescript +// Re-renderowanie fragmentu po indeksie +templateFragment.rerenderFragment(0); + +// Lub znalezienie fragmentu po warunku +const markers = templateFragment.getFragmentMarkers(); +const index = markers.findIndex(m => m.condition?.includes('showDetails')); +if (index >= 0) { + templateFragment.rerenderFragment(index); +} +``` + +### Re-renderowanie wszystkich fragmentów + +```typescript +templateFragment.rerenderAllFragments(); +``` + +## Przykłady użycia + +### Przykład 1: Toggle visibility + +```typescript +// Komponent +class MyComponent { + showDetails = false; + + toggleDetails() { + this.showDetails = !this.showDetails; + + // Re-renderuj fragment z showDetails + const appRoot = document.querySelector('app-root') as any; + const templateFragment = appRoot.templateFragment; + const markers = templateFragment.getFragmentMarkers(); + + const detailsIndex = markers.findIndex(m => + m.condition?.includes('showDetails') + ); + + if (detailsIndex >= 0) { + templateFragment.rerenderFragment(detailsIndex); + } + } +} +``` + +### Przykład 2: Conditional rendering + +```html + + +
Witaj, {{ userName }}!
+
+ + + + +``` + +```typescript +// Po zalogowaniu +login() { + this.isLoggedIn = true; + this.userName = 'Jan Kowalski'; + + // Re-renderuj wszystkie fragmenty + const appRoot = document.querySelector('app-root') as any; + appRoot.templateFragment.rerenderAllFragments(); +} +``` + +### Przykład 3: Debugowanie w DevTools + +```typescript +// W konsoli przeglądarki +const appRoot = document.querySelector('app-root'); +const markers = appRoot.templateFragment.getFragmentMarkers(); + +console.table(markers.map((m, i) => ({ + index: i, + condition: m.condition || 'none', + hasContent: m.originalTemplate.length > 0 +}))); + +// Re-renderuj konkretny fragment +appRoot.templateFragment.rerenderFragment(2); +``` + +## Inspekcja w DevTools + +W DevTools możesz zobaczyć markery jako komentarze: + +```html + +
app
+ + + +
Widoczna zawartość
+ +
+``` + +## Wydajność + +- Re-renderowanie fragmentu jest szybsze niż re-renderowanie całego komponentu +- Markery w DOM zajmują minimalną ilość pamięci (tylko komentarze) +- Property bindings są przetwarzane tylko dla re-renderowanego fragmentu + +## Ograniczenia + +1. Markery są dodawane tylko dla `ng-container` z `*ngIf` +2. Zagnieżdżone `ng-container` mają osobne pary markerów +3. Zmiana warunku wymaga ręcznego wywołania `rerenderFragment()` + +## Przyszłe rozszerzenia + +Planowane funkcjonalności: + +- Automatyczne re-renderowanie przy zmianie właściwości +- Obsługa `*ngFor` w fragmentach +- Animacje przy re-renderowaniu +- Lazy loading fragmentów diff --git a/cli/helpers/README.md b/cli/helpers/README.md new file mode 100644 index 0000000..2d0af0a --- /dev/null +++ b/cli/helpers/README.md @@ -0,0 +1,510 @@ +# Template Helpers + +System helperów do parsowania i przetwarzania atrybutów Angular w szablonach HTML. + +## Architektura + +``` +helpers/ +├── TemplateParser - Parser HTML do drzewa elementów +├── ControlFlowTransformer - Transformacja @if/@else do *ngIf +├── ContentInterpolation - Transformacja {{ }} do [innerText] (w TemplateTransformer) +├── BaseAttributeHelper - Klasa bazowa dla helperów atrybutów +├── StructuralDirectiveHelper - Obsługa dyrektyw strukturalnych (*ngIf, *ngFor) +├── InputBindingHelper - Obsługa bindowania wejść ([property]) +├── OutputBindingHelper - Obsługa bindowania wyjść ((event)) +├── TwoWayBindingHelper - Obsługa bindowania dwukierunkowego ([(model)]) +└── TemplateReferenceHelper - Obsługa referencji do elementów (#ref) +``` + +## TemplateParser + +Parser HTML, który konwertuje szablon na drzewo elementów z wyodrębnionymi atrybutami. + +### Interfejsy + +```typescript +interface ParsedElement { + tagName: string; + attributes: ParsedAttribute[]; + children: (ParsedElement | ParsedTextNode)[]; + textContent?: string; +} + +interface ParsedTextNode { + type: 'text'; + content: string; +} + +interface ParsedAttribute { + name: string; + value: string; + type: AttributeType; +} + +enum AttributeType { + STRUCTURAL_DIRECTIVE = 'structural', + INPUT_BINDING = 'input', + OUTPUT_BINDING = 'output', + TWO_WAY_BINDING = 'two-way', + TEMPLATE_REFERENCE = 'reference', + REGULAR = 'regular', +} +``` + +### Użycie + +```typescript +const parser = new TemplateParser(); +const elements = parser.parse('
Content
'); + +// Iteracja po wszystkich elementach (pomija text nodes) +parser.traverseElements(elements, (element) => { + console.log(element.tagName, element.attributes); +}); + +// Przetwarzanie wszystkich węzłów włącznie z text nodes +for (const node of elements) { + if ('type' in node && node.type === 'text') { + console.log('Text:', node.content); + } else { + console.log('Element:', node.tagName); + } +} +``` + +### Obsługa Text Nodes + +Parser automatycznie wykrywa i zachowuje text nodes jako osobne węzły w drzewie: + +```typescript +const template = ` +
+ Header text +

Paragraph

+ Footer text +
+`; + +const elements = parser.parse(template); +// Zwraca drzewo z text nodes jako osobnymi węzłami typu ParsedTextNode +``` + +**Cechy text nodes:** +- Zachowują białe znaki (spacje, nowe linie, tabulatory) +- Są pomijane przez `traverseElements()` (tylko elementy HTML) +- Mogą występować na poziomie root lub jako dzieci elementów +- Są prawidłowo rekonstruowane podczas budowania HTML + +## ControlFlowTransformer + +Transformer konwertujący nową składnię Angular control flow (`@if`, `@else if`, `@else`) na starą składnię z dyrektywami `*ngIf`. + +### Transformacje + +**Prosty @if:** +```typescript +// Input +@if (isVisible) { +
Content
+} + +// Output + +
Content
+
+``` + +**@if z @else:** +```typescript +// Input +@if (isAdmin) { + Admin +} @else { + User +} + +// Output + + Admin + + + User + +``` + +**@if z @else if:** +```typescript +// Input +@if (role === 'admin') { + Admin +} @else if (role === 'user') { + User +} @else { + Guest +} + +// Output + + Admin + + + User + + + Guest + +``` + +### Użycie + +```typescript +import { ControlFlowTransformer } from './control-flow-transformer'; + +const transformer = new ControlFlowTransformer(); +const template = '@if (show) {
Content
}'; +const result = transformer.transform(template); +// '
Content
' +``` + +### Optymalizacja + +`ControlFlowTransformer` jest współdzielony między `TemplateProcessor` a innymi komponentami, co zmniejsza rozmiar końcowej aplikacji poprzez uniknięcie duplikacji kodu. + +## Content Interpolation + +Transformacja interpolacji Angular (`{{ expression }}`) na property binding z `innerText` (bezpieczniejsze niż innerHTML - zapobiega XSS). + +### Transformacja + +```typescript +// Input +
{{ userName }}
+Value: {{ data }} + +// Output +
+Value: +``` + +### Użycie + +Transformacja jest częścią `TemplateTransformer`: + +```typescript +import { TemplateTransformer } from './processors/template/template-transformer'; + +const transformer = new TemplateTransformer(); +const template = '
{{ message }}
'; +const result = transformer.transformInterpolation(template); +// '
' +``` + +### Obsługa w Runtime + +Property bindings `[innerText]` są przetwarzane w runtime przez `TemplateFragment.processPropertyBindings()`: + +1. Znajduje wszystkie atrybuty w formacie `[propertyName]` +2. Tworzy `effect` który reaguje na zmiany sygnałów +3. Ewaluuje wyrażenie w kontekście komponentu +4. Przypisuje wartość do właściwości DOM elementu: `element[propertyName] = value` +5. Usuwa atrybut z elementu + +Dzięki temu `[innerText]`, `[innerHTML]`, `[textContent]`, `[value]` i inne property bindings działają automatycznie z granularną reaktywnością. + +## Fragment Re-rendering + +Każdy `ng-container` jest zastępowany parą komentarzy-markerów w DOM: + +```html + + + +``` + +### Zalety + +1. **Widoczność w DOM** - można zobaczyć gdzie był `ng-container` +2. **Selektywne re-renderowanie** - można przeładować tylko fragment zamiast całego komponentu +3. **Zachowanie struktury** - markery pokazują granice fragmentu + +### API + +```typescript +// Pobranie TemplateFragment z elementu +const appRoot = document.querySelector('app-root') as any; +const templateFragment = appRoot.templateFragment; + +// Pobranie informacji o markerach +const markers = templateFragment.getFragmentMarkers(); +// Returns: Array<{ startMarker, endMarker, condition?, originalTemplate }> + +// Re-renderowanie konkretnego fragmentu (po indeksie) +templateFragment.rerenderFragment(0); + +// Re-renderowanie wszystkich fragmentów +templateFragment.rerenderAllFragments(); +``` + +### Przykład użycia + +```typescript +// Zmiana właściwości komponentu +component.showDetails = true; + +// Re-renderowanie fragmentu który zależy od tej właściwości +const markers = templateFragment.getFragmentMarkers(); +const detailsFragmentIndex = markers.findIndex(m => + m.condition?.includes('showDetails') +); +if (detailsFragmentIndex >= 0) { + templateFragment.rerenderFragment(detailsFragmentIndex); +} +``` + +## BaseAttributeHelper + +Abstrakcyjna klasa bazowa dla wszystkich helperów obsługujących atrybuty. + +### Interfejs + +```typescript +abstract class BaseAttributeHelper { + abstract get supportedType(): string; + abstract canHandle(attribute: ParsedAttribute): boolean; + abstract process(context: AttributeProcessingContext): AttributeProcessingResult; +} + +interface AttributeProcessingContext { + element: ParsedElement; + attribute: ParsedAttribute; + filePath: string; +} + +interface AttributeProcessingResult { + transformed: boolean; + newAttribute?: ParsedAttribute; + additionalAttributes?: ParsedAttribute[]; + removeOriginal?: boolean; +} +``` + +## Helpery Atrybutów + +### StructuralDirectiveHelper + +Obsługuje dyrektywy strukturalne Angular. + +**Rozpoznawane atrybuty:** +- `*ngIf` - warunkowe renderowanie +- `*ngFor` - iteracja po kolekcji +- `*ngSwitch` - przełączanie widoków + +**Przykład:** +```html +
Content
+
  • {{ item }}
  • +``` + +### InputBindingHelper + +Obsługuje bindowanie właściwości wejściowych. + +**Format:** `[propertyName]="expression"` + +**Przykład:** +```html + + +``` + +### OutputBindingHelper + +Obsługuje bindowanie zdarzeń wyjściowych. + +**Format:** `(eventName)="handler()"` + +**Przykład:** +```html + + +``` + +### TwoWayBindingHelper + +Obsługuje bindowanie dwukierunkowe (banana-in-a-box). + +**Format:** `[(propertyName)]="variable"` + +**Przykład:** +```html + + +``` + +### TemplateReferenceHelper + +Obsługuje referencje do elementów w szablonie. + +**Format:** `#referenceName` + +**Przykład:** +```html + + +``` + +## Tworzenie Własnego Helpera + +```typescript +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; +import { AttributeType, ParsedAttribute } from './template-parser'; + +export class CustomAttributeHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'custom-attribute'; + } + + canHandle(attribute: ParsedAttribute): boolean { + // Logika określająca czy helper obsługuje dany atrybut + return attribute.name.startsWith('custom-'); + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + // Logika przetwarzania atrybutu + return { + transformed: true, + newAttribute: { + name: 'data-custom', + value: context.attribute.value, + type: AttributeType.REGULAR, + }, + }; + } +} +``` + +## Integracja z TemplateProcessor + +Helpery są automatycznie wykorzystywane przez `TemplateProcessor` podczas przetwarzania szablonów: + +```typescript +export class TemplateProcessor extends BaseProcessor { + private parser: TemplateParser; + private attributeHelpers: BaseAttributeHelper[]; + + constructor() { + super(); + this.parser = new TemplateParser(); + this.attributeHelpers = [ + new StructuralDirectiveHelper(), + new InputBindingHelper(), + new OutputBindingHelper(), + new TwoWayBindingHelper(), + new TemplateReferenceHelper(), + // Dodaj własne helpery tutaj + ]; + } +} +``` + +## Kolejność Przetwarzania + +1. **Parsowanie** - `TemplateParser` konwertuje HTML na drzewo elementów +2. **Transformacja control flow** - `@if/@else` → `*ngIf` +3. **Przetwarzanie atrybutów** - Helpery przetwarzają atrybuty każdego elementu +4. **Rekonstrukcja** - Drzewo jest konwertowane z powrotem na HTML +5. **Escapowanie** - Znaki specjalne są escapowane dla template string + +## Wykrywanie Typów Atrybutów + +Parser automatycznie wykrywa typ atrybutu na podstawie składni: + +| Składnia | Typ | Helper | +|----------|-----|--------| +| `*ngIf` | STRUCTURAL_DIRECTIVE | StructuralDirectiveHelper | +| `[property]` | INPUT_BINDING | InputBindingHelper | +| `(event)` | OUTPUT_BINDING | OutputBindingHelper | +| `[(model)]` | TWO_WAY_BINDING | TwoWayBindingHelper | +| `#ref` | TEMPLATE_REFERENCE | TemplateReferenceHelper | +| `class` | REGULAR | - | + +## Runtime Bindings (TemplateFragment) + +Podczas renderowania szablonu w runtime, `TemplateFragment` przetwarza bindingi: + +### Input Bindings dla Custom Elements + +Dla elementów z `-` w nazwie (custom elements), input bindings tworzą signale w `__inputs`: + +```html + +``` + +```typescript +// W runtime element będzie miał: +element.__inputs = { + mode: WritableSignal, // signal z wartością currentMode + title: WritableSignal, // signal z wartością pageTitle +}; + +// W komponencie można odczytać: +const mode = this._nativeElement.__inputs?.['mode']?.(); +``` + +### Output Bindings + +Output bindings są obsługiwane przez `addEventListener`: + +```html + +``` + +```typescript +// W runtime dodawane są event listenery: +element.addEventListener('menuClick', (event) => { + component.toggleMenu(); +}); +element.addEventListener('search', (event) => { + component.onSearch(event); +}); +``` + +### Specjalne Bindingi + +#### `[attr.X]` - Attribute Binding + +```html + +``` + +- `true` → ustawia pusty atrybut +- `false/null/undefined` → usuwa atrybut +- inne wartości → ustawia jako string + +#### `[style.X]` - Style Binding + +```html +
    +``` + +- Obsługuje camelCase (`fontSize`) i kebab-case (`font-size`) +- `false/null/undefined` → usuwa właściwość stylu + +#### `[class.X]` - Class Binding + +```html +
    +``` + +- `true` → dodaje klasę +- `false` → usuwa klasę + +### DOM Property Bindings + +Dla zwykłych elementów HTML, bindingi ustawiają właściwości DOM: + +```html +
    + +``` diff --git a/cli/helpers/base-attribute-helper.ts b/cli/helpers/base-attribute-helper.ts new file mode 100644 index 0000000..561d880 --- /dev/null +++ b/cli/helpers/base-attribute-helper.ts @@ -0,0 +1,30 @@ +import { ParsedAttribute, ParsedElement } from './template-parser'; + +export interface AttributeProcessingContext { + element: ParsedElement; + attribute: ParsedAttribute; + filePath: string; +} + +export interface AttributeProcessingResult { + transformed: boolean; + newAttribute?: ParsedAttribute; + additionalAttributes?: ParsedAttribute[]; + removeOriginal?: boolean; +} + +export abstract class BaseAttributeHelper { + abstract get supportedType(): string; + + abstract canHandle(attribute: ParsedAttribute): boolean; + + abstract process(context: AttributeProcessingContext): AttributeProcessingResult; + + protected extractAttributeName(fullName: string): string { + return fullName.replace(/^\*/, '') + .replace(/^\[/, '').replace(/\]$/, '') + .replace(/^\(/, '').replace(/\)$/, '') + .replace(/^\[\(/, '').replace(/\)\]$/, '') + .replace(/^#/, ''); + } +} diff --git a/cli/helpers/control-flow-transformer.ts b/cli/helpers/control-flow-transformer.ts new file mode 100644 index 0000000..45cbb73 --- /dev/null +++ b/cli/helpers/control-flow-transformer.ts @@ -0,0 +1,198 @@ +interface ControlFlowBlock { + condition: string | null; + content: string; +} + +interface ForBlock { + variable: string; + iterable: string; + content: string; + trackBy?: string; +} + +export class ControlFlowTransformer { + transform(content: string): string { + // Transform @for blocks first + content = this.transformForBlocks(content); + + // Then transform @if blocks + const ifBlockRegex = /@if\s*\(([^)]+)\)\s*\{([\s\S]*?)\}(?:\s*@else\s+if\s*\(([^)]+)\)\s*\{([\s\S]*?)\})*(?:\s*@else\s*\{([\s\S]*?)\})?/g; + + return content.replace(ifBlockRegex, (match) => { + const blocks = this.parseBlocks(match); + return this.buildNgContainers(blocks); + }); + } + + private transformForBlocks(content: string): string { + let result = content; + let startIndex = 0; + + while (startIndex < result.length) { + const forBlock = this.findForBlock(result, startIndex); + if (!forBlock) break; + + const parsedBlock = this.parseForBlock(forBlock.match); + if (!parsedBlock) { + startIndex = forBlock.endIndex; + continue; + } + + const replacement = this.buildNgForContainer(parsedBlock); + result = result.substring(0, forBlock.startIndex) + replacement + result.substring(forBlock.endIndex); + + // Move to the end of the replacement to avoid infinite loops + startIndex = forBlock.startIndex + replacement.length; + } + + return result; + } + + private findForBlock(content: string, startIndex: number): { match: string; startIndex: number; endIndex: number } | null { + const forIndex = content.indexOf('@for', startIndex); + if (forIndex === -1) return null; + + const openParenIndex = content.indexOf('(', forIndex); + const closeParenIndex = content.indexOf(')', openParenIndex); + const openBraceIndex = content.indexOf('{', closeParenIndex); + + if (openBraceIndex === -1) return null; + + let braceCount = 1; + let contentEndIndex = openBraceIndex + 1; + + while (contentEndIndex < content.length && braceCount > 0) { + const char = content[contentEndIndex]; + if (char === '{') braceCount++; + else if (char === '}') braceCount--; + contentEndIndex++; + } + + if (braceCount !== 0) return null; + + return { + match: content.substring(forIndex, contentEndIndex), + startIndex: forIndex, + endIndex: contentEndIndex + }; + } + + private parseForBlock(match: string): ForBlock | null { + const startIndex = match.indexOf('@for'); + if (startIndex === -1) return null; + + const openParenIndex = match.indexOf('(', startIndex); + const closeParenIndex = match.indexOf(')', openParenIndex); + const openBraceIndex = match.indexOf('{', closeParenIndex); + + if (openBraceIndex === -1) return null; + + let braceCount = 1; + let contentEndIndex = openBraceIndex + 1; + + while (contentEndIndex < match.length && braceCount > 0) { + const char = match[contentEndIndex]; + if (char === '{') braceCount++; + else if (char === '}') braceCount--; + contentEndIndex++; + } + + if (braceCount !== 0) return null; + + const header = match.substring(openParenIndex + 1, closeParenIndex).trim(); + const content = match.substring(openBraceIndex + 1, contentEndIndex - 1); + + // Parse header + const parts = header.split(';'); + const forPart = parts[0].trim(); + const trackPart = parts[1]?.trim(); + + const forMatch = forPart.match(/^\s*([^\s]+)\s+of\s+([^\s]+)\s*$/); + if (!forMatch) return null; + + const variable = forMatch[1].trim(); + const iterable = forMatch[2].trim(); + let trackBy = undefined; + + if (trackPart) { + const trackMatch = trackPart.match(/^track\s+(.+)$/); + if (trackMatch) { + trackBy = trackMatch[1].trim(); + } + } + + return { + variable, + iterable, + content, + trackBy + }; + } + + private buildNgForContainer(forBlock: ForBlock): string { + let ngForExpression = `let ${forBlock.variable} of ${forBlock.iterable}`; + + if (forBlock.trackBy) { + ngForExpression += `; trackBy: ${forBlock.trackBy}`; + } + + return `${forBlock.content}`; + } + + private parseBlocks(match: string): ControlFlowBlock[] { + const blocks: ControlFlowBlock[] = []; + let remaining = match; + + const ifMatch = remaining.match(/@if\s*\(([^)]+)\)\s*\{([\s\S]*?)\}/); + if (ifMatch) { + blocks.push({ condition: ifMatch[1].trim(), content: ifMatch[2] }); + remaining = remaining.substring(ifMatch[0].length); + } + + const elseIfRegex = /@else\s+if\s*\(([^)]+)\)\s*\{([\s\S]*?)\}/g; + let elseIfMatch; + while ((elseIfMatch = elseIfRegex.exec(remaining)) !== null) { + blocks.push({ condition: elseIfMatch[1].trim(), content: elseIfMatch[2] }); + } + + const elseMatch = remaining.match(/@else\s*\{([\s\S]*?)\}$/); + if (elseMatch) { + blocks.push({ condition: null, content: elseMatch[1] }); + } + + return blocks; + } + + private buildNgContainers(blocks: ControlFlowBlock[]): string { + let result = ''; + const negated: string[] = []; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const condition = this.buildCondition(block.condition, negated); + + result += `${block.content}`; + if (i < blocks.length - 1) { + result += '\n'; + } + + if (block.condition) { + negated.push(block.condition); + } + } + + return result; + } + + private buildCondition(condition: string | null, negated: string[]): string { + if (condition === null) { + return negated.map(c => `!(${c})`).join(' && '); + } + + if (negated.length > 0) { + return negated.map(c => `!(${c})`).join(' && ') + ` && ${condition}`; + } + + return condition; + } +} diff --git a/cli/helpers/example.md b/cli/helpers/example.md new file mode 100644 index 0000000..b53a033 --- /dev/null +++ b/cli/helpers/example.md @@ -0,0 +1,249 @@ +# Przykład Użycia Template Helpers + +## Przykład 1: Podstawowe Parsowanie + +```typescript +import { TemplateParser } from './template-parser'; + +const parser = new TemplateParser(); +const template = ` +
    +

    {{ title }}

    + +
    +`; + +const elements = parser.parse(template); +console.log(elements); +// Output: Drzewo elementów z wyodrębnionymi atrybutami i text nodes +``` + +## Przykład 1b: Parsowanie z Text Nodes + +```typescript +import { TemplateParser } from './template-parser'; + +const parser = new TemplateParser(); +const template = ` + Some text before +
    Inside div
    + Some text after +`; + +const elements = parser.parse(template); + +for (const node of elements) { + if ('type' in node && node.type === 'text') { + console.log('Text node:', node.content.trim()); + } else { + console.log('Element:', node.tagName); + } +} + +// Output: +// Text node: Some text before +// Element: div +// Text node: Some text after +``` + +## Przykład 2: Przetwarzanie Atrybutów + +```typescript +import { TemplateParser } from './template-parser'; +import { InputBindingHelper } from './input-binding-helper'; + +const parser = new TemplateParser(); +const inputHelper = new InputBindingHelper(); + +const template = '
    Content
    '; +const elements = parser.parse(template); + +parser.traverseElements(elements, (element) => { + for (const attr of element.attributes) { + if (inputHelper.canHandle(attr)) { + const result = inputHelper.process({ + element, + attribute: attr, + filePath: 'example.component.html', + }); + + console.log('Processed:', result); + } + } +}); +``` + +## Przykład 3: Transformacja Control Flow + +Wejście: +```html +@if (isLoggedIn) { +

    Welcome back!

    +} @else if (isGuest) { +

    Welcome guest!

    +} @else { +

    Please log in

    +} +``` + +Wyjście po transformacji: +```html + +

    Welcome back!

    +
    + +

    Welcome guest!

    +
    + +

    Please log in

    +
    +``` + +## Przykład 4: Kompletny Szablon + +Wejście: +```html + +``` + +Po przetworzeniu przez TemplateProcessor: +```html + +``` + +## Przykład 5: Własny Helper + +```typescript +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; +import { AttributeType, ParsedAttribute } from './template-parser'; + +export class DataAttributeHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'data-attribute'; + } + + canHandle(attribute: ParsedAttribute): boolean { + return attribute.name.startsWith('data-'); + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + // Konwersja data-* atrybutów na format Angular + const dataName = context.attribute.name.replace('data-', ''); + + return { + transformed: true, + newAttribute: { + name: `[attr.data-${dataName}]`, + value: context.attribute.value, + type: AttributeType.INPUT_BINDING, + }, + }; + } +} + +// Użycie w TemplateProcessor: +// this.attributeHelpers.push(new DataAttributeHelper()); +``` + +## Przykład 6: Debugowanie + +```typescript +import { TemplateParser } from './template-parser'; + +const parser = new TemplateParser(); +const template = '
    Text
    '; + +const elements = parser.parse(template); + +parser.traverseElements(elements, (element) => { + console.log(`Element: ${element.tagName}`); + console.log(`Attributes count: ${element.attributes.length}`); + + element.attributes.forEach(attr => { + console.log(` ${attr.name} [${attr.type}] = "${attr.value}"`); + }); + + if (element.textContent) { + console.log(` Text: ${element.textContent}`); + } +}); + +// Output: +// Element: div +// Attributes count: 2 +// [title] [input] = "value" +// (click) [output] = "handler()" +// Text: Text +``` + +## Przykład 7: Wykrywanie Wszystkich Typów Atrybutów + +```typescript +import { TemplateParser, AttributeType } from './template-parser'; + +const parser = new TemplateParser(); +const template = ` +
    + Content +
    +`; + +const elements = parser.parse(template); +const attributeTypes = new Map(); + +parser.traverseElements(elements, (element) => { + element.attributes.forEach(attr => { + const count = attributeTypes.get(attr.type) || 0; + attributeTypes.set(attr.type, count + 1); + }); +}); + +console.log('Attribute type statistics:'); +attributeTypes.forEach((count, type) => { + console.log(` ${type}: ${count}`); +}); + +// Output: +// Attribute type statistics: +// regular: 1 +// structural: 1 +// input: 1 +// output: 1 +// two-way: 1 +// reference: 1 +``` diff --git a/cli/helpers/index.ts b/cli/helpers/index.ts new file mode 100644 index 0000000..79a0814 --- /dev/null +++ b/cli/helpers/index.ts @@ -0,0 +1,15 @@ +export { + TemplateParser, + ParsedElement, + ParsedTextNode, + ParsedAttribute, + AttributeType +} from './template-parser'; +export * from './base-attribute-helper'; +export * from './structural-directive-helper'; +export * from './input-binding-helper'; +export * from './output-binding-helper'; +export * from './two-way-binding-helper'; +export * from './template-reference-helper'; +export * from './control-flow-transformer'; +export * from './interpolation-transformer'; diff --git a/cli/helpers/input-binding-helper.ts b/cli/helpers/input-binding-helper.ts new file mode 100644 index 0000000..3eb2e10 --- /dev/null +++ b/cli/helpers/input-binding-helper.ts @@ -0,0 +1,25 @@ +import { AttributeType, ParsedAttribute } from './template-parser'; +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; + +export class InputBindingHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'input-binding'; + } + + canHandle(attribute: ParsedAttribute): boolean { + return attribute.type === AttributeType.INPUT_BINDING; + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + const propertyName = this.extractAttributeName(context.attribute.name); + + return { + transformed: true, + newAttribute: { + name: `[${propertyName}]`, + value: context.attribute.value, + type: AttributeType.INPUT_BINDING, + }, + }; + } +} diff --git a/cli/helpers/output-binding-helper.ts b/cli/helpers/output-binding-helper.ts new file mode 100644 index 0000000..e1aae3a --- /dev/null +++ b/cli/helpers/output-binding-helper.ts @@ -0,0 +1,25 @@ +import { AttributeType, ParsedAttribute } from './template-parser'; +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; + +export class OutputBindingHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'output-binding'; + } + + canHandle(attribute: ParsedAttribute): boolean { + return attribute.type === AttributeType.OUTPUT_BINDING; + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + const eventName = this.extractAttributeName(context.attribute.name); + + return { + transformed: true, + newAttribute: { + name: `(${eventName})`, + value: context.attribute.value, + type: AttributeType.OUTPUT_BINDING, + }, + }; + } +} diff --git a/cli/helpers/structural-directive-helper.ts b/cli/helpers/structural-directive-helper.ts new file mode 100644 index 0000000..6a8e176 --- /dev/null +++ b/cli/helpers/structural-directive-helper.ts @@ -0,0 +1,63 @@ +import { AttributeType, ParsedAttribute } from './template-parser'; +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; + +export class StructuralDirectiveHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'structural-directive'; + } + + canHandle(attribute: ParsedAttribute): boolean { + return attribute.type === AttributeType.STRUCTURAL_DIRECTIVE; + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + const directiveName = this.extractAttributeName(context.attribute.name); + + switch (directiveName) { + case 'ngif': + case 'ngIf': + return this.processNgIf(context); + case 'ngfor': + case 'ngFor': + return this.processNgFor(context); + case 'ngswitch': + case 'ngSwitch': + return this.processNgSwitch(context); + default: + return { transformed: false }; + } + } + + private processNgIf(context: AttributeProcessingContext): AttributeProcessingResult { + return { + transformed: true, + newAttribute: { + name: '*ngIf', + value: context.attribute.value, + type: AttributeType.STRUCTURAL_DIRECTIVE, + }, + }; + } + + private processNgFor(context: AttributeProcessingContext): AttributeProcessingResult { + return { + transformed: true, + newAttribute: { + name: '*ngFor', + value: context.attribute.value, + type: AttributeType.STRUCTURAL_DIRECTIVE, + }, + }; + } + + private processNgSwitch(context: AttributeProcessingContext): AttributeProcessingResult { + return { + transformed: true, + newAttribute: { + name: '*ngSwitch', + value: context.attribute.value, + type: AttributeType.STRUCTURAL_DIRECTIVE, + }, + }; + } +} diff --git a/cli/helpers/template-parser.ts b/cli/helpers/template-parser.ts new file mode 100644 index 0000000..e9cebb1 --- /dev/null +++ b/cli/helpers/template-parser.ts @@ -0,0 +1,183 @@ +export interface ParsedAttribute { + name: string; + value: string; + type: AttributeType; +} + +export enum AttributeType { + STRUCTURAL_DIRECTIVE = 'structural', + INPUT_BINDING = 'input', + OUTPUT_BINDING = 'output', + TWO_WAY_BINDING = 'two-way', + TEMPLATE_REFERENCE = 'reference', + REGULAR = 'regular', +} + +export interface ParsedElement { + tagName: string; + attributes: ParsedAttribute[]; + children: (ParsedElement | ParsedTextNode)[]; + textContent?: string; +} + +export interface ParsedTextNode { + type: 'text'; + content: string; +} + +export class TemplateParser { + parse(template: string): (ParsedElement | ParsedTextNode)[] { + const elements: (ParsedElement | ParsedTextNode)[] = []; + const stack: ParsedElement[] = []; + let currentPos = 0; + + while (currentPos < template.length) { + const tagStart = template.indexOf('<', currentPos); + + if (tagStart === -1) { + const textContent = template.substring(currentPos); + if (textContent.trim()) { + const textNode: ParsedTextNode = { + type: 'text', + content: textContent, + }; + if (stack.length > 0) { + stack[stack.length - 1].children.push(textNode); + } else { + elements.push(textNode); + } + } + break; + } + + if (tagStart > currentPos) { + const textContent = template.substring(currentPos, tagStart); + if (textContent.trim()) { + const textNode: ParsedTextNode = { + type: 'text', + content: textContent, + }; + if (stack.length > 0) { + stack[stack.length - 1].children.push(textNode); + } else { + elements.push(textNode); + } + } + } + + if (template[tagStart + 1] === '/') { + const tagEnd = template.indexOf('>', tagStart); + if (tagEnd !== -1) { + const closingTag = template.substring(tagStart + 2, tagEnd).trim(); + + if (stack.length > 0 && stack[stack.length - 1].tagName === closingTag) { + const element = stack.pop()!; + if (stack.length === 0) { + elements.push(element); + } else { + stack[stack.length - 1].children.push(element); + } + } + + currentPos = tagEnd + 1; + } + } else if (template[tagStart + 1] === '!') { + const commentEnd = template.indexOf('-->', tagStart); + currentPos = commentEnd !== -1 ? commentEnd + 3 : tagStart + 1; + } else { + const tagEnd = template.indexOf('>', tagStart); + if (tagEnd === -1) break; + + const isSelfClosing = template[tagEnd - 1] === '/'; + const tagContent = template.substring( + tagStart + 1, + isSelfClosing ? tagEnd - 1 : tagEnd + ).trim(); + + const spaceIndex = tagContent.search(/\s/); + const tagName = spaceIndex === -1 ? tagContent : tagContent.substring(0, spaceIndex); + const attributesString = spaceIndex === -1 ? '' : tagContent.substring(spaceIndex + 1); + + const element: ParsedElement = { + tagName, + attributes: this.parseAttributes(attributesString), + children: [], + }; + + if (isSelfClosing) { + if (stack.length === 0) { + elements.push(element); + } else { + stack[stack.length - 1].children.push(element); + } + } else { + stack.push(element); + } + + currentPos = tagEnd + 1; + } + } + + while (stack.length > 0) { + const element = stack.pop()!; + if (stack.length === 0) { + elements.push(element); + } else { + stack[stack.length - 1].children.push(element); + } + } + + return elements; + } + + private parseAttributes(attributesString: string): ParsedAttribute[] { + const attributes: ParsedAttribute[] = []; + const regex = /([^\s=]+)(?:="([^"]*)")?/g; + let match; + + while ((match = regex.exec(attributesString)) !== null) { + const name = match[1]; + const value = match[2] || ''; + const type = this.detectAttributeType(name); + + attributes.push({ name, value, type }); + } + + return attributes; + } + + private detectAttributeType(name: string): AttributeType { + if (name.startsWith('*')) { + return AttributeType.STRUCTURAL_DIRECTIVE; + } + if (name.startsWith('[(') && name.endsWith(')]')) { + return AttributeType.TWO_WAY_BINDING; + } + if (name.startsWith('[') && name.endsWith(']')) { + return AttributeType.INPUT_BINDING; + } + if (name.startsWith('(') && name.endsWith(')')) { + return AttributeType.OUTPUT_BINDING; + } + if (name.startsWith('#')) { + return AttributeType.TEMPLATE_REFERENCE; + } + return AttributeType.REGULAR; + } + + traverseElements(elements: (ParsedElement | ParsedTextNode)[], callback: (element: ParsedElement) => void): void { + for (const element of elements) { + if (this.isTextNode(element)) { + continue; + } + callback(element); + if (element.children.length > 0) { + this.traverseElements(element.children, callback); + } + } + } + + private isTextNode(node: ParsedElement | ParsedTextNode): node is ParsedTextNode { + return 'type' in node && node.type === 'text'; + } +} diff --git a/cli/helpers/template-reference-helper.ts b/cli/helpers/template-reference-helper.ts new file mode 100644 index 0000000..385cbe5 --- /dev/null +++ b/cli/helpers/template-reference-helper.ts @@ -0,0 +1,25 @@ +import { AttributeType, ParsedAttribute } from './template-parser'; +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; + +export class TemplateReferenceHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'template-reference'; + } + + canHandle(attribute: ParsedAttribute): boolean { + return attribute.type === AttributeType.TEMPLATE_REFERENCE; + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + const referenceName = this.extractAttributeName(context.attribute.name); + + return { + transformed: true, + newAttribute: { + name: `#${referenceName}`, + value: context.attribute.value, + type: AttributeType.TEMPLATE_REFERENCE, + }, + }; + } +} diff --git a/cli/helpers/two-way-binding-helper.ts b/cli/helpers/two-way-binding-helper.ts new file mode 100644 index 0000000..2ed0e77 --- /dev/null +++ b/cli/helpers/two-way-binding-helper.ts @@ -0,0 +1,25 @@ +import { AttributeType, ParsedAttribute } from './template-parser'; +import { BaseAttributeHelper, AttributeProcessingContext, AttributeProcessingResult } from './base-attribute-helper'; + +export class TwoWayBindingHelper extends BaseAttributeHelper { + get supportedType(): string { + return 'two-way-binding'; + } + + canHandle(attribute: ParsedAttribute): boolean { + return attribute.type === AttributeType.TWO_WAY_BINDING; + } + + process(context: AttributeProcessingContext): AttributeProcessingResult { + const propertyName = this.extractAttributeName(context.attribute.name); + + return { + transformed: true, + newAttribute: { + name: `[(${propertyName})]`, + value: context.attribute.value, + type: AttributeType.TWO_WAY_BINDING, + }, + }; + } +} diff --git a/cli/lite-transformer.ts b/cli/lite-transformer.ts new file mode 100644 index 0000000..05fd420 --- /dev/null +++ b/cli/lite-transformer.ts @@ -0,0 +1,123 @@ +import * as esbuild from 'esbuild'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BaseProcessor } from './processors/base-processor'; +import { TemplateProcessor } from './processors/template-processor'; +import { StyleProcessor } from './processors/style-processor'; +import { DIProcessor } from './processors/di-processor'; +import { ClassDecoratorProcessor } from './processors/class-decorator-processor'; +import { SignalTransformerProcessor, SignalTransformerError } from './processors/signal-transformer-processor'; +import { DirectiveCollectorProcessor } from './processors/directive-collector-processor'; + +export class BuildError extends Error { + constructor( + message: string, + public filePath: string, + public processorName: string, + public originalError?: Error, + ) { + super(message); + this.name = 'BuildError'; + } +} + +export class LiteTransformer { + private processors: BaseProcessor[]; + + constructor(processors?: BaseProcessor[]) { + this.processors = processors || [ + new ClassDecoratorProcessor(), + new SignalTransformerProcessor(), + new TemplateProcessor(), + new StyleProcessor(), + new DIProcessor(), + new DirectiveCollectorProcessor(), + ]; + } + + createPlugin(): esbuild.Plugin { + return { + name: 'lite-transformer', + setup: (build) => { + build.onLoad({ filter: /\.(ts)$/ }, async (args) => { + if (args.path.includes('node_modules')) { + return { + contents: await fs.promises.readFile(args.path, 'utf8'), + loader: 'ts', + }; + } + + const source = await fs.promises.readFile(args.path, 'utf8'); + const fileDir = path.dirname(args.path); + + const skipPaths = [ + '/quarc/core/module/', + '/quarc/core/angular/', + '/quarc/router/angular/', + ]; + if (skipPaths.some(p => args.path.includes(p))) { + return { + contents: source, + loader: 'ts', + }; + } + + let currentSource = source; + + for (const processor of this.processors) { + try { + const result = await processor.process({ + filePath: args.path, + fileDir, + source: currentSource, + }); + + if (result.modified) { + currentSource = result.source; + } + } catch (error) { + if (error instanceof SignalTransformerError) { + return { + errors: [{ + text: error.message, + location: { + file: args.path, + namespace: 'file', + }, + }], + }; + } + + const buildError = new BuildError( + error instanceof Error ? error.message : String(error), + args.path, + processor.name, + error instanceof Error ? error : undefined, + ); + + return { + errors: [{ + text: `[${processor.name}] ${buildError.message}`, + location: { + file: args.path, + namespace: 'file', + }, + }], + }; + } + } + + return { + contents: currentSource, + loader: 'ts', + }; + }); + }, + }; + } +} + +export function liteTransformer(processors?: BaseProcessor[]): esbuild.Plugin { + const transformer = new LiteTransformer(processors); + return transformer.createPlugin(); +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..cd7860a --- /dev/null +++ b/cli/package.json @@ -0,0 +1,26 @@ +{ + "name": "@quarc/cli", + "version": "1.0.0", + "description": "Lightweight Angular-like framework CLI", + "main": "build.ts", + "bin": { + "qu": "./bin/qu.js", + "qrw": "./bin/qu.js" + }, + "scripts": { + "build": "ts-node build.ts" + }, + "dependencies": { + "cli-table3": "^0.6.3", + "sass": "^1.69.5", + "esbuild": "^0.19.11", + "terser": "^5.26.0", + "ws": "^8.14.2" + }, + "devDependencies": { + "typescript": "^5.3.3", + "ts-node": "^10.9.2", + "@types/node": "^20.10.6", + "@types/ws": "^8.5.10" + } +} diff --git a/cli/processors/README.md b/cli/processors/README.md new file mode 100644 index 0000000..2ecc16d --- /dev/null +++ b/cli/processors/README.md @@ -0,0 +1,221 @@ +# Lite Transformer Processors + +This directory contains the processor architecture for the `lite-transformer` plugin. + +## Architecture + +The lite-transformer uses a modular processor-based architecture where each processor handles a specific transformation: + +``` +lite-transformer +├── BaseProcessor (abstract) +├── ClassDecoratorProcessor +├── SignalTransformerProcessor +├── TemplateProcessor +├── StyleProcessor +└── DIProcessor +``` + +## Processors + +### BaseProcessor + +Abstract base class that all processors must extend. + +**Interface:** +```typescript +abstract class BaseProcessor { + abstract get name(): string; + abstract process(context: ProcessorContext): Promise; +} +``` + +### TemplateProcessor + +Transforms `templateUrl` properties to inline `template` properties and processes Angular attributes. + +**Transformation:** +```typescript +// Before +templateUrl = './component.html' + +// After +template = `
    Component content
    ` +``` + +**Features:** +- Reads HTML file from disk +- Transforms Angular control flow syntax (`@if/@else` → `*ngIf`) +- Parses and processes Angular attributes using helper system +- Escapes template string characters +- Throws error if file not found + +**Template Processing Pipeline:** +1. Read template file from disk +2. Transform control flow syntax +3. Parse HTML to element tree +4. Process attributes with specialized helpers +5. Reconstruct HTML from element tree +6. Escape template string characters + +**Supported Attribute Types:** +- Structural directives: `*ngIf`, `*ngFor`, `*ngSwitch` +- Input bindings: `[property]` +- Output bindings: `(event)` +- Two-way bindings: `[(model)]` +- Template references: `#ref` + +See [helpers/README.md](../helpers/README.md) for detailed information about the attribute helper system. + +### StyleProcessor + +Transforms `styleUrl` and `styleUrls` properties to inline `style` properties. + +**Transformations:** +```typescript +// Single style file +styleUrl = './component.scss' +// → style = `:host { ... }` + +// Multiple style files +styleUrls = ['./base.scss', './theme.scss'] +// → style = `/* base.scss */\n/* theme.scss */` +``` + +**Features:** +- Handles both `styleUrl` (single) and `styleUrls` (array) +- Combines multiple style files with newline separator +- Escapes template string characters +- Throws error if any file not found + +### DIProcessor + +Adds dependency injection metadata to classes with constructor parameters. + +**Transformation:** +```typescript +// Before +export class MyService { + constructor(private http: HttpClient) {} +} + +// After +export class MyService { + static __di_params__ = [HttpClient]; + constructor(private http: HttpClient) {} +} +``` + +**Features:** +- Extracts constructor parameter types +- Adds static `__di_params__` property +- Skips classes without constructor parameters + +### SignalTransformerProcessor + +Transforms Angular-like signal function calls (`input`, `output`) to include property name and `this` as arguments, and ensures `_nativeElement: HTMLElement` is available in the constructor. + +**Transformation:** +```typescript +// Before +export class MyComponent { + public userName = input(); + private clicked = output(); + readonly count = input(0); + + constructor(private service: SomeService) {} +} + +// After +export class MyComponent { + public userName = input("userName", this); + private clicked = output("clicked", this); + readonly count = input("count", this, 0); + + constructor(public _nativeElement: HTMLElement, private service: SomeService) {} +} +``` + +**Features:** +- Extracts property name and adds it as first argument (string) +- Adds `this` as second argument for component context +- Handles generic type parameters (e.g., `input()`) +- Preserves existing arguments after `this` +- Adds `public _nativeElement: HTMLElement` to constructor if not present +- Skips if `HTMLElement` or `_nativeElement` already exists in constructor +- Throws `SignalTransformerError` if property name cannot be determined + +**Supported Functions:** +- `input` - Input signal binding (property name used for attribute binding) +- `output` - Output signal binding (property name used for event name) + +**Error Handling:** +If the processor cannot determine the property name (e.g., signal used outside of property assignment), it throws a `SignalTransformerError` which stops the build with a clear error message. + +## Usage + +### Using the Default Transformer + +```typescript +import { liteTransformer } from './lite-transformer'; + +// Uses all processors in default order +plugins: [liteTransformer()] +``` + +### Custom Processor Configuration + +```typescript +import { liteTransformer } from './lite-transformer'; +import { TemplateProcessor, StyleProcessor } from './processors'; + +// Use only specific processors +plugins: [ + liteTransformer([ + new TemplateProcessor(), + new StyleProcessor() + ]) +] +``` + +### Creating Custom Processors + +```typescript +import { BaseProcessor, ProcessorContext, ProcessorResult } from './processors'; + +export class CustomProcessor extends BaseProcessor { + get name(): string { + return 'custom-processor'; + } + + async process(context: ProcessorContext): Promise { + // Your transformation logic + return { + source: context.source, + modified: false + }; + } +} +``` + +## Processor Execution Order + +Processors execute in the order they are registered: + +1. **ClassDecoratorProcessor** - Processes class decorators +2. **SignalTransformerProcessor** - Transforms signal function calls +3. **TemplateProcessor** - Inlines HTML templates +4. **StyleProcessor** - Inlines CSS styles +5. **DIProcessor** - Adds DI metadata + +Each processor receives the output of the previous processor, allowing for chained transformations. + +## Error Handling + +All processors throw errors for missing files: + +```typescript +throw new Error(`Template file not found: ${fullPath} (referenced in ${context.filePath})`); +``` + +This ensures build-time validation of all referenced assets. diff --git a/cli/processors/base-processor.ts b/cli/processors/base-processor.ts new file mode 100644 index 0000000..bb6ceb3 --- /dev/null +++ b/cli/processors/base-processor.ts @@ -0,0 +1,28 @@ +export interface ProcessorContext { + filePath: string; + fileDir: string; + source: string; +} + +export interface ProcessorResult { + source: string; + modified: boolean; +} + +export abstract class BaseProcessor { + abstract get name(): string; + + abstract process(context: ProcessorContext): Promise; + + protected shouldSkipFile(source: string): boolean { + return !source.includes('class ') && !source.includes('template'); + } + + protected noChange(source: string): ProcessorResult { + return { source, modified: false }; + } + + protected changed(source: string): ProcessorResult { + return { source, modified: true }; + } +} diff --git a/cli/processors/class-decorator-processor.ts b/cli/processors/class-decorator-processor.ts new file mode 100644 index 0000000..23ab37f --- /dev/null +++ b/cli/processors/class-decorator-processor.ts @@ -0,0 +1,120 @@ +import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; +import { ComponentIdRegistry } from './component-id-registry'; + +const DECORATOR_MAP: Record = { + 'Component': '_quarcComponent', + 'Directive': '_quarcDirective', + 'Pipe': '_quarcPipe', + 'Injectable': '_quarcInjectable', +}; + +export class ClassDecoratorProcessor extends BaseProcessor { + private componentIdRegistry = ComponentIdRegistry.getInstance(); + + get name(): string { + return 'class-decorator-processor'; + } + + async process(context: ProcessorContext): Promise { + const decoratorNames = Object.keys(DECORATOR_MAP); + const hasDecorator = decoratorNames.some(d => context.source.includes(`@${d}`)); + + if (!hasDecorator) { + return this.noChange(context.source); + } + + let source = context.source; + let modified = false; + + for (const [decoratorName, propertyName] of Object.entries(DECORATOR_MAP)) { + const result = this.processDecorator(source, decoratorName, propertyName, context.filePath); + if (result.modified) { + source = result.source; + modified = true; + } + } + + return modified ? this.changed(source) : this.noChange(source); + } + + private processDecorator( + source: string, + decoratorName: string, + propertyName: string, + filePath: string, + ): { source: string; modified: boolean } { + let result = source; + let modified = false; + let searchStart = 0; + + while (true) { + const decoratorStart = result.indexOf(`@${decoratorName}(`, searchStart); + if (decoratorStart === -1) break; + + const argsStart = decoratorStart + decoratorName.length + 2; + const argsEnd = this.findMatchingParen(result, argsStart - 1); + if (argsEnd === -1) { + searchStart = argsStart; + continue; + } + + const decoratorArgs = result.substring(argsStart, argsEnd).trim(); + + const afterDecorator = result.substring(argsEnd + 1); + const classMatch = afterDecorator.match(/^\s*\n?\s*export\s+class\s+(\w+)/); + + if (!classMatch) { + searchStart = argsEnd + 1; + continue; + } + + const className = classMatch[1]; + + const afterClassName = result.substring(argsEnd + 1 + classMatch[0].length); + const classBodyMatch = afterClassName.match(/^(\s*(?:extends\s+\w+\s*)?(?:implements\s+[\w,\s]+)?\s*)\{/); + + if (!classBodyMatch) { + searchStart = argsEnd + 1; + continue; + } + + const fullMatchEnd = argsEnd + 1 + classMatch[0].length + classBodyMatch[0].length; + + const staticProperty = `static ${propertyName} = [${decoratorArgs || '{}'}];`; + + let additionalProperties = ''; + if (decoratorName === 'Component') { + const scopeId = this.componentIdRegistry.getComponentId(filePath); + additionalProperties = `\n static _scopeId = '${scopeId}';\n static __quarc_original_name__ = '${className}';`; + } else if (decoratorName === 'Directive') { + additionalProperties = `\n static __quarc_original_name__ = '${className}';`; + } else if (decoratorName === 'Injectable') { + additionalProperties = `\n static __quarc_original_name__ = '${className}';`; + } + + const classDeclaration = `export class ${className}${classBodyMatch[1]}{\n ${staticProperty}${additionalProperties}`; + + result = result.slice(0, decoratorStart) + classDeclaration + result.slice(fullMatchEnd); + modified = true; + searchStart = decoratorStart + classDeclaration.length; + } + + return { source: result, modified }; + } + + private findMatchingParen(source: string, startIndex: number): number { + if (source[startIndex] !== '(') return -1; + + let depth = 1; + let i = startIndex + 1; + + while (i < source.length && depth > 0) { + const char = source[i]; + if (char === '(') depth++; + else if (char === ')') depth--; + i++; + } + + return depth === 0 ? i - 1 : -1; + } +} diff --git a/cli/processors/component-id-registry.ts b/cli/processors/component-id-registry.ts new file mode 100644 index 0000000..cd54509 --- /dev/null +++ b/cli/processors/component-id-registry.ts @@ -0,0 +1,39 @@ +/** + * Wspólny rejestr ID komponentów używany przez wszystkie procesory. + * Używa hasza ze ścieżki pliku, aby ID było deterministyczne niezależnie od kolejności przetwarzania. + */ +export class ComponentIdRegistry { + private static instance: ComponentIdRegistry; + private componentIdMap = new Map(); + + private constructor() {} + + static getInstance(): ComponentIdRegistry { + if (!ComponentIdRegistry.instance) { + ComponentIdRegistry.instance = new ComponentIdRegistry(); + } + return ComponentIdRegistry.instance; + } + + getComponentId(filePath: string): string { + if (!this.componentIdMap.has(filePath)) { + const id = this.generateHashId(filePath); + this.componentIdMap.set(filePath, id); + } + return this.componentIdMap.get(filePath)!; + } + + private generateHashId(filePath: string): string { + let hash = 0; + for (let i = 0; i < filePath.length; i++) { + const char = filePath.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return `c${Math.abs(hash).toString(36)}`; + } + + reset(): void { + this.componentIdMap.clear(); + } +} diff --git a/cli/processors/di-processor.ts b/cli/processors/di-processor.ts new file mode 100644 index 0000000..85f5c6a --- /dev/null +++ b/cli/processors/di-processor.ts @@ -0,0 +1,84 @@ +import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; + +export class DIProcessor extends BaseProcessor { + get name(): string { + return 'di-processor'; + } + + async process(context: ProcessorContext): Promise { + if (!context.source.includes('constructor')) { + return this.noChange(context.source); + } + + let source = context.source; + let modified = false; + + const typeOnlyImports = this.extractTypeOnlyImports(source); + + const classRegex = /export\s+class\s+(\w+)(?:\s+(?:extends\s+\w+\s*)?(?:implements\s+[\w,\s]+)?)?\s*\{/g; + + const matches = [...source.matchAll(classRegex)]; + + for (const match of matches.reverse()) { + const openBraceIndex = match.index! + match[0].length; + const classBody = this.extractClassBody(source, openBraceIndex); + + if (!classBody) continue; + + const params = this.extractConstructorParams(classBody, typeOnlyImports); + if (params.length === 0) continue; + + if (classBody.includes('__di_params__')) continue; + + const diProperty = `\n static __di_params__ = ['${params.join("', '")}'];`; + source = source.slice(0, openBraceIndex) + diProperty + source.slice(openBraceIndex); + modified = true; + } + + return modified ? this.changed(source) : this.noChange(source); + } + + private extractTypeOnlyImports(source: string): Set { + const typeOnlyImports = new Set(); + + const importTypeRegex = /import\s+type\s*\{([^}]+)\}/g; + for (const match of source.matchAll(importTypeRegex)) { + const types = match[1].split(',').map(t => t.trim()); + types.forEach(t => typeOnlyImports.add(t)); + } + + return typeOnlyImports; + } + + private extractClassBody(source: string, startIndex: number): string | null { + let braceCount = 1; + let i = startIndex; + + while (i < source.length && braceCount > 0) { + if (source[i] === '{') braceCount++; + else if (source[i] === '}') braceCount--; + i++; + } + + return braceCount === 0 ? source.slice(startIndex, i - 1) : null; + } + + private extractConstructorParams(classBody: string, typeOnlyImports: Set): string[] { + const constructorMatch = classBody.match(/constructor\s*\(([^)]*)\)/); + if (!constructorMatch || !constructorMatch[1].trim()) { + return []; + } + + const paramsStr = constructorMatch[1]; + // Don't skip HTMLElement - it's needed for DI to inject the native element + const skipTypes = new Set([...typeOnlyImports]); + + return paramsStr + .split(',') + .map(p => { + const typeMatch = p.match(/:\s*(\w+)/); + return typeMatch ? typeMatch[1] : null; + }) + .filter((p): p is string => p !== null && !skipTypes.has(p)); + } +} diff --git a/cli/processors/directive-collector-processor.ts b/cli/processors/directive-collector-processor.ts new file mode 100644 index 0000000..1d26609 --- /dev/null +++ b/cli/processors/directive-collector-processor.ts @@ -0,0 +1,72 @@ +import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; + +export class DirectiveCollectorProcessor extends BaseProcessor { + get name(): string { + return 'directive-collector-processor'; + } + + async process(context: ProcessorContext): Promise { + if (!context.source.includes('_quarcComponent')) { + return this.noChange(context.source); + } + + if (!context.source.includes('imports:')) { + return this.noChange(context.source); + } + + let source = context.source; + let modified = false; + + const scopeIdPattern = /static\s+_scopeId\s*=\s*'[^']*';/g; + + let match; + const replacements: { position: number; insert: string }[] = []; + + while ((match = scopeIdPattern.exec(source)) !== null) { + const scopeIdEnd = match.index + match[0].length; + + const beforeScopeId = source.substring(0, match.index); + const quarcComponentMatch = beforeScopeId.match(/static\s+_quarcComponent\s*=\s*\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\];[^]*$/); + + if (!quarcComponentMatch) { + continue; + } + + const componentOptions = quarcComponentMatch[1]; + + const importsMatch = componentOptions.match(/imports\s*:\s*\[([^\]]*)\]/); + if (!importsMatch) { + continue; + } + + const importsContent = importsMatch[1]; + const importNames = this.parseImportNames(importsContent); + + if (importNames.length === 0) { + continue; + } + + const directivesProperty = `\n static _quarcDirectives = [${importNames.join(', ')}];`; + + replacements.push({ + position: scopeIdEnd, + insert: directivesProperty, + }); + } + + for (let i = replacements.length - 1; i >= 0; i--) { + const r = replacements[i]; + source = source.slice(0, r.position) + r.insert + source.slice(r.position); + modified = true; + } + + return modified ? this.changed(source) : this.noChange(source); + } + + private parseImportNames(importsContent: string): string[] { + return importsContent + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0 && /^[A-Z]/.test(s)); + } +} diff --git a/cli/processors/index.ts b/cli/processors/index.ts new file mode 100644 index 0000000..95b2754 --- /dev/null +++ b/cli/processors/index.ts @@ -0,0 +1,8 @@ +export { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; +export { ClassDecoratorProcessor } from './class-decorator-processor'; +export { TemplateProcessor } from './template-processor'; +export { StyleProcessor } from './style-processor'; +export { DIProcessor } from './di-processor'; +export { SignalTransformerProcessor, SignalTransformerError } from './signal-transformer-processor'; +export { DirectiveCollectorProcessor } from './directive-collector-processor'; +export { TemplateTransformer } from './template/template-transformer'; diff --git a/cli/processors/signal-transformer-processor.ts b/cli/processors/signal-transformer-processor.ts new file mode 100644 index 0000000..460917c --- /dev/null +++ b/cli/processors/signal-transformer-processor.ts @@ -0,0 +1,138 @@ +import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; + +const SIGNAL_FUNCTIONS = ['input', 'output']; + +export class SignalTransformerError extends Error { + constructor( + message: string, + public filePath: string, + public propertyName?: string, + ) { + super(message); + this.name = 'SignalTransformerError'; + } +} + +export class SignalTransformerProcessor extends BaseProcessor { + get name(): string { + return 'signal-transformer-processor'; + } + + async process(context: ProcessorContext): Promise { + const hasSignal = SIGNAL_FUNCTIONS.some(fn => + context.source.includes(`${fn}(`) || context.source.includes(`${fn}<`), + ); + + if (!hasSignal) { + return this.noChange(context.source); + } + + let source = context.source; + let modified = false; + + const classRegex = /export\s+class\s+(\w+)(?:\s+(?:extends\s+\w+\s*)?(?:implements\s+[\w,\s]+)?)?\s*\{/g; + const matches = [...source.matchAll(classRegex)]; + + for (const match of matches.reverse()) { + const className = match[1]; + const bodyStart = match.index! + match[0].length; + const classBody = this.extractClassBody(source, bodyStart); + + if (!classBody) continue; + + const hasSignalInClass = SIGNAL_FUNCTIONS.some(fn => + classBody.includes(`${fn}(`) || classBody.includes(`${fn}<`), + ); + + if (!hasSignalInClass) continue; + + let newBody = this.transformSignals(classBody, className, context.filePath); + newBody = this.ensureNativeElement(newBody); + + if (newBody !== classBody) { + source = source.slice(0, bodyStart) + newBody + source.slice(bodyStart + classBody.length); + modified = true; + } + } + + return modified ? this.changed(source) : this.noChange(source); + } + + private extractClassBody(source: string, startIndex: number): string | null { + let depth = 1; + let i = startIndex; + + while (i < source.length && depth > 0) { + if (source[i] === '{') depth++; + else if (source[i] === '}') depth--; + i++; + } + + return depth === 0 ? source.slice(startIndex, i - 1) : null; + } + + private transformSignals(classBody: string, className: string, filePath: string): string { + let result = classBody; + + for (const fn of SIGNAL_FUNCTIONS) { + const pattern = new RegExp( + `((?:public|private|protected|readonly)\\s+)?(\\w+)(\\s*=\\s*)(${fn})(<[^>]+>)?\\(([^)]*)\\)`, + 'g', + ); + + result = result.replace(pattern, (_, modifier = '', propName, assignment, fnName, generic = '', args) => { + if (!propName || propName.trim() === '') { + throw new SignalTransformerError( + `Cannot determine property name for ${fn}() in ${className}`, + filePath, + ); + } + + const trimmedArgs = args.trim(); + const newArgs = trimmedArgs + ? `"${propName}", this, ${trimmedArgs}` + : `"${propName}", this`; + + return `${modifier}${propName}${assignment}${fnName}${generic}(${newArgs})`; + }); + } + + return result; + } + + private ensureNativeElement(classBody: string): string { + const constructorMatch = classBody.match(/constructor\s*\(([^)]*)\)\s*\{/); + + if (!constructorMatch) { + const insertIndex = classBody.match(/^\s*\n/)?.index ?? 0; + return classBody.slice(0, insertIndex) + + '\n constructor(public _nativeElement: HTMLElement) {}\n' + + classBody.slice(insertIndex); + } + + const params = constructorMatch[1].trim(); + + if (params.includes('_nativeElement')) { + return classBody; + } + + const htmlElementParamMatch = params.match(/(public|private|protected)?\s*(\w+)\s*:\s*HTMLElement/); + if (htmlElementParamMatch) { + const modifier = htmlElementParamMatch[1] || 'public'; + const existingParamName = htmlElementParamMatch[2]; + const newParams = params.replace( + new RegExp(`(${modifier})?\\s*${existingParamName}\\s*:\\s*HTMLElement`), + `${modifier} _nativeElement: HTMLElement`, + ); + return classBody + .replace(/constructor\s*\([^)]*\)\s*\{/, `constructor(${newParams}) {`) + .replace(new RegExp(`this\\.${existingParamName}`, 'g'), 'this._nativeElement'); + } + + const nativeParam = 'public _nativeElement: HTMLElement'; + const newParams = params ? `${nativeParam}, ${params}` : nativeParam; + + return classBody + .replace(/constructor\s*\([^)]*\)\s*\{/, `constructor(${newParams}) { void this._nativeElement;`); + } +} diff --git a/cli/processors/style-processor.ts b/cli/processors/style-processor.ts new file mode 100644 index 0000000..4fb5170 --- /dev/null +++ b/cli/processors/style-processor.ts @@ -0,0 +1,177 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as sass from 'sass'; +import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; +import { ComponentIdRegistry } from './component-id-registry'; + +export class StyleProcessor extends BaseProcessor { + private componentIdRegistry = ComponentIdRegistry.getInstance(); + + get name(): string { + return 'style-processor'; + } + + async process(context: ProcessorContext): Promise { + if (!context.source.includes('style')) { + return this.noChange(context.source); + } + + const componentId = this.componentIdRegistry.getComponentId(context.filePath); + let source = context.source; + let modified = false; + + const singleResult = await this.processStyleUrl(source, context, componentId); + if (singleResult.modified) { + source = singleResult.source; + modified = true; + } + + const multiResult = await this.processStyleUrls(source, context, componentId); + if (multiResult.modified) { + source = multiResult.source; + modified = true; + } + + return modified ? this.changed(source) : this.noChange(source); + } + + private async processStyleUrl( + source: string, + context: ProcessorContext, + componentId: string, + ): Promise<{ source: string; modified: boolean }> { + const regex = /styleUrl\s*[=:]\s*['"`]([^'"`]+)['"`]/g; + const matches = [...source.matchAll(regex)]; + + if (matches.length === 0) { + return { source, modified: false }; + } + + let result = source; + + for (const match of matches.reverse()) { + const stylePath = match[1]; + const fullPath = path.resolve(context.fileDir, stylePath); + + if (!fs.existsSync(fullPath)) { + throw new Error(`Style not found: ${fullPath}`); + } + + const content = await this.loadAndCompileStyle(fullPath); + const scoped = this.scopeStyles(content, componentId); + const escaped = this.escapeTemplate(scoped); + + result = result.slice(0, match.index!) + + `style: \`${escaped}\`` + + result.slice(match.index! + match[0].length); + } + + return { source: result, modified: true }; + } + + private async processStyleUrls( + source: string, + context: ProcessorContext, + componentId: string, + ): Promise<{ source: string; modified: boolean }> { + const regex = /styleUrls\s*[=:]\s*\[([\s\S]*?)\]/g; + const matches = [...source.matchAll(regex)]; + + if (matches.length === 0) { + return { source, modified: false }; + } + + let result = source; + + for (const match of matches.reverse()) { + const urlsContent = match[1]; + const urlMatches = urlsContent.match(/['"`]([^'"`]+)['"`]/g); + + if (!urlMatches) continue; + + const styles: string[] = []; + + for (const urlMatch of urlMatches) { + const stylePath = urlMatch.replace(/['"`]/g, ''); + const fullPath = path.resolve(context.fileDir, stylePath); + + if (!fs.existsSync(fullPath)) { + throw new Error(`Style not found: ${fullPath}`); + } + + styles.push(await this.loadAndCompileStyle(fullPath)); + } + + const combined = styles.join('\n'); + const scoped = this.scopeStyles(combined, componentId); + const escaped = this.escapeTemplate(scoped); + + result = result.slice(0, match.index!) + + `style: \`${escaped}\`` + + result.slice(match.index! + match[0].length); + } + + return { source: result, modified: true }; + } + + private async loadAndCompileStyle(filePath: string): Promise { + const ext = path.extname(filePath).toLowerCase(); + const content = await fs.promises.readFile(filePath, 'utf8'); + + if (ext === '.scss' || ext === '.sass') { + const result = sass.compileString(content, { + style: 'compressed', + sourceMap: false, + loadPaths: [path.dirname(filePath)], + }); + return result.css; + } + + return content; + } + + private scopeStyles(css: string, componentId: string): string { + const attr = `[_ngcontent-${componentId}]`; + const hostAttr = `[_nghost-${componentId}]`; + + let result = css; + + result = result.replace(/:host\s*\(([^)]+)\)/g, (_, selector) => `${hostAttr}${selector}`); + result = result.replace(/:host/g, hostAttr); + + result = result.replace(/([^{}]+)\{/g, (match, selector) => { + if (selector.includes(hostAttr)) return match; + + const selectors = selector.split(',').map((s: string) => { + s = s.trim(); + if (!s || s.startsWith('@') || s.startsWith('from') || s.startsWith('to')) { + return s; + } + + return s.split(/\s+/) + .map((part: string) => { + if (['>', '+', '~'].includes(part) || part.includes(hostAttr)) { + return part; + } + const pseudoMatch = part.match(/^([^:]+)(::?.+)$/); + if (pseudoMatch) { + return `${pseudoMatch[1]}${attr}${pseudoMatch[2]}`; + } + return `${part}${attr}`; + }) + .join(' '); + }); + + return selectors.join(', ') + ' {'; + }); + + return result; + } + + private escapeTemplate(content: string): string { + return content + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$/g, '\\$'); + } +} diff --git a/cli/processors/template-processor.ts b/cli/processors/template-processor.ts new file mode 100644 index 0000000..10afd4c --- /dev/null +++ b/cli/processors/template-processor.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor'; +import { TemplateTransformer } from './template/template-transformer'; + +export class TemplateProcessor extends BaseProcessor { + private transformer = new TemplateTransformer(); + + get name(): string { + return 'template-processor'; + } + + async process(context: ProcessorContext): Promise { + if (!context.source.includes('template')) { + return this.noChange(context.source); + } + + let source = context.source; + let modified = false; + + source = await this.processTemplateUrls(source, context); + if (source !== context.source) modified = true; + + const inlineResult = this.processInlineTemplates(source); + if (inlineResult.modified) { + source = inlineResult.source; + modified = true; + } + + return modified ? this.changed(source) : this.noChange(source); + } + + private async processTemplateUrls(source: string, context: ProcessorContext): Promise { + const patterns = [ + /templateUrl\s*[=:]\s*['"`]([^'"`]+)['"`]/g, + ]; + + let result = source; + + for (const pattern of patterns) { + const matches = [...source.matchAll(pattern)]; + + for (const match of matches.reverse()) { + const templatePath = match[1]; + const fullPath = path.resolve(context.fileDir, templatePath); + + if (!fs.existsSync(fullPath)) { + throw new Error(`Template not found: ${fullPath}`); + } + + let content = await fs.promises.readFile(fullPath, 'utf8'); + content = this.transformer.transformAll(content); + content = this.escapeTemplate(content); + + result = result.replace(match[0], `template: \`${content}\``); + } + } + + return result; + } + + private processInlineTemplates(source: string): { source: string; modified: boolean } { + const patterns = [ + { regex: /template\s*:\s*`([^`]*)`/g, quote: '`' }, + { regex: /template\s*:\s*'([^']*)'/g, quote: "'" }, + { regex: /template\s*:\s*"([^"]*)"/g, quote: '"' }, + ]; + + let result = source; + let modified = false; + + for (const { regex } of patterns) { + const matches = [...result.matchAll(regex)]; + + for (const match of matches.reverse()) { + let content = this.unescapeTemplate(match[1]); + content = this.transformer.transformAll(content); + content = this.escapeTemplate(content); + + const newTemplate = `template: \`${content}\``; + if (match[0] !== newTemplate) { + result = result.slice(0, match.index!) + newTemplate + result.slice(match.index! + match[0].length); + modified = true; + } + } + } + + return { source: result, modified }; + } + + private escapeTemplate(content: string): string { + return content + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$/g, '\\$'); + } + + private unescapeTemplate(content: string): string { + return content + .replace(/\\`/g, '`') + .replace(/\\\$/g, '$') + .replace(/\\\\/g, '\\'); + } +} diff --git a/cli/processors/template/template-transformer.ts b/cli/processors/template/template-transformer.ts new file mode 100644 index 0000000..cd122ec --- /dev/null +++ b/cli/processors/template/template-transformer.ts @@ -0,0 +1,316 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface TransformResult { + content: string; + modified: boolean; +} + +export class TemplateTransformer { + transformInterpolation(content: string): string { + let result = content; + + result = this.transformAttributeInterpolation(result); + result = this.transformContentInterpolation(result); + + return result; + } + + private transformAttributeInterpolation(content: string): string { + const tagRegex = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^>]*?)?)>/g; + + return content.replace(tagRegex, (fullMatch, tagName, attributesPart) => { + if (!attributesPart || !attributesPart.includes('{{')) { + return fullMatch; + } + + const interpolationRegex = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*\{\{[^"]*\}\}[^"]*)"/g; + const bindings: { attr: string; expr: string }[] = []; + let newAttributes = attributesPart; + + newAttributes = attributesPart.replace(interpolationRegex, (_attrMatch: string, attrName: string, attrValue: string) => { + const hasInterpolation = /\{\{.*?\}\}/.test(attrValue); + if (!hasInterpolation) { + return _attrMatch; + } + + const parts: string[] = []; + let lastIndex = 0; + const exprRegex = /\{\{\s*([^}]+?)\s*\}\}/g; + let match; + + while ((match = exprRegex.exec(attrValue)) !== null) { + if (match.index > lastIndex) { + const literal = attrValue.substring(lastIndex, match.index); + if (literal) { + parts.push(`'${literal}'`); + } + } + parts.push(`(${match[1].trim()})`); + lastIndex = exprRegex.lastIndex; + } + + if (lastIndex < attrValue.length) { + const literal = attrValue.substring(lastIndex); + if (literal) { + parts.push(`'${literal}'`); + } + } + + const expression = parts.length === 1 ? parts[0] : parts.join(' + '); + bindings.push({ attr: attrName, expr: expression }); + + return ''; + }); + + if (bindings.length === 0) { + return fullMatch; + } + + const bindingsJson = JSON.stringify(bindings).replace(/"/g, "'"); + const dataAttr = ` data-quarc-attr-bindings="${bindingsJson.replace(/'/g, ''')}"`; + + newAttributes = newAttributes.trim(); + return `<${tagName}${newAttributes ? ' ' + newAttributes : ''}${dataAttr}>`; + }); + } + + private transformContentInterpolation(content: string): string { + return content.replace( + /\{\{\s*([^}]+?)\s*\}\}/g, + (_, expr) => ``, + ); + } + + transformControlFlowIf(content: string): string { + let result = content; + let modified = true; + + while (modified) { + modified = false; + result = result.replace( + /@if\s*\(([^)]+)\)\s*\{([\s\S]*?)\}(?:\s*@else\s+if\s*\(([^)]+)\)\s*\{([\s\S]*?)\})*(?:\s*@else\s*\{([\s\S]*?)\})?/, + (match) => { + modified = true; + return this.parseIfBlock(match); + }, + ); + } + + return result; + } + + transformControlFlowFor(content: string): string { + let result = content; + let startIndex = 0; + + while (startIndex < result.length) { + const forIndex = result.indexOf('@for', startIndex); + if (forIndex === -1) break; + + const block = this.extractForBlock(result, forIndex); + if (!block) { + startIndex = forIndex + 4; + continue; + } + + const replacement = this.buildForDirective(block.header, block.body); + result = result.substring(0, forIndex) + replacement + result.substring(block.endIndex); + startIndex = forIndex + replacement.length; + } + + return result; + } + + transformNgIfDirective(content: string): string { + // Keep *ngIf as is - runtime handles it + return content; + } + + transformNgForDirective(content: string): string { + // Keep *ngFor as is - runtime handles it + return content; + } + + transformInputBindings(content: string): string { + return content.replace(/\[([a-zA-Z][a-zA-Z0-9]*)\]="/g, (match, propName) => { + const kebabName = this.camelToKebab(propName); + return `[${kebabName}]="`; + }); + } + + private camelToKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + } + + transformOutputBindings(content: string): string { + // Keep (event) as is - runtime handles it + return content; + } + + transformTwoWayBindings(content: string): string { + // Keep [(model)] as is - runtime handles it + return content; + } + + transformAll(content: string): string { + let result = content; + + result = this.transformInterpolation(result); + result = this.transformControlFlowFor(result); + result = this.transformControlFlowIf(result); + result = this.transformSelectNgFor(result); + result = this.transformNgIfDirective(result); + result = this.transformNgForDirective(result); + result = this.transformInputBindings(result); + result = this.transformOutputBindings(result); + result = this.transformTwoWayBindings(result); + + return result; + } + + async loadExternalTemplate(templatePath: string, fileDir: string): Promise { + const fullPath = path.resolve(fileDir, templatePath); + + if (!fs.existsSync(fullPath)) { + throw new Error(`Template file not found: ${fullPath}`); + } + + return fs.promises.readFile(fullPath, 'utf8'); + } + + private parseIfBlock(match: string): string { + const blocks: Array<{ condition: string | null; content: string }> = []; + let remaining = match; + + const ifMatch = remaining.match(/@if\s*\(([^)]+)\)\s*\{([\s\S]*?)\}/); + if (ifMatch) { + blocks.push({ condition: ifMatch[1].trim(), content: ifMatch[2] }); + remaining = remaining.substring(ifMatch[0].length); + } + + let elseIfMatch; + const elseIfRegex = /@else\s+if\s*\(([^)]+)\)\s*\{([\s\S]*?)\}/g; + while ((elseIfMatch = elseIfRegex.exec(remaining)) !== null) { + blocks.push({ condition: elseIfMatch[1].trim(), content: elseIfMatch[2] }); + } + + const elseMatch = remaining.match(/@else\s*\{([\s\S]*?)\}$/); + if (elseMatch) { + blocks.push({ condition: null, content: elseMatch[1] }); + } + + return this.buildIfDirectives(blocks); + } + + private buildIfDirectives(blocks: Array<{ condition: string | null; content: string }>): string { + const negated: string[] = []; + let result = ''; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + let condition: string; + + if (block.condition === null) { + condition = negated.map(c => `!(${c})`).join(' && '); + } else if (negated.length > 0) { + condition = negated.map(c => `!(${c})`).join(' && ') + ` && ${block.condition}`; + } else { + condition = block.condition; + } + + result += `${block.content}`; + if (i < blocks.length - 1) result += '\n'; + + if (block.condition) { + negated.push(block.condition); + } + } + + return result; + } + + private extractForBlock(content: string, startIndex: number): { header: string; body: string; endIndex: number } | null { + const openParenIndex = content.indexOf('(', startIndex); + if (openParenIndex === -1) return null; + + const closeParenIndex = this.findMatchingParen(content, openParenIndex); + if (closeParenIndex === -1) return null; + + const openBraceIndex = content.indexOf('{', closeParenIndex); + if (openBraceIndex === -1) return null; + + const closeBraceIndex = this.findMatchingBrace(content, openBraceIndex); + if (closeBraceIndex === -1) return null; + + return { + header: content.substring(openParenIndex + 1, closeParenIndex).trim(), + body: content.substring(openBraceIndex + 1, closeBraceIndex), + endIndex: closeBraceIndex + 1, + }; + } + + private buildForDirective(header: string, body: string): string { + const parts = header.split(';'); + const forPart = parts[0].trim(); + const trackPart = parts[1]?.trim(); + + const forMatch = forPart.match(/^\s*(\w+)\s+of\s+(.+)\s*$/); + if (!forMatch) return ``; + + const variable = forMatch[1]; + const iterable = forMatch[2].trim(); + + let ngForExpr = `let ${variable} of ${iterable}`; + if (trackPart) { + const trackMatch = trackPart.match(/^track\s+(.+)$/); + if (trackMatch) { + ngForExpr += `; trackBy: ${trackMatch[1].trim()}`; + } + } + + return `${body}`; + } + + transformSelectNgFor(content: string): string { + // Transform ng-container *ngFor inside '; + const output = templateTransformer.transformTwoWayBindings(input); + assertEqual(output, input); +}); + +test('transformAll: combined transformations', () => { + const input = ` + @if (isVisible) { +
    + {{ message() }} +
    + } + `; + const output = templateTransformer.transformAll(input); + assertContains(output, '*ngIf="isVisible"'); + assertContains(output, '[class]="myClass"'); + assertContains(output, '(click)="handleClick()"'); + assertContains(output, '[innerText]="message()"'); +}); + +// ============================================================================ +// ClassDecoratorProcessor Tests +// ============================================================================ + +console.log('\n📦 ClassDecoratorProcessor Tests\n'); + +const decoratorProcessor = new ClassDecoratorProcessor(); + +test('Component decorator: transforms to static property', async () => { + const input = ` +@Component({ + selector: 'app-test', + template: '
    Test
    ', +}) +export class TestComponent {} +`; + const result = await decoratorProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static _quarcComponent'); + assertContains(result.source, "selector: 'app-test'"); + assertContains(result.source, 'static _scopeId'); +}); + +test('Injectable decorator: transforms to static property', async () => { + const input = ` +@Injectable() +export class TestService {} +`; + const result = await decoratorProcessor.process({ + filePath: '/test/test.service.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static _quarcInjectable'); +}); + +test('Directive decorator: transforms to static property', async () => { + const input = ` +@Directive({ + selector: '[appHighlight]', +}) +export class HighlightDirective {} +`; + const result = await decoratorProcessor.process({ + filePath: '/test/highlight.directive.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static _quarcDirective'); +}); + +test('Pipe decorator: transforms to static property', async () => { + const input = ` +@Pipe({ + name: 'myPipe', +}) +export class MyPipe {} +`; + const result = await decoratorProcessor.process({ + filePath: '/test/my.pipe.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static _quarcPipe'); +}); + +// ============================================================================ +// DIProcessor Tests +// ============================================================================ + +console.log('\n📦 DIProcessor Tests\n'); + +const diProcessor = new DIProcessor(); + +test('DI: extracts constructor params', async () => { + const input = ` +export class TestComponent { + constructor(private userService: UserService, private http: HttpClient) {} +} +`; + const result = await diProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static __di_params__ = [UserService, HttpClient]'); +}); + +test('DI: includes HTMLElement param', async () => { + const input = ` +export class TestComponent { + constructor(public _nativeElement: HTMLElement, private service: MyService) {} +} +`; + const result = await diProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static __di_params__ = [HTMLElement, MyService]'); +}); + +test('DI: no params - no modification', async () => { + const input = ` +export class TestComponent { + constructor() {} +} +`; + const result = await diProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + if (result.modified !== false) { + throw new Error('Expected modified to be false'); + } +}); + +test('DI: class with extends', async () => { + const input = ` +export class ChildComponent extends BaseComponent { + constructor(private service: MyService) { + super(); + } +} +`; + const result = await diProcessor.process({ + filePath: '/test/child.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'static __di_params__ = [MyService]'); +}); + +// ============================================================================ +// SignalTransformerProcessor Tests +// ============================================================================ + +console.log('\n📦 SignalTransformerProcessor Tests\n'); + +const signalProcessor = new SignalTransformerProcessor(); + +test('Signal: transforms input()', async () => { + const input = ` +export class TestComponent { + data = input(); +} +`; + const result = await signalProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'input("data", this)'); +}); + +test('Signal: transforms output()', async () => { + const input = ` +export class TestComponent { + clicked = output(); +} +`; + const result = await signalProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'output("clicked", this)'); +}); + +test('Signal: transforms input with default value', async () => { + const input = ` +export class TestComponent { + count = input(0); +} +`; + const result = await signalProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'input("count", this, 0)'); +}); + +test('Signal: adds _nativeElement to constructor', async () => { + const input = ` +export class TestComponent { + data = input(); +} +`; + const result = await signalProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'constructor(public _nativeElement: HTMLElement)'); +}); + +test('Signal: preserves existing constructor params', async () => { + const input = ` +export class TestComponent { + data = input(); + constructor(private service: MyService) {} +} +`; + const result = await signalProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + assertContains(result.source, 'constructor(public _nativeElement: HTMLElement, private service: MyService)'); +}); + +test('Signal: does not duplicate _nativeElement', async () => { + const input = ` +export class TestComponent { + data = input(); + constructor(public _nativeElement: HTMLElement) {} +} +`; + const result = await signalProcessor.process({ + filePath: '/test/test.component.ts', + fileDir: '/test', + source: input, + }); + + const nativeElementCount = (result.source.match(/_nativeElement/g) || []).length; + if (nativeElementCount > 2) { + throw new Error(`_nativeElement appears ${nativeElementCount} times, expected max 2`); + } +}); + +// ============================================================================ +// Run all tests +// ============================================================================ + +async function runTests() { + await new Promise(resolve => setTimeout(resolve, 100)); + + console.log('\n' + '='.repeat(60)); + console.log('📊 TEST RESULTS'); + console.log('='.repeat(60)); + + let passed = 0; + let failed = 0; + + for (const result of results) { + if (result.passed) { + console.log(`✅ ${result.name}`); + passed++; + } else { + console.log(`❌ ${result.name}`); + console.log(` Error: ${result.error}`); + failed++; + } + } + + console.log('\n' + '-'.repeat(60)); + console.log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`); + console.log('-'.repeat(60)); + + if (failed > 0) { + process.exit(1); + } +} + +runTests(); diff --git a/tests/unit/test-router.ts b/tests/unit/test-router.ts new file mode 100644 index 0000000..c34c6f9 --- /dev/null +++ b/tests/unit/test-router.ts @@ -0,0 +1,622 @@ +/** + * Testy routera dla Quarc + * Sprawdzają czy routing działa poprawnie dla zagnieżdżonych route'ów + */ + +type LoadChildrenCallback = () => Promise; + +interface Route { + path?: string; + data?: object; + component?: any; + loadComponent?: () => Promise; + children?: Route[]; + loadChildren?: LoadChildrenCallback; + parent?: Route | null; +} + +interface Params { + [key: string]: any; +} + +class ActivatedRouteSnapshot { + constructor( + public path: string = '', + public params: Params = {}, + public queryParams: Params = {}, + public fragment: string | null = null, + public url: string[] = [], + public routeConfig: Route | null = null, + ) {} +} + +class ActivatedRoute implements Route { + path?: string; + data?: object; + component?: any; + loadComponent?: () => Promise; + children?: ActivatedRoute[]; + loadChildren?: LoadChildrenCallback; + parent?: ActivatedRoute | null = null; + outlet: string = 'primary'; + + private _snapshot: ActivatedRouteSnapshot = new ActivatedRouteSnapshot(); + + get snapshot(): ActivatedRouteSnapshot { + return this._snapshot; + } + + get routeConfig(): Route | null { + return this._snapshot.routeConfig ?? null; + } + + updateSnapshot( + path: string, + params: Params, + queryParams: Params, + fragment: string | null, + url: string[], + routeConfig?: Route, + ): void { + this._snapshot = new ActivatedRouteSnapshot(path, params, queryParams, fragment, url, routeConfig ?? null); + } +} + +interface MatchResult { + route: ActivatedRoute; + consumedSegments: number; + hasComponent: boolean; +} + +class RouteMatcher { + + static async findMatchingRouteAsync( + routes: Route[], + urlSegments: string[], + currentSegmentIndex: number, + parentRoute: ActivatedRoute | null, + accumulatedParams: Params, + accumulatedData: object, + ): Promise { + const remainingSegments = urlSegments.length - currentSegmentIndex; + + // Najpierw szukamy route z niepustą ścieżką, która pasuje + for (const route of routes) { + const routePath = route.path || ''; + const routeSegments = routePath.split('/').filter(segment => segment.length > 0); + + // Pomiń puste ścieżki w pierwszym przebiegu - szukamy najpierw konkretnych dopasowań + if (routeSegments.length === 0) { + continue; + } + + if (!this.doesRouteMatch(routeSegments, urlSegments, currentSegmentIndex)) { + continue; + } + + const result = await this.processRoute(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData); + if (result) { + return result; + } + } + + // Jeśli nie znaleziono dopasowania z niepustą ścieżką, szukamy route z pustą ścieżką + for (const route of routes) { + const routePath = route.path || ''; + const routeSegments = routePath.split('/').filter(segment => segment.length > 0); + + // Tylko puste ścieżki w drugim przebiegu + if (routeSegments.length !== 0) { + continue; + } + + const hasComponent = !!(route.component || route.loadComponent); + const hasChildren = !!(route.children || route.loadChildren); + + // Pusta ścieżka z komponentem pasuje tylko gdy nie ma więcej segmentów + if (hasComponent && remainingSegments > 0) { + continue; + } + + // Pusta ścieżka bez komponentu ale z children - pass-through + if (!hasComponent && hasChildren && remainingSegments > 0) { + const result = await this.processRoute(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData); + if (result) { + return result; + } + continue; + } + + // Pusta ścieżka pasuje gdy nie ma więcej segmentów + if (remainingSegments === 0) { + const result = await this.processRoute(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData); + if (result) { + return result; + } + } + } + + return null; + } + + private static async processRoute( + route: Route, + routeSegments: string[], + urlSegments: string[], + currentSegmentIndex: number, + parentRoute: ActivatedRoute | null, + accumulatedParams: Params, + accumulatedData: object, + ): Promise { + const params: Params = { ...accumulatedParams }; + this.extractParams(routeSegments, urlSegments, currentSegmentIndex, params); + + const data = { ...accumulatedData, ...route.data }; + const nextSegmentIndex = currentSegmentIndex + routeSegments.length; + const hasComponent = !!(route.component || route.loadComponent); + + if (hasComponent) { + const activatedRoute = this.createActivatedRoute( + route, + params, + data, + urlSegments, + currentSegmentIndex, + routeSegments.length, + parentRoute, + ); + return { route: activatedRoute, consumedSegments: nextSegmentIndex, hasComponent: true }; + } + + let children: Route[] = []; + if (route.children) { + children = route.children; + } else if (route.loadChildren) { + children = await route.loadChildren(); + } + + if (children.length > 0) { + const intermediateRoute = this.createActivatedRoute( + route, + params, + data, + urlSegments, + currentSegmentIndex, + routeSegments.length, + parentRoute, + ); + + const childResult = await this.findMatchingRouteAsync( + children, + urlSegments, + nextSegmentIndex, + intermediateRoute, + params, + data, + ); + + if (childResult) { + return childResult; + } + } + + return null; + } + + private static createActivatedRoute( + route: Route, + params: Params, + data: object, + urlSegments: string[], + startIndex: number, + segmentCount: number, + parentRoute: ActivatedRoute | null, + ): ActivatedRoute { + const activatedRoute = new ActivatedRoute(); + activatedRoute.path = route.path; + activatedRoute.component = route.component; + activatedRoute.loadComponent = route.loadComponent; + activatedRoute.loadChildren = route.loadChildren; + activatedRoute.data = data; + activatedRoute.parent = parentRoute; + + if (route.children) { + activatedRoute.children = route.children as ActivatedRoute[]; + } + + activatedRoute.updateSnapshot( + route.path ?? '', + params, + {}, + null, + urlSegments.slice(startIndex, startIndex + segmentCount), + route, + ); + + route.parent = parentRoute ?? undefined; + + return activatedRoute; + } + + private static doesRouteMatch(routeSegments: string[], urlSegments: string[], startIndex: number): boolean { + if (routeSegments.length === 0 && startIndex >= urlSegments.length) { + return true; + } + + if (startIndex + routeSegments.length > urlSegments.length) { + return false; + } + + for (let i = 0; i < routeSegments.length; i++) { + const routeSegment = routeSegments[i]; + const urlSegment = urlSegments[startIndex + i]; + + if (routeSegment.startsWith(':')) { + continue; + } + + if (routeSegment !== urlSegment) { + return false; + } + } + + return true; + } + + private static extractParams(routeSegments: string[], urlSegments: string[], startIndex: number, params: Record): void { + for (let i = 0; i < routeSegments.length; i++) { + const routeSegment = routeSegments[i]; + const urlSegment = urlSegments[startIndex + i]; + + if (routeSegment.startsWith(':')) { + const paramName = routeSegment.substring(1); + params[paramName] = urlSegment; + } + } + } +} + +console.log('=== TESTY ROUTERA QUARC ===\n'); + +let passedTests = 0; +let failedTests = 0; + +async function test(name: string, fn: () => Promise | boolean): Promise { + try { + const result = await fn(); + if (result) { + console.log(`✅ ${name}`); + passedTests++; + } else { + console.log(`❌ ${name}`); + failedTests++; + } + } catch (e) { + console.log(`❌ ${name} - Error: ${e}`); + failedTests++; + } +} + +class MockComponent {} +class AdminDashboardComponent {} + +(async () => { + +// Test 1: Prosty route z komponentem +await test('Prosty route z komponentem dla /', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}); + return result !== null && result.route.component === MockComponent; +}); + +// Test 2: Route z path 'admin' i komponentem +await test('Route z path admin i komponentem dla /admin', async () => { + const routes: Route[] = [ + { path: 'admin', component: MockComponent }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + return result !== null && result.route.component === MockComponent; +}); + +// Test 3: Route admin bez komponentu z loadChildren -> children -> component +await test('Route admin > loadChildren > children > component dla /admin', async () => { + const routes: Route[] = [ + { + path: 'admin', + loadChildren: async () => [ + { + path: '', + children: [ + { path: '', component: AdminDashboardComponent }, + ], + }, + ], + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + return result !== null && result.route.component === AdminDashboardComponent; +}); + +// Test 4: Route admin bez komponentu z children -> component +await test('Route admin > children > component dla /admin', async () => { + const routes: Route[] = [ + { + path: 'admin', + children: [ + { path: '', component: AdminDashboardComponent }, + ], + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + return result !== null && result.route.component === AdminDashboardComponent; +}); + +// Test 5: Głęboko zagnieżdżony route bez komponentów po drodze +await test('Głęboko zagnieżdżony route admin > empty > empty > component', async () => { + const routes: Route[] = [ + { + path: 'admin', + children: [ + { + path: '', + children: [ + { + path: '', + children: [ + { path: '', component: AdminDashboardComponent }, + ], + }, + ], + }, + ], + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + return result !== null && result.route.component === AdminDashboardComponent; +}); + +// Test 6: Scalanie params z parentów +await test('Scalanie params z parentów', async () => { + const routes: Route[] = [ + { + path: 'users/:userId', + children: [ + { + path: 'posts/:postId', + component: MockComponent, + }, + ], + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync( + routes, + ['users', '123', 'posts', '456'], + 0, + null, + {}, + {}, + ); + + if (!result) return false; + const params = result.route.snapshot.params; + return params['userId'] === '123' && params['postId'] === '456'; +}); + +// Test 7: Scalanie data z parentów +await test('Scalanie data z parentów', async () => { + const routes: Route[] = [ + { + path: 'admin', + data: { role: 'admin' }, + children: [ + { + path: '', + data: { section: 'dashboard' }, + component: MockComponent, + }, + ], + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + if (!result) return false; + const data = result.route.data as { role?: string; section?: string }; + return data.role === 'admin' && data.section === 'dashboard'; +}); + +// Test 8: Parent jest ustawiony poprawnie +await test('Parent jest ustawiony poprawnie', async () => { + const routes: Route[] = [ + { + path: 'admin', + children: [ + { path: '', component: MockComponent }, + ], + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + if (!result) return false; + return result.route.parent !== null && + result.route.parent !== undefined && + result.route.parent.path === 'admin'; +}); + +// Test 9: Route z loadChildren async +await test('Route z loadChildren async', async () => { + const routes: Route[] = [ + { + path: 'lazy', + loadChildren: async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return [ + { path: '', component: MockComponent }, + ]; + }, + }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['lazy'], 0, null, {}, {}); + return result !== null && result.route.component === MockComponent; +}); + +// Test 10: Nie pasuje gdy ścieżka nie istnieje +await test('Nie pasuje gdy ścieżka nie istnieje', async () => { + const routes: Route[] = [ + { path: 'admin', component: MockComponent }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['unknown'], 0, null, {}, {}); + return result === null; +}); + +// Test 11: Route z pustą ścieżką na root +await test('Route z pustą ścieżką na root /', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + { path: 'admin', component: AdminDashboardComponent }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}); + return result !== null && result.route.component === MockComponent; +}); + +// Test 12: Wybiera właściwy route gdy jest wiele opcji +await test('Wybiera właściwy route admin gdy jest wiele opcji', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + { path: 'admin', component: AdminDashboardComponent }, + ]; + + const result = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + return result !== null && result.route.component === AdminDashboardComponent; +}); + +// Test 13: Przełączanie z / na /admin - różne komponenty +await test('Przełączanie z / na /admin zwraca różne komponenty', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + { + path: 'admin', + loadChildren: async () => [ + { path: '', component: AdminDashboardComponent }, + ], + }, + ]; + + const resultRoot = await RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}); + const resultAdmin = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + + if (!resultRoot || !resultAdmin) return false; + + return resultRoot.route.component === MockComponent && + resultAdmin.route.component === AdminDashboardComponent && + resultRoot.route.component !== resultAdmin.route.component; +}); + +// Test 14: Przełączanie z /admin na / - różne komponenty +await test('Przełączanie z /admin na / zwraca różne komponenty', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + { + path: 'admin', + loadChildren: async () => [ + { path: '', component: AdminDashboardComponent }, + ], + }, + ]; + + const resultAdmin = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + const resultRoot = await RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}); + + if (!resultRoot || !resultAdmin) return false; + + return resultAdmin.route.component === AdminDashboardComponent && + resultRoot.route.component === MockComponent; +}); + +// Test 15: Oba route mają path '' ale różne komponenty - rozróżnienie po parent +await test('Route z path empty ale różnymi parentami mają różne komponenty', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + { + path: 'admin', + children: [ + { path: '', component: AdminDashboardComponent }, + ], + }, + ]; + + const resultRoot = await RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}); + const resultAdmin = await RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}); + + if (!resultRoot || !resultAdmin) return false; + + // Oba mają path '', ale różne komponenty + const rootPath = resultRoot.route.path; + const adminPath = resultAdmin.route.path; + + return resultRoot.route.component === MockComponent && + resultAdmin.route.component === AdminDashboardComponent && + rootPath === '' && + adminPath === ''; +}); + +// Test 16: Symulacja zmiany route - sprawdzenie czy komponent się zmienia +await test('Symulacja nawigacji - komponent zmienia się przy zmianie URL', async () => { + const routes: Route[] = [ + { path: '', component: MockComponent }, + { + path: 'admin', + loadChildren: async () => [ + { path: '', component: AdminDashboardComponent }, + ], + }, + { + path: 'users', + children: [ + { path: '', component: MockComponent }, + ], + }, + ]; + + // Nawigacja: / -> /admin -> /users -> / + const results = await Promise.all([ + RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}), + RouteMatcher.findMatchingRouteAsync(routes, ['admin'], 0, null, {}, {}), + RouteMatcher.findMatchingRouteAsync(routes, ['users'], 0, null, {}, {}), + RouteMatcher.findMatchingRouteAsync(routes, [], 0, null, {}, {}), + ]); + + if (results.some(r => r === null)) return false; + + const [r1, r2, r3, r4] = results; + + return r1!.route.component === MockComponent && + r2!.route.component === AdminDashboardComponent && + r3!.route.component === MockComponent && + r4!.route.component === MockComponent; +}); + +console.log('\n=== PODSUMOWANIE ==='); +console.log(`✅ Testy zaliczone: ${passedTests}`); +console.log(`❌ Testy niezaliczone: ${failedTests}`); +console.log(`📊 Procent sukcesu: ${((passedTests / (passedTests + failedTests)) * 100).toFixed(1)}%`); + +if (failedTests === 0) { + console.log('\n🎉 Wszystkie testy przeszły pomyślnie!'); + process.exit(0); +} else { + console.log('\n⚠️ Niektóre testy nie przeszły. Sprawdź implementację.'); + process.exit(1); +} + +})(); diff --git a/tests/unit/test-signals-reactivity.ts b/tests/unit/test-signals-reactivity.ts new file mode 100644 index 0000000..a0a6e25 --- /dev/null +++ b/tests/unit/test-signals-reactivity.ts @@ -0,0 +1,478 @@ +#!/usr/bin/env node + +// Polyfill window dla Node.js +(global as any).window = (global as any).window || { __quarc: {} }; +(global as any).window.__quarc = (global as any).window.__quarc || {}; + +import { signal, computed, effect, WritableSignal, Signal, EffectRef } from '../../core/angular/signals'; + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +function test(name: string, fn: () => void | Promise): void { + try { + const result = fn(); + if (result instanceof Promise) { + result + .then(() => results.push({ name, passed: true })) + .catch((e) => results.push({ name, passed: false, error: String(e) })); + } else { + results.push({ name, passed: true }); + } + } catch (e) { + results.push({ name, passed: false, error: String(e) }); + } +} + +function assertEqual(actual: T, expected: T, message?: string): void { + if (actual !== expected) { + throw new Error( + `${message || 'Assertion failed'}\nExpected: ${expected}\nActual: ${actual}`, + ); + } +} + +function assertTrue(condition: boolean, message?: string): void { + if (!condition) { + throw new Error(message || 'Expected condition to be true'); + } +} + +// ============================================================================ +// Signal Tests +// ============================================================================ + +console.log('\n=== TESTY SYGNAŁÓW I REAKTYWNOŚCI ===\n'); + +test('signal: tworzy sygnał z wartością początkową', () => { + const count = signal(0); + assertEqual(count(), 0); +}); + +test('signal: set zmienia wartość', () => { + const count = signal(0); + count.set(5); + assertEqual(count(), 5); +}); + +test('signal: update modyfikuje wartość', () => { + const count = signal(10); + count.update(v => v + 5); + assertEqual(count(), 15); +}); + +test('signal: asReadonly zwraca readonly signal', () => { + const count = signal(0); + const readonly = count.asReadonly(); + assertEqual(readonly(), 0); + count.set(10); + assertEqual(readonly(), 10); + assertTrue(!('set' in readonly), 'readonly signal nie powinien mieć metody set'); +}); + +test('signal: equal option zapobiega niepotrzebnym aktualizacjom', () => { + let updateCount = 0; + const obj = signal({ id: 1 }, { equal: (a, b) => a.id === b.id }); + + effect(() => { + obj(); + updateCount++; + }); + + // Poczekaj na pierwszy effect + return new Promise(resolve => { + setTimeout(() => { + const initialCount = updateCount; + obj.set({ id: 1 }); // Ten sam id - nie powinno triggerować + + setTimeout(() => { + assertEqual(updateCount, initialCount, 'Nie powinno być dodatkowych aktualizacji'); + resolve(); + }, 50); + }, 50); + }); +}); + +// ============================================================================ +// Computed Tests +// ============================================================================ + +test('computed: oblicza wartość z sygnałów', () => { + const a = signal(2); + const b = signal(3); + const sum = computed(() => a() + b()); + assertEqual(sum(), 5); +}); + +test('computed: aktualizuje się gdy zależności się zmieniają', () => { + const a = signal(2); + const b = signal(3); + const sum = computed(() => a() + b()); + + assertEqual(sum(), 5); + a.set(10); + + // Computed używa microtask do ustawienia isDirty, więc musimy poczekać + return new Promise(resolve => { + setTimeout(() => { + assertEqual(sum(), 13); + resolve(); + }, 10); + }); +}); + +test('computed: cachuje wartość', () => { + let computeCount = 0; + const a = signal(1); + const doubled = computed(() => { + computeCount++; + return a() * 2; + }); + + doubled(); + doubled(); + doubled(); + + assertEqual(computeCount, 1, 'Computed powinien być wywołany tylko raz'); +}); + +test('computed: zagnieżdżone computed', () => { + const a = signal(2); + const doubled = computed(() => a() * 2); + const quadrupled = computed(() => doubled() * 2); + + assertEqual(quadrupled(), 8); +}); + +// ============================================================================ +// Effect Tests +// ============================================================================ + +test('effect: uruchamia się przy pierwszym wywołaniu', () => { + let ran = false; + effect(() => { + ran = true; + }); + + return new Promise(resolve => { + setTimeout(() => { + assertTrue(ran, 'Effect powinien się uruchomić'); + resolve(); + }, 50); + }); +}); + +test('effect: reaguje na zmiany sygnału', () => { + const count = signal(0); + let effectValue = -1; + + effect(() => { + effectValue = count(); + }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(effectValue, 0); + count.set(5); + + setTimeout(() => { + assertEqual(effectValue, 5); + resolve(); + }, 50); + }, 50); + }); +}); + +test('effect: destroy zatrzymuje reakcje', () => { + const count = signal(0); + let effectValue = -1; + + const ref = effect(() => { + effectValue = count(); + }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(effectValue, 0); + ref.destroy(); + count.set(100); + + setTimeout(() => { + assertEqual(effectValue, 0, 'Effect nie powinien reagować po destroy'); + resolve(); + }, 50); + }, 50); + }); +}); + +test('effect: śledzi wiele sygnałów', () => { + const a = signal(1); + const b = signal(2); + let sum = 0; + + effect(() => { + sum = a() + b(); + }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(sum, 3); + a.set(10); + + setTimeout(() => { + assertEqual(sum, 12); + b.set(20); + + setTimeout(() => { + assertEqual(sum, 30); + resolve(); + }, 50); + }, 50); + }, 50); + }); +}); + +test('effect: reaguje na computed', () => { + const a = signal(2); + const doubled = computed(() => a() * 2); + let effectValue = 0; + + effect(() => { + effectValue = doubled(); + }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(effectValue, 4); + a.set(5); + + setTimeout(() => { + assertEqual(effectValue, 10); + resolve(); + }, 50); + }, 50); + }); +}); + +// ============================================================================ +// Granular Reactivity Tests +// ============================================================================ + +test('granular: wiele effects na tym samym sygnale', () => { + const count = signal(0); + let effect1Value = -1; + let effect2Value = -1; + + effect(() => { effect1Value = count(); }); + effect(() => { effect2Value = count() * 2; }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(effect1Value, 0); + assertEqual(effect2Value, 0); + count.set(5); + + setTimeout(() => { + assertEqual(effect1Value, 5); + assertEqual(effect2Value, 10); + resolve(); + }, 50); + }, 50); + }); +}); + +test('granular: niezależne sygnały nie wpływają na siebie', () => { + const a = signal(1); + const b = signal(2); + let aEffectCount = 0; + let bEffectCount = 0; + + effect(() => { a(); aEffectCount++; }); + effect(() => { b(); bEffectCount++; }); + + return new Promise(resolve => { + setTimeout(() => { + const initialA = aEffectCount; + const initialB = bEffectCount; + + a.set(10); + + setTimeout(() => { + assertEqual(aEffectCount, initialA + 1, 'Effect A powinien się uruchomić'); + assertEqual(bEffectCount, initialB, 'Effect B nie powinien się uruchomić'); + resolve(); + }, 50); + }, 50); + }); +}); + +// ============================================================================ +// Template Rendering Scenario Tests +// ============================================================================ + +test('template: computed aktualizuje się synchronicznie po set na signal', () => { + const containerDimensions = signal({ width: 0, height: 0 }); + const sizeAttribute = computed(() => { + const size = containerDimensions(); + return `${size.width} x ${size.height}`; + }); + + assertEqual(sizeAttribute(), '0 x 0'); + + containerDimensions.set({ width: 100, height: 200 }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(sizeAttribute(), '100 x 200', 'Computed powinien mieć nową wartość'); + resolve(); + }, 50); + }); +}); + +test('template: effect reaguje na zmianę computed który zależy od signal', () => { + const containerDimensions = signal({ width: 0, height: 0 }); + const sizeAttribute = computed(() => { + const size = containerDimensions(); + return `${size.width} x ${size.height}`; + }); + + let effectValue = ''; + let effectRunCount = 0; + + effect(() => { + effectValue = sizeAttribute(); + effectRunCount++; + }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(effectValue, '0 x 0'); + assertEqual(effectRunCount, 1); + + containerDimensions.set({ width: 100, height: 200 }); + + setTimeout(() => { + assertEqual(effectValue, '100 x 200', 'Effect powinien mieć nową wartość z computed'); + assertEqual(effectRunCount, 2, 'Effect powinien się uruchomić ponownie'); + resolve(); + }, 50); + }, 50); + }); +}); + +test('template: łańcuch signal -> computed -> computed -> effect', () => { + const base = signal(10); + const doubled = computed(() => base() * 2); + const quadrupled = computed(() => doubled() * 2); + + let effectValue = 0; + + effect(() => { + effectValue = quadrupled(); + }); + + return new Promise(resolve => { + setTimeout(() => { + assertEqual(effectValue, 40); + + base.set(5); + + setTimeout(() => { + assertEqual(effectValue, 20, 'Effect powinien reagować na zmianę w łańcuchu'); + resolve(); + }, 50); + }, 50); + }); +}); + +test('template: wielokrotne zmiany signal w krótkim czasie', () => { + const count = signal(0); + const doubled = computed(() => count() * 2); + + let effectValues: number[] = []; + + effect(() => { + effectValues.push(doubled()); + }); + + return new Promise(resolve => { + setTimeout(() => { + count.set(1); + count.set(2); + count.set(3); + + setTimeout(() => { + const lastValue = effectValues[effectValues.length - 1]; + assertEqual(lastValue, 6, 'Ostatnia wartość powinna być 6'); + resolve(); + }, 100); + }, 50); + }); +}); + +test('template: computed z obiektem - Object.is comparison', () => { + const dimensions = signal({ width: 0, height: 0 }); + let computeCount = 0; + + const formatted = computed(() => { + computeCount++; + const d = dimensions(); + return `${d.width}x${d.height}`; + }); + + assertEqual(formatted(), '0x0'); + assertEqual(computeCount, 1); + + // Ustawienie tego samego obiektu - Object.is zwróci false bo to nowy obiekt + dimensions.set({ width: 0, height: 0 }); + + return new Promise(resolve => { + setTimeout(() => { + formatted(); // Wymuszamy odczyt + assertEqual(computeCount, 2, 'Computed powinien się przeliczyć dla nowego obiektu'); + resolve(); + }, 50); + }); +}); + +// ============================================================================ +// Run all tests +// ============================================================================ + +async function runTests() { + await new Promise(resolve => setTimeout(resolve, 500)); + + console.log('\n=== PODSUMOWANIE ==='); + + let passed = 0; + let failed = 0; + + for (const result of results) { + if (result.passed) { + console.log(`✅ ${result.name}`); + passed++; + } else { + console.log(`❌ ${result.name}`); + console.log(` Error: ${result.error}`); + failed++; + } + } + + console.log(`\n✅ Testy zaliczone: ${passed}`); + console.log(`❌ Testy niezaliczone: ${failed}`); + console.log(`📊 Procent sukcesu: ${((passed / results.length) * 100).toFixed(1)}%`); + + if (failed === 0) { + console.log('\n🎉 Wszystkie testy przeszły pomyślnie!\n'); + } else { + console.log('\n❌ Niektóre testy nie przeszły.\n'); + process.exit(1); + } +} + +runTests(); diff --git a/tests/unit/test-style-injection.html b/tests/unit/test-style-injection.html new file mode 100644 index 0000000..2ff369a --- /dev/null +++ b/tests/unit/test-style-injection.html @@ -0,0 +1,81 @@ + + + + + + Test wstrzykiwania stylów - Quarc + + + +

    🧪 Test wstrzykiwania stylów - Quarc Framework

    +
    +

    Ten test sprawdza czy style są poprawnie wstrzykiwane z transformacją :host na [_nghost-scopeId]

    +

    Uwaga: Otwórz konsolę przeglądarki (F12) aby zobaczyć szczegółowe logi.

    +
    +
    + + + + diff --git a/tests/unit/test-style-injection.ts b/tests/unit/test-style-injection.ts new file mode 100644 index 0000000..fc88cd8 --- /dev/null +++ b/tests/unit/test-style-injection.ts @@ -0,0 +1,299 @@ +/** + * Test wstrzykiwania stylów z transformacją :host + */ + +import { WebComponent } from '../../core/module/web-component'; +import { IComponent, ViewEncapsulation } from '../../core/module/component'; +import { ComponentType } from '../../core/module/type'; + +console.log('=== TEST WSTRZYKIWANIA STYLÓW ===\n'); + +let passedTests = 0; +let failedTests = 0; + +// Funkcja pomocnicza do tworzenia mock komponentów z _scopeId jako właściwością statyczną klasy +function createMockComponent(options: { + selector: string; + template: string; + style?: string; + encapsulation?: ViewEncapsulation; + scopeId: string; +}): { type: ComponentType; instance: IComponent } { + // Tworzymy klasę z statycznymi właściwościami + class MockComponent implements IComponent { + static _quarcComponent = [{ + selector: options.selector, + template: options.template, + style: options.style || '', + encapsulation: options.encapsulation || ViewEncapsulation.Emulated, + }]; + static _scopeId = options.scopeId; + } + + return { + type: MockComponent as unknown as ComponentType, + instance: new MockComponent(), + }; +} + +function test(name: string, fn: () => boolean | Promise): void { + Promise.resolve(fn()).then(result => { + if (result) { + console.log(`✅ ${name}`); + passedTests++; + } else { + console.log(`❌ ${name}`); + failedTests++; + } + }).catch(e => { + console.log(`❌ ${name} - Error: ${e}`); + failedTests++; + }); +} + +// Mock document jeśli nie istnieje (dla środowiska Node.js) +if (typeof document === 'undefined') { + console.log('⚠️ Testy wymagają środowiska przeglądarki (JSDOM)'); + console.log('Uruchom testy w przeglądarce lub zainstaluj jsdom: npm install --save-dev jsdom'); +} + +// Test 1: Transformacja :host na [_nghost-scopeId] +test('Transformacja :host na [_nghost-scopeId]', () => { + const { type, instance } = createMockComponent({ + selector: 'test-component', + template: '
    Test
    ', + style: ':host { display: block; }', + encapsulation: ViewEncapsulation.Emulated, + scopeId: 'test123', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + // Sprawdź czy style zostały wstrzyknięte do head + const styleElements = document.head.querySelectorAll('style[data-scope-id="test123"]'); + if (styleElements.length === 0) return false; + + const styleContent = styleElements[0].textContent || ''; + + // Sprawdź czy :host został zamieniony na [_nghost-test123] + return styleContent.includes('[_nghost-test123]') && + !styleContent.includes(':host') && + styleContent.includes('display: block'); +}); + +// Test 2: Transformacja :host() z selektorem +test('Transformacja :host() z selektorem', () => { + const { type, instance } = createMockComponent({ + selector: 'test-component', + template: '
    Test
    ', + style: ':host(.active) { background: red; }', + encapsulation: ViewEncapsulation.Emulated, + scopeId: 'test456', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + const styleElements = document.head.querySelectorAll('style[data-scope-id="test456"]'); + if (styleElements.length === 0) return false; + + const styleContent = styleElements[0].textContent || ''; + + // Sprawdź czy :host(.active) został zamieniony na [_nghost-test456].active + return styleContent.includes('[_nghost-test456].active') && + !styleContent.includes(':host') && + styleContent.includes('background: red'); +}); + +// Test 3: Wiele wystąpień :host w jednym pliku +test('Wiele wystąpień :host', () => { + const { type, instance } = createMockComponent({ + selector: 'test-component', + template: '
    Test
    ', + style: ':host { display: block; } :host(.active) { color: blue; } :host:hover { opacity: 0.8; }', + encapsulation: ViewEncapsulation.Emulated, + scopeId: 'test789', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + const styleElements = document.head.querySelectorAll('style[data-scope-id="test789"]'); + if (styleElements.length === 0) return false; + + const styleContent = styleElements[0].textContent || ''; + + return styleContent.includes('[_nghost-test789]') && + styleContent.includes('[_nghost-test789].active') && + styleContent.includes('[_nghost-test789]:hover') && + !styleContent.includes(':host ') && + !styleContent.includes(':host.') && + !styleContent.includes(':host:'); +}); + +// Test 4: ShadowDom - style bez transformacji +test('ShadowDom: style bez transformacji :host', () => { + const { type, instance } = createMockComponent({ + selector: 'test-component', + template: '
    Test
    ', + style: ':host { display: flex; }', + encapsulation: ViewEncapsulation.ShadowDom, + scopeId: 'shadow123', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + // Dla ShadowDom style powinny być w shadow root, nie w head + const styleElements = document.head.querySelectorAll('style[data-scope-id="shadow123"]'); + + // Nie powinno być żadnych stylów w head dla ShadowDom + return styleElements.length === 0; +}); + +// Test 5: ViewEncapsulation.None - style bez transformacji +test('ViewEncapsulation.None: style bez transformacji', () => { + const { type, instance } = createMockComponent({ + selector: 'test-component', + template: '
    Test
    ', + style: ':host { display: inline; }', + encapsulation: ViewEncapsulation.None, + scopeId: 'none123', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + // Dla None style są dodawane bezpośrednio do komponentu + const styleElements = webComponent.querySelectorAll('style'); + + if (styleElements.length === 0) return false; + + const styleContent = styleElements[0].textContent || ''; + + // Style powinny pozostać nietknięte (z :host) + return styleContent.includes(':host'); +}); + +// Test 6: Atrybut _nghost-scopeId na elemencie hosta +test('Atrybut _nghost-scopeId na elemencie hosta', () => { + const { type, instance } = createMockComponent({ + selector: 'test-component', + template: '
    Test
    ', + style: ':host { display: block; }', + encapsulation: ViewEncapsulation.Emulated, + scopeId: 'host123', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + // Sprawdź czy element ma atrybut _nghost-host123 + return webComponent.hasAttribute('_nghost-host123'); +}); + +// Test 7: Złożone selektory :host +test('Złożone selektory :host', () => { + const { type, instance } = createMockComponent({ + selector: 'test-complex', + template: '
    Complex
    ', + style: ':host { display: flex; } :host:hover { background: blue; } :host(.active) .inner { color: red; }', + encapsulation: ViewEncapsulation.Emulated, + scopeId: 'complex123', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + const styleElements = document.head.querySelectorAll('style[data-scope-id="complex123"]'); + if (styleElements.length === 0) return false; + + const styleContent = styleElements[0].textContent || ''; + + return styleContent.includes('[_nghost-complex123]') && + styleContent.includes('[_nghost-complex123]:hover') && + styleContent.includes('[_nghost-complex123].active .inner') && + !styleContent.includes(':host ') && + !styleContent.includes(':host.') && + !styleContent.includes(':host:'); +}); + +// Test 8: Brak transformacji dla ViewEncapsulation.ShadowDom +test('Brak transformacji dla ViewEncapsulation.ShadowDom', () => { + const { type, instance } = createMockComponent({ + selector: 'test-shadow', + template: '
    Shadow
    ', + style: ':host { display: block; }', + encapsulation: ViewEncapsulation.ShadowDom, + scopeId: 'shadow789', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + // Dla ShadowDom style powinny być w shadow root, nie w head + const styleElements = document.head.querySelectorAll('style[data-scope-id="shadow789"]'); + + // Nie powinno być żadnych stylów w head dla ShadowDom + return styleElements.length === 0; +}); + +// Test 9: Brak transformacji dla ViewEncapsulation.None +test('Brak transformacji dla ViewEncapsulation.None', () => { + const { type, instance } = createMockComponent({ + selector: 'test-none', + template: '
    None
    ', + style: ':host { display: block; }', + encapsulation: ViewEncapsulation.None, + scopeId: 'none123', + }); + + const webComponent = new WebComponent(); + webComponent.setComponentInstance(instance, type); + + // Dla None style są dodawane bezpośrednio do komponentu + const styleElements = webComponent.querySelectorAll('style'); + + if (styleElements.length === 0) return false; + + const styleContent = styleElements[0].textContent || ''; + + // Style powinny pozostać nietknięte (z :host) + return styleContent.includes(':host'); +}); + +// Test 10: Komponent bez stylów +test('Komponent bez stylów', () => { + const { type, instance } = createMockComponent({ + selector: 'test-no-style', + template: '
    No styles
    ', + encapsulation: ViewEncapsulation.Emulated, + scopeId: 'nostyle789', + }); + + const webComponent1 = new WebComponent(); + webComponent1.setComponentInstance(instance, type); + + const webComponent2 = new WebComponent(); + webComponent2.setComponentInstance(instance, type); + + // Powinien być tylko jeden element style dla tego scopeId + const styleElements = document.head.querySelectorAll('style[data-scope-id="unique123"]'); + + return styleElements.length === 1; +}); + +// Poczekaj na zakończenie wszystkich testów +setTimeout(() => { + console.log('\n=== PODSUMOWANIE ==='); + console.log(`✅ Testy zaliczone: ${passedTests}`); + console.log(`❌ Testy niezaliczone: ${failedTests}`); + console.log(`📊 Procent sukcesu: ${((passedTests / (passedTests + failedTests)) * 100).toFixed(1)}%`); + + if (failedTests === 0) { + console.log('\n🎉 Wszystkie testy przeszły pomyślnie!'); + } else { + console.log('\n⚠️ Niektóre testy nie przeszły. Sprawdź implementację.'); + } +}, 1000); diff --git a/tests/unit/test-template-reactivity.ts b/tests/unit/test-template-reactivity.ts new file mode 100644 index 0000000..7b99477 --- /dev/null +++ b/tests/unit/test-template-reactivity.ts @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +// Polyfill window dla Node.js +(global as any).window = (global as any).window || { __quarc: {} }; +(global as any).window.__quarc = (global as any).window.__quarc || {}; + +import { signal, computed, effect } from '../../core/angular/signals'; + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +function test(name: string, fn: () => void | Promise): void { + try { + const result = fn(); + if (result instanceof Promise) { + result + .then(() => results.push({ name, passed: true })) + .catch((e) => results.push({ name, passed: false, error: String(e) })); + } else { + results.push({ name, passed: true }); + } + } catch (e) { + results.push({ name, passed: false, error: String(e) }); + } +} + +function assertEqual(actual: T, expected: T, message?: string): void { + if (actual !== expected) { + throw new Error( + `${message || 'Assertion failed'}\nExpected: ${expected}\nActual: ${actual}`, + ); + } +} + +function assertTrue(condition: boolean, message?: string): void { + if (!condition) { + throw new Error(message || 'Expected condition to be true'); + } +} + +// Symulacja DOM dla testów +class MockElement { + attributes: Record = {}; + textContent = ''; + + setAttribute(name: string, value: string): void { + this.attributes[name] = value; + } + + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } +} + +console.log('\n=== TESTY REAKTYWNOŚCI TEMPLATE ===\n'); + +test('template-reactivity: atrybut aktualizuje się po zmianie sygnału', () => { + const mockElement = new MockElement(); + const size = signal({ width: 0, height: 0 }); + const sizeAttr = computed(() => `${size().width}x${size().height}`); + + effect(() => { + mockElement.setAttribute('size', sizeAttr()); + }); + + assertEqual(mockElement.getAttribute('size'), '0x0', 'Początkowa wartość atrybutu'); + + size.set({ width: 100, height: 200 }); + + assertEqual(mockElement.getAttribute('size'), '100x200', 'Atrybut powinien się zaktualizować po set()'); +}); + +test('template-reactivity: textContent aktualizuje się po zmianie sygnału', () => { + const mockElement = new MockElement(); + const count = signal(0); + + effect(() => { + mockElement.textContent = `Count: ${count()}`; + }); + + assertEqual(mockElement.textContent, 'Count: 0', 'Początkowa wartość textContent'); + + count.set(5); + + assertEqual(mockElement.textContent, 'Count: 5', 'textContent powinien się zaktualizować po set()'); +}); + +test('template-reactivity: łańcuch signal -> computed -> effect aktualizuje DOM', () => { + const mockElement = new MockElement(); + const items = signal<{ id: number }[]>([]); + const itemCount = computed(() => items().length); + + effect(() => { + mockElement.setAttribute('count', String(itemCount())); + }); + + assertEqual(mockElement.getAttribute('count'), '0', 'Początkowa liczba elementów'); + + items.set([{ id: 1 }, { id: 2 }, { id: 3 }]); + + assertEqual(mockElement.getAttribute('count'), '3', 'Liczba elementów powinna się zaktualizować'); +}); + +test('template-reactivity: wielokrotne zmiany sygnału aktualizują DOM', () => { + const mockElement = new MockElement(); + const value = signal('initial'); + + effect(() => { + mockElement.setAttribute('value', value()); + }); + + assertEqual(mockElement.getAttribute('value'), 'initial'); + + value.set('first'); + assertEqual(mockElement.getAttribute('value'), 'first'); + + value.set('second'); + assertEqual(mockElement.getAttribute('value'), 'second'); + + value.set('third'); + assertEqual(mockElement.getAttribute('value'), 'third'); +}); + +test('template-reactivity: computed z obiektem aktualizuje DOM po zmianie', () => { + const mockElement = new MockElement(); + const layout = signal({ lines: [] as { id: number }[], width: 0, height: 0 }); + const lineCount = computed(() => layout().lines.length); + + effect(() => { + mockElement.setAttribute('lines', String(lineCount())); + }); + + assertEqual(mockElement.getAttribute('lines'), '0'); + + layout.set({ + lines: [{ id: 1 }, { id: 2 }], + width: 100, + height: 200, + }); + + assertEqual(mockElement.getAttribute('lines'), '2', 'Liczba linii powinna się zaktualizować'); +}); + +test('template-reactivity: wiele effectów na tym samym sygnale aktualizują różne elementy', () => { + const el1 = new MockElement(); + const el2 = new MockElement(); + const value = signal(10); + + effect(() => { + el1.setAttribute('value', String(value())); + }); + + effect(() => { + el2.setAttribute('doubled', String(value() * 2)); + }); + + assertEqual(el1.getAttribute('value'), '10'); + assertEqual(el2.getAttribute('doubled'), '20'); + + value.set(25); + + assertEqual(el1.getAttribute('value'), '25'); + assertEqual(el2.getAttribute('doubled'), '50'); +}); + +test('template-reactivity: destroy effectu zatrzymuje aktualizacje DOM', () => { + const mockElement = new MockElement(); + const value = signal('start'); + + const effectRef = effect(() => { + mockElement.setAttribute('value', value()); + }); + + assertEqual(mockElement.getAttribute('value'), 'start'); + + value.set('changed'); + assertEqual(mockElement.getAttribute('value'), 'changed'); + + effectRef.destroy(); + + value.set('after-destroy'); + assertEqual(mockElement.getAttribute('value'), 'changed', 'Wartość nie powinna się zmienić po destroy'); +}); + +test('template-reactivity: zagnieżdżone computed aktualizują DOM', () => { + const mockElement = new MockElement(); + const base = signal(5); + const doubled = computed(() => base() * 2); + const quadrupled = computed(() => doubled() * 2); + const formatted = computed(() => `Value: ${quadrupled()}`); + + effect(() => { + mockElement.textContent = formatted(); + }); + + assertEqual(mockElement.textContent, 'Value: 20'); + + base.set(10); + + assertEqual(mockElement.textContent, 'Value: 40'); +}); + +test('template-reactivity: symulacja scenariusza z camera-list', () => { + const mockContainer = new MockElement(); + const mockPre = new MockElement(); + + const containerDimensions = signal({ width: 0, height: 0 }); + const layout = signal({ lines: [] as { id: number }[], width: 0, height: 0, mismatch: 0 }); + + const sizeAttribute = computed(() => { + const size = containerDimensions(); + return `${size.width} x ${size.height}`; + }); + + effect(() => { + mockContainer.setAttribute('size', sizeAttribute()); + }); + + effect(() => { + mockPre.textContent = `{ lines: ${layout().lines.length}, attribute: ${sizeAttribute()} }`; + }); + + assertEqual(mockContainer.getAttribute('size'), '0 x 0'); + assertEqual(mockPre.textContent, '{ lines: 0, attribute: 0 x 0 }'); + + containerDimensions.set({ width: 800, height: 600 }); + + assertEqual(mockContainer.getAttribute('size'), '800 x 600', 'Atrybut size powinien się zaktualizować'); + assertEqual(mockPre.textContent, '{ lines: 0, attribute: 800 x 600 }', 'Pre powinien się zaktualizować'); + + layout.set({ + lines: [{ id: 1 }, { id: 2 }, { id: 3 }], + width: 800, + height: 600, + mismatch: 0, + }); + + assertEqual(mockPre.textContent, '{ lines: 3, attribute: 800 x 600 }', 'Pre powinien pokazać 3 linie'); +}); + +async function runTests() { + await new Promise(resolve => setTimeout(resolve, 100)); + + console.log('\n=== PODSUMOWANIE ==='); + + let passed = 0; + let failed = 0; + + for (const result of results) { + if (result.passed) { + console.log(`✅ ${result.name}`); + passed++; + } else { + console.log(`❌ ${result.name}`); + console.log(` Error: ${result.error}`); + failed++; + } + } + + console.log(`\n✅ Testy zaliczone: ${passed}`); + console.log(`❌ Testy niezaliczone: ${failed}`); + console.log(`📊 Procent sukcesu: ${((passed / results.length) * 100).toFixed(1)}%`); + + if (failed === 0) { + console.log('\n🎉 Wszystkie testy przeszły pomyślnie!\n'); + } else { + console.log('\n❌ Niektóre testy nie przeszły.\n'); + process.exit(1); + } +} + +runTests(); diff --git a/tests/unit/tsconfig.json b/tests/unit/tsconfig.json new file mode 100644 index 0000000..e688db1 --- /dev/null +++ b/tests/unit/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./compiled", + "resolveJsonModule": true, + "declaration": false, + "baseUrl": "../..", + "paths": { + "@quarc/cli/*": ["cli/*"], + "@quarc/core": ["core/main"], + "@quarc/core/*": ["core/*"], + "@quarc/rxjs": ["rxjs/main"], + "@quarc/rxjs/*": ["rxjs/*"], + "@quarc/router": ["router/main"], + "@quarc/router/*": ["router/*"] + } + }, + "include": [ + "*.ts", + "../../cli/**/*.ts", + "../../router/**/*.ts", + "../../core/**/*.ts", + "../../rxjs/**/*.ts" + ], + "exclude": [ + "node_modules", + "compiled" + ], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +}