initial commit
This commit is contained in:
commit
2f1137b1d5
|
|
@ -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*
|
||||||
|
|
@ -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!
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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" />
|
||||||
|
```
|
||||||
|
|
@ -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(/^#/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, '\\$');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, '\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface TransformResult {
|
||||||
|
content: string;
|
||||||
|
modified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TemplateTransformer {
|
||||||
|
transformInterpolation(content: string): string {
|
||||||
|
let result = content;
|
||||||
|
|
||||||
|
result = this.transformAttributeInterpolation(result);
|
||||||
|
result = this.transformContentInterpolation(result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformAttributeInterpolation(content: string): string {
|
||||||
|
const tagRegex = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^>]*?)?)>/g;
|
||||||
|
|
||||||
|
return content.replace(tagRegex, (fullMatch, tagName, attributesPart) => {
|
||||||
|
if (!attributesPart || !attributesPart.includes('{{')) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interpolationRegex = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*\{\{[^"]*\}\}[^"]*)"/g;
|
||||||
|
const bindings: { attr: string; expr: string }[] = [];
|
||||||
|
let newAttributes = attributesPart;
|
||||||
|
|
||||||
|
newAttributes = attributesPart.replace(interpolationRegex, (_attrMatch: string, attrName: string, attrValue: string) => {
|
||||||
|
const hasInterpolation = /\{\{.*?\}\}/.test(attrValue);
|
||||||
|
if (!hasInterpolation) {
|
||||||
|
return _attrMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
const exprRegex = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = exprRegex.exec(attrValue)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
const literal = attrValue.substring(lastIndex, match.index);
|
||||||
|
if (literal) {
|
||||||
|
parts.push(`'${literal}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(`(${match[1].trim()})`);
|
||||||
|
lastIndex = exprRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < attrValue.length) {
|
||||||
|
const literal = attrValue.substring(lastIndex);
|
||||||
|
if (literal) {
|
||||||
|
parts.push(`'${literal}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expression = parts.length === 1 ? parts[0] : parts.join(' + ');
|
||||||
|
bindings.push({ attr: attrName, expr: expression });
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bindings.length === 0) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingsJson = JSON.stringify(bindings).replace(/"/g, "'");
|
||||||
|
const dataAttr = ` data-quarc-attr-bindings="${bindingsJson.replace(/'/g, ''')}"`;
|
||||||
|
|
||||||
|
newAttributes = newAttributes.trim();
|
||||||
|
return `<${tagName}${newAttributes ? ' ' + newAttributes : ''}${dataAttr}>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformContentInterpolation(content: string): string {
|
||||||
|
return content.replace(
|
||||||
|
/\{\{\s*([^}]+?)\s*\}\}/g,
|
||||||
|
(_, expr) => `<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function HostBinding(hostPropertyName?: string): PropertyDecorator {
|
||||||
|
return (target: Object, propertyKey: string | symbol) => {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function HostListener(
|
||||||
|
eventName: string,
|
||||||
|
args?: string[]
|
||||||
|
): MethodDecorator {
|
||||||
|
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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) => {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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) => {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {};
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export enum ViewEncapsulation {
|
||||||
|
None,
|
||||||
|
ShadowDom,
|
||||||
|
Emulated,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IComponent {
|
||||||
|
_nativeElement?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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(/'/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 {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { bootstrapApplication, bootstrapPlugin } from "./browser";
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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('&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { Subject, BehaviorSubject } from './subject';
|
||||||
|
export type { Subscription, Observer } from './subject';
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 = {}));
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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ę.');
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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
Loading…
Reference in New Issue