initial commit

This commit is contained in:
Michał Sieciechowicz 2026-01-16 10:27:30 +01:00
commit 2f1137b1d5
110 changed files with 15624 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@ -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*

301
DEPENDENCY_REGISTRATION.md Normal file
View File

@ -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: '<dashboard></dashboard>',
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 `<dashboard>` custom element
8. Registers `AppComponent` as `<app-root>` 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<IComponent>(
importItem as Type<IComponent>
);
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: '<svg><path d="..."/></svg>',
})
export class IconComponent {}
// Level 2: Button Component (depends on Icon)
@Component({
selector: 'app-button',
template: `
<button>
<app-icon></app-icon>
<span>Click Me</span>
</button>
`,
imports: [IconComponent],
})
export class ButtonComponent {}
// Level 1: Dashboard Component (depends on Button)
@Component({
selector: 'dashboard',
template: `
<div class="dashboard">
<h1>Dashboard</h1>
<app-button></app-button>
</div>
`,
imports: [ButtonComponent],
})
export class DashboardComponent {}
// Level 0: Root Component (depends on Dashboard)
@Component({
selector: 'app-root',
template: '<dashboard></dashboard>',
imports: [DashboardComponent],
})
export class AppComponent {}
```
**Registration Order:**
1. `IconComponent``<app-icon>`
2. `ButtonComponent``<app-button>`
3. `DashboardComponent``<dashboard>`
4. `AppComponent``<app-root>`
## 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<IComponent>(
importItem as Type<IComponent>
);
```
## 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: '<child></child>',
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: '<router-outlet></router-outlet>',
// 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!

21
LICENSE Normal file
View File

@ -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.

304
NATIVE_WEB_COMPONENTS.md Normal file
View File

@ -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
<!DOCTYPE html>
<html>
<body>
<!-- Native custom element - automatically recognized by browser -->
<app-root></app-root>
<script src="main.js"></script>
</body>
</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: '<child-component></child-component>',
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<IComponent>)` - 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: '<div class="card">User Info</div>',
style: '.card { border: 1px solid #ccc; }',
})
export class UserCardComponent {}
// Parent component
@Component({
selector: 'user-list',
template: `
<div>
<user-card></user-card>
<user-card></user-card>
</div>
`,
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 `<user-card>` custom element
5. **UserListComponent** is registered as `<user-list>` custom element
6. Template renders with `<user-card>` elements working natively
### Nested Dependencies
Dependencies can be nested multiple levels deep:
```typescript
@Component({
selector: 'icon',
template: '<svg>...</svg>',
})
export class IconComponent {}
@Component({
selector: 'button',
template: '<button><icon></icon> Click</button>',
imports: [IconComponent],
})
export class ButtonComponent {}
@Component({
selector: 'form',
template: '<form><button></button></form>',
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: '<div>Content</div>',
style: 'div { color: red; }',
encapsulation: ViewEncapsulation.ShadowDom,
})
```
### ViewEncapsulation.Emulated
Angular-style scoped attributes (`_nghost-*`, `_ngcontent-*`):
```typescript
@Component({
selector: 'my-component',
template: '<div>Content</div>',
style: 'div { color: red; }',
encapsulation: ViewEncapsulation.Emulated, // Default
})
```
### ViewEncapsulation.None
No encapsulation - styles are global:
```typescript
@Component({
selector: 'my-component',
template: '<div>Content</div>',
style: 'div { color: red; }',
encapsulation: ViewEncapsulation.None,
})
```
## Migration from Old System
The old system created wrapper `<div>` 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.

286
cli/ARCHITECTURE.md Normal file
View File

@ -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<ProcessorResult> {
// 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('<div [title]="value"></div>');
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

73
cli/bin/qu.js Executable file
View File

@ -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 <command> [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 <command> [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);
}

710
cli/build.ts Normal file
View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 += ` <link rel="stylesheet" href="./${cssFile}">\n`;
}
}
if (styleInjections) {
html = html.replace('</head>', `${styleInjections}</head>`);
}
let scriptInjections = '';
for (const scriptPath of scripts) {
const basename = path.basename(scriptPath);
if (!html.includes(basename)) {
scriptInjections += ` <script type="module" src="./${basename}"></script>\n`;
}
}
const mainScript = ` <script type="module" src="./main.js"></script>\n`;
if (!html.includes('main.js')) {
scriptInjections += mainScript;
}
if (scriptInjections) {
html = html.replace('</body>', `${scriptInjections}</body>`);
}
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<void> {
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);
});

View File

@ -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'
};
});
}
};
}

75
cli/di-plugin.ts Normal file
View File

@ -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' };
});
},
};
}

View File

@ -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
<!-- Przed przetworzeniem -->
<ng-container *ngIf="showDetails">
<div>Szczegóły</div>
</ng-container>
<!-- Po przetworzeniu w DOM -->
<!--ng-container-start *ngIf="showDetails"-->
<div>Szczegóły</div>
<!--ng-container-end-->
```
## 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
<!-- Template -->
<ng-container *ngIf="isLoggedIn">
<div>Witaj, {{ userName }}!</div>
</ng-container>
<ng-container *ngIf="!isLoggedIn">
<button (click)="login()">Zaloguj się</button>
</ng-container>
```
```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-root>
<div>app</div>
<!--ng-container-start *ngIf="false"-->
<!--ng-container-end-->
<!--ng-container-start *ngIf="true"-->
<div>Widoczna zawartość</div>
<!--ng-container-end-->
</app-root>
```
## 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

510
cli/helpers/README.md Normal file
View File

@ -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('<div [title]="value">Content</div>');
// 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 = `
<div>
Header text
<p>Paragraph</p>
Footer text
</div>
`;
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) {
<div>Content</div>
}
// Output
<ng-container *ngIf="isVisible">
<div>Content</div>
</ng-container>
```
**@if z @else:**
```typescript
// Input
@if (isAdmin) {
<span>Admin</span>
} @else {
<span>User</span>
}
// Output
<ng-container *ngIf="isAdmin">
<span>Admin</span>
</ng-container>
<ng-container *ngIf="!(isAdmin)">
<span>User</span>
</ng-container>
```
**@if z @else if:**
```typescript
// Input
@if (role === 'admin') {
<span>Admin</span>
} @else if (role === 'user') {
<span>User</span>
} @else {
<span>Guest</span>
}
// Output
<ng-container *ngIf="role === 'admin'">
<span>Admin</span>
</ng-container>
<ng-container *ngIf="!(role === 'admin') && role === 'user'">
<span>User</span>
</ng-container>
<ng-container *ngIf="!(role === 'admin') && !(role === 'user')">
<span>Guest</span>
</ng-container>
```
### Użycie
```typescript
import { ControlFlowTransformer } from './control-flow-transformer';
const transformer = new ControlFlowTransformer();
const template = '@if (show) { <div>Content</div> }';
const result = transformer.transform(template);
// '<ng-container *ngIf="show"> <div>Content</div> </ng-container>'
```
### 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
<div>{{ userName }}</div>
<span>Value: {{ data }}</span>
// Output
<div><span [innerText]="userName"></span></div>
<span>Value: <span [innerText]="data"></span></span>
```
### Użycie
Transformacja jest częścią `TemplateTransformer`:
```typescript
import { TemplateTransformer } from './processors/template/template-transformer';
const transformer = new TemplateTransformer();
const template = '<div>{{ message }}</div>';
const result = transformer.transformInterpolation(template);
// '<div><span [innerText]="message"></span></div>'
```
### 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
<!-- ng-container-start *ngIf="condition" -->
<!-- zawartość renderowana gdy warunek jest true -->
<!-- ng-container-end -->
```
### 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
<div *ngIf="isVisible">Content</div>
<li *ngFor="let item of items">{{ item }}</li>
```
### InputBindingHelper
Obsługuje bindowanie właściwości wejściowych.
**Format:** `[propertyName]="expression"`
**Przykład:**
```html
<app-child [title]="parentTitle"></app-child>
<img [src]="imageUrl" />
```
### OutputBindingHelper
Obsługuje bindowanie zdarzeń wyjściowych.
**Format:** `(eventName)="handler()"`
**Przykład:**
```html
<button (click)="handleClick()">Click</button>
<app-child (valueChange)="onValueChange($event)"></app-child>
```
### TwoWayBindingHelper
Obsługuje bindowanie dwukierunkowe (banana-in-a-box).
**Format:** `[(propertyName)]="variable"`
**Przykład:**
```html
<input [(ngModel)]="username" />
<app-custom [(value)]="data"></app-custom>
```
### TemplateReferenceHelper
Obsługuje referencje do elementów w szablonie.
**Format:** `#referenceName`
**Przykład:**
```html
<input #nameInput type="text" />
<button (click)="nameInput.focus()">Focus</button>
```
## 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
<app-toolbar [mode]="currentMode" [title]="pageTitle"></app-toolbar>
```
```typescript
// W runtime element będzie miał:
element.__inputs = {
mode: WritableSignal<any>, // signal z wartością currentMode
title: WritableSignal<any>, // 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
<app-toolbar (menu-click)="toggleMenu()" (search)="onSearch($event)"></app-toolbar>
```
```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
<button [attr.disabled]="isDisabled" [attr.aria-label]="label"></button>
```
- `true` → ustawia pusty atrybut
- `false/null/undefined` → usuwa atrybut
- inne wartości → ustawia jako string
#### `[style.X]` - Style Binding
```html
<div [style.color]="textColor" [style.fontSize]="size + 'px'"></div>
```
- Obsługuje camelCase (`fontSize`) i kebab-case (`font-size`)
- `false/null/undefined` → usuwa właściwość stylu
#### `[class.X]` - Class Binding
```html
<div [class.active]="isActive" [class.hidden]="!isVisible"></div>
```
- `true` → dodaje klasę
- `false` → usuwa klasę
### DOM Property Bindings
Dla zwykłych elementów HTML, bindingi ustawiają właściwości DOM:
```html
<div [innerHTML]="htmlContent"></div>
<input [value]="inputValue" />
```

View File

@ -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(/^#/, '');
}
}

View File

@ -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 `<ng-container *ngFor="${ngForExpression}">${forBlock.content}</ng-container>`;
}
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 += `<ng-container *ngIf="${condition}">${block.content}</ng-container>`;
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;
}
}

249
cli/helpers/example.md Normal file
View File

@ -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 = `
<div class="container">
<h1 [title]="pageTitle">{{ title }}</h1>
<button (click)="save()">Save</button>
</div>
`;
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
<div>Inside div</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 = '<div [title]="myTitle">Content</div>';
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) {
<p>Welcome back!</p>
} @else if (isGuest) {
<p>Welcome guest!</p>
} @else {
<p>Please log in</p>
}
```
Wyjście po transformacji:
```html
<ng-container *ngIf="isLoggedIn">
<p>Welcome back!</p>
</ng-container>
<ng-container *ngIf="!(isLoggedIn) && isGuest">
<p>Welcome guest!</p>
</ng-container>
<ng-container *ngIf="!(isLoggedIn) && !(isGuest)">
<p>Please log in</p>
</ng-container>
```
## Przykład 4: Kompletny Szablon
Wejście:
```html
<div class="user-profile">
<h1 [title]="user.fullName">{{ user.name }}</h1>
@if (user.isAdmin) {
<span class="badge">Admin</span>
}
<form>
<input [(ngModel)]="user.email" #emailInput type="email" />
<button (click)="saveUser()">Save</button>
</form>
<ul *ngFor="let post of user.posts">
<li [class.active]="post.isActive">{{ post.title }}</li>
</ul>
</div>
```
Po przetworzeniu przez TemplateProcessor:
```html
<div class="user-profile">
<h1 [title]="user.fullName">{{ user.name }}</h1>
<ng-container *ngIf="user.isAdmin">
<span class="badge">Admin</span>
</ng-container>
<form>
<input [(ngModel)]="user.email" #emailInput type="email" />
<button (click)="saveUser()">Save</button>
</form>
<ul *ngFor="let post of user.posts">
<li [class.active]="post.isActive">{{ post.title }}</li>
</ul>
</div>
```
## 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 = '<div [title]="value" (click)="handler()">Text</div>';
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 = `
<div class="container"
*ngIf="show"
[title]="tooltip"
(click)="onClick()"
[(ngModel)]="value"
#myDiv>
Content
</div>
`;
const elements = parser.parse(template);
const attributeTypes = new Map<AttributeType, number>();
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
```

15
cli/helpers/index.ts Normal file
View File

@ -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';

View File

@ -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,
},
};
}
}

View File

@ -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,
},
};
}
}

View File

@ -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,
},
};
}
}

View File

@ -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';
}
}

View File

@ -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,
},
};
}
}

View File

@ -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,
},
};
}
}

123
cli/lite-transformer.ts Normal file
View File

@ -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();
}

26
cli/package.json Normal file
View File

@ -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"
}
}

221
cli/processors/README.md Normal file
View File

@ -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<ProcessorResult>;
}
```
### TemplateProcessor
Transforms `templateUrl` properties to inline `template` properties and processes Angular attributes.
**Transformation:**
```typescript
// Before
templateUrl = './component.html'
// After
template = `<div>Component content</div>`
```
**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<string>();
private clicked = output<void>();
readonly count = input<number>(0);
constructor(private service: SomeService) {}
}
// After
export class MyComponent {
public userName = input<string>("userName", this);
private clicked = output<void>("clicked", this);
readonly count = input<number>("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<string>()`)
- 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<ProcessorResult> {
// 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.

View File

@ -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<ProcessorResult>;
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 };
}
}

View File

@ -0,0 +1,120 @@
import { BaseProcessor, ProcessorContext, ProcessorResult } from './base-processor';
import { ComponentIdRegistry } from './component-id-registry';
const DECORATOR_MAP: Record<string, string> = {
'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<ProcessorResult> {
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;
}
}

View File

@ -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<string, string>();
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();
}
}

View File

@ -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<ProcessorResult> {
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<string> {
const typeOnlyImports = new Set<string>();
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>): 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));
}
}

View File

@ -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<ProcessorResult> {
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));
}
}

8
cli/processors/index.ts Normal file
View File

@ -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';

View File

@ -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<ProcessorResult> {
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;`);
}
}

View File

@ -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<ProcessorResult> {
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<string> {
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, '\\$');
}
}

View File

@ -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<ProcessorResult> {
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<string> {
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, '\\');
}
}

View File

@ -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, '&apos;')}"`;
newAttributes = newAttributes.trim();
return `<${tagName}${newAttributes ? ' ' + newAttributes : ''}${dataAttr}>`;
});
}
private transformContentInterpolation(content: string): string {
return content.replace(
/\{\{\s*([^}]+?)\s*\}\}/g,
(_, expr) => `<span [innerText]="${expr.trim()}"></span>`,
);
}
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<string> {
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 += `<ng-container *ngIf="${condition}">${block.content}</ng-container>`;
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 `<!-- Invalid @for syntax: ${header} -->`;
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 `<ng-container *ngFor="${ngForExpr}">${body}</ng-container>`;
}
transformSelectNgFor(content: string): string {
// Transform ng-container *ngFor inside <select> to use comment markers
// This is needed because browser removes ng-container from inside select during parsing
const selectRegex = /<(select|optgroup)([^>]*)>([\s\S]*?)<\/\1>/gi;
return content.replace(selectRegex, (_, tag, attrs, innerContent) => {
const ngForRegex = /<ng-container\s+\*ngFor\s*=\s*"let\s+(\w+)\s+of\s+([^"]+)"[^>]*>([\s\S]*?)<\/ng-container>/gi;
const processed = innerContent.replace(ngForRegex, (_m: string, varName: string, iterableExpr: string, tmpl: string) => {
return `<!--F:${varName}:${iterableExpr}-->${tmpl.trim()}<!--/F-->`;
});
return `<${tag}${attrs}>${processed}</${tag}>`;
});
}
private findMatchingParen(content: string, startIndex: number): number {
let depth = 1;
let i = startIndex + 1;
while (i < content.length && depth > 0) {
if (content[i] === '(') depth++;
else if (content[i] === ')') depth--;
i++;
}
return depth === 0 ? i - 1 : -1;
}
private findMatchingBrace(content: string, startIndex: number): number {
let depth = 1;
let i = startIndex + 1;
while (i < content.length && depth > 0) {
if (content[i] === '{') depth++;
else if (content[i] === '}') depth--;
i++;
}
return depth === 0 ? i - 1 : -1;
}
}

618
cli/serve.ts Normal file
View File

@ -0,0 +1,618 @@
#!/usr/bin/env node
import * as fs from 'fs';
import * as path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import * as http from 'http';
import * as https from 'https';
import { WebSocketServer, WebSocket } from 'ws';
const projectRoot = process.cwd();
const srcDir = path.join(projectRoot, 'src');
const distDir = path.join(projectRoot, 'dist');
const configPath = path.join(projectRoot, 'quarc.json');
let isBuilding = false;
let buildQueued = false;
let wsClients: Set<WebSocket> = new Set();
let httpServer: http.Server | null = null;
let wsServer: WebSocketServer | null = null;
let actionProcesses: ChildProcess[] = [];
let mergedWsConnections: WebSocket[] = [];
interface DevServerConfig {
port: number;
websocket?: WebSocketConfig;
}
interface WebSocketConfig {
mergeFrom?: string[];
}
interface StaticLocalPath {
location: string;
path: string;
}
interface StaticRemotePath {
location: string;
url: string;
}
type StaticPath = StaticLocalPath | StaticRemotePath;
interface ActionsConfig {
preserve?: string[];
postserve?: string[];
}
interface ServeConfig {
actions?: ActionsConfig;
staticPaths?: StaticPath[];
}
interface EnvironmentConfig {
treatWarningsAsErrors: boolean;
minifyNames: boolean;
generateSourceMaps: boolean;
devServer?: DevServerConfig;
}
interface QuarcConfig {
environment: string;
serve?: ServeConfig;
environments: {
[key: string]: EnvironmentConfig;
};
}
function loadConfig(): QuarcConfig {
if (!fs.existsSync(configPath)) {
return {
environment: 'development',
environments: {
development: {
treatWarningsAsErrors: false,
minifyNames: false,
generateSourceMaps: true,
devServer: {
port: 4300,
},
},
},
};
}
const content = fs.readFileSync(configPath, 'utf-8');
return JSON.parse(content) as QuarcConfig;
}
function getDevServerPort(): number {
const args = process.argv.slice(2);
const portIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
if (portIndex !== -1 && args[portIndex + 1]) {
const port = parseInt(args[portIndex + 1], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
return port;
}
}
const config = loadConfig();
const envConfig = config.environments[config.environment];
return envConfig?.devServer?.port || 4300;
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: { [key: string]: string } = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
};
return mimeTypes[ext] || 'application/octet-stream';
}
function getWebSocketConfig(): WebSocketConfig | undefined {
const config = loadConfig();
const envConfig = config.environments[config.environment];
return envConfig?.devServer?.websocket;
}
function attachWebSocketServer(server: http.Server): void {
wsServer = new WebSocketServer({ server, path: '/qu-ws/' });
wsServer.on('connection', (ws: WebSocket) => {
wsClients.add(ws);
console.log('Client connected to live reload WebSocket');
ws.on('message', (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
handleIncomingMessage(message, ws);
} catch {
// ignore invalid messages
}
});
ws.on('close', () => {
wsClients.delete(ws);
console.log('Client disconnected from live reload WebSocket');
});
ws.send(JSON.stringify({ type: 'connected' }));
});
console.log('WebSocket server attached to HTTP server');
connectToMergedSources();
}
function connectToMergedSources(): void {
const wsConfig = getWebSocketConfig();
const mergeFrom = wsConfig?.mergeFrom || [];
for (const url of mergeFrom) {
connectToMergedSource(url);
}
}
function connectToMergedSource(url: string): void {
console.log(`Connecting to merged WebSocket source: ${url}`);
const ws = new WebSocket(url);
ws.on('open', () => {
console.log(`Connected to merged source: ${url}`);
mergedWsConnections.push(ws);
});
ws.on('message', (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'reload') {
broadcastToClients(message);
}
} catch {
// ignore invalid messages
}
});
ws.on('close', () => {
console.log(`Disconnected from merged source: ${url}, reconnecting...`);
mergedWsConnections = mergedWsConnections.filter(c => c !== ws);
setTimeout(() => connectToMergedSource(url), 2000);
});
ws.on('error', (err: Error) => {
console.warn(`WebSocket error for ${url}:`, err.message);
});
}
function handleIncomingMessage(message: { type: string; [key: string]: unknown }, sender: WebSocket): void {
if (message.type === 'reload') {
broadcastToClients(message, sender);
broadcastToMergedSources(message);
}
}
function broadcastToClients(message: { type: string; [key: string]: unknown }, excludeSender?: WebSocket): void {
const data = JSON.stringify(message);
for (const client of wsClients) {
if (client !== excludeSender && client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
function broadcastToMergedSources(message: { type: string; [key: string]: unknown }): void {
const data = JSON.stringify(message);
for (const ws of mergedWsConnections) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
function getStaticPaths(): StaticPath[] {
const config = loadConfig();
return config.serve?.staticPaths || [];
}
function proxyRequest(targetUrl: string, req: http.IncomingMessage, res: http.ServerResponse): void {
const parsedUrl = new URL(targetUrl);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const proxyReq = protocol.request(
targetUrl,
{
method: req.method,
headers: {
...req.headers,
host: parsedUrl.host,
},
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on('error', (err) => {
console.error('Proxy error:', err.message);
res.writeHead(502);
res.end('Bad Gateway');
});
req.pipe(proxyReq);
}
function isRemotePath(staticPath: StaticPath): staticPath is StaticRemotePath {
return 'url' in staticPath;
}
function tryServeStaticPath(reqUrl: string, req: http.IncomingMessage, res: http.ServerResponse): boolean {
const staticPaths = getStaticPaths();
for (const staticPath of staticPaths) {
if (reqUrl.startsWith(staticPath.location)) {
const relativePath = reqUrl.slice(staticPath.location.length);
if (isRemotePath(staticPath)) {
const targetUrl = staticPath.url + relativePath;
proxyRequest(targetUrl, req, res);
return true;
}
const basePath = path.resolve(projectRoot, staticPath.path);
let filePath = path.join(basePath, relativePath || 'index.html');
const normalizedFilePath = path.normalize(filePath);
if (!normalizedFilePath.startsWith(basePath)) {
res.writeHead(403);
res.end('Forbidden');
return true;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end('Not Found');
return true;
}
const mimeType = getMimeType(filePath);
const content = fs.readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mimeType,
'Cache-Control': 'no-cache',
});
res.end(content);
return true;
}
}
return false;
}
function startHttpServer(port: number): void {
httpServer = http.createServer((req, res) => {
const reqUrl = req.url || '/';
if (tryServeStaticPath(reqUrl, req, res)) {
return;
}
let filePath = path.join(distDir, reqUrl === '/' ? 'index.html' : reqUrl);
if (filePath.includes('..')) {
res.writeHead(403);
res.end('Forbidden');
return;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
if (!fs.existsSync(filePath)) {
const indexPath = path.join(distDir, 'index.html');
if (fs.existsSync(indexPath)) {
filePath = indexPath;
} else {
res.writeHead(404);
res.end('Not Found');
return;
}
}
const mimeType = getMimeType(filePath);
const content = fs.readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mimeType,
'Cache-Control': 'no-cache',
});
res.end(content);
});
httpServer.listen(port, () => {
console.log(`\n** Quarc Live Development Server is listening on localhost:${port} **`);
console.log(`** Open your browser on http://localhost:${port}/ **\n`);
});
attachWebSocketServer(httpServer);
}
function notifyClients(): void {
const message = { type: 'reload' };
broadcastToClients(message);
broadcastToMergedSources(message);
if (wsClients.size > 0) {
console.log('📢 Notified clients to reload');
}
}
async function runBuild(): Promise<void> {
if (isBuilding) {
buildQueued = true;
return;
}
isBuilding = true;
buildQueued = false;
console.log('\n🔨 Building application...');
const startTime = Date.now();
try {
const buildScript = path.join(__dirname, 'build.ts');
const tsNodePath = path.join(projectRoot, 'node_modules', '.bin', 'ts-node');
execSync(`${tsNodePath} ${buildScript}`, {
stdio: 'inherit',
cwd: projectRoot,
});
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`✅ Build completed in ${duration}s`);
notifyClients();
} catch (error) {
console.error('❌ Build failed');
} finally {
isBuilding = false;
if (buildQueued) {
console.log('⏳ Running queued build...');
setTimeout(() => runBuild(), 100);
}
}
}
function watchFiles(): void {
console.log(`👀 Watching for changes in ${srcDir}...`);
const debounceDelay = 300;
let debounceTimer: NodeJS.Timeout | null = null;
const watcher = fs.watch(srcDir, { recursive: true }, (eventType, filename) => {
if (!filename) return;
const ext = path.extname(filename);
if (!['.ts', '.scss', '.sass', '.css', '.html'].includes(ext)) {
return;
}
console.log(`📝 File changed: ${filename}`);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
runBuild();
}, debounceDelay);
});
const cleanup = () => {
console.log('\n👋 Stopping watch mode...');
watcher.close();
for (const client of wsClients) {
client.close();
}
wsClients.clear();
for (const ws of mergedWsConnections) {
ws.close();
}
mergedWsConnections = [];
if (wsServer) {
wsServer.close();
}
if (httpServer) {
httpServer.close();
}
terminateActionProcesses();
runPostServeActions();
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
}
function injectLiveReloadScript(): void {
const indexPath = path.join(distDir, 'index.html');
const wsPort = getDevServerPort();
if (!fs.existsSync(indexPath)) {
console.warn('index.html not found in dist directory');
return;
}
let html = fs.readFileSync(indexPath, 'utf-8');
const liveReloadScript = `
<script>
(function() {
let ws;
let reconnectAttempts = 0;
const maxReconnectDelay = 5000;
function connect() {
ws = new WebSocket('ws://localhost:${wsPort}/qu-ws/');
ws.onopen = () => {
console.log('[Live Reload] Connected');
reconnectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'reload') {
console.log('[Live Reload] Reloading page...');
window.location.reload();
}
} catch {}
};
ws.onclose = () => {
console.warn('[Live Reload] Connection lost, attempting to reconnect...');
reconnectAttempts++;
const delay = Math.min(1000 * reconnectAttempts, maxReconnectDelay);
setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
}
connect();
})();
</script>
`;
if (!html.includes('Live Reload')) {
html = html.replace('</body>', `${liveReloadScript}</body>`);
fs.writeFileSync(indexPath, html, 'utf-8');
console.log('✅ Injected live reload script into index.html');
}
}
function runPreServeActions(): void {
const config = loadConfig();
const actions = config.serve?.actions?.preserve || [];
if (actions.length === 0) return;
console.log('🔧 Running preserve actions...');
for (const action of actions) {
console.log(`${action}`);
const child = spawn(action, [], {
shell: true,
cwd: projectRoot,
stdio: 'inherit',
detached: true,
});
actionProcesses.push(child);
child.on('error', (err) => {
console.error(` ❌ Action failed: ${action}`, err.message);
});
}
}
function runPostServeActions(): void {
const config = loadConfig();
const actions = config.serve?.actions?.postserve || [];
if (actions.length === 0) return;
console.log('🔧 Running postserve actions...');
for (const action of actions) {
console.log(`${action}`);
try {
execSync(action, {
cwd: projectRoot,
stdio: 'inherit',
});
} catch (err) {
console.error(` ❌ Action failed: ${action}`);
}
}
}
function terminateActionProcesses(): void {
if (actionProcesses.length === 0) return;
console.log('🛑 Terminating action processes...');
for (const child of actionProcesses) {
if (child.pid && !child.killed) {
try {
process.kill(-child.pid, 'SIGTERM');
console.log(` ✓ Terminated process group ${child.pid}`);
} catch (err) {
try {
child.kill('SIGTERM');
console.log(` ✓ Terminated process ${child.pid}`);
} catch {
console.warn(` ⚠ Could not terminate process ${child.pid}`);
}
}
}
}
actionProcesses = [];
}
async function serve(): Promise<void> {
const port = getDevServerPort();
console.log('🚀 Starting development server...\n');
runPreServeActions();
console.log('📦 Running initial build...');
await runBuild();
injectLiveReloadScript();
startHttpServer(port);
console.log('✨ Development server is ready!');
console.log('📂 Serving files from:', distDir);
console.log('🔄 Live reload WebSocket enabled on port', port);
console.log('\nPress Ctrl+C to stop\n');
watchFiles();
}
serve().catch(error => {
console.error('Serve process failed:', error);
process.exit(1);
});

112
cli/template-plugin.ts Normal file
View File

@ -0,0 +1,112 @@
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
export function templatePlugin(): esbuild.Plugin {
return {
name: 'template-inliner',
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');
if (source.includes('import type') || source.includes('export interface')) {
return { contents: source, loader: 'ts' };
}
const fileDir = path.dirname(args.path);
let modified = false;
const templateUrlRegex = /templateUrl\s*[:=]\s*['"`]([^'"`]+)['"`]/g;
let match;
console.log(`[template-plugin] Processing: ${args.path}`);
while ((match = templateUrlRegex.exec(source)) !== null) {
console.log(`[template-plugin] Found templateUrl: ${match[1]}`);
const templatePath = match[1];
const fullPath = path.resolve(fileDir, templatePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Template file not found: ${fullPath} (referenced in ${args.path})`);
}
const templateContent = await fs.promises.readFile(fullPath, 'utf8');
const escapedContent = templateContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
const replacement = `template = \`${escapedContent}\``;
console.log(`[template-plugin] Replacing "${match[0]}" with "${replacement}"`);
source = source.replace(match[0], `template = \`${escapedContent}\``);
modified = true;
}
const styleUrlRegex = /styleUrl\s*[:=]\s*['"`]([^'"`]+)['"`]/g;
while ((match = styleUrlRegex.exec(source)) !== null) {
const stylePath = match[1];
const fullPath = path.resolve(fileDir, stylePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Style file not found: ${fullPath} (referenced in ${args.path})`);
}
const styleContent = await fs.promises.readFile(fullPath, 'utf8');
const escapedContent = styleContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
source = source.replace(match[0], `style = \`${escapedContent}\``);
modified = true;
}
const styleUrlsRegex = /styleUrls\s*[:=]\s*\[([\s\S]*?)\]/g;
while ((match = styleUrlsRegex.exec(source)) !== null) {
const urlsContent = match[1];
const urlMatches = urlsContent.match(/['"`]([^'"`]+)['"`]/g);
if (urlMatches) {
const styles: string[] = [];
for (const urlMatch of urlMatches) {
const stylePath = urlMatch.replace(/['"`]/g, '');
const fullPath = path.resolve(fileDir, stylePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Style file not found: ${fullPath} (referenced in ${args.path})`);
}
const styleContent = await fs.promises.readFile(fullPath, 'utf8');
styles.push(styleContent);
}
const combinedStyles = styles.join('\n');
const escapedContent = combinedStyles
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
source = source.replace(match[0], `style = \`${escapedContent}\``);
modified = true;
}
}
if (modified) {
return {
contents: source,
loader: 'ts',
};
}
return { contents: source, loader: 'ts' };
});
},
};
}

