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