53
cli/transformer.ts Normal file
View File

@ -0,0 +1,53 @@
import * as ts from 'typescript';
export function createDependencyInjectionTransformer(): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => {
return (sourceFile: ts.SourceFile) => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isClassDeclaration(node) && node.name) {
const constructor = node.members.find(
member => ts.isConstructorDeclaration(member)
) as ts.ConstructorDeclaration | undefined;
if (constructor && constructor.parameters.length > 0) {
const paramTypes = constructor.parameters
.map(param => {
if (param.type && ts.isTypeReferenceNode(param.type)) {
return param.type.typeName.getText(sourceFile);
}
return null;
})
.filter(Boolean);
if (paramTypes.length > 0) {
const staticProperty = ts.factory.createPropertyDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.StaticKeyword)],
'__di_params__',
undefined,
undefined,
ts.factory.createArrayLiteralExpression(
paramTypes.map(typeName =>
ts.factory.createIdentifier(typeName as string)
)
)
);
return ts.factory.updateClassDeclaration(
node,
node.modifiers,
node.name,
node.typeParameters,
node.heritageClauses,
[staticProperty, ...node.members]
);
}
}
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor) as ts.SourceFile;
};
};
}

22
cli/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}

323
core/LAZY_LOADING.md Normal file
View File

@ -0,0 +1,323 @@
# Lazy Loading Architecture
The Core framework now uses WebComponents with lazy loading support based on component imports.
## Key Concepts
### 1. Component Registry
A centralized registry that tracks all components, their dependencies, and loading status.
```typescript
const registry = Core.getRegistry();
// Check if component is loaded
const isLoaded = registry.isLoaded(MyComponent);
// Get component metadata
const metadata = registry.getMetadata(MyComponent);
// Get all dependencies
const deps = registry.getAllDependencies(MainComponent);
```
### 2. Main Component
The main component is set during bootstrap and stored as a static member.
```typescript
Core.bootstrap(AppComponent);
// Access main component type
const mainComponentType = Core.MainComponent;
// Access main WebComponent instance
const mainWebComponent = Core.getMainWebComponent();
```
### 3. Component Imports
Components declare their dependencies using the `imports` property.
```typescript
export class AppComponent implements IComponent {
selector = 'app-root';
template = '<div>App</div>';
imports = [HeaderComponent, FooterComponent, SidebarComponent];
}
```
## Bootstrap Process
When `Core.bootstrap()` is called:
1. **Set Main Component** - Stores the component type in `Core.MainComponent`
2. **Register Component** - Adds the main component to the registry
3. **Resolve Dependencies** - Recursively finds all dependencies from `imports`
4. **Preload Dependencies** - Creates instances of all dependencies (but doesn't render them)
5. **Create WebComponent** - Creates and renders the main component
6. **Mark as Loaded** - Updates registry to show main component is loaded
```typescript
// Bootstrap flow
Core.bootstrap(AppComponent, element);
// ↓
// Core.MainComponent = AppComponent
// ↓
// Registry: [AppComponent, HeaderComponent, FooterComponent, ...]
// ↓
// Only AppComponent is rendered (loaded: true)
// Dependencies are preloaded (loaded: false)
```
## Lazy Loading Components
Components are lazy loaded when explicitly requested:
```typescript
// Load a component on demand
const webComponent = Core.loadComponent(DashboardComponent, element);
// Component is now loaded and rendered
const isLoaded = Core.getRegistry().isLoaded(DashboardComponent);
// → true
```
## Component Lifecycle
### Preloaded (Not Rendered)
```typescript
{
type: HeaderComponent,
instance: headerInstance, // ✓ Created
webComponent: undefined, // ✗ Not rendered
loaded: false, // ✗ Not loaded
dependencies: []
}
```
### Loaded (Rendered)
```typescript
{
type: HeaderComponent,
instance: headerInstance, // ✓ Created
webComponent: webComponent, // ✓ Rendered
loaded: true, // ✓ Loaded
dependencies: []
}
```
## Complete Example
### 1. Define Components
```typescript
// header.component.ts
export class HeaderComponent implements IComponent {
selector = 'app-header';
template = '<header>Header</header>';
style = 'header { background: #333; }';
}
// sidebar.component.ts
export class SidebarComponent implements IComponent {
selector = 'app-sidebar';
template = '<aside>Sidebar</aside>';
style = 'aside { width: 200px; }';
}
// dashboard.component.ts
export class DashboardComponent implements IComponent {
selector = 'app-dashboard';
template = '<div>Dashboard</div>';
imports = []; // No dependencies
}
// app.component.ts
export class AppComponent implements IComponent {
selector = 'app-root';
template = `
<div class="app">
<app-header></app-header>
<app-sidebar></app-sidebar>
<main id="content"></main>
</div>
`;
imports = [HeaderComponent, SidebarComponent];
// DashboardComponent is NOT imported - will be lazy loaded
}
```
### 2. Bootstrap Application
```typescript
// main.ts
import { Core } from '@nglite/core/core/main';
import { AppComponent } from './app.component';
// Bootstrap - loads AppComponent + its imports
const core = Core.bootstrap(AppComponent);
// At this point:
// - AppComponent: loaded (rendered)
// - HeaderComponent: preloaded (not rendered)
// - SidebarComponent: preloaded (not rendered)
// - DashboardComponent: not loaded
console.log('Main component:', Core.MainComponent);
// → AppComponent
const registry = Core.getRegistry();
console.log('Is HeaderComponent loaded?', registry.isLoaded(HeaderComponent));
// → false (preloaded but not rendered)
```
### 3. Lazy Load Components
```typescript
// Later, when user navigates to dashboard
const contentElement = document.getElementById('content');
if (contentElement) {
// Lazy load and render DashboardComponent
const dashboardWC = Core.loadComponent(DashboardComponent, contentElement);
// Now it's loaded
console.log('Is DashboardComponent loaded?',
Core.getRegistry().isLoaded(DashboardComponent));
// → true
// Access the WebComponent
const children = dashboardWC.getChildElements();
const attributes = dashboardWC.getAttributes();
}
```
### 4. Load Preloaded Components
```typescript
// Render a preloaded component
const headerElement = document.querySelector('app-header');
if (headerElement) {
const headerWC = Core.loadComponent(HeaderComponent, headerElement);
// Now it's rendered
console.log('Is HeaderComponent loaded?',
Core.getRegistry().isLoaded(HeaderComponent));
// → true
}
```
## API Reference
### Core Static Methods
#### `Core.bootstrap(component, element?): Core`
Bootstraps the application with the main component.
```typescript
const core = Core.bootstrap(AppComponent);
```
#### `Core.MainComponent: Type<IComponent> | null`
The main component type.
```typescript
if (Core.MainComponent === AppComponent) {
console.log('App is bootstrapped');
}
```
#### `Core.getMainWebComponent(): WebComponent | null`
Gets the main component's WebComponent instance.
```typescript
const mainWC = Core.getMainWebComponent();
if (mainWC) {
const children = mainWC.getChildElements();
}
```
#### `Core.loadComponent(componentType, element?): WebComponent`
Loads and renders a component.
```typescript
const wc = Core.loadComponent(MyComponent, element);
```
#### `Core.getRegistry(): ComponentRegistry`
Gets the component registry.
```typescript
const registry = Core.getRegistry();
const all = registry.getAll();
```
### ComponentRegistry Methods
#### `register(type, instance): void`
Registers a component instance.
#### `markAsLoaded(type, webComponent): void`
Marks a component as loaded.
#### `isLoaded(type): boolean`
Checks if a component is loaded.
#### `getMetadata(type): ComponentMetadata | undefined`
Gets component metadata.
#### `getBySelector(selector): ComponentMetadata | undefined`
Gets component by selector.
#### `getWebComponent(type): WebComponent | undefined`
Gets the WebComponent instance.
#### `getDependencies(type): Type<IComponent>[]`
Gets direct dependencies.
#### `getAllDependencies(type): Type<IComponent>[]`
Gets all dependencies recursively.
#### `getAll(): ComponentMetadata[]`
Gets all registered components.
## Benefits
1. **Faster Initial Load** - Only main component and its dependencies are rendered
2. **Memory Efficient** - Components are preloaded but not rendered until needed
3. **Dependency Tracking** - Automatic resolution of component dependencies
4. **Lazy Loading** - Load components on demand
5. **Centralized Registry** - Single source of truth for component state
## Performance
```
Without Lazy Loading:
- Load all components: 100ms
- Render all components: 200ms
- Total: 300ms
With Lazy Loading:
- Load main + dependencies: 50ms
- Render main component: 50ms
- Total initial: 100ms
- Lazy load on demand: 20ms per component
```

View File

@ -0,0 +1,27 @@
export interface Provider {
provide: any;
useValue?: any;
useFactory?: any;
useExisting?: any;
useClass?: any;
multi?: boolean;
}
export interface EnvironmentProviders {
}
export type Providers = Provider | EnvironmentProviders;
export interface ApplicationConfig {
providers: Providers[];
externalUrls?: string | string[];
enablePlugins?: boolean;
}
export type PluginRoutingMode = 'root' | 'internal';
export interface PluginConfig {
providers?: Providers[];
routingMode?: PluginRoutingMode;
styleUrl?: string;
}

View File

@ -0,0 +1,18 @@
import '../global';
export class ChangeDetectorRef {
private webComponentId: string;
constructor(webComponentId: string) {
this.webComponentId = webComponentId;
}
detectChanges(): void {
const webComponent = window.__quarc.webComponentInstances?.get(this.webComponentId);
webComponent?.rerender();
}
markForCheck(): void {
queueMicrotask(() => this.detectChanges());
}
}

41
core/angular/component.ts Normal file
View File

@ -0,0 +1,41 @@
import { ViewEncapsulation } from '../module/component';
import { Type } from '../module/type';
/**
* Opcje konfiguracji komponentu.
*
* Ten interfejs służy wyłącznie do zapewnienia poprawności typów w TypeScript.
* Cała logika przetwarzania (np. ładowanie templateUrl, styleUrl) odbywa się
* w transformerach podczas kompilacji (quarc/cli/processors/).
*/
export interface ComponentOptions {
selector: string;
template?: string;
templateUrl?: string;
style?: string;
styles?: string[];
styleUrl?: string;
styleUrls?: string[];
standalone?: boolean;
imports?: Array<Type<any> | any[]>;
encapsulation?: ViewEncapsulation;
changeDetection?: any;
providers?: any[];
exportAs?: string;
preserveWhitespaces?: boolean;
jit?: boolean;
}
/**
* Dekorator komponentu.
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformer (quarc/cli/processors/class-decorator-processor.ts).
* Cała logika przetwarzania templateUrl, styleUrl, control flow itp. odbywa się w transformerach,
* co minimalizuje rozmiar końcowej aplikacji.
*/
export function Component(options: ComponentOptions): ClassDecorator {
return (target: any) => {
return target;
};
}

34
core/angular/directive.ts Normal file
View File

@ -0,0 +1,34 @@
/**
* Opcje konfiguracji dyrektywy.
*
* Ten interfejs służy wyłącznie do zapewnienia poprawności typów w TypeScript.
* Cała logika przetwarzania odbywa się w transformerach podczas kompilacji.
*/
export interface DirectiveOptions {
selector: string;
inputs?: string[];
outputs?: string[];
standalone?: boolean;
host?: { [key: string]: string };
providers?: any[];
exportAs?: string[];
jit?: boolean;
}
export interface IDirective {
ngOnInit?(): void;
ngOnDestroy?(): void;
ngOnChanges?(changes: Record<string, { previousValue: any; currentValue: any; firstChange: boolean }>): void;
}
/**
* Dekorator dyrektywy.
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformery.
*/
export function Directive(options: DirectiveOptions): ClassDecorator {
return (target: any) => {
return target;
};
}

View File

@ -0,0 +1,4 @@
export function HostBinding(hostPropertyName?: string): PropertyDecorator {
return (target: Object, propertyKey: string | symbol) => {
};
}

View File

@ -0,0 +1,8 @@
export function HostListener(
eventName: string,
args?: string[]
): MethodDecorator {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
return descriptor;
};
}

15
core/angular/index.ts Normal file
View File

@ -0,0 +1,15 @@
export * from './injectable';
export * from './component';
export * from './directive';
export * from './pipe';
export { Input, input, createInput, createRequiredInput } from './input';
export type { InputSignal, InputOptions } from './input';
export { Output, output, createOutput } from './output';
export type { OutputEmitterRef, OutputOptions } from './output';
export * from './host-binding';
export * from './host-listener';
export { Type } from '../module/type';
export { signal, computed, effect } from './signals';
export type { Signal, WritableSignal, EffectRef, CreateSignalOptions, CreateEffectOptions } from './signals';

View File

@ -0,0 +1,23 @@
import { Type } from '../module/type';
/**
* Opcje konfiguracji serwisu.
*
* Ten interfejs służy wyłącznie do zapewnienia poprawności typów w TypeScript.
* Cała logika przetwarzania odbywa się w transformerach podczas kompilacji.
*/
export interface InjectableOptions {
providedIn?: Type<any> | 'root' | 'platform' | 'any' | null;
}
/**
* Dekorator serwisu (Injectable).
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformery.
*/
export function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: any) => {
return target;
};
}

127
core/angular/input.ts Normal file
View File

@ -0,0 +1,127 @@
import { IComponent } from '../module/component';
import { Signal, WritableSignal } from './signals';
const INPUT_SIGNAL = Symbol('inputSignal');
export interface InputSignal<T> extends Signal<T> {
[INPUT_SIGNAL]: true;
}
export interface InputOptions<T, TransformT> {
alias?: string;
transform?: (value: TransformT) => T;
}
interface InputFunction {
<T>(): InputSignal<T | undefined>;
<T>(propertyName: string, component: IComponent): InputSignal<T | undefined>;
<T>(propertyName: string, component: IComponent, initialValue: T, options?: InputOptions<T, T>): InputSignal<T>;
<T, TransformT>(propertyName: string, component: IComponent, initialValue: T, options: InputOptions<T, TransformT>): InputSignal<T>;
required: InputRequiredFunction;
}
interface InputRequiredFunction {
<T>(): InputSignal<T>;
<T>(propertyName: string, component: IComponent, options?: InputOptions<T, T>): InputSignal<T>;
<T, TransformT>(propertyName: string, component: IComponent, options: InputOptions<T, TransformT>): InputSignal<T>;
}
function createInputSignal<T>(
propertyName: string,
component: IComponent,
initialValue: T,
options?: InputOptions<T, any>,
): InputSignal<T> {
let currentValue = initialValue;
const alias = options?.alias ?? propertyName;
const transform = options?.transform;
const getter = (() => {
const element = component._nativeElement;
const inputSignal = element?.__inputs?.[alias];
if (inputSignal) {
const rawValue = inputSignal();
currentValue = transform ? transform(rawValue) : rawValue;
} else if (element) {
const attrValue = element.getAttribute(alias);
if (attrValue !== null) {
currentValue = transform ? transform(attrValue as any) : attrValue as any;
}
}
return currentValue;
}) as InputSignal<T>;
(getter as any)[INPUT_SIGNAL] = true;
return getter;
}
function inputFn<T>(
propertyNameOrInitialValue?: string | T,
componentOrOptions?: IComponent | InputOptions<T, any>,
initialValue?: T,
options?: InputOptions<T, any>,
): InputSignal<T | undefined> {
if (typeof propertyNameOrInitialValue === 'string' && componentOrOptions && '_nativeElement' in (componentOrOptions as any)) {
return createInputSignal(
propertyNameOrInitialValue,
componentOrOptions as IComponent,
initialValue as T,
options,
);
}
return createInputSignal('', {} as IComponent, propertyNameOrInitialValue as T, componentOrOptions as InputOptions<T, any>);
}
function inputRequired<T>(
propertyNameOrOptions?: string | InputOptions<T, any>,
componentOrOptions?: IComponent | InputOptions<T, any>,
options?: InputOptions<T, any>,
): InputSignal<T> {
if (typeof propertyNameOrOptions === 'string' && componentOrOptions && '_nativeElement' in (componentOrOptions as any)) {
return createInputSignal(
propertyNameOrOptions,
componentOrOptions as IComponent,
undefined as T,
options,
);
}
return createInputSignal('', {} as IComponent, undefined as T, propertyNameOrOptions as InputOptions<T, any>);
}
(inputFn as InputFunction).required = inputRequired as InputRequiredFunction;
export const input: InputFunction = inputFn as InputFunction;
export function createInput<T>(
propertyName: string,
component: IComponent,
initialValue?: T,
options?: InputOptions<T, any>,
): InputSignal<T> {
return createInputSignal(propertyName, component, initialValue as T, options);
}
export function createRequiredInput<T>(
propertyName: string,
component: IComponent,
options?: InputOptions<T, any>,
): InputSignal<T> {
return createInputSignal(propertyName, component, undefined as T, options);
}
/**
* Dekorator Input.
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformery.
*/
export function Input(bindingPropertyName?: string): PropertyDecorator {
return (target: Object, propertyKey: string | symbol) => {
};
}

41
core/angular/lifecycle.ts Normal file
View File

@ -0,0 +1,41 @@
export interface OnInit {
ngOnInit(): void;
}
export interface OnDestroy {
ngOnDestroy(): void;
}
export interface AfterViewInit {
ngAfterViewInit(): void;
}
export interface AfterViewChecked {
ngAfterViewChecked(): void;
}
export interface AfterContentInit {
ngAfterContentInit(): void;
}
export interface AfterContentChecked {
ngAfterContentChecked(): void;
}
export interface SimpleChanges {
[key: string]: {
currentValue: any;
previousValue: any;
isFirstChange: boolean;
};
}
export interface OnChanges {
ngOnChanges(changes: SimpleChanges): void;
}
export interface DoCheck {
ngDoCheck(): void;
}

82
core/angular/output.ts Normal file
View File

@ -0,0 +1,82 @@
import { IComponent } from '../module/component';
const OUTPUT_EMITTER = Symbol('outputEmitter');
export interface OutputEmitterRef<T> {
emit(value: T): void;
[OUTPUT_EMITTER]: true;
}
export interface OutputOptions {
alias?: string;
}
interface OutputFunction {
<T = void>(): OutputEmitterRef<T>;
<T = void>(propertyName: string, component: IComponent, options?: OutputOptions): OutputEmitterRef<T>;
}
function createOutputEmitter<T>(
propertyName: string,
component: IComponent,
options?: OutputOptions,
): OutputEmitterRef<T> {
const alias = options?.alias ?? propertyName;
const emitter: OutputEmitterRef<T> = {
alias,
component,
options,
emit: (value: T) => {
const element = component._nativeElement;
if (element) {
const event = new CustomEvent(alias, {
detail: value,
bubbles: true,
cancelable: true,
});
element.dispatchEvent(event);
}
},
[OUTPUT_EMITTER]: true as const,
};
return emitter;
}
function outputFn<T = void>(
propertyNameOrOptions?: string | OutputOptions,
componentOrOptions?: IComponent | OutputOptions,
options?: OutputOptions,
): OutputEmitterRef<T> {
if (typeof propertyNameOrOptions === 'string' && componentOrOptions && '_nativeElement' in (componentOrOptions as any)) {
return createOutputEmitter<T>(
propertyNameOrOptions,
componentOrOptions as IComponent,
options,
);
}
return createOutputEmitter<T>('', {} as IComponent, propertyNameOrOptions as OutputOptions);
}
export const output: OutputFunction = outputFn;
export function createOutput<T = void>(
propertyName: string,
component: IComponent,
options?: OutputOptions,
): OutputEmitterRef<T> {
return createOutputEmitter<T>(propertyName, component, options);
}
/**
* Dekorator Output.
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformery.
*/
export function Output(bindingPropertyName?: string): PropertyDecorator {
return (target: Object, propertyKey: string | symbol) => {
};
}

23
core/angular/pipe.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* Opcje konfiguracji pipe.
*
* Ten interfejs służy wyłącznie do zapewnienia poprawności typów w TypeScript.
* Cała logika przetwarzania odbywa się w transformerach podczas kompilacji.
*/
export interface PipeOptions {
name: string;
standalone?: boolean;
pure?: boolean;
}
/**
* Dekorator pipe.
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformery.
*/
export function Pipe(options: PipeOptions): ClassDecorator {
return (target: any) => {
return target;
};
}

154
core/angular/signals.ts Normal file
View File

@ -0,0 +1,154 @@
import '../global';
import type { InternalEffectRef } from '../global';
const SIGNAL = Symbol('signal');
function getCurrentEffect(): InternalEffectRef | null {
return window.__quarc.currentEffect ?? null;
}
function setCurrentEffect(effect: InternalEffectRef | null): void {
window.__quarc.currentEffect = effect;
}
export type Signal<T> = (() => T) & {
[SIGNAL]: true;
};
export interface WritableSignal<T> extends Signal<T> {
set(value: T): void;
update(updateFn: (value: T) => T): void;
asReadonly(): Signal<T>;
}
export interface EffectRef {
destroy(): void;
}
export interface CreateSignalOptions<T> {
equal?: (a: T, b: T) => boolean;
}
export interface CreateEffectOptions {
manualCleanup?: boolean;
}
export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T> {
let value = initialValue;
const subscribers = new Set<InternalEffectRef>();
const equal = options?.equal ?? Object.is;
const getter = (() => {
const current = getCurrentEffect();
if (current) {
subscribers.add(current);
}
return value;
}) as WritableSignal<T>;
(getter as any)[SIGNAL] = true;
getter.set = (newValue: T) => {
if (!equal(value, newValue)) {
value = newValue;
notifySubscribers(subscribers);
}
};
getter.update = (updateFn: (value: T) => T) => {
getter.set(updateFn(value));
};
getter.asReadonly = (): Signal<T> => {
const readonlyGetter = (() => getter()) as Signal<T>;
(readonlyGetter as any)[SIGNAL] = true;
return readonlyGetter;
};
return getter;
}
export function computed<T>(computation: () => T, options?: CreateSignalOptions<T>): Signal<T> {
let cachedValue: T;
let isDirty = true;
const subscribers = new Set<InternalEffectRef>();
const equal = options?.equal ?? Object.is;
const internalEffect: InternalEffectRef = {
destroy: () => {},
_run: () => {
isDirty = true;
notifySubscribers(subscribers);
},
};
const recompute = () => {
const previousEffect = getCurrentEffect();
setCurrentEffect(internalEffect);
try {
const newValue = computation();
if (!equal(cachedValue, newValue)) {
cachedValue = newValue;
}
} finally {
setCurrentEffect(previousEffect);
}
isDirty = false;
};
recompute();
const getter = (() => {
const current = getCurrentEffect();
if (current) {
subscribers.add(current);
}
if (isDirty) {
recompute();
}
return cachedValue;
}) as Signal<T>;
(getter as any)[SIGNAL] = true;
return getter;
}
export function effect(effectFn: () => void, options?: CreateEffectOptions): EffectRef {
let isDestroyed = false;
const runEffect = () => {
if (isDestroyed) return;
const previousEffect = getCurrentEffect();
setCurrentEffect(effectRef);
try {
effectFn();
} finally {
setCurrentEffect(previousEffect);
}
};
const effectRef: InternalEffectRef = {
destroy: () => {
isDestroyed = true;
},
_run: runEffect,
};
runEffect();
return effectRef;
}
function notifySubscribers(subscribers: Set<InternalEffectRef>) {
const toRun = Array.from(subscribers);
for (const subscriber of toRun) {
subscriber._run?.();
}
}

97
core/core.ts Normal file
View File

@ -0,0 +1,97 @@
import type { ComponentType } from "./module/type";
import { Injector } from "./module/injector";
import type { IComponent } from "./module/component";
import { WebComponent } from "./module/web-component";
import { ComponentRegistry } from "./module/component-registry";
import { WebComponentFactory } from "./module/web-component-factory";
import { Providers } from "./angular/app-config";
import "./global";
export class Core {
static MainComponent: ComponentType<IComponent> | null = null;
private static mainWebComponent: WebComponent | null = null;
private injector = Injector.get();
private registry = ComponentRegistry.get();
private instance: IComponent;
private webComponent?: WebComponent;
private constructor(
private component: ComponentType<IComponent>
) {
this.registry.register(component);
this.instance = {} as IComponent; // Instance will be created when element is connected
}
public static bootstrap(component: ComponentType<any>, providers?: Providers[], element?: HTMLElement): Core {
Core.MainComponent = component;
const instance = new Core(component);
const registry = ComponentRegistry.get();
const dependencies = registry.getAllDependencies(component);
dependencies.forEach(dep => {
if (!registry.isLoaded(dep)) {
instance.preloadComponent(dep);
}
});
element ??= document.querySelector(component._quarcComponent[0].selector) as HTMLElement ?? document.body;
const webComponent = instance.createWebComponent(element);
Core.mainWebComponent = webComponent;
registry.markAsLoaded(component, webComponent);
return instance;
}
private preloadComponent(componentType: ComponentType<IComponent>): void {
this.registry.register(componentType);
}
private createWebComponent(element: HTMLElement): WebComponent {
const webComponent = WebComponentFactory.createFromElement(this.component, element);
this.webComponent = webComponent;
return webComponent;
}
public static getMainWebComponent(): WebComponent | null {
return Core.mainWebComponent;
}
public getWebComponent(): WebComponent | undefined {
return this.webComponent;
}
public static loadComponent(componentType: ComponentType<IComponent>, element?: HTMLElement): WebComponent {
const injector = Injector.get();
const registry = ComponentRegistry.get();
let metadata = registry.getMetadata(componentType);
if (!metadata) {
registry.register(componentType);
metadata = registry.getMetadata(componentType);
}
if (metadata && !metadata.loaded) {
const targetElement = element ?? document.querySelector(componentType._quarcComponent[0].selector) as HTMLElement;
if (!targetElement) {
throw new Error(`Cannot find element for component: ${componentType._quarcComponent[0].selector}`);
}
const webComponent = WebComponentFactory.createFromElement(componentType, targetElement);
registry.markAsLoaded(componentType, webComponent);
return webComponent;
}
return metadata!.webComponent!;
}
public static getRegistry(): ComponentRegistry {
return ComponentRegistry.get();
}
}

49
core/global.ts Normal file
View File

@ -0,0 +1,49 @@
import { Type } from "./module/type";
import { Routes, ActivatedRoute } from "../router";
import { Core } from "./core";
import { Router } from "../router/angular/router";
import type { WebComponent } from "./module/web-component";
import type { ComponentType } from "./module/type";
import type { IComponent } from "./module/component";
export type PluginRoutingMode = 'root' | 'internal';
export interface PluginConfig {
component?: Type<unknown>;
routes?: Routes;
routingMode?: PluginRoutingMode;
selector?: string;
styleUrl?: string;
}
export interface PendingPluginRoute {
pluginId: string;
scriptUrl: string;
selector?: string;
}
export interface InternalEffectRef {
destroy(): void;
_run: () => void;
}
declare global{
interface Window {
__quarc: {
Core?: typeof Core;
router?: Router;
plugins?: Record<string, PluginConfig>;
registeredComponents?: Map<string, typeof WebComponent>;
componentTypes?: Map<string, ComponentType<IComponent>>;
webComponentInstances?: Map<string, WebComponent>;
webComponentIdCounter?: number;
currentEffect?: InternalEffectRef | null;
activatedRouteStack?: ActivatedRoute[];
sharedInstances?: Record<string, any>;
};
}
}
window.__quarc ??= {};
export {};

34
core/index.ts Normal file
View File

@ -0,0 +1,34 @@
// Core types and classes
export { Core } from "./core";
export type { Type, ComponentType, DirectiveType } from "./module/type";
export { Injector, LocalProvider } from "./module/injector";
// Component system
export { IComponent, ViewEncapsulation } from "./module/component";
// Web Components
export { WebComponent } from "./module/web-component";
export { WebComponentFactory } from "./module/web-component-factory";
export { DirectiveRegistry } from "./module/directive-registry";
export { DirectiveRunner, DirectiveInstance } from "./module/directive-runner";
// Decorators
export { Component, ComponentOptions } from "./angular/component";
export { Directive, DirectiveOptions, IDirective } from "./angular/directive";
export { Pipe, PipeOptions } from "./angular/pipe";
export { Injectable, InjectableOptions } from "./angular/injectable";
export { Input, input, createInput, createRequiredInput } from "./angular/input";
export type { InputSignal, InputOptions } from "./angular/input";
export { Output, output, createOutput } from "./angular/output";
export type { OutputEmitterRef, OutputOptions } from "./angular/output";
export { HostBinding } from "./angular/host-binding";
export { HostListener } from "./angular/host-listener";
export { OnInit, OnDestroy } from "./angular/lifecycle";
export { ChangeDetectorRef } from "./angular/change-detector-ref";
export { signal, computed, effect } from "./angular/signals";
export type { Signal, WritableSignal, EffectRef, CreateSignalOptions, CreateEffectOptions } from "./angular/signals";
// types
export type { ApplicationConfig, EnvironmentProviders, PluginConfig, PluginRoutingMode } from "./angular/app-config";
export { ComponentUtils } from "./utils/component-utils";
export { TemplateFragment } from "./module/template-renderer";

View File

@ -0,0 +1,94 @@
import { ComponentType, IComponent, WebComponent } from '../index';
export interface ComponentMetadata {
type: ComponentType<IComponent>;
instance?: IComponent;
webComponent?: WebComponent;
loaded: boolean;
dependencies: ComponentType<IComponent>[] | any[];
}
export class ComponentRegistry {
private static instance: ComponentRegistry;
private components = new Map<ComponentType<IComponent>, ComponentMetadata>();
private componentsBySelector = new Map<string, ComponentType<IComponent>>();
private constructor() {}
static get(): ComponentRegistry {
if (!ComponentRegistry.instance) {
ComponentRegistry.instance = new ComponentRegistry();
}
return ComponentRegistry.instance;
}
register(type: ComponentType<IComponent>, instance?: IComponent): void {
const dependencies = type._quarcComponent[0].imports || [];
this.components.set(type, {
type,
instance,
loaded: false,
dependencies,
});
this.componentsBySelector.set(type._quarcComponent[0].selector, type);
}
markAsLoaded(type: ComponentType<IComponent>, webComponent: WebComponent): void {
const metadata = this.components.get(type);
if (metadata) {
metadata.loaded = true;
metadata.webComponent = webComponent;
}
}
isLoaded(type: ComponentType<IComponent>): boolean {
return this.components.get(type)?.loaded ?? false;
}
getMetadata(type: ComponentType<IComponent>): ComponentMetadata | undefined {
return this.components.get(type);
}
getBySelector(selector: string): ComponentMetadata | undefined {
const type = this.componentsBySelector.get(selector);
return type ? this.components.get(type) : undefined;
}
getWebComponent(type: ComponentType<IComponent>): WebComponent | undefined {
return this.components.get(type)?.webComponent;
}
getDependencies(type: ComponentType<IComponent>): ComponentType<IComponent>[] {
return this.components.get(type)?.dependencies ?? [];
}
getAllDependencies(type: ComponentType<IComponent>): ComponentType<IComponent>[] {
const visited = new Set<ComponentType<IComponent>>();
const dependencies: ComponentType<IComponent>[] = [];
const collectDependencies = (componentType: ComponentType<IComponent>) => {
if (visited.has(componentType)) return;
visited.add(componentType);
const deps = this.getDependencies(componentType);
deps.forEach(dep => {
dependencies.push(dep);
collectDependencies(dep);
});
};
collectDependencies(type);
return dependencies;
}
clear(): void {
this.components.clear();
this.componentsBySelector.clear();
}
getAll(): ComponentMetadata[] {
return Array.from(this.components.values());
}
}

9
core/module/component.ts Normal file
View File

@ -0,0 +1,9 @@
export enum ViewEncapsulation {
None,
ShadowDom,
Emulated,
}
export interface IComponent {
_nativeElement?: HTMLElement;
}

View File

@ -0,0 +1,82 @@
import { DirectiveType, DirectiveOptions } from '../index';
interface DirectiveMetadata {
type: DirectiveType<any>;
options: DirectiveOptions;
selectorMatcher: (element: HTMLElement) => boolean;
}
export class DirectiveRegistry {
private static instance: DirectiveRegistry;
private directives = new Map<DirectiveType<any>, DirectiveMetadata>();
private constructor() {}
static get(): DirectiveRegistry {
if (!DirectiveRegistry.instance) {
DirectiveRegistry.instance = new DirectiveRegistry();
}
return DirectiveRegistry.instance;
}
register(directiveType: DirectiveType<any>): void {
if (this.directives.has(directiveType)) {
return;
}
const options = directiveType._quarcDirective?.[0];
if (!options) {
return;
}
const selectorMatcher = this.createSelectorMatcher(options.selector);
this.directives.set(directiveType, {
type: directiveType,
options,
selectorMatcher,
});
}
private createSelectorMatcher(selector: string): (element: HTMLElement) => boolean {
if (selector.startsWith('[') && selector.endsWith(']')) {
const attrName = selector.slice(1, -1);
return (el: HTMLElement) => el.hasAttribute(attrName);
}
if (selector.startsWith('.')) {
const className = selector.slice(1);
return (el: HTMLElement) => el.classList.contains(className);
}
if (selector.includes('[')) {
return (el: HTMLElement) => el.matches(selector);
}
return (el: HTMLElement) => el.matches(selector);
}
getMatchingDirectives(element: HTMLElement): DirectiveMetadata[] {
const matching: DirectiveMetadata[] = [];
for (const metadata of this.directives.values()) {
if (metadata.selectorMatcher(element)) {
matching.push(metadata);
}
}
return matching;
}
getDirectiveMetadata(directiveType: DirectiveType<any>): DirectiveMetadata | undefined {
return this.directives.get(directiveType);
}
isRegistered(directiveType: DirectiveType<any>): boolean {
return this.directives.has(directiveType);
}
getSelector(directiveType: DirectiveType<any>): string | undefined {
return this.directives.get(directiveType)?.options.selector;
}
}

View File

@ -0,0 +1,211 @@
import {
DirectiveType,
DirectiveRegistry,
Injector,
LocalProvider,
IDirective,
effect,
EffectRef,
WritableSignal,
} from '../index';
import { ActivatedRoute } from '../../router/angular/types';
import { WebComponent } from './web-component';
export interface DirectiveInstance {
directive: IDirective;
element: HTMLElement;
type: DirectiveType<any>;
effects: EffectRef[];
}
export class DirectiveRunner {
private static registry = DirectiveRegistry.get();
static apply(
hostElement: HTMLElement,
scopeId: string,
directiveTypes: DirectiveType<any>[],
): DirectiveInstance[] {
const instances: DirectiveInstance[] = [];
for (const directiveType of directiveTypes) {
this.registry.register(directiveType);
}
for (const directiveType of directiveTypes) {
const selector = directiveType._quarcDirective?.[0]?.selector;
if (!selector) continue;
const scopedSelector = `[_ngcontent-${scopeId}]${selector}`;
const dataBindSelector = this.convertToDataBindSelector(selector, scopeId);
const combinedSelector = `${scopedSelector}, ${dataBindSelector}`;
const elements = hostElement.querySelectorAll(combinedSelector);
for (const el of Array.from(elements)) {
const instance = this.createDirectiveForElement(
directiveType,
el as HTMLElement,
);
if (instance) {
instances.push(instance);
}
}
}
return instances;
}
private static createDirectiveForElement(
directiveType: DirectiveType<any>,
element: HTMLElement,
): DirectiveInstance | null {
const injector = Injector.get();
const localProviders: LocalProvider[] = [
{ provide: HTMLElement, useValue: element },
];
const activatedRoute = this.findActivatedRouteFromElement(element);
localProviders.push({ provide: ActivatedRoute, useValue: activatedRoute });
const directive = injector.createInstanceWithProviders<IDirective>(
directiveType,
localProviders,
);
(directive as any)._nativeElement = element;
const instance: DirectiveInstance = {
directive,
element,
type: directiveType,
effects: [],
};
this.bindInputs(instance, element);
this.bindHostListeners(instance, element);
this.bindHostBindings(instance, element);
if (directive.ngOnInit) {
directive.ngOnInit();
}
return instance;
}
private static bindInputs(instance: DirectiveInstance, element: HTMLElement): void {
const options = instance.type._quarcDirective?.[0];
const inputs = options?.inputs ?? [];
const directive = instance.directive as any;
for (const inputName of inputs) {
const attrValue = element.getAttribute(`[${inputName}]`) ?? element.getAttribute(inputName);
if (attrValue !== null) {
if (typeof directive[inputName] === 'function' && directive[inputName].set) {
directive[inputName].set(attrValue);
} else {
directive[inputName] = attrValue;
}
}
}
}
private static bindHostListeners(instance: DirectiveInstance, element: HTMLElement): void {
const directive = instance.directive as any;
const proto = Object.getPrototypeOf(directive);
if (!proto.__hostListeners) return;
for (const [eventName, methodName] of Object.entries(proto.__hostListeners as Record<string, string>)) {
const handler = (event: Event) => {
if (typeof directive[methodName] === 'function') {
directive[methodName](event);
}
};
element.addEventListener(eventName, handler);
}
}
private static bindHostBindings(instance: DirectiveInstance, element: HTMLElement): void {
const directive = instance.directive as any;
const proto = Object.getPrototypeOf(directive);
if (!proto.__hostBindings) return;
for (const [propertyName, hostProperty] of Object.entries(proto.__hostBindings as Record<string, string>)) {
const eff = effect(() => {
const value = typeof directive[propertyName] === 'function'
? directive[propertyName]()
: directive[propertyName];
if (hostProperty.startsWith('class.')) {
const className = hostProperty.slice(6);
value ? element.classList.add(className) : element.classList.remove(className);
} else if (hostProperty.startsWith('style.')) {
const styleProp = hostProperty.slice(6);
element.style.setProperty(styleProp, value ?? '');
} else if (hostProperty.startsWith('attr.')) {
const attrName = hostProperty.slice(5);
value != null ? element.setAttribute(attrName, String(value)) : element.removeAttribute(attrName);
} else {
(element as any)[hostProperty] = value;
}
});
instance.effects.push(eff);
}
}
static destroyInstances(instances: DirectiveInstance[]): void {
for (const instance of instances) {
for (const eff of instance.effects) {
eff.destroy();
}
if (instance.directive.ngOnDestroy) {
instance.directive.ngOnDestroy();
}
}
}
private static convertToDataBindSelector(selector: string, scopeId: string): string {
const attrMatch = selector.match(/^\[(\w+)\]$/);
if (attrMatch) {
const attrName = attrMatch[1];
const kebabName = this.camelToKebab(attrName);
return `[_ngcontent-${scopeId}][${kebabName}]`;
}
return `[_ngcontent-${scopeId}]${selector}`;
}
private static camelToKebab(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
private static findActivatedRouteFromElement(element: HTMLElement): ActivatedRoute | null {
// Start from the directive's element and go up to find router-outlet
let currentElement: Element | null = element;
while (currentElement) {
// Check if current element is a router-outlet
if (currentElement.tagName.toLowerCase() === 'router-outlet') {
const routerOutlet = (currentElement as WebComponent).componentInstance;
if (routerOutlet && 'activatedRoute' in routerOutlet) {
const route = (routerOutlet as any).activatedRoute;
return route ?? null;
}
}
// Move to parent
currentElement = currentElement.parentElement;
}
// Fallback to global stack
const stack = window.__quarc?.activatedRouteStack;
if (stack && stack.length > 0) {
const route = stack[stack.length - 1];
return route;
}
return null;
}
}

291
core/module/injector.ts Normal file
View File

@ -0,0 +1,291 @@
import { Type } from "../index";
export interface LocalProvider {
provide: Type<any> | any;
useValue: any;
}
export class Injector {
private static instance: Injector;
private instanceCache: Record<string, any> = {};
private dependencyCache: Record<string, any[]> = {};
private sharedInstances: Record<string, any>;
private constructor() {
this.sharedInstances = this.getSharedInstances();
}
private getSharedInstances(): Record<string, any> {
window.__quarc.sharedInstances ??= {};
return window.__quarc.sharedInstances;
}
public static get(): Injector {
if (!Injector.instance) {
Injector.instance = new Injector();
}
return Injector.instance;
}
public createInstance<T>(classType: Type<T>): T {
return this.createInstanceWithProviders(classType, {});
}
public createInstanceWithProvidersOld<T>(classType: Type<T>, localProviders: Record<string, any>): T {
if (!classType) {
throw new Error(`[DI] createInstance called with undefined classType`);
}
const key = (classType as any).__quarc_original_name__ || classType.name;
// Prevent instantiation of built-in classes
if (key === "HTMLElement") {
throw new Error(`[DI] Cannot create instance of HTMLElement`);
}
// First check local cache
if (this.instanceCache[key]) {
return this.instanceCache[key];
}
// Then check shared instances (cross-build sharing)
if (this.sharedInstances[key]) {
const sharedInstance = this.sharedInstances[key];
return sharedInstance;
}
try {
const dependencies = this.resolveDependencies(classType);
const instance = new classType(...dependencies);
this.instanceCache[key] = instance;
this.sharedInstances[key] = instance;
return instance;
} catch (error) {
const className = this.getReadableClassName(classType);
const dependencyInfo = this.getDependencyInfo(classType);
throw new Error(`[DI] Failed to create instance of "${className}": ${(error as Error).message}\nDependencies: ${dependencyInfo}`);
}
}
private convertLocalProvidersToRecord(localProviders: LocalProvider[]): Record<string, any> {
const record: Record<string, any> = {};
for (const provider of localProviders) {
const key = typeof provider.provide === 'string'
? provider.provide
: (provider.provide as any).__quarc_original_name__ || provider.provide.name;
record[key] = provider.useValue;
}
return record;
}
public createInstanceWithProviders<T>(classType: Type<T>, localProviders: Record<string, any>): T;
public createInstanceWithProviders<T>(classType: Type<T>, localProviders: LocalProvider[]): T;
public createInstanceWithProviders<T>(classType: Type<T>, localProviders: Record<string, any> | LocalProvider[]): T {
if (!classType) {
throw new Error(`[DI] createInstanceWithProviders called with undefined classType`);
}
// Convert LocalProvider[] to Record<string, any> if needed
const providersRecord = Array.isArray(localProviders)
? this.convertLocalProvidersToRecord(localProviders)
: localProviders;
try {
const dependencies = this.resolveDependenciesWithProviders(classType, providersRecord);
/** /
console.log({
className: (classType as any).__quarc_original_name__ || classType.name,
localProviders: providersRecord,
dependencies,
classType,
});
/**/
const instance = new classType(...dependencies);
const key = (classType as any).__quarc_original_name__ || classType.name;
this.instanceCache[key] = instance;
return instance;
} catch (error) {
const className = this.getReadableClassName(classType);
const dependencyInfo = this.getDependencyInfo(classType);
throw new Error(`[DI] Failed to create instance of "${className}" with providers: ${(error as Error).message}\nDependencies: ${dependencyInfo}`);
}
}
private getReadableClassName(classType: Type<any>): string {
// Try to get original name from static metadata (saved during compilation)
const staticOriginalName = (classType as any).__quarc_original_name__;
if (staticOriginalName) {
return staticOriginalName;
}
// Try to get from instance metadata
const originalName = (classType as any).__quarc_original_name__;
if (originalName) {
return originalName;
}
// Try to get from constructor name
const constructorName = classType?.name;
if (constructorName && constructorName !== 'Unknown' && constructorName.length > 1) {
return constructorName;
}
// Try to get from selector for components/directives
const metadata = (classType as any)._quarcComponent?.[0] || (classType as any)._quarcDirective?.[0];
if (metadata?.selector) {
return `${metadata.selector} (class)`;
}
console.log({
classType,
metadata,
});
return 'Unknown class';
}
private getDependencyInfo(classType: Type<any>): string {
try {
const paramTypes = this.getConstructorParameterTypes(classType);
if (paramTypes.length === 0) {
return 'none';
}
const dependencyNames = paramTypes.map((depType, index) => {
const depName = depType;
const isUndefined = depType === undefined;
return isUndefined ? `index ${index}: undefined` : `index ${index}: ${depName}`;
});
return dependencyNames.join(', ');
} catch (depError) {
return `failed to resolve: ${(depError as Error).message}`;
}
}
private resolveDependencies(classType: Type<any>): any[] {
const key = (classType as any).__quarc_original_name__ || classType.name;
if (this.dependencyCache[key]) {
const cachedDependencies = this.dependencyCache[key]!;
return cachedDependencies.map(token => {
if (typeof token === 'string') {
// This should not happen in global context
throw new Error(`[DI] Cannot resolve string token in global context: ${token}`);
}
return this.createInstance(token);
});
}
const tokens = this.getConstructorParameterTypes(classType);
this.dependencyCache[key] = tokens;
return tokens.map(token => {
if (typeof token === 'string') {
throw new Error(`[DI] Cannot resolve string token in global context: ${token}`);
}
return this.createInstance(token);
});
}
private resolveDependenciesWithProviders(classType: Type<any>, localProviders: Record<string, any>): any[] {
const tokens = this.getConstructorParameterTypes(classType);
const contextProviders: Record<string, any> = {
...this.sharedInstances,
...this.instanceCache,
...localProviders,
};
return tokens.map(token => {
const dep = this.resolveDependency(token, contextProviders, localProviders);
const depName = dep.__quarc_original_name__ || dep.name;
return dep;
});
}
private resolveDependency(token: any, contextProviders: Record<string, any>, localProviders: Record<string, any>): any {
const tokenName = typeof token === 'string' ? token : (token as any).__quarc_original_name__ || token.name;
// First check local providers (they have highest priority)
if (localProviders[tokenName]) {
const providerValue = localProviders[tokenName];
// If the provider value is a constructor (type), create a new instance
if (typeof providerValue === 'function' && providerValue.prototype && providerValue.prototype.constructor === providerValue) {
return this.createInstanceWithProviders(providerValue, localProviders);
}
return providerValue;
}
// Then check other context providers
if (contextProviders[tokenName]) {
const providerValue = contextProviders[tokenName];
// If the provider value is a constructor (type), create a new instance
if (typeof providerValue === 'function' && providerValue.prototype && providerValue.prototype.constructor === providerValue) {
return this.createInstanceWithProviders(providerValue, localProviders);
}
return providerValue;
}
return this.createInstanceWithProviders(token, localProviders);
}
private getConstructorParameterTypes(classType: Type<any>): any[] {
const className = classType?.name || 'Unknown';
console.log({
className,
classType,
diParams: (classType as any).__di_params__,
});
if (!classType) {
throw new Error(`[DI] Cannot resolve dependencies: classType is undefined`);
}
if ((classType as any).__di_params__) {
const params = (classType as any).__di_params__;
for (let i = 0; i < params.length; i++) {
if (params[i] === undefined) {
throw new Error(
`[DI] Cannot resolve dependency at index ${i} for class "${className}". ` +
`The dependency type is undefined. This usually means:\n` +
` 1. Circular dependency between modules\n` +
` 2. The dependency class is not exported or imported correctly\n` +
` 3. The import is type-only but used for DI`
);
}
}
return params;
}
const reflectMetadata = (Reflect as any).getMetadata;
if (reflectMetadata) {
return reflectMetadata('design:paramtypes', classType) || [];
}
return [];
}
public register<T>(classType: Type<T>, instance: T | Type<T>): void {
const key = (classType as any).__quarc_original_name__ || classType.name;
this.instanceCache[key] = instance;
console.log('injector register', classType, key, instance);
}
public registerShared<T>(classType: Type<T>, instance: T | Type<T>): void {
const key = (classType as any).__quarc_original_name__ || classType.name;
console.log('injector registerShared', classType, key, instance);
this.sharedInstances[key] = instance;
}
public clear(): void {
this.instanceCache = {};
this.dependencyCache = {};
}
}

View File

@ -0,0 +1,249 @@
<!DOCTYPE html>
<html>
<head>
<title>Template Renderer Tests</title>
<style>
body { font-family: monospace; padding: 20px; }
.test { margin: 10px 0; padding: 10px; border: 1px solid #ccc; }
.pass { background: #d4edda; }
.fail { background: #f8d7da; }
pre { background: #f5f5f5; padding: 10px; overflow: auto; }
</style>
</head>
<body>
<h1>Template Renderer Tests</h1>
<div id="results"></div>
<div id="test-container" style="display: none;"></div>
<script>
// Symulacja interfejsu HTMLElement z __quarcContext
const results = document.getElementById('results');
const testContainer = document.getElementById('test-container');
function log(message, data) {
const div = document.createElement('div');
div.innerHTML = `<strong>${message}</strong><pre>${JSON.stringify(data, null, 2)}</pre>`;
results.appendChild(div);
}
function test(name, fn) {
const div = document.createElement('div');
div.className = 'test';
try {
const result = fn();
div.className += ' pass';
div.innerHTML = `✅ ${name}<pre>${JSON.stringify(result, null, 2)}</pre>`;
} catch (e) {
div.className += ' fail';
div.innerHTML = `❌ ${name}<pre>${e.message}\n${e.stack}</pre>`;
}
results.appendChild(div);
}
// Test 1: Sprawdź czy __quarcContext można ustawić na elemencie
test('__quarcContext można ustawić na HTMLElement', () => {
const div = document.createElement('div');
div.__quarcContext = { theme: { name: 'Light', value: 'light' } };
return {
hasContext: !!div.__quarcContext,
context: div.__quarcContext,
};
});
// Test 2: Sprawdź czy __quarcContext jest zachowany po appendChild
test('__quarcContext jest zachowany po appendChild', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
child.__quarcContext = { item: 'test' };
parent.appendChild(child);
return {
hasContext: !!child.__quarcContext,
context: child.__quarcContext,
parentHasChild: parent.contains(child),
};
});
// Test 3: Sprawdź czy __quarcContext jest zachowany po insertBefore
test('__quarcContext jest zachowany po insertBefore', () => {
const parent = document.createElement('div');
const marker = document.createComment('end');
parent.appendChild(marker);
const child = document.createElement('span');
child.__quarcContext = { item: 'test' };
parent.insertBefore(child, marker);
return {
hasContext: !!child.__quarcContext,
context: child.__quarcContext,
childBeforeMarker: child.nextSibling === marker,
};
});
// Test 4: Symulacja renderForItemInPlace dla SELECT
test('renderForItemInPlace dla SELECT', () => {
const select = document.createElement('select');
const endMarker = document.createComment('ngFor-end');
select.appendChild(endMarker);
const template = '<option [attr.value]="theme.value" [innerHTML]="theme.name"></option>';
const loopContext = { theme: { name: 'Light', value: 'light' } };
const tempContainer = document.createElement('div');
tempContainer.innerHTML = template.trim();
const allElements = Array.from(tempContainer.querySelectorAll('*'));
for (const el of allElements) {
el.__quarcContext = loopContext;
}
const children = Array.from(tempContainer.children);
for (const child of children) {
child.__quarcContext = loopContext;
select.insertBefore(child, endMarker);
}
// Sprawdź czy option ma kontekst
const option = select.querySelector('option');
return {
selectChildren: select.children.length,
optionExists: !!option,
optionHasContext: !!option?.__quarcContext,
optionContext: option?.__quarcContext,
optionParent: option?.parentElement?.tagName,
};
});
// Test 5: Sprawdź czy kontekst jest zachowany po przeniesieniu do innego kontenera
test('Kontekst zachowany po przeniesieniu między kontenerami', () => {
// Symulacja: renderedContent -> tempContainer
const renderedContent = document.createDocumentFragment();
const select = document.createElement('select');
renderedContent.appendChild(select);
const endMarker = document.createComment('ngFor-end');
select.appendChild(endMarker);
// Symulacja renderForItemInPlace
const option = document.createElement('option');
option.__quarcContext = { theme: { name: 'Dark', value: 'dark' } };
select.insertBefore(option, endMarker);
// Sprawdź przed przeniesieniem
const beforeMove = {
optionHasContext: !!option.__quarcContext,
optionContext: option.__quarcContext,
};
// Symulacja przeniesienia do tempContainer (jak w render())
const tempContainer = document.createElement('div');
while (renderedContent.firstChild) {
tempContainer.appendChild(renderedContent.firstChild);
}
// Sprawdź po przeniesieniu
const optionAfter = tempContainer.querySelector('option');
const afterMove = {
optionExists: !!optionAfter,
optionHasContext: !!optionAfter?.__quarcContext,
optionContext: optionAfter?.__quarcContext,
optionParent: optionAfter?.parentElement?.tagName,
};
return { beforeMove, afterMove };
});
// Test 6: Sprawdź buildContextForElement
test('buildContextForElement - traversowanie w górę DOM', () => {
const container = document.createElement('div');
container.component = { themes: [] }; // Symulacja komponentu
const select = document.createElement('select');
container.appendChild(select);
const option = document.createElement('option');
option.__quarcContext = { theme: { name: 'Light', value: 'light' } };
select.appendChild(option);
// Symulacja buildContextForElement
const contextChain = [];
let current = option;
const traversed = [];
while (current) {
traversed.push(`${current.tagName}[hasContext=${!!current.__quarcContext}, hasComponent=${!!current.component}]`);
if (current.__quarcContext) {
contextChain.unshift(current.__quarcContext);
}
if (current.component) {
break;
}
current = current.parentElement;
}
return {
traversed,
contextChain,
foundContext: contextChain.length > 0,
};
});
// Test 7: Pełna symulacja flow z render()
test('Pełna symulacja flow render() z SELECT', () => {
const template = `
<select>
<ng-container *ngFor="let theme of themes">
<option [attr.value]="theme.value" [innerHTML]="theme.name"></option>
</ng-container>
</select>
`;
// Krok 1: Parsowanie template
const templateElement = document.createElement('template');
templateElement.innerHTML = template;
const renderedContent = templateElement.content.cloneNode(true);
// Sprawdź strukturę po parsowaniu
const selectInFragment = renderedContent.querySelector('select');
const ngContainerInSelect = selectInFragment?.querySelector('ng-container');
return {
selectExists: !!selectInFragment,
ngContainerExists: !!ngContainerInSelect,
ngContainerParent: ngContainerInSelect?.parentElement?.tagName,
selectChildren: selectInFragment?.children.length,
selectChildTags: Array.from(selectInFragment?.children || []).map(c => c.tagName),
};
});
// Test 8: Sprawdź czy przeglądarka poprawnie parsuje ng-container w SELECT
test('Parsowanie ng-container wewnątrz SELECT', () => {
const div = document.createElement('div');
div.innerHTML = `
<select>
<ng-container>
<option value="1">One</option>
</ng-container>
</select>
`;
const select = div.querySelector('select');
const ngContainer = div.querySelector('ng-container');
const option = div.querySelector('option');
return {
selectExists: !!select,
ngContainerExists: !!ngContainer,
ngContainerParent: ngContainer?.parentElement?.tagName,
optionExists: !!option,
optionParent: option?.parentElement?.tagName,
selectInnerHTML: select?.innerHTML,
};
});
</script>
</body>
</html>

View File

@ -0,0 +1,599 @@
import { IComponent, effect, EffectRef, signal, WritableSignal } from "../index";
import { WebComponent } from "./web-component";
interface NgContainerMarker {
startMarker: Comment;
endMarker: Comment;
condition?: string;
originalTemplate: string;
ngForExpression?: string;
}
declare global {
interface HTMLElement {
templateFragment?: TemplateFragment;
component?: IComponent;
template?: string;
originalContent?: DocumentFragment;
__inputs?: Record<string, WritableSignal<any>>;
__quarcContext?: Record<string, any>;
__effects?: EffectRef[];
}
}
export class TemplateFragment {
public container: HTMLElement;
public component: IComponent;
public template: string;
public originalContent: DocumentFragment;
private ngContainerMarkers: NgContainerMarker[] = [];
private currentContext: any = null;
constructor(
container: HTMLElement,
component: IComponent,
template?: string,
) {
this.container = container;
this.component = component;
this.template = template ?? '';
this.originalContent = document.createDocumentFragment();
while (container.firstChild) {
this.originalContent.appendChild(container.firstChild);
}
container.templateFragment = this;
container.component = component;
container.template = this.template;
container.originalContent = this.originalContent;
}
render(): void {
if (!this.template) return;
const templateElement = document.createElement('template');
templateElement.innerHTML = this.template;
const renderedContent = templateElement.content.cloneNode(true) as DocumentFragment;
// Process structural directives before appending
this.processStructuralDirectives(renderedContent);
// Process property bindings BEFORE adding elements to DOM
// This ensures __inputs is set before child component's connectedCallback runs
const tempContainer = document.createElement('div');
while (renderedContent.firstChild) {
tempContainer.appendChild(renderedContent.firstChild);
}
this.processPropertyBindings(tempContainer);
while (tempContainer.firstChild) {
this.container.appendChild(tempContainer.firstChild);
}
}
private processStructuralDirectives(fragment: DocumentFragment): void {
this.processSelectFor(fragment);
const ngContainers = Array.from(fragment.querySelectorAll('ng-container'));
for (const c of ngContainers) {
this.processNgContainer(c as HTMLElement);
}
}
private processSelectFor(fragment: DocumentFragment): void {
for (const s of Array.from(fragment.querySelectorAll('select,optgroup'))) {
const w = document.createTreeWalker(s, NodeFilter.SHOW_COMMENT);
const m: Comment[] = [];
let n;
while ((n = w.nextNode())) {
if ((n.textContent || '').startsWith('F:')) m.push(n as Comment);
}
for (const c of m) this.expandFor(s as HTMLElement, c);
}
}
private expandFor(p: HTMLElement, m: Comment): void {
const [, v, e] = (m.textContent || '').split(':');
const t: HTMLElement[] = [];
let c: Node | null = m.nextSibling;
while (c && !(c.nodeType === 8 && c.textContent === '/F')) {
if (c.nodeType === 1) t.push(c as HTMLElement);
c = c.nextSibling;
}
if (!t.length) return;
try {
const items = this.evaluateExpression(e);
if (!items) return;
for (const i of Array.isArray(items) ? items : Object.values(items)) {
for (const el of t) {
const cl = el.cloneNode(true) as HTMLElement;
cl.__quarcContext = { [v]: i };
p.insertBefore(cl, m);
}
}
t.forEach(x => x.remove());
m.remove();
c?.parentNode?.removeChild(c);
} catch {}
}
private processNgContainer(ngContainer: HTMLElement): void {
const ngIfAttr = ngContainer.getAttribute('*ngIf');
const ngForAttr = ngContainer.getAttribute('*ngFor');
const parent = ngContainer.parentNode;
if (!parent) return;
// Create marker comments to track ng-container position
let markerComment = 'ng-container-start';
if (ngIfAttr) markerComment += ` *ngIf="${ngIfAttr}"`;
if (ngForAttr) markerComment += ` *ngFor="${ngForAttr}"`;
const startMarker = document.createComment(markerComment);
const endMarker = document.createComment('ng-container-end');
// Store marker information for later re-rendering
const originalTemplate = ngContainer.innerHTML;
this.ngContainerMarkers.push({
startMarker,
endMarker,
condition: ngIfAttr || undefined,
originalTemplate,
ngForExpression: ngForAttr || undefined
});
parent.insertBefore(startMarker, ngContainer);
if (ngForAttr) {
// Handle *ngFor directive
this.processNgForDirective(ngContainer, ngForAttr, parent, endMarker);
} else if (ngIfAttr && !this.evaluateConditionWithContext(ngIfAttr, ngContainer.__quarcContext)) {
// Condition is false - don't render content, just add end marker
parent.insertBefore(endMarker, ngContainer);
ngContainer.remove();
} else {
// Condition is true or no condition - render content between markers
while (ngContainer.firstChild) {
parent.insertBefore(ngContainer.firstChild, ngContainer);
}
parent.insertBefore(endMarker, ngContainer);
ngContainer.remove();
}
}
private processNgForDirective(ngContainer: HTMLElement, ngForExpression: string, parent: Node, endMarker: Comment): void {
const parts = ngForExpression.split(';').map(part => part.trim());
const forPart = parts[0];
const forOfMatch = forPart.match(/^let\s+(\w+)\s+of\s+(.+)$/);
const forInMatch = forPart.match(/^let\s+(\w+)\s+in\s+(.+)$/);
const match = forOfMatch || forInMatch;
const isForIn = !!forInMatch;
if (!match) {
console.warn('Invalid ngFor expression:', ngForExpression);
parent.insertBefore(endMarker, ngContainer);
ngContainer.remove();
return;
}
const variableName = match[1];
const iterableExpression = match[2];
const loopTemplate = ngContainer.innerHTML;
const startMarker = document.createComment(`ngFor-start: ${ngForExpression}`);
const parentContext = ngContainer.__quarcContext;
parent.insertBefore(startMarker, ngContainer);
parent.insertBefore(endMarker, ngContainer);
ngContainer.remove();
const renderLoop = () => {
let current = startMarker.nextSibling;
while (current && current !== endMarker) {
const next = current.nextSibling;
if (current.nodeType === 1) {
TemplateFragment.destroyEffects(current as HTMLElement);
}
current.parentNode?.removeChild(current);
current = next;
}
try {
const iterable = this.evaluateExpressionWithContext(iterableExpression, parentContext);
if (iterable == null) return;
const fragment = document.createDocumentFragment();
if (isForIn) {
for (const key in iterable) {
if (Object.prototype.hasOwnProperty.call(iterable, key)) {
this.renderForItem(fragment, loopTemplate, variableName, key, parentContext);
}
}
} else {
const items = Array.isArray(iterable) ? iterable : Object.values(iterable);
for (const item of items) {
this.renderForItem(fragment, loopTemplate, variableName, item, parentContext);
}
}
parent.insertBefore(fragment, endMarker);
this.reapplyDirectives();
} catch {}
};
this.registerEffect(this.container, effect(renderLoop));
}
private getWebComponent(): WebComponent | null {
let el: HTMLElement | null = this.container;
while (el) {
if (el instanceof WebComponent) {
return el;
}
el = el.parentElement;
}
return null;
}
private reapplyDirectives(): void {
const webComponent = this.getWebComponent();
if (webComponent) {
queueMicrotask(() => webComponent.applyDirectives());
}
}
private renderForItem(fragment: DocumentFragment, template: string, variableName: string, value: any, parentContext?: any): void {
const ctx = { ...parentContext, [variableName]: value };
const t = document.createElement('template');
t.innerHTML = template;
const content = t.content;
for (const el of Array.from(content.querySelectorAll('*'))) {
(el as HTMLElement).__quarcContext = ctx;
}
this.processStructuralDirectivesWithContext(content, ctx);
const tempDiv = document.createElement('div');
while (content.firstChild) {
tempDiv.appendChild(content.firstChild);
}
this.processPropertyBindings(tempDiv);
this.applyScopeAttributes(tempDiv);
while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
}
private getScopeId(): string | null {
let el: HTMLElement | null = this.container;
while (el) {
for (const attr of Array.from(el.attributes)) {
if (attr.name.startsWith('_nghost-')) {
return attr.name.substring(8);
}
}
el = el.parentElement;
}
return null;
}
private applyScopeAttributes(container: HTMLElement): void {
const scopeId = this.getScopeId();
if (!scopeId) return;
const attr = `_ngcontent-${scopeId}`;
container.querySelectorAll('*').forEach(e => e.setAttribute(attr, ''));
Array.from(container.children).forEach(e => e.setAttribute(attr, ''));
}
private processStructuralDirectivesWithContext(fragment: DocumentFragment, ctx: any): void {
const ngContainers = Array.from(fragment.querySelectorAll('ng-container'));
for (const c of ngContainers) {
(c as HTMLElement).__quarcContext = ctx;
this.processNgContainer(c as HTMLElement);
}
}
private evaluateCondition(condition: string): boolean {
try {
return new Function('component', `with(component) { return ${condition}; }`)(this.component);
} catch {
return false;
}
}
private evaluateConditionWithContext(condition: string, ctx?: any): boolean {
try {
const mergedContext = { ...this.component, ...(ctx || {}) };
return new Function('c', `with(c) { return ${condition}; }`)(mergedContext);
} catch {
return false;
}
}
/**
* Re-renders a specific ng-container fragment based on marker position
*/
rerenderFragment(markerIndex: number): void {
if (markerIndex < 0 || markerIndex >= this.ngContainerMarkers.length) {
console.warn('Invalid marker index:', markerIndex);
return;
}
const marker = this.ngContainerMarkers[markerIndex];
const { startMarker, endMarker, condition, originalTemplate } = marker;
// Remove all nodes between markers
let currentNode = startMarker.nextSibling;
while (currentNode && currentNode !== endMarker) {
const nextNode = currentNode.nextSibling;
currentNode.remove();
currentNode = nextNode;
}
// Re-evaluate condition and render if true
if (!condition || this.evaluateCondition(condition)) {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = originalTemplate;
const fragment = document.createDocumentFragment();
while (tempContainer.firstChild) {
fragment.appendChild(tempContainer.firstChild);
}
// Process property bindings on the fragment
const tempWrapper = document.createElement('div');
tempWrapper.appendChild(fragment);
this.processPropertyBindings(tempWrapper);
// Insert processed nodes between markers
const parent = startMarker.parentNode;
if (parent) {
while (tempWrapper.firstChild) {
parent.insertBefore(tempWrapper.firstChild, endMarker);
}
}
}
}
/**
* Re-renders all ng-container fragments
*/
rerenderAllFragments(): void {
for (let i = 0; i < this.ngContainerMarkers.length; i++) {
this.rerenderFragment(i);
}
}
/**
* Gets all ng-container markers for inspection
*/
getFragmentMarkers(): NgContainerMarker[] {
return this.ngContainerMarkers;
}
private processPropertyBindings(container: HTMLElement | DocumentFragment): void {
const allElements = Array.from(container.querySelectorAll('*'));
for (const element of allElements) {
this.currentContext = this.buildContextForElement(element as HTMLElement);
this.processElementBindings(element as HTMLElement);
this.currentContext = null;
}
}
private buildContextForElement(el: HTMLElement): any {
const chain: Record<string, any>[] = [];
let c: HTMLElement | null = el;
while (c) {
if (c.__quarcContext) chain.unshift(c.__quarcContext);
if (c.component) break;
c = c.parentElement;
}
const ctx = Object.create(this.component);
for (const x of chain) Object.assign(ctx, x);
return ctx;
}
private processElementBindings(element: HTMLElement): void {
const attributesToRemove: string[] = [];
const attributes = Array.from(element.attributes);
for (const attr of attributes) {
if (attr.name.startsWith('(') && attr.name.endsWith(')')) {
this.processOutputBinding(element, attr.name, attr.value);
attributesToRemove.push(attr.name);
} else if (attr.name.startsWith('[') && attr.name.endsWith(']')) {
const propertyName = attr.name.slice(1, -1);
const expression = attr.value;
if (propertyName.startsWith('attr.')) {
this.processAttrBinding(element, propertyName.slice(5), expression);
} else if (propertyName.startsWith('style.')) {
this.processStyleBinding(element, propertyName.slice(6), expression);
} else if (propertyName.startsWith('class.')) {
this.processClassBinding(element, propertyName.slice(6), expression);
} else if (this.isCustomElement(element)) {
this.processInputBinding(element, propertyName, expression);
} else {
const camelCaseName = this.kebabToCamel(propertyName);
this.processDomPropertyBinding(element, camelCaseName, expression);
this.processInputBinding(element, camelCaseName, expression);
this.setInputAttribute(element, propertyName, expression);
}
attributesToRemove.push(attr.name);
} else if (attr.name === 'data-bind') {
this.processDataBind(element, attr.value);
attributesToRemove.push(attr.name);
} else if (attr.name.startsWith('data-input-')) {
const propertyName = attr.name.slice(11);
this.processInputBinding(element, propertyName, attr.value);
attributesToRemove.push(attr.name);
} else if (attr.name.startsWith('data-on-')) {
const eventName = attr.name.slice(8);
this.processDataOutputBinding(element, eventName, attr.value);
attributesToRemove.push(attr.name);
} else if (attr.name === 'data-quarc-attr-bindings') {
this.processQuarcAttrBindings(element, attr.value);
attributesToRemove.push(attr.name);
}
}
for (const attrName of attributesToRemove) {
element.removeAttribute(attrName);
}
}
private processQuarcAttrBindings(el: HTMLElement, json: string): void {
try {
const b: { attr: string; expr: string }[] = JSON.parse(json.replace(/&apos;/g, "'").replace(/'/g, '"'));
for (const { attr, expr } of b) this.setAttr(el, attr, this.eval(expr));
} catch {}
}
private isCustomElement(element: HTMLElement): boolean {
return element.tagName.includes('-');
}
private processOutputBinding(element: HTMLElement, attrName: string, expression: string): void {
const eventName = this.camelToKebab(attrName.slice(1, -1));
this.processDataOutputBinding(element, eventName, expression);
}
private processDataOutputBinding(el: HTMLElement, ev: string, expr: string): void {
const ctx = this.currentContext ?? this.component;
el.addEventListener(ev, (e: Event) => {
try { new Function('c', '$event', `with(c){return ${expr}}`)(ctx, (e as CustomEvent).detail ?? e); } catch {}
});
}
private processDataBind(el: HTMLElement, expr: string): void {
const ctx = this.currentContext ?? this.component;
this.registerEffect(el, effect(() => {
try { el.innerHTML = String(this.evalWithContext(expr, ctx) ?? ''); } catch {}
}));
}
private processInputBinding(el: HTMLElement, prop: string, expr: string): void {
if (!el.__inputs) el.__inputs = {};
const ctx = this.currentContext ?? this.component;
const initialValue = this.evalWithContext(expr, ctx);
const s = signal<any>(initialValue);
el.__inputs[prop] = s;
this.registerEffect(el, effect(() => {
try { s.set(this.evalWithContext(expr, ctx)); } catch {}
}));
}
private processAttrBinding(el: HTMLElement, attr: string, expr: string): void {
const ctx = this.currentContext ?? this.component;
this.registerEffect(el, effect(() => {
try { this.setAttr(el, attr, this.evalWithContext(expr, ctx)); } catch {}
}));
}
private setAttr(el: HTMLElement, attr: string, v: any): void {
if (v == null || v === false) el.removeAttribute(attr);
else el.setAttribute(attr, v === true ? '' : String(v));
}
private eval(expr: string): any {
return new Function('c', `with(c){return ${expr}}`)(this.currentContext ?? this.component);
}
private evalWithContext(expr: string, ctx: any): any {
return new Function('c', `with(c){return ${expr}}`)(ctx);
}
private registerEffect(el: HTMLElement, effectRef: EffectRef): void {
if (!el.__effects) el.__effects = [];
el.__effects.push(effectRef);
}
private processStyleBinding(el: HTMLElement, prop: string, expr: string): void {
const ctx = this.currentContext ?? this.component;
const p = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
this.registerEffect(el, effect(() => {
try {
const v = this.evalWithContext(expr, ctx);
v == null || v === false ? el.style.removeProperty(p) : el.style.setProperty(p, String(v));
} catch {}
}));
}
private processClassBinding(el: HTMLElement, cls: string, expr: string): void {
const ctx = this.currentContext ?? this.component;
this.registerEffect(el, effect(() => {
try { this.evalWithContext(expr, ctx) ? el.classList.add(cls) : el.classList.remove(cls); } catch {}
}));
}
private processDomPropertyBinding(el: HTMLElement, prop: string, expr: string): void {
const m: Record<string, string> = { innerhtml: 'innerHTML', textcontent: 'textContent', innertext: 'innerText', classname: 'className' };
const ctx = this.currentContext ?? this.component;
const resolvedProp = m[prop.toLowerCase()] ?? prop;
this.registerEffect(el, effect(() => {
try { (el as any)[resolvedProp] = this.evalWithContext(expr, ctx); } catch {}
}));
}
private evaluateExpression(expr: string): any {
try { return this.eval(expr); } catch { return undefined; }
}
private evaluateExpressionWithContext(expr: string, ctx?: any): any {
try {
const mergedContext = { ...this.component, ...(ctx || {}) };
return new Function('c', `with(c){return ${expr}}`)(mergedContext);
} catch { return undefined; }
}
static getOrCreate(container: HTMLElement, component: IComponent, template?: string): TemplateFragment {
if (container.templateFragment) {
return container.templateFragment;
}
return new TemplateFragment(container, component, template);
}
static destroyEffects(container: HTMLElement): void {
const allElements = container.querySelectorAll('*');
for (const el of Array.from(allElements)) {
const htmlEl = el as HTMLElement;
if (htmlEl.__effects) {
for (const e of htmlEl.__effects) e.destroy();
htmlEl.__effects = [];
}
}
if (container.__effects) {
for (const e of container.__effects) e.destroy();
container.__effects = [];
}
}
private camelToKebab(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
private kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
private setInputAttribute(el: HTMLElement, attrName: string, expr: string): void {
const ctx = this.currentContext ?? this.component;
this.registerEffect(el, effect(() => {
try {
const value = this.evalWithContext(expr, ctx);
if (value == null || value === false) {
el.removeAttribute(attrName);
} else if (value === true) {
el.setAttribute(attrName, '');
} else if (typeof value === 'object') {
el.setAttribute(attrName, JSON.stringify(value));
} else {
el.setAttribute(attrName, String(value));
}
} catch {}
}));
}
}

15
core/module/type.ts Normal file
View File

@ -0,0 +1,15 @@
import { ComponentOptions, DirectiveOptions } from '../index';
export interface Type<T> {
new(...args: any[]): T;
}
export interface ComponentType<T> extends Type<T> {
_quarcComponent: [ComponentOptions];
_quarcDirectives?: DirectiveType<any>[];
_scopeId: string;
}
export interface DirectiveType<T> extends Type<T> {
_quarcDirective: [DirectiveOptions];
}

View File

@ -0,0 +1,221 @@
import { IComponent, WebComponent, Injector, LocalProvider, ComponentType, ComponentUtils, ChangeDetectorRef } from '../index';
import { ActivatedRoute } from '../../router';
import '../global';
export class WebComponentFactory {
private static get registeredComponents(): Map<string, typeof WebComponent> {
window.__quarc.registeredComponents ??= new Map();
return window.__quarc.registeredComponents;
}
private static get componentTypes(): Map<string, ComponentType<IComponent>> {
window.__quarc.componentTypes ??= new Map();
return window.__quarc.componentTypes;
}
static registerWithDependencies(componentType: ComponentType<IComponent>): boolean {
const selector = ComponentUtils.getSelector(componentType);
const tagName = ComponentUtils.selectorToTagName(selector);
if (this.registeredComponents.has(tagName)) {
return false;
}
const componentMeta = componentType._quarcComponent?.[0];
if (!componentMeta) {
console.warn(`Component ${componentType.name} has no _quarcComponent metadata`);
return false;
}
const imports = componentMeta.imports || [];
for (const importItem of imports) {
if (ComponentUtils.isComponentType(importItem)) {
const depType = importItem as ComponentType<IComponent>;
this.registerWithDependencies(depType);
}
}
return this.tryRegister(componentType);
}
static tryRegister(componentType: ComponentType<IComponent>): boolean {
const selector = ComponentUtils.getSelector(componentType);
const tagName = ComponentUtils.selectorToTagName(selector);
if (this.registeredComponents.has(tagName)) {
return false;
}
try {
const WebComponentClass = class extends WebComponent {
constructor() {
super();
}
connectedCallback(): void {
const compType = WebComponentFactory.componentTypes.get(tagName);
if (compType && !this.isInitialized()) {
const instance = WebComponentFactory.createComponentInstance(compType, this);
this.setComponentInstance(instance, compType);
}
super.connectedCallback();
}
};
customElements.define(tagName, WebComponentClass);
this.registeredComponents.set(tagName, WebComponentClass);
this.componentTypes.set(tagName, componentType);
return true;
} catch (error) {
console.warn(`Failed to register component ${tagName}:`, error);
return false;
}
}
private static getWebComponentInstances(): Map<string, WebComponent> {
window.__quarc.webComponentInstances ??= new Map();
return window.__quarc.webComponentInstances;
}
private static generateWebComponentId(): string {
window.__quarc.webComponentIdCounter ??= 0;
return `wc-${window.__quarc.webComponentIdCounter++}`;
}
static createComponentInstance(componentType: ComponentType<IComponent>, element: HTMLElement): IComponent {
const injector = Injector.get();
const webComponent = element as WebComponent;
const webComponentId = this.generateWebComponentId();
this.getWebComponentInstances().set(webComponentId, webComponent);
//const changeDetectorRef = new ChangeDetectorRef(webComponentId);
const localProviders: Record<string, any> = {
HTMLElement: element,
//ChangeDetectorRef: changeDetectorRef,
ActivatedRoute: this.findActivatedRouteFromElement(element),
};
const componentMeta = componentType._quarcComponent?.[0];
if (componentMeta?.providers) {
for (const providerType of componentMeta.providers) {
if (typeof providerType === 'function' && !localProviders[providerType]) {
const providerInstance = injector.createInstanceWithProviders(providerType, localProviders);
const provider = providerType.__quarc_original_name__ || providerType.name || providerType.constructor?.name || providerType;
localProviders[provider] = providerInstance;
}
}
}
return injector.createInstanceWithProviders<IComponent>(componentType, localProviders);
}
private static findActivatedRouteFromElement(element: HTMLElement): ActivatedRoute | null {
// Start from the component's element and go up to find router-outlet
let currentElement: Element | null = element;
let depth = 0;
const elementPath: string[] = [];
// Log the starting element
while (currentElement) {
elementPath.push(`${currentElement.tagName.toLowerCase()}${currentElement.id ? '#' + currentElement.id : ''}${currentElement.className ? '.' + currentElement.className.replace(/\s+/g, '.') : ''}`);
// Check if current element is a router-outlet
if (currentElement.tagName.toLowerCase() === 'router-outlet') {
const routerOutlet = (currentElement as WebComponent).componentInstance;
if (routerOutlet && 'activatedRoute' in routerOutlet) {
const route = (routerOutlet as any).activatedRoute;
return route ?? null;
}
}
// Move to parent
currentElement = currentElement.parentElement;
depth++;
}
// Fallback to global stack
const stack = window.__quarc?.activatedRouteStack;
if (stack && stack.length > 0) {
const route = stack[stack.length - 1];
return route;
}
return null;
}
static create(componentType: ComponentType<IComponent>, selector?: string): WebComponent {
const targetSelector = selector ?? ComponentUtils.getSelector(componentType);
const tagName = ComponentUtils.selectorToTagName(targetSelector);
this.registerWithDependencies(componentType);
let element = document.querySelector(tagName) as WebComponent;
if (!element) {
element = document.createElement(tagName) as WebComponent;
document.body.appendChild(element);
}
return element;
}
static createInElement(componentType: ComponentType<IComponent>, parent: HTMLElement): WebComponent {
const tagName = ComponentUtils.selectorToTagName(ComponentUtils.getSelector(componentType));
this.registerWithDependencies(componentType);
const element = document.createElement(tagName) as WebComponent;
parent.appendChild(element);
return element;
}
static createFromElement(componentType: ComponentType<IComponent>, element: HTMLElement): WebComponent {
const tagName = ComponentUtils.selectorToTagName(ComponentUtils.getSelector(componentType));
this.registerWithDependencies(componentType);
if (element.tagName.toLowerCase() === tagName) {
const webComponent = element as WebComponent;
// Jeśli element już jest w DOM, ręcznie zainicjalizuj komponent
if (!webComponent.isInitialized()) {
const instance = this.createComponentInstance(componentType, webComponent);
webComponent.setComponentInstance(instance, componentType);
}
return webComponent;
}
const newElement = document.createElement(tagName) as WebComponent;
element.replaceWith(newElement);
return newElement;
}
static isRegistered(selector: string): boolean {
const tagName = ComponentUtils.selectorToTagName(selector);
return this.registeredComponents.has(tagName);
}
static getRegisteredTagName(selector: string): string | undefined {
const tagName = ComponentUtils.selectorToTagName(selector);
return this.registeredComponents.has(tagName) ? tagName : undefined;
}
}
export function createWebComponent(
componentType: ComponentType<IComponent>,
selectorOrElement?: string | HTMLElement,
): WebComponent {
if (!selectorOrElement) {
return WebComponentFactory.create(componentType);
}
if (typeof selectorOrElement === 'string') {
return WebComponentFactory.create(componentType, selectorOrElement);
}
return WebComponentFactory.createFromElement(componentType, selectorOrElement);
}

View File

@ -0,0 +1,277 @@
import {
IComponent,
ViewEncapsulation,
TemplateFragment,
ComponentType,
ComponentOptions,
DirectiveRunner,
DirectiveInstance,
effect,
EffectRef,
} from '../index';
interface QuarcScopeRegistry {
counter: number;
scopeMap: Map<string, string>;
injectedStyles: Set<string>;
}
declare global {
interface Window {
__quarcScopeRegistry?: QuarcScopeRegistry;
}
}
function getScopeRegistry(): QuarcScopeRegistry {
if (!window.__quarcScopeRegistry) {
window.__quarcScopeRegistry = {
counter: 0,
scopeMap: new Map(),
injectedStyles: new Set(),
};
}
return window.__quarcScopeRegistry;
}
function getUniqueScopeId(compiledScopeId: string): string {
const registry = getScopeRegistry();
if (!registry.scopeMap.has(compiledScopeId)) {
registry.scopeMap.set(compiledScopeId, `q${registry.counter++}`);
}
return registry.scopeMap.get(compiledScopeId)!;
}
export interface AttributeInfo {
name: string;
value: string | null;
}
export interface ChildElementInfo {
tagName: string;
element: Element;
attributes: AttributeInfo[];
textContent: string | null;
}
export class WebComponent extends HTMLElement {
public componentInstance?: IComponent;
private componentType?: ComponentType<IComponent>;
private compiledScopeId?: string;
private runtimeScopeId?: string;
private _shadowRoot?: ShadowRoot;
private _initialized = false;
private directiveInstances: DirectiveInstance[] = [];
private renderEffect?: EffectRef;
private isRendering = false;
constructor() {
super();
}
setComponentInstance(component: IComponent, componentType: ComponentType<IComponent>): void {
this.componentInstance = component;
this.componentType = componentType;
if (componentType._scopeId) {
this.compiledScopeId = componentType._scopeId;
this.runtimeScopeId = getUniqueScopeId(componentType._scopeId);
}
this.initialize();
}
getComponentOptions(): ComponentOptions {
return this.componentType!._quarcComponent[0];
}
isInitialized(): boolean {
return this._initialized;
}
connectedCallback(): void {
if (this.componentInstance) {
this.initialize();
}
}
disconnectedCallback(): void {
this.destroy();
}
private initialize(): void {
if (!this.componentInstance || !this.componentType || this._initialized) return;
const encapsulation = this.componentType._quarcComponent[0].encapsulation ?? ViewEncapsulation.Emulated;
if (encapsulation === ViewEncapsulation.ShadowDom && !this._shadowRoot) {
this._shadowRoot = this.attachShadow({ mode: 'open' });
} else if (encapsulation === ViewEncapsulation.Emulated && this.runtimeScopeId) {
this.setAttribute(`_nghost-${this.runtimeScopeId}`, '');
}
this._initialized = true;
this.renderComponent();
}
renderComponent(): void {
if (!this.componentInstance || !this.componentType) return;
const style = this.componentType._quarcComponent[0].style ?? '';
const encapsulation = this.componentType._quarcComponent[0].encapsulation ?? ViewEncapsulation.Emulated;
const renderTarget = this._shadowRoot ?? this;
if (style) {
if (encapsulation === ViewEncapsulation.ShadowDom) {
const styleElement = document.createElement('style');
styleElement.textContent = style;
renderTarget.appendChild(styleElement);
} else if (encapsulation === ViewEncapsulation.Emulated && this.runtimeScopeId) {
const registry = getScopeRegistry();
if (!registry.injectedStyles.has(this.runtimeScopeId)) {
const styleElement = document.createElement('style');
styleElement.textContent = this.transformScopeAttributes(style);
styleElement.setAttribute('data-scope-id', this.runtimeScopeId);
document.head.appendChild(styleElement);
registry.injectedStyles.add(this.runtimeScopeId);
}
} else if (encapsulation === ViewEncapsulation.None) {
const styleElement = document.createElement('style');
styleElement.textContent = style;
renderTarget.appendChild(styleElement);
}
}
this.renderEffect = effect(() => this.renderTemplate());
queueMicrotask(() => {
this.callNgOnInit();
});
}
private renderTemplate(): void {
if (!this.componentInstance || !this.componentType) return;
if (this.isRendering) return;
this.isRendering = true;
const template = this.componentType._quarcComponent[0].template ?? '';
const encapsulation = this.componentType._quarcComponent[0].encapsulation ?? ViewEncapsulation.Emulated;
const renderTarget = this._shadowRoot ?? this;
DirectiveRunner.destroyInstances(this.directiveInstances);
this.directiveInstances = [];
TemplateFragment.destroyEffects(renderTarget as HTMLElement);
while (renderTarget.firstChild) {
renderTarget.removeChild(renderTarget.firstChild);
}
const templateFragment = new TemplateFragment(
renderTarget as HTMLElement,
this.componentInstance,
template,
);
templateFragment.render();
if (encapsulation === ViewEncapsulation.Emulated && this.runtimeScopeId) {
this.applyScopeAttributes(renderTarget as HTMLElement);
}
this.isRendering = false;
queueMicrotask(() => {
this.applyDirectives();
});
}
rerender(): void {
if (!this.componentInstance || !this.componentType || !this._initialized) return;
this.renderTemplate();
}
public applyDirectives(): void {
const directives = this.componentType?._quarcDirectives;
if (!directives || directives.length === 0 || !this.runtimeScopeId) {
return;
}
const renderTarget = this._shadowRoot ?? this;
this.directiveInstances = DirectiveRunner.apply(
renderTarget as HTMLElement,
this.runtimeScopeId,
directives,
);
}
getAttributes(): AttributeInfo[] {
return Array.from(this.attributes).map(a => ({ name: a.name, value: a.value }));
}
private toChildInfo(el: Element): ChildElementInfo {
return {
tagName: el.tagName.toLowerCase(),
element: el,
attributes: Array.from(el.attributes).map(a => ({ name: a.name, value: a.value })),
textContent: el.textContent,
};
}
getChildElements(): ChildElementInfo[] {
return Array.from((this._shadowRoot ?? this).querySelectorAll('*')).map(e => this.toChildInfo(e));
}
getChildElementsByTagName(tag: string): ChildElementInfo[] {
return this.getChildElements().filter(c => c.tagName === tag.toLowerCase());
}
getChildElementsBySelector(sel: string): ChildElementInfo[] {
return Array.from((this._shadowRoot ?? this).querySelectorAll(sel)).map(e => this.toChildInfo(e));
}
getHostElement(): HTMLElement {
return this;
}
getShadowRoot(): ShadowRoot | undefined {
return this._shadowRoot;
}
private applyScopeAttributes(c: HTMLElement): void {
if (!this.runtimeScopeId) return;
const a = `_ngcontent-${this.runtimeScopeId}`;
c.querySelectorAll('*').forEach(e => e.setAttribute(a, ''));
Array.from(c.children).forEach(e => e.setAttribute(a, ''));
}
private transformScopeAttributes(css: string): string {
if (!this.compiledScopeId || !this.runtimeScopeId) return css;
return css
.replace(new RegExp(`_nghost-${this.compiledScopeId}`, 'g'), `_nghost-${this.runtimeScopeId}`)
.replace(new RegExp(`_ngcontent-${this.compiledScopeId}`, 'g'), `_ngcontent-${this.runtimeScopeId}`);
}
destroy(): void {
this.callNgOnDestroy();
this.renderEffect?.destroy();
DirectiveRunner.destroyInstances(this.directiveInstances);
this.directiveInstances = [];
const renderTarget = this._shadowRoot ?? this;
TemplateFragment.destroyEffects(renderTarget as HTMLElement);
while (renderTarget.firstChild) {
renderTarget.removeChild(renderTarget.firstChild);
}
this._initialized = false;
}
private callNgOnInit(): void {
if (this.componentInstance && 'ngOnInit' in this.componentInstance) {
(this.componentInstance as any).ngOnInit();
}
}
private callNgOnDestroy(): void {
if (this.componentInstance && 'ngOnDestroy' in this.componentInstance) {
(this.componentInstance as any).ngOnDestroy();
}
}
}

11
core/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "@quarc/core",
"version": "1.0.0",
"description": "Lightweight Angular-like framework core",
"main": "main.ts",
"types": "main.ts",
"dependencies": {},
"devDependencies": {
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,5 @@
// Wspólne typy dla optymalizacji rozmiaru
export type EmptyObj = Record<string, never>;
export type StringMap = Record<string, string>;
export type AnyFunction = (...args: any[]) => any;
export type ComponentSelector = string;

View File

@ -0,0 +1,21 @@
import { Type, IComponent } from '../index';
export class ComponentUtils {
static selectorToTagName(selector: string): string {
return selector.toLowerCase().replace(/[^a-z0-9-]/g, '-');
}
static isComponentType(item: any): boolean {
if (typeof item === 'function' && item._quarcComponent) {
return true;
}
if (item && typeof item === 'object' && item._quarcComponent) {
return true;
}
return false;
}
static getSelector(componentType: Type<IComponent>): string {
return componentType._quarcComponent?.[0]?.selector || '';
}
}

View File

@ -0,0 +1,90 @@
import { Core, Type, ComponentType, ApplicationConfig, PluginConfig, WebComponentFactory } from "../core";
import { PluginConfig as GlobalPluginConfig } from "../core/global";
import "../core/global";
function loadStylesheet(url: string, pluginId: string): void {
const existingLink = document.querySelector(`link[data-plugin-id="${pluginId}"]`);
if (existingLink) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
link.dataset.pluginId = pluginId;
document.head.appendChild(link);
}
function loadExternalScript(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.type = "module";
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load: ${url}`));
document.head.appendChild(script);
});
}
async function tryLoadExternalScripts(urls: string | string[]): Promise<void> {
const urlList = Array.isArray(urls) ? urls : [urls];
for (const url of urlList) {
try {
await loadExternalScript(url);
return;
} catch {
console.warn(`[External] Could not load from: ${url}`);
}
}
console.info("[External] No external scripts loaded - app continues without enhancements");
}
export async function bootstrapApplication(
component: Type<unknown>,
options?: ApplicationConfig | undefined,
): Promise<unknown> {
const instance = Core.bootstrap(component as ComponentType<any>, options?.providers);
if (options?.externalUrls) {
tryLoadExternalScripts(options.externalUrls);
}
if (options?.enablePlugins) {
window.__quarc ??= {};
window.__quarc.Core = Core;
window.__quarc.plugins ??= {};
}
return instance;
}
export async function bootstrapPlugin(
pluginId: string,
component: Type<unknown>,
options?: PluginConfig | undefined,
): Promise<string> {
const componentType = component as ComponentType<any>;
const selector = componentType._quarcComponent?.[0]?.selector;
if (!selector) {
throw new Error(`Plugin component must have a selector defined`);
}
window.__quarc.plugins ??= {};
window.__quarc.plugins[pluginId] ??= {};
window.__quarc.plugins[pluginId].component = component;
window.__quarc.plugins[pluginId].selector = selector;
window.__quarc.plugins[pluginId].routingMode = options?.routingMode ?? 'internal';
if (options?.styleUrl) {
window.__quarc.plugins[pluginId].styleUrl = options.styleUrl;
loadStylesheet(options.styleUrl, pluginId);
}
WebComponentFactory.registerWithDependencies(componentType);
return selector;
}

View File

@ -0,0 +1 @@
export { bootstrapApplication, bootstrapPlugin } from "./browser";

View File

@ -0,0 +1,13 @@
{
"name": "@quarc/platform-browser",
"version": "1.0.0",
"description": "Lightweight Angular-like framework platform-browser",
"main": "main.ts",
"types": "main.ts",
"dependencies": {
"@quarc/core": "file:../core"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,44 @@
import { EnvironmentProviders, Injector, PluginRoutingMode } from "../../core";
import { Routes } from "./types";
import { Router } from "./router";
import "../../core/global";
import { RouterLink } from "../directives/router-link.directive";
export interface PluginRouterOptions {
pluginId: string;
routingMode?: PluginRoutingMode;
}
export function provideRouter(routes: Routes, options?: PluginRouterOptions): EnvironmentProviders {
const injector = Injector.get();
if (!window.__quarc.router) {
window.__quarc.router ??= new Router(routes);;
} else {
if (options?.pluginId) {
window.__quarc.plugins ??= {};
window.__quarc.plugins[options.pluginId] ??= {};
window.__quarc.plugins[options.pluginId].routes = routes;
window.__quarc.plugins[options.pluginId].routingMode = options.routingMode ?? 'internal';
if (options.routingMode === 'root') {
window.__quarc.router.resetConfig([
...window.__quarc.router.config,
...routes,
]);
}
} else {
window.__quarc.router.resetConfig([
...window.__quarc.router.config,
...routes,
]);
}
// Ensure the existing router is also shared for plugins
}
injector.registerShared(Router, window.__quarc.router);
injector.registerShared(RouterLink, RouterLink);
return window.__quarc.router;
}

197
router/angular/router.ts Normal file
View File

@ -0,0 +1,197 @@
import { computed, effect, EnvironmentProviders, signal } from "../../core";
import { Subject } from "../../rxjs";
import { ActivatedRoute, NavigationExtras, Route, Routes } from "./types";
export interface NavigationEvent {
url: string;
previousUrl: string;
}
export interface RouterOutletRef {
onNavigationChange(event: NavigationEvent): void;
}
export class Router implements EnvironmentProviders {
public readonly events$ = new Subject<NavigationEvent>();
public readonly routes = signal<Route[]>([]);
public readonly activeRoutes = signal<ActivatedRoute[]>([]);
private rootOutlets: Set<RouterOutletRef> = new Set();
private currentUrl = signal<string>("/");
private readonly activatedRoutePaths = computed<string[]>(() => {
return this.activeRoutes().map(route => this.generateAbsolutePath(route));
});
public constructor(
public config: Routes,
) {
this.currentUrl.set(location.pathname);
this.setupPopStateListener();
this.initializeRouteParents(this.config, null);
this.routes.set(this.config);
}
private initializeRouteParents(routes: Route[], parent: Route | null): void {
for (const route of routes) {
route.parent = parent;
if (route.children) {
this.initializeRouteParents(route.children, route);
}
}
}
public generateAbsolutePath(route: ActivatedRoute): string {
const routes: ActivatedRoute[] = [];
routes.push(route);
while (route.parent) {
routes.push(route);
route = route.parent;
}
routes.reverse();
const paths = routes.map(route => route.path || '').filter(path => path.length > 0);
return paths.join('/');
}
public resetConfig(routes: Route[]) {
this.config = routes;
this.initializeRouteParents(routes, null);
this.routes.set([...routes]);
this.refresh();
}
public refresh(): void {
this.emitNavigationEvent(this.currentUrl());
}
private isRouteMatch(activatedRoute: ActivatedRoute, route: Route): boolean {
return activatedRoute.routeConfig === route ||
(activatedRoute.path === route.path &&
activatedRoute.component === route.component &&
activatedRoute.loadComponent === route.loadComponent);
}
public registerActiveRoute(route: ActivatedRoute): void {
const current = this.activeRoutes();
if (!current.includes(route)) {
this.activeRoutes.set([...current, route]);
}
}
public unregisterActiveRoute(route: ActivatedRoute): void {
const current = this.activeRoutes();
this.activeRoutes.set(current.filter(r => r !== route));
}
public clearActiveRoutes(): void {
this.activeRoutes.set([]);
}
private withoutLeadingSlash(path: string): string {
return path.startsWith('/') ? path.slice(1) : path;
}
private setupPopStateListener(): void {
window.addEventListener('popstate', () => {
this.emitNavigationEvent(location.pathname);
});
}
private emitNavigationEvent(newUrl: string): void {
const event: NavigationEvent = {
url: newUrl,
previousUrl: this.currentUrl(),
};
this.currentUrl.set(newUrl);
this.events$.next(event);
this.notifyRootOutlets(event);
}
private notifyRootOutlets(event: NavigationEvent): void {
for (const outlet of this.rootOutlets) {
outlet.onNavigationChange(event);
}
}
public registerRootOutlet(outlet: RouterOutletRef): void {
this.rootOutlets.add(outlet);
}
public unregisterRootOutlet(outlet: RouterOutletRef): void {
this.rootOutlets.delete(outlet);
}
public navigateByUrl(url: string, extras?: NavigationExtras): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let finalUrl = url;
// Jeśli URL nie zaczyna się od /, to jest relatywny
if (!url.startsWith('/')) {
if (extras?.relativeTo) {
const basePath = extras.relativeTo.snapshot.url.join('/');
finalUrl = basePath ? '/' + basePath + '/' + url : '/' + url;
} else {
finalUrl = '/' + url;
}
}
// Normalizuj URL - usuń podwójne slashe i trailing slash (oprócz root)
finalUrl = finalUrl.replace(/\/+/g, '/');
if (finalUrl.length > 1 && finalUrl.endsWith('/')) {
finalUrl = finalUrl.slice(0, -1);
}
if (!extras?.skipLocationChange) {
if (extras?.replaceUrl) {
history.replaceState(finalUrl, '', finalUrl);
} else {
history.pushState(finalUrl, '', finalUrl);
}
}
this.emitNavigationEvent(finalUrl);
resolve(true);
});
}
public navigate(commands: readonly any[], extras?: NavigationExtras): Promise<boolean> {
const url = this.createUrlFromCommands(commands, extras);
return this.navigateByUrl(url, extras);
}
private createUrlFromCommands(commands: readonly any[], extras?: NavigationExtras): string {
let path: string;
if (extras?.relativeTo) {
const basePath = extras.relativeTo.snapshot.url.join('/') || '';
path = '/' + basePath + '/' + commands.join('/');
} else {
path = '/' + commands.join('/');
}
if (extras?.queryParams) {
const queryString = this.serializeQueryParams(extras.queryParams);
if (queryString) {
path += '?' + queryString;
}
}
return path;
}
private serializeQueryParams(params: Record<string, any>, prefix: string = ''): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) {
continue;
}
const paramKey = prefix ? `${prefix}[${key}]` : key;
if (typeof value === 'object' && !Array.isArray(value)) {
parts.push(this.serializeQueryParams(value, paramKey));
} else {
parts.push(`${encodeURIComponent(paramKey)}=${encodeURIComponent(String(value))}`);
}
}
return parts.filter(p => p).join('&');
}
}

148
router/angular/types.ts Normal file
View File

@ -0,0 +1,148 @@
import type { IComponent, Type } from "../../core";
import { BehaviorSubject } from "../../rxjs";
export type LoadChildrenCallback = () => Promise<Route[]>;
export type ComponentLoader = () => Promise<string>;
export interface Route {
path?: string;
data?: object;
component?: Type<any> | string | ComponentLoader;
loadComponent?: () => Promise<Type<IComponent>>;
children?: Routes;
loadChildren?: LoadChildrenCallback;
parent?: Route | null;
}
export type Routes = Route[];
export interface Params {
[key: string]: any;
}
export 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,
) {}
}
export class ActivatedRoute implements Route {
public __quarc_original_name__? = 'ActivatedRoute';
path?: string;
data?: object;
component?: Type<any> | string | ComponentLoader;
loadComponent?: () => Promise<Type<IComponent>>;
children?: ActivatedRoute[];
loadChildren?: LoadChildrenCallback;
parent?: ActivatedRoute | null = null;
outlet: string = 'primary';
private readonly _params = new BehaviorSubject<Params>({});
private readonly _queryParams = new BehaviorSubject<Params>({});
private readonly _fragment = new BehaviorSubject<string | null>(null);
private readonly _url = new BehaviorSubject<string[]>([]);
private _snapshot: ActivatedRouteSnapshot = new ActivatedRouteSnapshot();
get params(): BehaviorSubject<Params> {
return this._params;
}
get queryParams(): BehaviorSubject<Params> {
return this._queryParams;
}
get fragment(): BehaviorSubject<string | null> {
return this._fragment;
}
get url(): BehaviorSubject<string[]> {
return this._url;
}
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 {
const paramsChanged = !this.areParamsEqual(this._snapshot.params, params);
const queryParamsChanged = !this.areParamsEqual(this._snapshot.queryParams, queryParams);
const fragmentChanged = this._snapshot.fragment !== fragment;
const urlChanged = this._snapshot.url.join('/') !== url.join('/');
// IMPORTANT: Always update URL for proper child outlet routing
// Even if URL appears unchanged, the segments might be different for parent routes
this._snapshot = new ActivatedRouteSnapshot(path, params, queryParams, fragment, url, routeConfig ?? null);
if (paramsChanged) {
this._params.next(params);
}
if (queryParamsChanged) {
this._queryParams.next(queryParams);
}
if (fragmentChanged) {
this._fragment.next(fragment);
}
if (urlChanged) {
this._url.next(url);
} else {
// Force URL update for child outlet routing even if unchanged
this._url.next(url);
}
}
private areParamsEqual(params1: Params, params2: Params): boolean {
const keys1 = Object.keys(params1);
const keys2 = Object.keys(params2);
if (keys1.length !== keys2.length) return false;
return keys1.every(key => params1[key] === params2[key]);
}
}
export interface UrlCreationOptions {
relativeTo?: ActivatedRoute | null | undefined;
//queryParams?: Params | null | undefined;
//fragment?: string | undefined;
//queryParamsHandling?: QueryParamsHandling | null | undefined;
//preserveFragment?: boolean | undefined;
}
export interface NavigationBehaviorOptions {
//onSameUrlNavigation?: OnSameUrlNavigation | undefined;
skipLocationChange?: boolean | undefined;
replaceUrl?: boolean | undefined;
//state?: { [k: string]: any; } | undefined;
//readonly info?: unknown;
//readonly browserUrl?: string | UrlTree | undefined;
}
export interface NavigationExtras extends UrlCreationOptions, NavigationBehaviorOptions {
relativeTo?: ActivatedRoute | null;
queryParams?: Params | null | undefined;
//fragment?: string | undefined;
//queryParamsHandling?: QueryParamsHandling | null | undefined;
//preserveFragment?: boolean | undefined;
//onSameUrlNavigation?: OnSameUrlNavigation | undefined;
skipLocationChange?: boolean | undefined;
replaceUrl?: boolean | undefined;
//state?: { [k: string]: any; } | undefined;
//readonly info?: unknown;
//readonly browserUrl?: string | UrlTree | undefined;
}

View File

@ -0,0 +1,332 @@
import { Component, IComponent, ComponentType, WebComponent, WebComponentFactory } from "../../../core";
import { Subject } from "../../../rxjs";
import { Router, RouterOutletRef, NavigationEvent } from "../../angular/router";
import { Route, ActivatedRoute } from "../../angular/types";
import { RouteMatcher } from "../../utils/route-matcher";
import "../../../core/global";
@Component({
selector: 'router-outlet',
style: "router-outlet{ display: contents; }",
template: '',
})
export class RouterOutlet implements RouterOutletRef {
public urlSegments: string[] = [];
public parentUrlSegments: string[] = [];
public parentRoutes: Route[] = [];
public activatedRoute?: ActivatedRoute;
public parentRouterOutlet?: RouterOutlet;
public parentRoute?: ActivatedRoute;
public readonly navigationChange$ = new Subject<NavigationEvent>();
private childOutlets: Set<RouterOutlet> = new Set();
private isRootOutlet = false;
constructor(
private router: Router,
public element: HTMLElement,
) {
this.initialize();
}
private async initialize() {
this.element.innerHTML = '';
const parentRouterOutlet = this.getParentRouterOutlet();
if (parentRouterOutlet) {
this.parentRouterOutlet = parentRouterOutlet;
this.parentUrlSegments = parentRouterOutlet.urlSegments;
parentRouterOutlet.registerChildOutlet(this);
if (!parentRouterOutlet.activatedRoute) {
throw Error('Parent ActivatedRoute not set!');
}
// Set parentRoute to be the same as parent's activatedRoute
this.parentRoute = parentRouterOutlet.activatedRoute;
this.parentRoutes = await this.loadRoutes(parentRouterOutlet.activatedRoute);
} else {
this.isRootOutlet = true;
this.router.registerRootOutlet(this);
this.parentUrlSegments = location.pathname.split('/').filter((segment) => segment.length > 0);
this.parentRoutes = this.router.config;
// Root outlet has no parent route
this.parentRoute = undefined;
}
const matchedRoutes = await this.getMatchedRoutes();
await this.updateContent(matchedRoutes);
}
public onNavigationChange(event: NavigationEvent): void {
this.handleNavigationChange(event);
}
private async handleNavigationChange(event: NavigationEvent): Promise<void> {
const urlWithoutQueryAndFragment = event.url.split('?')[0].split('#')[0];
const newUrlSegments = urlWithoutQueryAndFragment.split('/').filter((segment) => segment.length > 0);
const queryParams = this.parseQueryParams(event.url);
const fragment = this.parseFragment(event.url);
if (this.parentRouterOutlet) {
this.parentUrlSegments = this.parentRouterOutlet.urlSegments;
if (this.parentRouterOutlet.activatedRoute) {
// Update parentRoute to match parent's activatedRoute
this.parentRoute = this.parentRouterOutlet.activatedRoute;
this.parentRoutes = await this.loadRoutes(this.parentRouterOutlet.activatedRoute);
}
} else {
this.parentUrlSegments = newUrlSegments;
this.parentRoutes = this.router.config;
// Root outlet has no parent route
this.parentRoute = undefined;
}
const matchedRoutes = await this.getMatchedRoutes();
const newRoute = matchedRoutes[0];
const newParams = newRoute?.snapshot.params ?? {};
const componentChanged = this.hasComponentChanged(this.activatedRoute, newRoute);
if (componentChanged || !this.activatedRoute) {
await this.updateContent(matchedRoutes);
} else if (this.activatedRoute && newRoute) {
// IMPORTANT: Use newRoute's URL segments, not newUrlSegments
// newUrlSegments contains the full URL, but newRoute.url contains only the consumed segments
const routeUrlSegments = newRoute.url.getValue();
this.activatedRoute.updateSnapshot(
newRoute.path ?? '',
newParams,
queryParams,
fragment || null,
routeUrlSegments,
newRoute.routeConfig ?? undefined,
);
// IMPORTANT: Always update urlSegments for proper child outlet routing
this.urlSegments = this.calculateUrlSegments();
}
this.navigationChange$.next(event);
this.notifyChildOutlets(event);
}
private hasComponentChanged(current?: ActivatedRoute, next?: ActivatedRoute): boolean {
if (!current && !next) return false;
if (!current || !next) return true;
const currentComponent = current.component ?? current.loadComponent;
const nextComponent = next.component ?? next.loadComponent;
if (currentComponent !== nextComponent) return true;
const currentParentPath = this.getFullParentPath(current);
const nextParentPath = this.getFullParentPath(next);
return currentParentPath !== nextParentPath;
}
private getFullParentPath(route: ActivatedRoute): string {
const paths: string[] = [];
let current: ActivatedRoute | null | undefined = route.parent;
while (current) {
if (current.path) {
paths.unshift(current.path);
}
current = current.parent;
}
return paths.join('/');
}
private parseQueryParams(url: string): Record<string, string> {
const queryString = url.split('?')[1]?.split('#')[0] ?? '';
const params: Record<string, string> = {};
if (!queryString) return params;
for (const pair of queryString.split('&')) {
const [key, value] = pair.split('=');
if (key) {
params[decodeURIComponent(key)] = decodeURIComponent(value ?? '');
}
}
return params;
}
private parseFragment(url: string): string {
return url.split('#')[1] ?? '';
}
private areParamsEqual(
params1?: Record<string, string>,
params2?: Record<string, string>,
): boolean {
if (!params1 && !params2) return true;
if (!params1 || !params2) return false;
const keys1 = Object.keys(params1);
const keys2 = Object.keys(params2);
if (keys1.length !== keys2.length) return false;
return keys1.every(key => params1[key] === params2[key]);
}
private notifyChildOutlets(event: NavigationEvent): void {
for (const child of this.childOutlets) {
child.onNavigationChange(event);
}
}
public registerChildOutlet(outlet: RouterOutlet): void {
this.childOutlets.add(outlet);
}
public unregisterChildOutlet(outlet: RouterOutlet): void {
this.childOutlets.delete(outlet);
}
private async updateContent(matchedRoutes: ActivatedRoute[]): Promise<void> {
this.childOutlets.clear();
if (this.activatedRoute) {
this.router.unregisterActiveRoute(this.activatedRoute);
this.popActivatedRouteFromStack(this.activatedRoute);
this.activatedRoute = undefined;
}
if (matchedRoutes.length > 0) {
this.activatedRoute = matchedRoutes[0];
this.urlSegments = this.calculateUrlSegments();
this.router.registerActiveRoute(this.activatedRoute);
this.pushActivatedRouteToStack(this.activatedRoute);
} else {
this.urlSegments = this.parentUrlSegments;
}
await this.renderComponents(matchedRoutes);
}
private pushActivatedRouteToStack(route: ActivatedRoute): void {
window.__quarc.activatedRouteStack ??= [];
window.__quarc.activatedRouteStack.push(route);
}
private popActivatedRouteFromStack(route: ActivatedRoute): void {
if (!window.__quarc.activatedRouteStack) return;
const index = window.__quarc.activatedRouteStack.indexOf(route);
if (index !== -1) {
window.__quarc.activatedRouteStack.splice(index, 1);
}
}
private calculateUrlSegments(): string[] {
if (!this.activatedRoute?.path) {
return this.parentUrlSegments;
}
// Use actual URL segments from activated route, not path segments
const routeUrlSegments = this.activatedRoute.url.getValue();
const consumedSegments = routeUrlSegments.length;
const remainingSegments = this.parentUrlSegments.slice(consumedSegments);
return remainingSegments;
}
private async loadRoutes(route: ActivatedRoute): Promise<Route[]> {
let routes: Route[] = [];
if (route.children) {
routes = route.children as Route[];
} else if (route.loadChildren) {
routes = await route.loadChildren();
}
for (const r of routes) {
r.parent = route;
}
return routes;
}
private getParentRouterOutlet(): RouterOutlet | null {
let parent = this.element.parentElement;
while (parent) {
if (parent.tagName.toLowerCase() === 'router-outlet') {
return (parent as WebComponent).componentInstance as IComponent as RouterOutlet;
}
parent = parent.parentElement;
}
return null;
}
public async getMatchedRoutes(): Promise<ActivatedRoute[]> {
const result = await RouteMatcher.findMatchingRouteAsync(
this.parentRoutes,
this.parentUrlSegments,
0,
this.parentRouterOutlet?.activatedRoute ?? null,
{},
{},
);
if (result) {
return [result.route];
}
return [];
}
private async renderComponents(matchedRoutes: ActivatedRoute[]): Promise<void> {
const tags: string[] = [];
for (const route of matchedRoutes) {
const selector = await this.resolveComponentSelector(route);
if (selector) {
tags.push(`<${selector}></${selector}>`);
}
}
this.element.innerHTML = tags.join('');
}
private async resolveComponentSelector(route: ActivatedRoute): Promise<string | null> {
if (typeof route.component === 'string') {
return route.component;
}
if (typeof route.component === 'function' && !this.isComponentType(route.component)) {
const selector = await (route.component as () => Promise<string>)();
return selector;
}
let componentType: ComponentType<IComponent> | undefined;
if (route.component && this.isComponentType(route.component)) {
componentType = route.component as ComponentType<IComponent>;
} else if (route.loadComponent) {
componentType = await route.loadComponent() as ComponentType<IComponent>;
}
if (componentType) {
WebComponentFactory.registerWithDependencies(componentType);
return componentType._quarcComponent[0].selector;
}
return null;
}
private isComponentType(component: unknown): component is ComponentType<IComponent> {
return typeof component === 'function' && '_quarcComponent' in component;
}
public destroy(): void {
if (this.activatedRoute) {
this.router.unregisterActiveRoute(this.activatedRoute);
this.popActivatedRouteFromStack(this.activatedRoute);
}
if (this.isRootOutlet) {
this.router.unregisterRootOutlet(this);
} else if (this.parentRouterOutlet) {
this.parentRouterOutlet.unregisterChildOutlet(this);
}
this.navigationChange$.complete();
this.childOutlets.clear();
}
}

View File

@ -0,0 +1,85 @@
import { Directive, IDirective, input, effect, IComponent, InputSignal } from "../../core";
import { Router } from "../angular/router";
import { Subscription } from "../../rxjs";
@Directive({
selector: '[routerLinkActive]',
})
export class RouterLinkActive implements IDirective {
public routerLinkActive!: InputSignal<string>;
public routerLinkActiveOptions!: InputSignal<{ exact?: boolean }>;
private subscription?: Subscription;
constructor(
private router: Router,
public _nativeElement: HTMLElement,
) {
this.routerLinkActive = input<string>('routerLinkActive', this as unknown as IComponent, '');
this.routerLinkActiveOptions = input<{ exact?: boolean }>('routerLinkActiveOptions', this as unknown as IComponent, {});
this.updateActiveState();
this.subscription = this.router.events$.subscribe(() => {
this.updateActiveState();
});
effect(() => {
this.routerLinkActive();
this.routerLinkActiveOptions();
this.updateActiveState();
});
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
private updateActiveState(): void {
const isActive = this.checkIsActive();
const activeClass = this.routerLinkActive();
if (activeClass) {
if (isActive) {
this._nativeElement.classList.add(activeClass);
} else {
this._nativeElement.classList.remove(activeClass);
}
}
}
private checkIsActive(): boolean {
let routerLinkValue: string | string[] | undefined;
const inputs = (this._nativeElement as any).__inputs;
if (inputs?.routerLink) {
routerLinkValue = inputs.routerLink();
}
if (!routerLinkValue) {
routerLinkValue = this._nativeElement.getAttribute('router-link') || this._nativeElement.getAttribute('routerLink') || undefined;
}
if (!routerLinkValue) {
return false;
}
const linkPath = Array.isArray(routerLinkValue) ? routerLinkValue.join('/') : routerLinkValue;
const currentUrl = this.normalizeUrl(location.pathname);
const linkUrl = this.normalizeUrl(linkPath);
const options = this.routerLinkActiveOptions();
if (options.exact) {
return currentUrl === linkUrl;
}
return currentUrl === linkUrl || currentUrl.startsWith(linkUrl + '/');
}
private normalizeUrl(url: string): string {
let normalized = url.startsWith('/') ? url : '/' + url;
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
}

View File

@ -0,0 +1,87 @@
import { Directive, IDirective, input, IComponent, InputSignal } from "../../core";
import { Router } from "../angular/router";
import { ActivatedRoute } from "../angular/types";
@Directive({
selector: '[routerLink]',
})
export class RouterLink implements IDirective {
static __quarc_original_name__ = "RouterLink";
public routerLink = input<string | string[]>();
constructor(
private router: Router,
public _nativeElement: HTMLElement,
private activatedRoute?: ActivatedRoute,
) {
console.log({ routerLink: this.routerLink() });
this._nativeElement.addEventListener('click', (event) => {
this.onClick(event);
});
}
ngOnInit(): void {
// Required by IDirective interface
}
ngOnDestroy(): void {
// Required by IDirective interface
}
public onClick(event: Event): void {
event.preventDefault();
const link = this.routerLink();
const commands = Array.isArray(link) ? link : [link];
// For sidebar navigation, use DOM traversal to get proper root route context
// For other cases, use injected route, global stack, then DOM fallback
const isSidebarNavigation = this._nativeElement.closest('app-sidebar') !== null;
const routeForNavigation = isSidebarNavigation
? this.findActivatedRouteFromDOM()
: (this.activatedRoute || this.getCurrentActivatedRoute() || this.findActivatedRouteFromDOM());
const extras = routeForNavigation ? { relativeTo: routeForNavigation } : undefined;
this.router.navigate(commands, extras).then(success => {
}).catch(error => {
console.error('RouterLink CLICK - Navigation failed:', error);
});
}
private getCurrentActivatedRoute(): ActivatedRoute | null {
// Try to get from global activated route stack
const stack = window.__quarc?.activatedRouteStack;
if (stack && stack.length > 0) {
return stack[stack.length - 1];
}
return null;
}
private findActivatedRouteFromDOM(): ActivatedRoute | null {
// Start from the directive's element and go up to find router-outlet
let currentElement: Element | null = this._nativeElement;
let depth = 0;
while (currentElement) {
// Check if current element is a router-outlet
if (currentElement.tagName.toLowerCase() === 'router-outlet') {
const routerOutlet = (currentElement as any).componentInstance;
if (routerOutlet && 'activatedRoute' in routerOutlet) {
const route = routerOutlet.activatedRoute;
// For sidebar navigation between root routes, don't use parentRoute
// Only use parentRoute for navigation within plugin contexts
const isSidebarNavigation = this._nativeElement.closest('app-sidebar') !== null;
const navigationRoute = isSidebarNavigation ? (routerOutlet.parentRoute || route) : (routerOutlet.parentRoute || route);
return navigationRoute ?? null;
}
}
// Move to parent
currentElement = currentElement.parentElement;
depth++;
}
return null;
}
}

7
router/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { provideRouter, PluginRouterOptions } from "./angular/provide-router";
export { RouterOutlet } from "./components/router-outlet/router-outlet.component";
export { Route, Routes, ActivatedRoute, ActivatedRouteSnapshot, Params, NavigationExtras, ComponentLoader } from "./angular/types";
export { Router, NavigationEvent, RouterOutletRef } from "./angular/router";
export { RouteMatcher } from "./utils/route-matcher";
export { RouterLink } from "./directives/router-link.directive";
export { RouterLinkActive } from "./directives/router-link-active.directive";

13
router/package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "@quarc/router",
"version": "1.0.0",
"description": "Lightweight Angular-like framework router",
"main": "main.ts",
"types": "main.ts",
"dependencies": {
"@quarc/core": "file:../core"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,352 @@
import { Route, ActivatedRoute, Params } from "../angular/types";
export interface MatchResult {
route: ActivatedRoute;
consumedSegments: number;
hasComponent: boolean;
}
export class RouteMatcher {
static matchRoutesRecursive(
routes: Route[],
urlSegments: string[],
currentSegmentIndex: number,
matchedRoutes: ActivatedRoute[],
): void {
const result = this.findMatchingRoute(routes, urlSegments, currentSegmentIndex, null, {}, {});
if (result) {
matchedRoutes.push(result.route);
}
}
static async findMatchingRouteAsync(
routes: Route[],
urlSegments: string[],
currentSegmentIndex: number,
parentRoute: ActivatedRoute | null,
accumulatedParams: Params,
accumulatedData: object,
): Promise<MatchResult | null> {
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);
if (routeSegments.length === 0) {
continue;
}
if (!this.doesRouteMatch(routeSegments, urlSegments, currentSegmentIndex)) {
continue;
}
const result = await this.processRouteAsync(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);
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.processRouteAsync(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.processRouteAsync(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData);
if (result) {
return result;
}
}
}
return null;
}
private static async processRouteAsync(
route: Route,
routeSegments: string[],
urlSegments: string[],
currentSegmentIndex: number,
parentRoute: ActivatedRoute | null,
accumulatedParams: Params,
accumulatedData: object,
): Promise<MatchResult | null> {
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 findMatchingRoute(
routes: Route[],
urlSegments: string[],
currentSegmentIndex: number,
parentRoute: ActivatedRoute | null,
accumulatedParams: Params,
accumulatedData: object,
): MatchResult | null {
const remainingSegments = urlSegments.length - currentSegmentIndex;
// Najpierw szukamy route z niepustą ścieżką
for (const route of routes) {
const routePath = route.path || '';
const routeSegments = routePath.split('/').filter(segment => segment.length > 0);
if (routeSegments.length === 0) {
continue;
}
if (!this.doesRouteMatch(routeSegments, urlSegments, currentSegmentIndex)) {
continue;
}
const result = this.processRoute(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData);
if (result) {
return result;
}
}
// Szukamy route z pustą ścieżką
for (const route of routes) {
const routePath = route.path || '';
const routeSegments = routePath.split('/').filter(segment => segment.length > 0);
if (routeSegments.length !== 0) {
continue;
}
const hasComponent = !!(route.component || route.loadComponent);
const hasChildren = !!(route.children);
if (hasComponent && remainingSegments > 0) {
continue;
}
if (!hasComponent && hasChildren && remainingSegments > 0) {
const result = this.processRoute(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData);
if (result) {
return result;
}
continue;
}
if (remainingSegments === 0) {
const result = this.processRoute(route, routeSegments, urlSegments, currentSegmentIndex, parentRoute, accumulatedParams, accumulatedData);
if (result) {
return result;
}
}
}
return null;
}
private static processRoute(
route: Route,
routeSegments: string[],
urlSegments: string[],
currentSegmentIndex: number,
parentRoute: ActivatedRoute | null,
accumulatedParams: Params,
accumulatedData: object,
): MatchResult | null {
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 };
}
if (route.children && route.children.length > 0) {
const intermediateRoute = this.createActivatedRoute(
route,
params,
data,
urlSegments,
currentSegmentIndex,
routeSegments.length,
parentRoute,
);
const childResult = this.findMatchingRoute(
route.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 {
// Jeśli nie ma już segmentów w URL, a route ma pustą ścieżkę, pasuje
if (routeSegments.length === 0 && startIndex >= urlSegments.length) {
return true;
}
// Jeśli nie ma wystarczającej liczby segmentów w URL, nie pasuje
if (startIndex + routeSegments.length > urlSegments.length) {
return false;
}
// Porównaj każdy segment
for (let i = 0; i < routeSegments.length; i++) {
const routeSegment = routeSegments[i];
const urlSegment = urlSegments[startIndex + i];
// Jeśli segment route zaczyna się od ':', to jest parametr i pasuje do wszystkiego
if (routeSegment.startsWith(':')) {
continue;
}
// Segmenty muszą być identyczne
if (routeSegment !== urlSegment) {
return false;
}
}
return true;
}
private static extractParams(routeSegments: string[], urlSegments: string[], startIndex: number, params: Record<string, string>): void {
for (let i = 0; i < routeSegments.length; i++) {
const routeSegment = routeSegments[i];
const urlSegment = urlSegments[startIndex + i];
if (routeSegment.startsWith(':')) {
// Wyodrębnij nazwę parametru
const paramName = routeSegment.substring(1);
params[paramName] = urlSegment;
}
}
}
}

2
rxjs/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { Subject, BehaviorSubject } from './subject';
export type { Subscription, Observer } from './subject';

11
rxjs/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "@quarc/rxjs",
"version": "1.0.0",
"description": "Lightweight reactive extensions for Quarc framework",
"main": "index.ts",
"types": "index.ts",
"dependencies": {},
"devDependencies": {
"typescript": "^5.3.3"
}
}

48
rxjs/subject.ts Normal file
View File

@ -0,0 +1,48 @@
export type Subscription = {
unsubscribe: () => void;
};
export type Observer<T> = (value: T) => void;
export class Subject<T> {
private observers: Set<Observer<T>> = new Set();
next(value: T): void {
for (const observer of this.observers) {
observer(value);
}
}
subscribe(observer: Observer<T>): Subscription {
this.observers.add(observer);
return {
unsubscribe: () => {
this.observers.delete(observer);
},
};
}
complete(): void {
this.observers.clear();
}
}
export class BehaviorSubject<T> extends Subject<T> {
constructor(private currentValue: T) {
super();
}
override next(value: T): void {
this.currentValue = value;
super.next(value);
}
subscribe(observer: Observer<T>): Subscription {
observer(this.currentValue);
return super.subscribe(observer);
}
getValue(): T {
return this.currentValue;
}
}

100
tests/unit/README.md Normal file
View File

@ -0,0 +1,100 @@
# Testy Quarc Framework
Ta struktura zawiera wszystkie testy dla Quarc Framework.
## Struktura
- `test-functionality.ts` - Testy podstawowej funkcjonalności (ControlFlowTransformer, TemplateParser, etc.)
- `test-style-injection.ts` - Testy wstrzykiwania stylów i transformacji `:host`
- `test-style-injection.html` - Strona HTML do uruchamiania testów stylów w przeglądarce
- `test-lifecycle.ts` - Testy interfejsów lifecycle (OnInit, OnDestroy, AfterViewInit, etc.)
- `run-tests.ts` - Główny skrypt do uruchamiania wszystkich testów
## Uruchamianie testów
### Instalacja zależności
```bash
cd quarc/tests/unit
npm install
```
### Wszystkie testy (jednostkowe + e2e)
```bash
npm test
# lub
npm run test:all
```
### Tylko testy jednostkowe
```bash
npm run test:unit
```
### Tylko testy e2e
```bash
npm run test:e2e
```
### Indywidualne testy
```bash
# Testy funkcjonalne
npm run test:functionality
# Testy wstrzykiwania stylów
npm run test:style
# Testy lifecycle
npm run test:lifecycle
```
### Testy w przeglądarce
```bash
npm run test:browser
# lub
xdg-open test-style-injection.html
```
## Wymagania
- `ts-node` - do uruchamiania testów TypeScript
- `@types/node` - typy Node.js
- Środowisko przeglądarki dla testów stylów (lub JSDOM)
## Co testujemy?
### Testy funkcjonalne
- ✅ Transformacja `@if` na `*ngIf`
- ✅ Transformacja `@for` na `*ngFor`
- ✅ Parsowanie szablonów
- ✅ Helpery dla dyrektyw strukturalnych
### Testy wstrzykiwania stylów
- ✅ Transformacja `:host` na `[_nghost-scopeId]`
- ✅ Transformacja `:host()` z selektorami
- ✅ Obsługa różnych ViewEncapsulation
- ✅ Dodawanie atrybutów `_nghost` i `_ngcontent`
- ✅ Wiele wystąpień `:host` w jednym pliku
### Testy lifecycle
- ✅ OnInit - `ngOnInit()`
- ✅ OnDestroy - `ngOnDestroy()`
- ✅ AfterViewInit - `ngAfterViewInit()`
- ✅ AfterViewChecked - `ngAfterViewChecked()`
- ✅ AfterContentInit - `ngAfterContentInit()`
- ✅ AfterContentChecked - `ngAfterContentChecked()`
- ✅ DoCheck - `ngDoCheck()`
- ✅ OnChanges - `ngOnChanges(changes: SimpleChanges)`
- ✅ Wielokrotna implementacja hooków
- ✅ Poprawna kolejność wywołań lifecycle
## Rozwój
Aby dodać nowy test:
1. Stwórz nowy plik `test-nazwa.ts` w tym katalogu
2. Dodaj go do listy `testFiles` w `run-tests.ts`
3. Użyj funkcji `test()` z istniejących plików jako wzoru
## Problemy
Jeśli testy stylów nie działają w Node.js, uruchom je w przeglądarce przez `test-style-injection.html`.

View File

@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseAttributeHelper = void 0;
class BaseAttributeHelper {
extractAttributeName(fullName) {
return fullName.replace(/^\*/, '')
.replace(/^\[/, '').replace(/\]$/, '')
.replace(/^\(/, '').replace(/\)$/, '')
.replace(/^\[\(/, '').replace(/\)\]$/, '')
.replace(/^#/, '');
}
}
exports.BaseAttributeHelper = BaseAttributeHelper;

View File

@ -0,0 +1,57 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ControlFlowTransformer = void 0;
class ControlFlowTransformer {
transform(content) {
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);
});
}
parseBlocks(match) {
const blocks = [];
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;
}
buildNgContainers(blocks) {
let result = '';
const negated = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const condition = this.buildCondition(block.condition, negated);
result += `<ng-container *ngIf="${condition}">${block.content}</ng-container>`;
if (i < blocks.length - 1) {
result += '\n';
}
if (block.condition) {
negated.push(block.condition);
}
}
return result;
}
buildCondition(condition, negated) {
if (condition === null) {
return negated.map(c => `!(${c})`).join(' && ');
}
if (negated.length > 0) {
return negated.map(c => `!(${c})`).join(' && ') + ` && ${condition}`;
}
return condition;
}
}
exports.ControlFlowTransformer = ControlFlowTransformer;

View File

@ -0,0 +1,60 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.StructuralDirectiveHelper = void 0;
const template_parser_1 = require("./template-parser");
const base_attribute_helper_1 = require("./base-attribute-helper");
class StructuralDirectiveHelper extends base_attribute_helper_1.BaseAttributeHelper {
get supportedType() {
return 'structural-directive';
}
canHandle(attribute) {
return attribute.type === template_parser_1.AttributeType.STRUCTURAL_DIRECTIVE;
}
process(context) {
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 };
}
}
processNgIf(context) {
return {
transformed: true,
newAttribute: {
name: '*ngIf',
value: context.attribute.value,
type: template_parser_1.AttributeType.STRUCTURAL_DIRECTIVE,
},
};
}
processNgFor(context) {
return {
transformed: true,
newAttribute: {
name: '*ngFor',
value: context.attribute.value,
type: template_parser_1.AttributeType.STRUCTURAL_DIRECTIVE,
},
};
}
processNgSwitch(context) {
return {
transformed: true,
newAttribute: {
name: '*ngSwitch',
value: context.attribute.value,
type: template_parser_1.AttributeType.STRUCTURAL_DIRECTIVE,
},
};
}
}
exports.StructuralDirectiveHelper = StructuralDirectiveHelper;

View File

@ -0,0 +1,155 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TemplateParser = exports.AttributeType = void 0;
var AttributeType;
(function (AttributeType) {
AttributeType["STRUCTURAL_DIRECTIVE"] = "structural";
AttributeType["INPUT_BINDING"] = "input";
AttributeType["OUTPUT_BINDING"] = "output";
AttributeType["TWO_WAY_BINDING"] = "two-way";
AttributeType["TEMPLATE_REFERENCE"] = "reference";
AttributeType["REGULAR"] = "regular";
})(AttributeType || (exports.AttributeType = AttributeType = {}));
class TemplateParser {
parse(template) {
const elements = [];
const stack = [];
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 = {
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 = {
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 = {
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;
}
parseAttributes(attributesString) {
const attributes = [];
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;
}
detectAttributeType(name) {
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, callback) {
for (const element of elements) {
if (this.isTextNode(element)) {
continue;
}
callback(element);
if (element.children.length > 0) {
this.traverseElements(element.children, callback);
}
}
}
isTextNode(node) {
return 'type' in node && node.type === 'text';
}
}
exports.TemplateParser = TemplateParser;

View File

@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Component = Component;
/**
* Dekorator komponentu.
*
* Ten dekorator służy wyłącznie do zapewnienia poprawności typów w TypeScript
* i jest podmieniany podczas kompilacji przez transformer (quarc/cli/processors/class-decorator-processor.ts).
* Cała logika przetwarzania templateUrl, styleUrl, control flow itp. odbywa się w transformerach,
* co minimalizuje rozmiar końcowej aplikacji.
*/
function Component(options) {
return (target) => {
target._quarcComponent = options;
return target;
};
}

View File

@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ViewEncapsulation = void 0;
var ViewEncapsulation;
(function (ViewEncapsulation) {
ViewEncapsulation[ViewEncapsulation["None"] = 0] = "None";
ViewEncapsulation[ViewEncapsulation["ShadowDom"] = 1] = "ShadowDom";
ViewEncapsulation[ViewEncapsulation["Emulated"] = 2] = "Emulated";
})(ViewEncapsulation || (exports.ViewEncapsulation = ViewEncapsulation = {}));

View File

@ -0,0 +1,180 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TemplateFragment = void 0;
class TemplateFragment {
constructor(container, component, template) {
this.ngContainerMarkers = [];
this.container = container;
this.component = component;
this.template = template ?? '';
this.originalContent = document.createDocumentFragment();
while (container.firstChild) {
this.originalContent.appendChild(container.firstChild);
}
container.templateFragment = this;
container.component = component;
container.template = this.template;
container.originalContent = this.originalContent;
}
render() {
if (!this.template)
return;
const templateElement = document.createElement('template');
templateElement.innerHTML = this.template;
const renderedContent = templateElement.content.cloneNode(true);
// Process structural directives before appending
this.processStructuralDirectives(renderedContent);
while (renderedContent.firstChild) {
this.container.appendChild(renderedContent.firstChild);
}
// Process property bindings after elements are in DOM
this.processPropertyBindings(this.container);
}
processStructuralDirectives(fragment) {
const ngContainers = Array.from(fragment.querySelectorAll('ng-container'));
for (const ngContainer of ngContainers) {
this.processNgContainer(ngContainer);
}
}
processNgContainer(ngContainer) {
const ngIfAttr = ngContainer.getAttribute('*ngIf');
const parent = ngContainer.parentNode;
if (!parent)
return;
// Create marker comments to track ng-container position
const startMarker = document.createComment(`ng-container-start${ngIfAttr ? ` *ngIf="${ngIfAttr}"` : ''}`);
const endMarker = document.createComment('ng-container-end');
// Store marker information for later re-rendering
const originalTemplate = ngContainer.innerHTML;
this.ngContainerMarkers.push({
startMarker,
endMarker,
condition: ngIfAttr || undefined,
originalTemplate
});
parent.insertBefore(startMarker, ngContainer);
if (ngIfAttr && !this.evaluateCondition(ngIfAttr)) {
// Condition is false - don't render content, just add end marker
parent.insertBefore(endMarker, ngContainer);
ngContainer.remove();
}
else {
// Condition is true or no condition - render content between markers
while (ngContainer.firstChild) {
parent.insertBefore(ngContainer.firstChild, ngContainer);
}
parent.insertBefore(endMarker, ngContainer);
ngContainer.remove();
}
}
evaluateCondition(condition) {
try {
return new Function('component', `with(component) { return ${condition}; }`)(this.component);
}
catch {
return false;
}
}
/**
* Re-renders a specific ng-container fragment based on marker position
*/
rerenderFragment(markerIndex) {
if (markerIndex < 0 || markerIndex >= this.ngContainerMarkers.length) {
console.warn('Invalid marker index:', markerIndex);
return;
}
const marker = this.ngContainerMarkers[markerIndex];
const { startMarker, endMarker, condition, originalTemplate } = marker;
// Remove all nodes between markers
let currentNode = startMarker.nextSibling;
while (currentNode && currentNode !== endMarker) {
const nextNode = currentNode.nextSibling;
currentNode.remove();
currentNode = nextNode;
}
// Re-evaluate condition and render if true
if (!condition || this.evaluateCondition(condition)) {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = originalTemplate;
const fragment = document.createDocumentFragment();
while (tempContainer.firstChild) {
fragment.appendChild(tempContainer.firstChild);
}
// Process property bindings on the fragment
const tempWrapper = document.createElement('div');
tempWrapper.appendChild(fragment);
this.processPropertyBindings(tempWrapper);
// Insert processed nodes between markers
const parent = startMarker.parentNode;
if (parent) {
while (tempWrapper.firstChild) {
parent.insertBefore(tempWrapper.firstChild, endMarker);
}
}
}
}
/**
* Re-renders all ng-container fragments
*/
rerenderAllFragments() {
for (let i = 0; i < this.ngContainerMarkers.length; i++) {
this.rerenderFragment(i);
}
}
/**
* Gets all ng-container markers for inspection
*/
getFragmentMarkers() {
return this.ngContainerMarkers;
}
processPropertyBindings(container) {
const allElements = Array.from(container.querySelectorAll('*'));
for (const element of allElements) {
const attributesToRemove = [];
const attributes = Array.from(element.attributes);
for (const attr of attributes) {
if (attr.name.startsWith('[') && attr.name.endsWith(']')) {
let propertyName = attr.name.slice(1, -1);
const expression = attr.value;
// Map common property names from lowercase to camelCase
const propertyMap = {
'innerhtml': 'innerHTML',
'textcontent': 'textContent',
'innertext': 'innerText',
'classname': 'className',
};
if (propertyMap[propertyName.toLowerCase()]) {
propertyName = propertyMap[propertyName.toLowerCase()];
}
try {
const value = this.evaluateExpression(expression);
element[propertyName] = value;
attributesToRemove.push(attr.name);
}
catch (error) {
console.warn(`Failed to evaluate property binding [${propertyName}]:`, error);
}
}
}
for (const attrName of attributesToRemove) {
element.removeAttribute(attrName);
}
}
}
evaluateExpression(expression) {
try {
return new Function('component', `with(component) { return ${expression}; }`)(this.component);
}
catch (error) {
console.error(`Failed to evaluate expression: ${expression}`, error);
return undefined;
}
}
static getOrCreate(container, component, template) {
if (container.templateFragment) {
return container.templateFragment;
}
return new TemplateFragment(container, component, template);
}
}
exports.TemplateFragment = TemplateFragment;

View File

@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@ -0,0 +1,169 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebComponent = void 0;
const component_1 = require("./component");
const template_renderer_1 = require("./template-renderer");
const injectedStyles = new Set();
class WebComponent extends HTMLElement {
constructor() {
super();
this._initialized = false;
}
setComponentInstance(component) {
this.componentInstance = component;
this.scopeId = component._scopeId;
this.initialize();
}
connectedCallback() {
if (this.componentInstance) {
this.initialize();
}
}
disconnectedCallback() {
this.destroy();
}
initialize() {
if (!this.componentInstance || this._initialized)
return;
const encapsulation = this.componentInstance._quarcComponent[0].encapsulation ?? component_1.ViewEncapsulation.Emulated;
if (encapsulation === component_1.ViewEncapsulation.ShadowDom && !this._shadowRoot) {
this._shadowRoot = this.attachShadow({ mode: 'open' });
}
else if (encapsulation === component_1.ViewEncapsulation.Emulated && this.scopeId) {
this.setAttribute(`_nghost-${this.scopeId}`, '');
}
this._initialized = true;
this.renderComponent();
}
renderComponent() {
if (!this.componentInstance)
return;
const template = this.componentInstance._quarcComponent[0].template ?? '';
const style = this.componentInstance._quarcComponent[0].style ?? '';
const encapsulation = this.componentInstance._quarcComponent[0].encapsulation ?? component_1.ViewEncapsulation.Emulated;
const renderTarget = this._shadowRoot ?? this;
if (style) {
if (encapsulation === component_1.ViewEncapsulation.ShadowDom) {
const styleElement = document.createElement('style');
styleElement.textContent = style;
renderTarget.appendChild(styleElement);
}
else if (encapsulation === component_1.ViewEncapsulation.Emulated && this.scopeId) {
if (!injectedStyles.has(this.scopeId)) {
const styleElement = document.createElement('style');
styleElement.textContent = this.transformHostSelector(style);
styleElement.setAttribute('data-scope-id', this.scopeId);
document.head.appendChild(styleElement);
injectedStyles.add(this.scopeId);
}
}
else if (encapsulation === component_1.ViewEncapsulation.None) {
const styleElement = document.createElement('style');
styleElement.textContent = style;
renderTarget.appendChild(styleElement);
}
}
const templateFragment = template_renderer_1.TemplateFragment.getOrCreate(renderTarget, this.componentInstance, template);
templateFragment.render();
if (encapsulation === component_1.ViewEncapsulation.Emulated && this.scopeId) {
this.applyScopeAttributes(renderTarget);
}
}
getAttributes() {
const attributes = [];
const attrs = this.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
attributes.push({
name: attr.name,
value: attr.value,
});
}
return attributes;
}
getChildElements() {
const renderTarget = this._shadowRoot ?? this;
const children = [];
const elements = renderTarget.querySelectorAll('*');
elements.forEach(element => {
const attributes = [];
const attrs = element.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
attributes.push({
name: attr.name,
value: attr.value,
});
}
children.push({
tagName: element.tagName.toLowerCase(),
element: element,
attributes: attributes,
textContent: element.textContent,
});
});
return children;
}
getChildElementsByTagName(tagName) {
return this.getChildElements().filter(child => child.tagName === tagName.toLowerCase());
}
getChildElementsBySelector(selector) {
const renderTarget = this._shadowRoot ?? this;
const elements = renderTarget.querySelectorAll(selector);
const children = [];
elements.forEach(element => {
const attributes = [];
const attrs = element.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
attributes.push({
name: attr.name,
value: attr.value,
});
}
children.push({
tagName: element.tagName.toLowerCase(),
element: element,
attributes: attributes,
textContent: element.textContent,
});
});
return children;
}
getHostElement() {
return this;
}
getShadowRoot() {
return this._shadowRoot;
}
applyScopeAttributes(container) {
if (!this.scopeId)
return;
const attr = `_ngcontent-${this.scopeId}`;
const elements = container.querySelectorAll('*');
elements.forEach(element => {
element.setAttribute(attr, '');
});
if (container.children.length > 0) {
Array.from(container.children).forEach(child => {
child.setAttribute(attr, '');
});
}
}
transformHostSelector(css) {
if (!this.scopeId)
return css;
const hostAttr = `[_nghost-${this.scopeId}]`;
return css
.replace(/:host\(([^)]+)\)/g, `${hostAttr}$1`)
.replace(/:host/g, hostAttr);
}
destroy() {
const renderTarget = this._shadowRoot ?? this;
while (renderTarget.firstChild) {
renderTarget.removeChild(renderTarget.firstChild);
}
this._initialized = false;
}
}
exports.WebComponent = WebComponent;

View File

@ -0,0 +1,167 @@
"use strict";
/**
* Testy funkcjonalne dla Quarc
* Sprawdzają czy podstawowa funkcjonalność działa poprawnie
*/
Object.defineProperty(exports, "__esModule", { value: true });
const control_flow_transformer_1 = require("../cli/helpers/control-flow-transformer");
const template_parser_1 = require("../cli/helpers/template-parser");
const structural_directive_helper_1 = require("../cli/helpers/structural-directive-helper");
console.log('=== TESTY FUNKCJONALNE QUARC ===\n');
let passedTests = 0;
let failedTests = 0;
function test(name, fn) {
try {
const result = fn();
if (result) {
console.log(`${name}`);
passedTests++;
}
else {
console.log(`${name}`);
failedTests++;
}
}
catch (e) {
console.log(`${name} - Error: ${e}`);
failedTests++;
}
}
// Test 1: ControlFlowTransformer - prosty @if
test('ControlFlowTransformer: @if -> *ngIf', () => {
const transformer = new control_flow_transformer_1.ControlFlowTransformer();
const input = '@if (show) { <div>Content</div> }';
const result = transformer.transform(input);
return result.includes('<ng-container *ngIf="show">') && result.includes('Content');
});
// Test 2: ControlFlowTransformer - @if @else
test('ControlFlowTransformer: @if @else', () => {
const transformer = new control_flow_transformer_1.ControlFlowTransformer();
const input = '@if (a) { <div>A</div> } @else { <div>B</div> }';
const result = transformer.transform(input);
return result.includes('*ngIf="a"') && result.includes('*ngIf="!(a)"');
});
// Test 3: ControlFlowTransformer - @if @else if @else
test('ControlFlowTransformer: @if @else if @else', () => {
const transformer = new control_flow_transformer_1.ControlFlowTransformer();
const input = '@if (a) { <div>A</div> } @else if (b) { <div>B</div> } @else { <div>C</div> }';
const result = transformer.transform(input);
return result.includes('*ngIf="a"') &&
result.includes('*ngIf="!(a) && b"') &&
result.includes('*ngIf="!(a) && !(b)"');
});
// Test 4: TemplateParser - parsowanie prostego HTML
test('TemplateParser: prosty HTML', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<div>Content</div>');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'div';
});
// Test 5: TemplateParser - parsowanie atrybutów
test('TemplateParser: atrybuty', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<div class="test" id="main">Content</div>');
return elements.length === 1 &&
'attributes' in elements[0] &&
elements[0].attributes.length === 2;
});
// Test 6: TemplateParser - *ngIf jako structural directive
test('TemplateParser: *ngIf detection', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<div *ngIf="show">Content</div>');
if (elements.length === 0 || !('attributes' in elements[0]))
return false;
const attr = elements[0].attributes.find(a => a.name === '*ngIf');
return attr !== undefined && attr.type === 'structural';
});
// Test 7: TemplateParser - text nodes
test('TemplateParser: text nodes', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('Text before <div>Content</div> Text after');
return elements.length === 3 &&
'type' in elements[0] && elements[0].type === 'text' &&
'tagName' in elements[1] && elements[1].tagName === 'div' &&
'type' in elements[2] && elements[2].type === 'text';
});
// Test 8: TemplateParser - zagnieżdżone elementy
test('TemplateParser: zagnieżdżone elementy', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<div><span>Nested</span></div>');
return elements.length === 1 &&
'children' in elements[0] &&
elements[0].children.length === 1 &&
'tagName' in elements[0].children[0] &&
elements[0].children[0].tagName === 'span';
});
// Test 9: StructuralDirectiveHelper - canHandle *ngIf
test('StructuralDirectiveHelper: canHandle *ngIf', () => {
const helper = new structural_directive_helper_1.StructuralDirectiveHelper();
const attr = { name: '*ngIf', value: 'show', type: template_parser_1.AttributeType.STRUCTURAL_DIRECTIVE };
return helper.canHandle(attr);
});
// Test 10: StructuralDirectiveHelper - process *ngIf
test('StructuralDirectiveHelper: process *ngIf', () => {
const helper = new structural_directive_helper_1.StructuralDirectiveHelper();
const attr = { name: '*ngIf', value: 'show', type: template_parser_1.AttributeType.STRUCTURAL_DIRECTIVE };
const element = { tagName: 'div', attributes: [attr], children: [] };
const result = helper.process({ element, attribute: attr, filePath: 'test.ts' });
return result.transformed === true &&
result.newAttribute?.name === '*ngIf' &&
result.newAttribute?.value === 'show';
});
// Test 11: ControlFlowTransformer - brak transformacji bez @if
test('ControlFlowTransformer: brak @if', () => {
const transformer = new control_flow_transformer_1.ControlFlowTransformer();
const input = '<div>Regular content</div>';
const result = transformer.transform(input);
return result === input;
});
// Test 12: TemplateParser - self-closing tags
test('TemplateParser: self-closing tags', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<img src="test.jpg" />');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'img' &&
elements[0].children.length === 0;
});
// Test 13: TemplateParser - komentarze są pomijane
test('TemplateParser: komentarze', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<!-- comment --><div>Content</div>');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'div';
});
// Test 14: ControlFlowTransformer - wieloliniowy @if
test('ControlFlowTransformer: wieloliniowy @if', () => {
const transformer = new control_flow_transformer_1.ControlFlowTransformer();
const input = `@if (show) {
<div>
Multi-line content
</div>
}`;
const result = transformer.transform(input);
return result.includes('<ng-container *ngIf="show">') &&
result.includes('Multi-line content');
});
// Test 15: TemplateParser - puste elementy
test('TemplateParser: puste elementy', () => {
const parser = new template_parser_1.TemplateParser();
const elements = parser.parse('<div></div>');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'div' &&
elements[0].children.length === 0;
});
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ę.');
}

View File

@ -0,0 +1,242 @@
"use strict";
/**
* Test wstrzykiwania stylów z transformacją :host
*/
Object.defineProperty(exports, "__esModule", { value: true });
const web_component_1 = require("../core/module/web-component");
const component_1 = require("../core/module/component");
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ą klasy
function createMockComponent(options) {
const component = {
_quarcComponent: [{
selector: options.selector,
template: options.template,
style: options.style || '',
encapsulation: options.encapsulation || component_1.ViewEncapsulation.Emulated,
}],
};
// Dodaj _scopeId jako właściwość klasy
component._scopeId = options.scopeId;
return component;
}
function test(name, fn) {
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 component = createMockComponent({
selector: 'test-component',
template: '<div>Test</div>',
style: ':host { display: block; }',
encapsulation: component_1.ViewEncapsulation.Emulated,
scopeId: 'test123',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
// 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 component = createMockComponent({
selector: 'test-component',
template: '<div>Test</div>',
style: ':host(.active) { background: red; }',
encapsulation: component_1.ViewEncapsulation.Emulated,
scopeId: 'test456',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
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 component = createMockComponent({
selector: 'test-component',
template: '<div>Test</div>',
style: ':host { display: block; } :host(.active) { color: blue; } :host:hover { opacity: 0.8; }',
encapsulation: component_1.ViewEncapsulation.Emulated,
scopeId: 'test789',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
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 component = createMockComponent({
selector: 'test-component',
template: '<div>Test</div>',
style: ':host { display: flex; }',
encapsulation: component_1.ViewEncapsulation.ShadowDom,
scopeId: 'shadow123',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
// 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 component = createMockComponent({
selector: 'test-component',
template: '<div>Test</div>',
style: ':host { display: inline; }',
encapsulation: component_1.ViewEncapsulation.None,
scopeId: 'none123',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
// 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 component = createMockComponent({
selector: 'test-component',
template: '<div>Test</div>',
style: ':host { display: block; }',
encapsulation: component_1.ViewEncapsulation.Emulated,
scopeId: 'host123',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
// 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 component = createMockComponent({
selector: 'test-complex',
template: '<div>Complex</div>',
style: ':host { display: flex; } :host:hover { background: blue; } :host(.active) .inner { color: red; }',
encapsulation: component_1.ViewEncapsulation.Emulated,
scopeId: 'complex123',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
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 component = createMockComponent({
selector: 'test-shadow',
template: '<div>Shadow</div>',
style: ':host { display: block; }',
encapsulation: component_1.ViewEncapsulation.ShadowDom,
scopeId: 'shadow789',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
// 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 component = createMockComponent({
selector: 'test-none',
template: '<div>None</div>',
style: ':host { display: block; }',
encapsulation: component_1.ViewEncapsulation.None,
scopeId: 'none123',
});
const webComponent = new web_component_1.WebComponent();
webComponent.setComponentInstance(component);
// 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 component = createMockComponent({
selector: 'test-no-style',
template: '<div>No styles</div>',
encapsulation: component_1.ViewEncapsulation.Emulated,
scopeId: 'nostyle789',
});
const webComponent1 = new web_component_1.WebComponent();
webComponent1.setComponentInstance(component);
const webComponent2 = new web_component_1.WebComponent();
webComponent2.setComponentInstance(component);
// 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);

20
tests/unit/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "quarc-tests",
"version": "1.0.0",
"description": "Test suite for Quarc Framework",
"scripts": {
"test": "npm run test:unit && npm run test:e2e",
"test:unit": "npx ts-node run-tests.ts",
"test:e2e": "echo 'E2E tests not yet implemented'",
"test:all": "npm run test",
"test:browser": "open test-style-injection.html",
"test:functionality": "npx ts-node test-functionality.ts",
"test:style": "npx ts-node test-style-injection.ts",
"test:lifecycle": "npx ts-node test-lifecycle.ts"
},
"devDependencies": {
"@types/node": "^20.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.0"
}
}

58
tests/unit/run-tests.sh Executable file
View File

@ -0,0 +1,58 @@
#!/bin/bash
echo "🧪 Uruchamianie wszystkich testów Quarc Framework"
echo "============================================="
TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$TEST_DIR"
# Kompilacja testów
echo "🔨 Kompilacja testów TypeScript..."
npx tsc test-functionality.ts --target es2020 --module commonjs --outDir ./compiled --skipLibCheck
npx tsc test-style-injection.ts --target es2020 --module commonjs --outDir ./compiled --skipLibCheck
total_passed=0
total_failed=0
# Testy funkcjonalne
echo ""
echo "📂 Uruchamianie testów funkcjonalnych..."
echo "----------------------------------------"
if node compiled/tests/test-functionality.js; then
echo "✅ Testy funkcjonalne przeszły"
total_passed=$((total_passed + 1))
else
echo "❌ Testy funkcjonalne nie przeszły"
total_failed=$((total_failed + 1))
fi
# Testy stylów
echo ""
echo "📂 Uruchamianie testów wstrzykiwania stylów..."
echo "--------------------------------------------"
echo "⚠️ Uwaga: Testy stylów wymagają środowiska przeglądarki (JSDOM)"
if node compiled/tests/test-style-injection.js 2>/dev/null; then
echo "✅ Testy stylów przeszły"
total_passed=$((total_passed + 1))
else
echo "❌ Testy stylów nie przeszły (uruchom w przeglądarce przez test-style-injection.html)"
total_failed=$((total_failed + 1))
fi
echo ""
echo "============================================="
echo "📊 PODSUMOWANIE WSZYSTKICH TESTÓW"
echo "============================================="
echo "✅ Przeszło: $total_passed"
echo "❌ Niepowodzenia: $total_failed"
if [ $total_failed -eq 0 ]; then
echo ""
echo "✅ Wszystkie testy przeszły pomyślnie!"
exit 0
else
echo ""
echo "❌ Niektóre testy nie przeszły!"
echo "💡 Uruchom testy stylów w przeglądarce: open test-style-injection.html"
exit 1
fi

88
tests/unit/run-tests.ts Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Główny skrypt do uruchamiania wszystkich testów Quarc
*/
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
const testDir = __dirname;
console.log('🧪 Uruchamianie wszystkich testów Quarc Framework\n');
// Lista plików testowych (tylko testy działające w Node.js)
// test-style-injection.ts wymaga środowiska przeglądarki (HTMLElement)
const testFiles = [
'test-processors.ts',
'test-functionality.ts',
'test-lifecycle.ts',
'test-signals-reactivity.ts',
'test-directives.ts',
];
let totalPassed = 0;
let totalFailed = 0;
for (const testFile of testFiles) {
const testPath = join(testDir, testFile);
if (!existsSync(testPath)) {
console.log(`⚠️ Plik testowy nie istnieje: ${testFile}`);
continue;
}
console.log(`\n📂 Uruchamianie testów z: ${testFile}`);
console.log('─'.repeat(50));
try {
// Uruchom test przez ts-node lub node (jeśli skompilowany)
const isCompiled = testFile.endsWith('.js');
const command = isCompiled
? `node "${testPath}"`
: `npx ts-node "${testPath}"`;
const output = execSync(command, {
encoding: 'utf8',
stdio: 'pipe',
cwd: testDir
});
console.log(output);
// Próba wyodrębnienia wyników
const lines = output.split('\n');
const summaryLine = lines.find(line => line.includes('✅') || line.includes('❌'));
if (summaryLine) {
const passed = (summaryLine.match(/✅/g) || []).length;
const failed = (summaryLine.match(/❌/g) || []).length;
totalPassed += passed;
totalFailed += failed;
}
} catch (error: any) {
console.error(`❌ Błąd podczas uruchamiania ${testFile}:`);
console.error(error.stdout || error.message);
totalFailed++;
}
}
console.log('\n' + '='.repeat(60));
console.log('📊 PODSUMOWANIE WSZYSTKICH TESTÓW');
console.log('='.repeat(60));
console.log(`✅ Przeszło: ${totalPassed}`);
console.log(`❌ Niepowodzenia: ${totalFailed}`);
console.log(`📈 Współczynnik sukcesu: ${totalPassed + totalFailed > 0 ? ((totalPassed / (totalPassed + totalFailed)) * 100).toFixed(1) : 0}%`);
if (totalFailed > 0) {
console.log('\n❌ Niektóre testy nie przeszły!');
process.exit(1);
} else if (totalPassed === 0) {
console.log('\n⚠ Żadne testy nie zostały uruchomione!');
process.exit(1);
} else {
console.log('\n✅ Wszystkie testy przeszły pomyślnie!');
process.exit(0);
}

View File

@ -0,0 +1,331 @@
#!/usr/bin/env node
import { DirectiveCollectorProcessor } from '../../cli/processors/directive-collector-processor';
interface TestResult {
name: string;
passed: boolean;
error?: string;
}
const results: TestResult[] = [];
function test(name: string, fn: () => void | Promise<void>): 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 assertContains(actual: string, expected: string, message?: string): void {
if (!actual.includes(expected)) {
throw new Error(
`${message || 'Assertion failed'}\nExpected to contain:\n${expected}\nActual:\n${actual}`,
);
}
}
function assertNotContains(actual: string, expected: string, message?: string): void {
if (actual.includes(expected)) {
throw new Error(
`${message || 'Assertion failed'}\nExpected NOT to contain:\n${expected}\nActual:\n${actual}`,
);
}
}
function assertTrue(condition: boolean, message?: string): void {
if (!condition) {
throw new Error(message || 'Expected true but got false');
}
}
// ============================================================================
// DirectiveCollectorProcessor Tests
// ============================================================================
console.log('\n📦 DirectiveCollectorProcessor Tests\n');
const directiveCollector = new DirectiveCollectorProcessor();
test('DirectiveCollector: no @Component - no modification', async () => {
const input = `
export class SimpleClass {
constructor() {}
}
`;
const result = await directiveCollector.process({
filePath: '/test/simple.ts',
fileDir: '/test',
source: input,
});
assertTrue(result.modified === false, 'Expected no modification');
});
test('DirectiveCollector: @Component without imports - no modification', async () => {
const input = `
export class TestComponent {
static _quarcComponent = [{ selector: 'app-test', template: '<div>Test</div>' }];
static _scopeId = 'c0';
}
`;
const result = await directiveCollector.process({
filePath: '/test/test.component.ts',
fileDir: '/test',
source: input,
});
assertTrue(result.modified === false, 'Expected no modification');
});
test('DirectiveCollector: @Component with directive import - adds _quarcDirectives', async () => {
const input = `
import { HighlightDirective } from './highlight.directive';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {}
export class TestComponent {
static _quarcComponent = [{ selector: 'app-test', template: '<div appHighlight>Test</div>', imports: [HighlightDirective] }];
static _scopeId = 'c0';
}
`;
const result = await directiveCollector.process({
filePath: '/test/test.component.ts',
fileDir: '/test',
source: input,
});
assertContains(result.source, '_quarcDirectives = [HighlightDirective]');
});
test('DirectiveCollector: multiple directive imports', async () => {
const input = `
import { HighlightDirective } from './highlight.directive';
import { TooltipDirective } from './tooltip.directive';
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {}
@Directive({
selector: '[appTooltip]',
})
export class TooltipDirective {}
export class TestComponent {
static _quarcComponent = [{ selector: 'app-test', template: '<div>Test</div>', imports: [HighlightDirective, TooltipDirective] }];
static _scopeId = 'c0';
}
`;
const result = await directiveCollector.process({
filePath: '/test/test.component.ts',
fileDir: '/test',
source: input,
});
assertContains(result.source, '_quarcDirectives = [HighlightDirective, TooltipDirective]');
});
test('DirectiveCollector: component import (not directive) - still adds to list', async () => {
const input = `
import { ChildComponent } from './child.component';
export class TestComponent {
static _quarcComponent = [{ selector: 'app-test', template: '<child></child>', imports: [ChildComponent] }];
static _scopeId = 'c0';
}
`;
const result = await directiveCollector.process({
filePath: '/test/test.component.ts',
fileDir: '/test',
source: input,
});
assertContains(result.source, '_quarcDirectives = [ChildComponent]');
});
// ============================================================================
// DirectiveRegistry Tests (mock)
// ============================================================================
console.log('\n📦 DirectiveRegistry Tests\n');
test('DirectiveRegistry: selector matcher for attribute selector', () => {
const createSelectorMatcher = (selector: string): (element: any) => boolean => {
if (selector.startsWith('[') && selector.endsWith(']')) {
const attrName = selector.slice(1, -1);
return (el: any) => el.hasAttribute(attrName);
}
if (selector.startsWith('.')) {
const className = selector.slice(1);
return (el: any) => el.classList?.contains(className);
}
return () => false;
};
const matcher = createSelectorMatcher('[appHighlight]');
const mockElement = {
hasAttribute: (name: string) => name === 'appHighlight',
};
assertTrue(matcher(mockElement), 'Should match element with appHighlight attribute');
});
test('DirectiveRegistry: selector matcher for class selector', () => {
const createSelectorMatcher = (selector: string): (element: any) => boolean => {
if (selector.startsWith('[') && selector.endsWith(']')) {
const attrName = selector.slice(1, -1);
return (el: any) => el.hasAttribute(attrName);
}
if (selector.startsWith('.')) {
const className = selector.slice(1);
return (el: any) => el.classList?.contains(className);
}
return () => false;
};
const matcher = createSelectorMatcher('.highlight');
const mockElement = {
classList: {
contains: (name: string) => name === 'highlight',
},
};
assertTrue(matcher(mockElement), 'Should match element with highlight class');
});
// ============================================================================
// DirectiveRunner Tests (mock)
// ============================================================================
console.log('\n📦 DirectiveRunner Tests\n');
test('DirectiveRunner: scoped selector generation', () => {
const scopeId = 'c0';
const selector = '[appHighlight]';
const scopedSelector = `[_ngcontent-${scopeId}]${selector}`;
assertTrue(
scopedSelector === '[_ngcontent-c0][appHighlight]',
`Expected '[_ngcontent-c0][appHighlight]' but got '${scopedSelector}'`,
);
});
test('DirectiveRunner: scoped selector for class', () => {
const scopeId = 'c1';
const selector = '.my-directive';
const scopedSelector = `[_ngcontent-${scopeId}]${selector}`;
assertTrue(
scopedSelector === '[_ngcontent-c1].my-directive',
`Expected '[_ngcontent-c1].my-directive' but got '${scopedSelector}'`,
);
});
// ============================================================================
// IDirective Interface Tests
// ============================================================================
console.log('\n📦 IDirective Interface Tests\n');
test('IDirective: lifecycle hooks interface', () => {
interface IDirective {
ngOnInit?(): void;
ngOnDestroy?(): void;
ngOnChanges?(changes: Record<string, any>): void;
}
class TestDirective implements IDirective {
initialized = false;
destroyed = false;
ngOnInit(): void {
this.initialized = true;
}
ngOnDestroy(): void {
this.destroyed = true;
}
}
const directive = new TestDirective();
directive.ngOnInit?.();
assertTrue(directive.initialized, 'ngOnInit should set initialized to true');
directive.ngOnDestroy?.();
assertTrue(directive.destroyed, 'ngOnDestroy should set destroyed to true');
});
test('IDirective: ngOnChanges receives changes', () => {
interface IDirective {
ngOnChanges?(changes: Record<string, { previousValue: any; currentValue: any; firstChange: boolean }>): void;
}
class TestDirective implements IDirective {
lastChanges: Record<string, any> | null = null;
ngOnChanges(changes: Record<string, { previousValue: any; currentValue: any; firstChange: boolean }>): void {
this.lastChanges = changes;
}
}
const directive = new TestDirective();
const changes = {
color: { previousValue: 'red', currentValue: 'blue', firstChange: false },
};
directive.ngOnChanges?.(changes);
assertTrue(directive.lastChanges !== null, 'ngOnChanges should store changes');
assertTrue(
directive.lastChanges?.color?.currentValue === 'blue',
'ngOnChanges should receive correct current value',
);
});
// ============================================================================
// Run all tests
// ============================================================================
async function runTests() {
await new Promise(resolve => setTimeout(resolve, 100));
console.log('\n' + '='.repeat(60));
console.log('📊 DIRECTIVE 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();

View File

@ -0,0 +1,229 @@
/**
* Testy funkcjonalne dla Quarc
* Sprawdzają czy podstawowa funkcjonalność działa poprawnie
*/
import { ControlFlowTransformer } from '../../cli/helpers/control-flow-transformer';
import { TemplateParser, AttributeType } from '../../cli/helpers/template-parser';
import { StructuralDirectiveHelper } from '../../cli/helpers/structural-directive-helper';
console.log('=== TESTY FUNKCJONALNE QUARC ===\n');
let passedTests = 0;
let failedTests = 0;
function test(name: string, fn: () => boolean): void {
try {
const result = fn();
if (result) {
console.log(`${name}`);
passedTests++;
} else {
console.log(`${name}`);
failedTests++;
}
} catch (e) {
console.log(`${name} - Error: ${e}`);
failedTests++;
}
}
// Test 1: ControlFlowTransformer - prosty @if
test('ControlFlowTransformer: @if -> *ngIf', () => {
const transformer = new ControlFlowTransformer();
const input = '@if (show) { <div>Content</div> }';
const result = transformer.transform(input);
return result.includes('<ng-container *ngIf="show">') && result.includes('Content');
});
// Test 2: ControlFlowTransformer - @if @else
test('ControlFlowTransformer: @if @else', () => {
const transformer = new ControlFlowTransformer();
const input = '@if (a) { <div>A</div> } @else { <div>B</div> }';
const result = transformer.transform(input);
return result.includes('*ngIf="a"') && result.includes('*ngIf="!(a)"');
});
// Test 3: ControlFlowTransformer - @if @else if @else
test('ControlFlowTransformer: @if @else if @else', () => {
const transformer = new ControlFlowTransformer();
const input = '@if (a) { <div>A</div> } @else if (b) { <div>B</div> } @else { <div>C</div> }';
const result = transformer.transform(input);
return result.includes('*ngIf="a"') &&
result.includes('*ngIf="!(a) && b"') &&
result.includes('*ngIf="!(a) && !(b)"');
});
// Test 4: TemplateParser - parsowanie prostego HTML
test('TemplateParser: prosty HTML', () => {
const parser = new TemplateParser();
const elements = parser.parse('<div>Content</div>');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'div';
});
// Test 5: TemplateParser - parsowanie atrybutów
test('TemplateParser: atrybuty', () => {
const parser = new TemplateParser();
const elements = parser.parse('<div class="test" id="main">Content</div>');
return elements.length === 1 &&
'attributes' in elements[0] &&
elements[0].attributes.length === 2;
});
// Test 6: TemplateParser - *ngIf jako structural directive
test('TemplateParser: *ngIf detection', () => {
const parser = new TemplateParser();
const elements = parser.parse('<div *ngIf="show">Content</div>');
if (elements.length === 0 || !('attributes' in elements[0])) return false;
const attr = elements[0].attributes.find(a => a.name === '*ngIf');
return attr !== undefined && attr.type === 'structural';
});
// Test 7: TemplateParser - text nodes
test('TemplateParser: text nodes', () => {
const parser = new TemplateParser();
const elements = parser.parse('Text before <div>Content</div> Text after');
return elements.length === 3 &&
'type' in elements[0] && elements[0].type === 'text' &&
'tagName' in elements[1] && elements[1].tagName === 'div' &&
'type' in elements[2] && elements[2].type === 'text';
});
// Test 8: TemplateParser - zagnieżdżone elementy
test('TemplateParser: zagnieżdżone elementy', () => {
const parser = new TemplateParser();
const elements = parser.parse('<div><span>Nested</span></div>');
return elements.length === 1 &&
'children' in elements[0] &&
elements[0].children.length === 1 &&
'tagName' in elements[0].children[0] &&
elements[0].children[0].tagName === 'span';
});
// Test 9: StructuralDirectiveHelper - canHandle *ngIf
test('StructuralDirectiveHelper: canHandle *ngIf', () => {
const helper = new StructuralDirectiveHelper();
const attr = { name: '*ngIf', value: 'show', type: AttributeType.STRUCTURAL_DIRECTIVE };
return helper.canHandle(attr);
});
// Test 10: StructuralDirectiveHelper - process *ngIf
test('StructuralDirectiveHelper: process *ngIf', () => {
const helper = new StructuralDirectiveHelper();
const attr = { name: '*ngIf', value: 'show', type: AttributeType.STRUCTURAL_DIRECTIVE };
const element = { tagName: 'div', attributes: [attr], children: [] };
const result = helper.process({ element, attribute: attr, filePath: 'test.ts' });
return result.transformed === true &&
result.newAttribute?.name === '*ngIf' &&
result.newAttribute?.value === 'show';
});
// Test 11: ControlFlowTransformer - brak transformacji bez @if
test('ControlFlowTransformer: brak @if', () => {
const transformer = new ControlFlowTransformer();
const input = '<div>Regular content</div>';
const result = transformer.transform(input);
return result === input;
});
// Test 12: TemplateParser - self-closing tags
test('TemplateParser: self-closing tags', () => {
const parser = new TemplateParser();
const elements = parser.parse('<img src="test.jpg" />');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'img' &&
elements[0].children.length === 0;
});
// Test 13: TemplateParser - komentarze są pomijane
test('TemplateParser: komentarze', () => {
const parser = new TemplateParser();
const elements = parser.parse('<!-- comment --><div>Content</div>');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'div';
});
// Test 14: ControlFlowTransformer - wieloliniowy @if
test('ControlFlowTransformer: wieloliniowy @if', () => {
const transformer = new ControlFlowTransformer();
const input = `@if (show) {
<div>
Multi-line content
</div>
}`;
const result = transformer.transform(input);
return result.includes('<ng-container *ngIf="show">') &&
result.includes('Multi-line content');
});
// Test 15: TemplateParser - puste elementy
test('TemplateParser: puste elementy', () => {
const parser = new TemplateParser();
const elements = parser.parse('<div></div>');
return elements.length === 1 &&
'tagName' in elements[0] &&
elements[0].tagName === 'div' &&
elements[0].children.length === 0;
});
// Test 16: ControlFlowTransformer - prosty @for
test('ControlFlowTransformer: @for -> *ngFor', () => {
const transformer = new ControlFlowTransformer();
const input = '@for (item of items) { <div>{{ item }}</div> }';
const result = transformer.transform(input);
return result.includes('<ng-container *ngFor="let item of items">') &&
result.includes('<div>{{ item }}</div>');
});
// Test 17: ControlFlowTransformer - @for z trackBy
test('ControlFlowTransformer: @for z trackBy', () => {
const transformer = new ControlFlowTransformer();
const input = '@for (item of items; track item.id) { <div>{{ item.name }}</div> }';
const result = transformer.transform(input);
return result.includes('<ng-container *ngFor="let item of items; trackBy: item.id">') &&
result.includes('<div>{{ item.name }}</div>');
});
// Test 18: ControlFlowTransformer - @for z wieloliniową zawartością
test('ControlFlowTransformer: @for wieloliniowy', () => {
const transformer = new ControlFlowTransformer();
const input = `@for (user of users) {
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
}`;
const result = transformer.transform(input);
return result.includes('<ng-container *ngFor="let user of users">') &&
result.includes('user-card') &&
result.includes('{{ user.name }}');
});
// Test 19: ControlFlowTransformer - @for i @if razem
test('ControlFlowTransformer: @for i @if razem', () => {
const transformer = new ControlFlowTransformer();
const input = `@for (item of items) {
@if (item.active) {
<div>Active item: {{ item.name }}</div>
}
}`;
const result = transformer.transform(input);
return result.includes('<ng-container *ngFor="let item of items">') &&
result.includes('<ng-container *ngIf="item.active">') &&
result.includes('Active item:');
});
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ę.');
}

Some files were not shown because too many files have changed in this diff Show More