├── .gitignore ├── App ├── Bridge │ └── ViewiReactBridge.php ├── Controller │ ├── AuthSessionAction.php │ ├── AuthTokenAction.php │ ├── PostsAction.php │ └── PostsActionAsync.php ├── Message │ └── RawJsonResponse.php └── Middleware │ ├── RequestsHandlerMiddleware.php │ └── StaticFilesMiddleware.php ├── DemoOverview.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── public ├── about.txt ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── main.css └── site.webmanifest ├── screenshots ├── async.png ├── asyncInterceptors.png ├── asyncMiddleware.png ├── counter.png ├── homePage.png └── todo.png ├── server.php └── viewi-app ├── Components ├── Models │ └── PostModel.php ├── Services │ ├── Interceptors │ │ ├── AuthorizationInterceptor.php │ │ └── SessionInterceptor.php │ ├── Middleware │ │ ├── AuthGuard.php │ │ ├── AuthGuardFail.php │ │ └── SessionGuard.php │ └── Reducers │ │ ├── CounterReducer.php │ │ └── TodoReducer.php └── Views │ ├── AsyncTest │ ├── AsyncTestComponent.html │ ├── AsyncTestComponent.php │ ├── PostComponent.html │ └── PostComponent.php │ ├── Common │ ├── MenuBar.html │ └── MenuBar.php │ ├── Counter │ ├── Counter.html │ └── Counter.php │ ├── Home │ ├── HomePage.html │ └── HomePage.php │ ├── InterceptorsTest │ ├── InterceptorsTestComponent.html │ └── InterceptorsTestComponent.php │ ├── Layouts │ ├── Layout.html │ └── Layout.php │ ├── MiddlewareTest │ ├── MiddlewareFailTestComponent.html │ ├── MiddlewareFailTestComponent.php │ ├── MiddlewareTestComponent.html │ └── MiddlewareTestComponent.php │ ├── NotFound │ ├── NotFoundPage.html │ └── NotFoundPage.php │ ├── Pages │ ├── CounterPage.html │ ├── CounterPage.php │ ├── CurrentUrlTestPage.html │ ├── CurrentUrlTestPage.php │ ├── RedirectTestComponent.html │ ├── RedirectTestComponent.php │ ├── TodoAppPage.html │ └── TodoAppPage.php │ ├── StatefulCounter │ ├── StatefulCounter.html │ └── StatefulCounter.php │ ├── StatefulTodoApp │ ├── StatefulTodoApp.html │ └── StatefulTodoApp.php │ └── TodoApp │ ├── TodoApp.html │ ├── TodoApp.php │ ├── TodoList.html │ └── TodoList.php ├── assets ├── app.css └── mui.css ├── build.php ├── config.php ├── js ├── build.mjs ├── modules │ └── main │ │ └── index.ts ├── package.json └── viewi │ ├── core │ ├── anchor │ │ ├── anchor.ts │ │ ├── anchors.ts │ │ ├── createAnchorNode.ts │ │ ├── foreachAnchorEnum.ts │ │ ├── getAnchor.ts │ │ ├── nodeAnchor.ts │ │ └── textAnchor.ts │ ├── component │ │ ├── baseComponent.ts │ │ ├── componentMetaData.ts │ │ ├── componentsJson.ts │ │ ├── componentsMeta.ts │ │ ├── iStartUp.ts │ │ ├── isComponent.ts │ │ ├── makeGlobal.ts │ │ ├── makeGlobalMethod.ts │ │ └── reserverProps.ts │ ├── di │ │ ├── delay.ts │ │ ├── diContainer.ts │ │ ├── factory.ts │ │ ├── globalScope.ts │ │ ├── register.ts │ │ ├── resolve.ts │ │ ├── scopeType.ts │ │ └── setUp.ts │ ├── directive │ │ ├── conditionalDirective.ts │ │ ├── directive.ts │ │ ├── directiveMap.ts │ │ ├── directiveStorageType.ts │ │ └── directiveType.ts │ ├── environment │ │ └── platform.ts │ ├── events │ │ └── resolver.ts │ ├── helpers │ │ ├── ensureType.ts │ │ ├── isBlob.ts │ │ ├── isSvg.ts │ │ └── svgNameSpace.ts │ ├── http │ │ ├── httpClient.ts │ │ ├── iHttpInterceptor.ts │ │ ├── iRequestHandler.ts │ │ ├── iResponseHandler.ts │ │ ├── injectScript.ts │ │ ├── methodType.ts │ │ ├── request.ts │ │ ├── response.ts │ │ └── runRequest.ts │ ├── hydrate │ │ ├── hydrateComment.ts │ │ ├── hydrateRaw.ts │ │ ├── hydrateTag.ts │ │ └── hydrateText.ts │ ├── lifecycle │ │ ├── arrayScope.ts │ │ ├── contextScope.ts │ │ ├── dispose.ts │ │ ├── iDestroyable.ts │ │ ├── imiddleware.ts │ │ ├── propsContext.ts │ │ └── scopeState.ts │ ├── node │ │ ├── htmlNodeType.ts │ │ ├── inputType.ts │ │ ├── nodeType.ts │ │ ├── slots.ts │ │ ├── templateNode.ts │ │ └── unpack.ts │ ├── portal │ │ ├── portal.ts │ │ ├── portals.ts │ │ └── renderPortal.ts │ ├── reactivity │ │ ├── handlers │ │ │ ├── getComponentModelHandler.ts │ │ │ ├── getModelHandler.ts │ │ │ ├── modelHandler.ts │ │ │ ├── updateComment.ts │ │ │ ├── updateComponentModel.ts │ │ │ ├── updateModelValue.ts │ │ │ └── updateProp.ts │ │ ├── makeProxy.ts │ │ ├── makeProxyOrigin.ts │ │ └── track.ts │ ├── render │ │ ├── iRenderable.ts │ │ ├── render.ts │ │ ├── renderApp.ts │ │ ├── renderAttributeValue.ts │ │ ├── renderComponent.ts │ │ ├── renderDynamic.ts │ │ ├── renderForeach.ts │ │ ├── renderIf.ts │ │ ├── renderRaw.ts │ │ └── renderText.ts │ ├── router │ │ ├── handleUrl.ts │ │ ├── locationScope.ts │ │ ├── routeItem.ts │ │ ├── routeRecord.ts │ │ ├── router.ts │ │ └── watchLinks.ts │ └── viewi.ts │ └── index.ts ├── routes.php └── viewi.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | **/build/**/* 4 | !**/build/.gitkeep 5 | **/build/**/* 6 | !**/build/.gitkeep 7 | **/viewi-react/**/* 8 | viewi-app/publicConfig.php 9 | viewi-app/js/app/**/* 10 | viewi-app/js/dist/**/* 11 | **/node_modules 12 | -------------------------------------------------------------------------------- /App/Bridge/ViewiReactBridge.php: -------------------------------------------------------------------------------- 1 | requestHandler = $requestHandler; 25 | } 26 | 27 | public function request(Request $request, Engine $engine): mixed 28 | { 29 | if ($request->isExternal) { 30 | // HTTP call to some third party resource 31 | $browser = new \React\Http\Browser(); 32 | $promise = $browser->request($request->method, $request->url, $request->headers, $request->body ? json_encode($request->body) : ''); 33 | $response = await($promise); 34 | return @json_decode($response->getBody(), true); 35 | } 36 | 37 | $reactRequest = new ServerRequest($request->method, $request->url, $request->headers, $request->body ? json_encode($request->body) : ''); 38 | $response = ($this->requestHandler)($reactRequest, true); 39 | if ($response instanceof PromiseInterface) { 40 | $response = await($response); 41 | } 42 | $data = null; 43 | if ($response instanceof RawJsonResponse) { 44 | $data = $response->getData(); 45 | $response = $response->getResponse(); 46 | } else { 47 | $data = @json_decode($response->getBody()->__toString(), true); 48 | } 49 | /** 50 | * @var \React\Http\Message\Response $response 51 | */ 52 | $viewiResponse = new \Viewi\Components\Http\Message\Response($request->url, $response->getStatusCode(), $response->getReasonPhrase(), $response->getHeaders(), $data); 53 | return $viewiResponse; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /App/Controller/AuthSessionAction.php: -------------------------------------------------------------------------------- 1 | '000-1111-2222']); 18 | $resolve($response); 19 | }); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /App/Controller/AuthTokenAction.php: -------------------------------------------------------------------------------- 1 | getAttribute('params')['valid'] === 'true'; 19 | if (!$valid) { 20 | $resolve(new Response(401, [], '')); 21 | return; 22 | } 23 | $response = new RawJsonResponse(['token' => 'base64string']); 24 | $resolve($response); 25 | }); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /App/Controller/PostsAction.php: -------------------------------------------------------------------------------- 1 | Id = $request->getAttribute('params')['id'] ?? 0; 15 | $post->Name = 'Viewi ft. ReactPHP'; 16 | $post->Version = 1; 17 | $response = new RawJsonResponse($post); 18 | return $response; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /App/Controller/PostsActionAsync.php: -------------------------------------------------------------------------------- 1 | getAttribute('params')['ms'] ?? 50; 17 | if ($ms > 5000) // we don't want it to be more than 5 sec 18 | { 19 | $ms = 5000; 20 | } 21 | // Simulating I/O delay (DB/file read) with timer 22 | Loop::addTimer($ms / 1000 + (rand(1, 20) / 100), function () use ($resolve, $request) { 23 | $postId = $request->getAttribute('params')['id'] ?? 0; 24 | $post = new PostModel(); 25 | $post->Id = $postId; 26 | $post->Name = "Viewi ft. ReactPHP $postId"; 27 | $post->Version = $postId + 1000; 28 | $response = new RawJsonResponse($post); 29 | // echo "request Loop:1. \n"; 30 | $resolve($response); 31 | }); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /App/Message/RawJsonResponse.php: -------------------------------------------------------------------------------- 1 | data = $data; 17 | $this->response = (Response::json($data))->withStatus($status); 18 | } 19 | 20 | public function getStatusCode() 21 | { 22 | return $this->response->getStatusCode(); 23 | } 24 | 25 | public function withStatus(int $code, string $reasonPhrase = '') 26 | { 27 | $this->response = $this->response->withStatus($code, $reasonPhrase); 28 | return $this; 29 | } 30 | 31 | public function getReasonPhrase() 32 | { 33 | return $this->response->getReasonPhrase(); 34 | } 35 | 36 | public function getProtocolVersion() 37 | { 38 | return $this->response->getProtocolVersion(); 39 | } 40 | 41 | public function withProtocolVersion(string $version) 42 | { 43 | $this->response = $this->response->withProtocolVersion($version); 44 | return $this; 45 | } 46 | 47 | public function getHeaders() 48 | { 49 | return $this->response->getHeaders(); 50 | } 51 | 52 | public function hasHeader(string $name) 53 | { 54 | return $this->response->hasHeader($name); 55 | } 56 | 57 | public function getHeader(string $name) 58 | { 59 | return $this->response->getHeader($name); 60 | } 61 | 62 | public function getHeaderLine(string $name) 63 | { 64 | return $this->response->getHeaderLine($name); 65 | } 66 | 67 | public function withHeader(string $name, $value) 68 | { 69 | $this->response = $this->response->withHeader($name, $value); 70 | return $this; 71 | } 72 | 73 | public function withAddedHeader(string $name, $value) 74 | { 75 | $this->response = $this->response->withAddedHeader($name, $value); 76 | return $this; 77 | } 78 | 79 | public function withoutHeader(string $name) 80 | { 81 | $this->response = $this->response->withoutHeader($name); 82 | return $this; 83 | } 84 | 85 | public function getBody() 86 | { 87 | return $this->response->getBody(); 88 | } 89 | 90 | public function withBody(StreamInterface $body) 91 | { 92 | $this->response = $this->response->withBody($body); 93 | return $this; 94 | } 95 | 96 | public function getData(): mixed 97 | { 98 | return $this->data; 99 | } 100 | 101 | public function getResponse(): Response 102 | { 103 | return $this->response; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /App/Middleware/RequestsHandlerMiddleware.php: -------------------------------------------------------------------------------- 1 | router->resolve($request->getUri()->getPath(), $request->getMethod()); 28 | if ($match === null) { 29 | throw new Exception('No route was matched!'); 30 | } 31 | /** @var RouteItem */ 32 | $routeItem = $match['item']; 33 | $action = $routeItem->action; 34 | $response = ''; 35 | if (is_callable($action) && !is_string($action)) { 36 | if ($match['params']) { 37 | $request = $request->withAttribute('params', $match['params']); 38 | } 39 | $response = $action($request); 40 | } elseif ($action instanceof ComponentRoute) { 41 | $viewiRequest = new Request($request->getUri()->getPath(), strtolower($request->getMethod())); 42 | $response = $this->viewiApp->engine()->render($action->component, $match['params'], $viewiRequest); 43 | } else { 44 | throw new Exception('Unknown action type.'); 45 | } 46 | 47 | // if ($response instanceof PromiseInterface) { 48 | // return $response; 49 | // // $response = await($response); 50 | // } 51 | if (is_string($response)) { // string as html 52 | return new Response( 53 | 200, 54 | array( 55 | 'Content-Type' => 'text/html; charset=utf-8' 56 | ), 57 | $response 58 | ); 59 | } elseif ($response instanceof ViewiResponse) { 60 | return new Response( 61 | isset($response->headers['Location']) ? 302 : $response->status, 62 | $response->headers, 63 | is_string($response->body) ? $response->body : json_encode($response->body) 64 | ); 65 | } elseif ($response instanceof RawJsonResponse) { 66 | return $keepRaw ? $response : $response->getResponse(); 67 | } elseif ($response instanceof PromiseInterface) { 68 | return $response; 69 | // $response = await($response); 70 | } else { // json 71 | new Response( 72 | 200, 73 | array( 74 | 'Content-Type' => 'application/json' 75 | ), 76 | json_encode($response) 77 | ); 78 | } 79 | } catch (Throwable $t) { 80 | echo $t->getMessage() . "\n"; 81 | echo $t->getTraceAsString() . "\n"; 82 | // !! For production: Consider using $reject and don't output stack trace 83 | return new Response( 84 | 500, 85 | array( 86 | 'Content-Type' => 'text/text' 87 | ), 88 | $t->getMessage() . $t->getTraceAsString() 89 | ); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /App/Middleware/StaticFilesMiddleware.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 14 | } 15 | 16 | public function __invoke(\Psr\Http\Message\ServerRequestInterface $request, callable $next) 17 | { 18 | $filePath = $request->getUri()->getPath(); 19 | $file = $this->directory . $filePath; 20 | if (file_exists($file) && !is_dir($file)) { 21 | $fileExt = pathinfo($file, PATHINFO_EXTENSION); 22 | $contentType = 'text/text'; 23 | switch ($fileExt) { 24 | case 'js': { 25 | $contentType = 'application/javascript'; 26 | break; 27 | } 28 | case 'json': { 29 | $contentType = 'application/json'; 30 | break; 31 | } 32 | case 'css': { 33 | $contentType = 'text/css'; 34 | break; 35 | } 36 | case 'ico': { 37 | $contentType = 'image/x-icon'; 38 | break; 39 | } 40 | } 41 | return new Response(200, ['Content-Type' => $contentType], file_get_contents($file)); 42 | } 43 | return $next($request); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DemoOverview.md: -------------------------------------------------------------------------------- 1 | # ReactPHP and Viewi demo application overview 2 | 3 | ## Home page 4 | 5 | [http://localhost:8080/](http://localhost:8080/) 6 | 7 | Home page loads one post using HttpClient to call an API 8 | 9 | ![Alt text](screenshots/homePage.png?raw=true "Home Page") 10 | 11 | ```php 12 | http->get('/api/posts/45')->then( 32 | function (PostModel $post) { 33 | $this->post = $post; 34 | }, 35 | function ($error) { 36 | echo $error; 37 | } 38 | ); 39 | } 40 | } 41 | ``` 42 | 43 | ```html 44 | 45 |

$title

46 |
Data from the Server: {json_encode($post)}
47 |
48 |

{$post->Name}

49 |
50 | Id: {$post->Id} 51 |
52 |
53 | Version: {$post->Version} 54 |
55 |
56 |
57 | ``` 58 | 59 | PostsAction middleware for handling requests to the API endpoint `/api/posts/{id}` 60 | 61 | ```php 62 | class PostsAction 63 | { 64 | public function __invoke(ServerRequestInterface $request) { 65 | $post = new PostModel(); 66 | $post->Id = $request->getAttribute('params')['id'] ?? 0; 67 | $post->Name = 'Viewi ft. ReactPHP'; 68 | $post->Version = 1; 69 | return new RawJsonResponse($post); 70 | } 71 | } 72 | ``` 73 | 74 | ## Async SSR 75 | 76 | [http://localhost:8080/async-ssr-test/100500](http://localhost:8080/async-ssr-test/100500) 77 | 78 | This page demonstrates async server side rendering. It loads 4 posts in async mode. 79 | 80 | `PostsActionAsync` simulates I/O DB read with random timer value. 81 | 82 | ![Alt text](screenshots/async.png?raw=true "Async SSR") 83 | 84 | ```html 85 | 86 |

$title

87 |

This page loads 4 posts in non-blocking asynchronous mode.

88 | 89 | 90 | 91 | 92 |
93 | ``` 94 | 95 | ```php 96 | http->get("/api/posts/{$this->id}/async")->then( 116 | function (PostModel $post) { 117 | $this->post = $post; 118 | // print_r(['$http->get->then->success', $post]); 119 | }, 120 | function ($error) { 121 | echo $error; 122 | // print_r(['$http->get->then->error', $error]); 123 | } 124 | ); 125 | } 126 | } 127 | ``` 128 | 129 | `PostsActionAsync` 130 | 131 | ```php 132 | getAttribute('params')['ms'] ?? 50; 148 | if ($ms > 5000) // we don't want it to be more than 5 sec 149 | { 150 | $ms = 5000; 151 | } 152 | // Simulating I/O delay (DB/file read) with timer 153 | Loop::addTimer($ms / 1000 + (rand(1, 20) / 100), function () use ($resolve, $request) { 154 | $postId = $request->getAttribute('params')['id'] ?? 0; 155 | $post = new PostModel(); 156 | $post->Id = $postId; 157 | $post->Name = "Viewi ft. ReactPHP $postId"; 158 | $post->Version = $postId + 1000; 159 | $response = new RawJsonResponse($post); 160 | // echo "request Loop:1. \n"; 161 | $resolve($response); 162 | }); 163 | }); 164 | } 165 | } 166 | ``` 167 | 168 | ## Async Interceptors 169 | 170 | [http://localhost:8080/interceptors-test/100500](http://localhost:8080/interceptors-test/100500) 171 | 172 | This page demonstrates async interceptors during SSR 173 | 174 | ![Alt text](screenshots/asyncInterceptors.png?raw=true "Async Interceptors") 175 | 176 | ```php 177 | http 200 | ->withInterceptor(SessionInterceptor::class) 201 | ->withInterceptor(AuthorizationInterceptor::class) 202 | ->get("/api/posts/{$this->id}/async/200")->then( 203 | function (PostModel $post) { 204 | $this->post = $post; 205 | }, 206 | function ($error) { 207 | $this->message = $error; 208 | } 209 | ); 210 | } 211 | } 212 | ``` 213 | 214 | ## Async Middleware (Viewi IMiddleware) 215 | 216 | [http://localhost:8080/middleware-test/100500](http://localhost:8080/middleware-test/100500) 217 | 218 | This page demonstrates using guards in asynchronous mode 219 | 220 | ![Alt text](screenshots/asyncMiddleware.png?raw=true "Async middleware") 221 | 222 | ```php 223 | http 248 | ->get("/api/posts/{$this->id}/async/1")->then( 249 | function (PostModel $post) { 250 | $this->post = $post; 251 | }, 252 | function ($error) { 253 | $this->message = $error; 254 | } 255 | ); 256 | } 257 | } 258 | ``` 259 | 260 | ## Middleware Unauthorized example 261 | 262 | [http://localhost:8080/middleware-fail-test/100500](http://localhost:8080/middleware-fail-test/100500) 263 | 264 | This page should redirect you back to home page. Guard will check authorization and it won't pass. 265 | 266 | ## Counter 267 | 268 | [http://localhost:8080/counter](http://localhost:8080/counter) 269 | 270 | and 271 | 272 | [http://localhost:8080/counter/123](http://localhost:8080/counter/123) 273 | 274 | Demo of a handling click events in order to update the viewi with a new count value. 275 | 276 | ![Alt text](screenshots/counter.png?raw=true "Counter") 277 | 278 | ```html 279 | 280 | $count 281 | 282 | ``` 283 | 284 | Also it demonstrates how to inject route parameter into the component: 285 | 286 | `$router->get('/counter/{page}', CounterPage::class);` 287 | 288 | ```php 289 | page = $page; 302 | } 303 | } 304 | ``` 305 | 306 | ## Todo 307 | 308 | [http://localhost:8080/todo](http://localhost:8080/todo) 309 | 310 | Demonstrates a small Todo application. 311 | 312 | ![Alt text](screenshots/todo.png?raw=true "Todo") 313 | 314 | ## Redirect test 315 | 316 | [http://localhost:8080/redirect-test](http://localhost:8080/redirect-test) 317 | 318 | Demonstrates how to redirect to another page using `ClientRouter` 319 | 320 | ```php 321 | router->navigate('/'); 337 | } 338 | } 339 | ``` 340 | 341 | ## Curren Url page 342 | 343 | [http://localhost:8080/current-url](http://localhost:8080/current-url) 344 | 345 | This page demonstrates how to get a current url path of the page with `ClientRouter` 346 | 347 | ```php 348 | currentUrl = $router->getUrl(); 362 | } 363 | } 364 | ``` 365 | 366 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Ivan Voitovych 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "react/http": "^1.9", 4 | "viewi/viewi": "^2.0", 5 | "react/async": "^4.1" 6 | }, 7 | "autoload": { 8 | "psr-4": { 9 | "App\\": "App/", 10 | "Components\\": "viewi-app/Components/" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Leckerli One 4 | - Font Author: Copyright (c) 2011 Gesine Todt (www.gesine-todt.de), with Reserved Font Names "Leckerli" 5 | - Font Source: http://fonts.gstatic.com/s/leckerlione/v14/V8mCoQH8VCsNttEnxnGQ-1itLZxcBtItFw.ttf 6 | - Font License: SIL Open Font License, 1.1 (http://scripts.sil.org/OFL)) 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/public/favicon.ico -------------------------------------------------------------------------------- /public/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 1em; 3 | } -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /screenshots/async.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/screenshots/async.png -------------------------------------------------------------------------------- /screenshots/asyncInterceptors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/screenshots/asyncInterceptors.png -------------------------------------------------------------------------------- /screenshots/asyncMiddleware.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/screenshots/asyncMiddleware.png -------------------------------------------------------------------------------- /screenshots/counter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/screenshots/counter.png -------------------------------------------------------------------------------- /screenshots/homePage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/screenshots/homePage.png -------------------------------------------------------------------------------- /screenshots/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivanvoitovych/viewi-reactphp-demo/dd5056ee6e3c696570e1cfefa6bc77fd452502fa/screenshots/todo.png -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | router(); 21 | $viewiRequestHandler = new RequestsHandlerMiddleware($router, $viewiApp); 22 | 23 | $viewiReactBridge = new ViewiReactBridge($viewiRequestHandler); 24 | $app->factory()->add(IViewiBridge::class, function () use ($viewiReactBridge) { 25 | return $viewiReactBridge; 26 | }); 27 | 28 | 29 | $router->register('get', '/api/posts/{id}', new PostsAction()); 30 | $router->register('get', '/api/posts/{id}/async/{ms?}', new PostsActionAsync()); 31 | $router->register('post', '/api/authorization/session', new AuthSessionAction()); 32 | $router->register('post', '/api/authorization/token/{valid}', new AuthTokenAction()); 33 | 34 | // include viewi routes 35 | include __DIR__ . '/viewi-app/routes.php'; 36 | 37 | $http = new React\Http\HttpServer( 38 | new StaticFilesMiddleware(__DIR__ . '/public'), 39 | $viewiRequestHandler 40 | ); 41 | 42 | $socket = new React\Socket\SocketServer(isset($argv[1]) ? $argv[1] : '127.0.0.1:8080'); 43 | $http->listen($socket); 44 | 45 | echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; 46 | -------------------------------------------------------------------------------- /viewi-app/Components/Models/PostModel.php: -------------------------------------------------------------------------------- 1 | withHeader 23 | // call handle to continue with the request 24 | $this->http->post('/api/authorization/token/true')->then(function ($response) use ($request, $handler) { 25 | $newRequest = $request->withHeader('Authorization', $response['token']); 26 | $handler->next($newRequest); 27 | }, function ($error) use ($request, $handler) { 28 | $handler->reject($request); 29 | }); 30 | } 31 | 32 | public function response(Response $response, IResponseHandler $handler) 33 | { 34 | // access or modify $response 35 | // call $handler->next if you are good with the response 36 | // call $handler->reject to reject the response 37 | if ($response->status === 0) { 38 | // rejected 39 | // make it Unauthorized 40 | $response->status = 401; 41 | $response->body = 'Unauthorized'; 42 | } 43 | $handler->next($response); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /viewi-app/Components/Services/Interceptors/SessionInterceptor.php: -------------------------------------------------------------------------------- 1 | withHeader 23 | // call handle to continue with the request 24 | $this->http->post('/api/authorization/session')->then(function ($response) use ($request, $handler) { 25 | $newRequest = $request->withHeader('X-SESSION-ID', $response['session']); 26 | $handler->next($newRequest); 27 | }, function ($error) use ($request, $handler) { 28 | $handler->reject($request); 29 | }); 30 | } 31 | 32 | public function response(Response $response, IResponseHandler $handler) 33 | { 34 | // access or modify $response 35 | // call $handler->next if you are good with the response 36 | // call $handler->reject to reject the response 37 | if ($response->status === 0) { 38 | // rejected 39 | // make it Unauthorized 40 | $response->status = 440; 41 | $response->body = 'Session has expired.'; 42 | } 43 | $handler->next($response); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /viewi-app/Components/Services/Middleware/AuthGuard.php: -------------------------------------------------------------------------------- 1 | http->post('/api/authorization/token/true')->then(function ($response) use ($c) { 21 | // all good - call $c->next(); or $c->next(true); 22 | $c->next(); 23 | }, function () use ($c) { 24 | // If we want to cancel - we call $c->next(false); 25 | $c->next(false); 26 | $this->router->navigate('/'); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /viewi-app/Components/Services/Middleware/AuthGuardFail.php: -------------------------------------------------------------------------------- 1 | http->post('/api/authorization/token/false')->then(function ($response) use ($c) { 21 | // all good - call $c->next(); or $c->next(true); 22 | $c->next(); 23 | }, function () use ($c) { 24 | // If we want to cancel - we call $c->next(false); 25 | $c->next(false); 26 | $this->router->navigate('/'); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /viewi-app/Components/Services/Middleware/SessionGuard.php: -------------------------------------------------------------------------------- 1 | http->post('/api/authorization/session')->then(function ($response) use ($c) { 21 | // all good - call $c->next(); or $c->next(true); 22 | $c->next(); 23 | }, function () use ($c) { 24 | // If we want to cancel - we call $c->next(false); 25 | $c->next(false); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /viewi-app/Components/Services/Reducers/CounterReducer.php: -------------------------------------------------------------------------------- 1 | count++; 15 | } 16 | 17 | public function decrement() 18 | { 19 | $this->count--; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /viewi-app/Components/Services/Reducers/TodoReducer.php: -------------------------------------------------------------------------------- 1 | items = [...$this->items, $text]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/AsyncTest/AsyncTestComponent.html: -------------------------------------------------------------------------------- 1 | 2 |

$title

3 |

This page loads 4 posts in non-blocking asynchronous mode.

4 | 5 | 6 | 7 | 8 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/AsyncTest/AsyncTestComponent.php: -------------------------------------------------------------------------------- 1 | Data from the Server: {json_encode($post)} 2 |
3 |

{$post->Name}

4 |
5 | Id: {$post->Id} 6 |
7 |
8 | Version: {$post->Version} 9 |
10 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/AsyncTest/PostComponent.php: -------------------------------------------------------------------------------- 1 | http->get("/api/posts/{$this->id}/async")->then( 22 | function (PostModel $post) { 23 | $this->post = $post; 24 | // print_r(['$http->get->then->success', $post]); 25 | }, 26 | function (Response $response) { 27 | echo $response->body; 28 | // print_r(['$http->get->then->error', $error]); 29 | } 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Common/MenuBar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Common/MenuBar.php: -------------------------------------------------------------------------------- 1 | - 2 | $count 3 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Counter/Counter.php: -------------------------------------------------------------------------------- 1 | count++; 14 | } 15 | 16 | public function decrement() 17 | { 18 | $this->count--; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Home/HomePage.html: -------------------------------------------------------------------------------- 1 | 2 |

$title

3 |
Data from the Server: {json_encode($post)}
4 |
5 |

{$post->Name}

6 |
7 | Id: {$post->Id} 8 |
9 |
10 | Version: {$post->Version} 11 |
12 |
13 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/Home/HomePage.php: -------------------------------------------------------------------------------- 1 | http->get('/api/posts/45')->then( 22 | function (PostModel $post) { 23 | $this->post = $post; 24 | }, 25 | function (Response $response) { 26 | echo $response->body; 27 | } 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/InterceptorsTest/InterceptorsTestComponent.html: -------------------------------------------------------------------------------- 1 | 2 |

$title

3 |

4 | On this page we call session API first using SessionInterceptor. And only if everything is fine we proceed with getting the Post. 5 |

6 |

7 | Expected response time 600 - 800 ms (randomly with Loop::addTimer) 8 |

9 |
10 |

{$post->Name}

11 |
12 | Id: {$post->Id} 13 |
14 |
15 | Version: {$post->Version} 16 |
17 |
18 |
19 | $message 20 |
21 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/InterceptorsTest/InterceptorsTestComponent.php: -------------------------------------------------------------------------------- 1 | http 25 | ->withInterceptor(SessionInterceptor::class) 26 | ->withInterceptor(AuthorizationInterceptor::class) 27 | ->get("/api/posts/{$this->id}/async/200")->then( 28 | function (PostModel $post) { 29 | $this->post = $post; 30 | }, 31 | function (Response $response) { 32 | $this->message = $response->body; 33 | } 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Layouts/Layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | $title | Viewi 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 |
19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Layouts/Layout.php: -------------------------------------------------------------------------------- 1 | 2 |

$title

3 |

4 | On this page we use Middleware list (Guards) and guard should not allow as to open this page. Expected redirect 5 | to home page. 6 |

7 |
8 |

{$post->Name}

9 |
10 | Id: {$post->Id} 11 |
12 |
13 | Version: {$post->Version} 14 |
15 |
16 |
17 | $message 18 |
19 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/MiddlewareTest/MiddlewareFailTestComponent.php: -------------------------------------------------------------------------------- 1 | http 27 | ->get("/api/posts/{$this->id}/async/1")->then( 28 | function (PostModel $post) { 29 | $this->post = $post; 30 | }, 31 | function (Response $response) { 32 | $this->message = $response->body; 33 | } 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/MiddlewareTest/MiddlewareTestComponent.html: -------------------------------------------------------------------------------- 1 | 2 |

$title

3 |

4 | On this page we use Middleware list (Guards). And only if everything is fine the component will be rendered. 5 |

6 |

7 | Expected response time 400 - 600 ms (randomly with Loop::addTimer) 8 |

9 |
10 |

{$post->Name}

11 |
12 | Id: {$post->Id} 13 |
14 |
15 | Version: {$post->Version} 16 |
17 |
18 |
19 | $message 20 |
21 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/MiddlewareTest/MiddlewareTestComponent.php: -------------------------------------------------------------------------------- 1 | http 27 | ->get("/api/posts/{$this->id}/async/1")->then( 28 | function (PostModel $post) { 29 | $this->post = $post; 30 | }, 31 | function (Response $response) { 32 | $this->message = $response->body; 33 | } 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/NotFound/NotFoundPage.html: -------------------------------------------------------------------------------- 1 | 2 |

Page not found

3 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/NotFound/NotFoundPage.php: -------------------------------------------------------------------------------- 1 | 2 |

Counter

3 |

Route argument page = $page

4 |
5 |
6 |
7 |

Regular Counter

8 | 9 |
10 |
11 |

Counter with shared state

12 | 13 |
14 | Unlike the regular counter, this one remembers its state 15 | for further use. No matter how you change the count, it will keep it in reducer. And foremore - you 16 | can use it in different places (share with another component for example) and the count will be the 17 | same. 18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/CounterPage.php: -------------------------------------------------------------------------------- 1 | page = $page; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/CurrentUrlTestPage.html: -------------------------------------------------------------------------------- 1 | 2 |

Current Url is $currentUrl

3 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/CurrentUrlTestPage.php: -------------------------------------------------------------------------------- 1 | currentUrl = $router->getUrl(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/RedirectTestComponent.html: -------------------------------------------------------------------------------- 1 | 2 |

You should never see me

3 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/RedirectTestComponent.php: -------------------------------------------------------------------------------- 1 | router->navigate('/'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/TodoAppPage.html: -------------------------------------------------------------------------------- 1 | 2 |

Todo

3 |
4 |
5 |
6 |

Regular Todo

7 | 8 |
9 |
10 |

Todo with shared state

11 | 12 |
13 | Unlike the regular todo, this one remembers its state for 14 | further use. No matter how you change the todo list, it will keep it in reducer. And foremore - you 15 | can use it in different places (share with another component for example) and the list will be the 16 | same. 17 |
18 |
19 |
20 |
21 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/Pages/TodoAppPage.php: -------------------------------------------------------------------------------- 1 | decrement" class="mui-btn mui-btn--accent">- 2 | {$counter->count} 3 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/StatefulCounter/StatefulCounter.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 | 9 | 10 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/StatefulTodoApp/StatefulTodoApp.php: -------------------------------------------------------------------------------- 1 | todo = $reducer; 17 | } 18 | 19 | public function handleSubmit(DomEvent $event) 20 | { 21 | $event->preventDefault(); 22 | if (strlen($this->text) === 0) { 23 | return; 24 | } 25 | $this->todo->addNewItem($this->text); 26 | $this->text = ''; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/TodoApp/TodoApp.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 | 9 |
10 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/TodoApp/TodoApp.php: -------------------------------------------------------------------------------- 1 | preventDefault(); 16 | if (strlen($this->text) == 0) { 17 | return; 18 | } 19 | $this->items = [...$this->items, $this->text]; 20 | $this->text = ''; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /viewi-app/Components/Views/TodoApp/TodoList.html: -------------------------------------------------------------------------------- 1 |
    2 |
  • $item
  • 3 |
-------------------------------------------------------------------------------- /viewi-app/Components/Views/TodoApp/TodoList.php: -------------------------------------------------------------------------------- 1 | build(); 13 | echo $logs; 14 | -------------------------------------------------------------------------------- /viewi-app/config.php: -------------------------------------------------------------------------------- 1 | buildTo($buildPath) 16 | ->buildFrom($componentsPath) 17 | ->withJsEntry($jsPath) 18 | ->putAssetsTo($publicPath) 19 | ->assetsPublicUrl($assetsPublicUrl) 20 | ->withAssets($assetsSourcePath) 21 | ->combine(false) 22 | ->minify(false) 23 | ->developmentMode(true) 24 | ->buildJsSourceCode() 25 | ->watchWithNPM(true); 26 | -------------------------------------------------------------------------------- /viewi-app/js/build.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as esbuild from 'esbuild' 3 | import * as chokidar from 'chokidar'; 4 | import anymatch from 'anymatch'; 5 | import path from 'path'; 6 | import { exec } from 'node:child_process'; 7 | import { PurgeCSS } from 'purgecss'; 8 | import { writeFile } from 'node:fs/promises'; 9 | import { lazyGroups } from './app/lazyGroups.mjs'; 10 | import { buildActions } from './app/buildActions.mjs'; 11 | import { readFile } from 'fs/promises'; 12 | 13 | let watchMode = false; 14 | 15 | process.argv.forEach(function (val, index, array) { 16 | switch (val) { 17 | case '--watch': { 18 | watchMode = true; 19 | break; 20 | } 21 | default: 22 | break; // noop 23 | } 24 | }); 25 | 26 | const runBuild = async function () { 27 | const base = { 28 | entryPoints: ['./viewi/index.ts'], 29 | logLevel: "info", 30 | treeShaking: true, 31 | bundle: true, 32 | }; 33 | 34 | await esbuild.build({ ...base, outfile: './dist/viewi.js' }); 35 | await esbuild.build({ ...base, minify: true, outfile: './dist/viewi.min.js' }); 36 | 37 | for (let group in lazyGroups) { 38 | const entry = lazyGroups[group]; 39 | await esbuild.build({ ...base, entryPoints: [entry], outfile: `./dist/viewi.${group}.js` }); 40 | await esbuild.build({ ...base, entryPoints: [entry], minify: true, outfile: `./dist/viewi.${group}.min.js` }); 41 | } 42 | 43 | // build actions 44 | for (let i = 0; i < buildActions.items.length; i++) { 45 | const buildItem = buildActions.items[i]; 46 | switch (buildItem.type) { 47 | case 'css': { 48 | const cssItems = buildItem.data.links.map(x => './../assets' + x); 49 | const buildList = []; 50 | if (buildItem.data.combine) { 51 | buildList.push(cssItems); 52 | } else { 53 | for (let c = 0; c < cssItems.length; c++) { 54 | buildList.push([cssItems[c]]); 55 | } 56 | } 57 | for (let c = 0; c < buildList.length; c++) { 58 | const entries = buildList[c]; 59 | let combinedCss = ''; 60 | if (buildItem.data.purge) { 61 | const purgeCSSResult = await new PurgeCSS().purge({ 62 | content: ['./../**/*.js', './../**/*.php', './../**/*.html'], 63 | css: entries, 64 | skippedContentGlobs: ['**/node_modules/**', '**/build/**'] 65 | }); 66 | for (let i = 0; i < purgeCSSResult.length; i++) { 67 | const purgedCSS = purgeCSSResult[i]; 68 | combinedCss += purgedCSS.css; 69 | } 70 | } else { 71 | for (let f = 0; f < entries.length; f++) { 72 | combinedCss += await readFile(entries[f]); 73 | } 74 | } 75 | await esbuild.build({ 76 | stdin: { 77 | contents: combinedCss, 78 | // These are all optional: 79 | resolveDir: './../assets', 80 | loader: 'css' 81 | }, 82 | // entryPoints: buildItem.data.links.map(x => './../assets' + x), 83 | bundle: true, 84 | // loader: { '.png': 'copy', '.jpg': 'copy' }, 85 | external: ['*.png', '*.jpg'], 86 | minify: !!buildItem.data.minify, 87 | // outdir: './dist/assets', 88 | outfile: './dist/assets' + (buildItem.data.combine ? buildItem.data.output : buildItem.data.links[c]), 89 | }); 90 | } 91 | break; 92 | } 93 | default: { 94 | console.warn(`Type action ${buildItem.type} is not implemented.`); 95 | break 96 | } 97 | } 98 | } 99 | }; 100 | 101 | const runServerBuild = function () { 102 | // exec php viewi build 103 | return new Promise(function (resolve, reject) { 104 | exec('php ./../build.php', (error, stdout, stderr) => { 105 | if (error) { 106 | reject(`exec error: ${error} \n${stdout}${stderr}`); 107 | return; 108 | } 109 | console.log(`PHP build: ${stdout}`); 110 | if (stderr) { 111 | console.error(`PHP build error: ${stderr}`); 112 | } 113 | resolve(); 114 | }); 115 | }); 116 | }; 117 | 118 | const runBuildAll = async function () { 119 | console.log('Running build..'); 120 | try { 121 | await runServerBuild(); 122 | } catch (ex) { 123 | console.error(ex); 124 | } 125 | // await runBuild(); 126 | }; 127 | 128 | let buildTimer = 0; 129 | 130 | const runWatch = async function () { 131 | const ignored = ['**/build/**', '/js/dist/**', '/js/viewi/**', '**/node_modules/**', '**/app/**', '**/combined.css']; 132 | // https://github.com/paulmillr/chokidar 133 | chokidar.watch(['.\\..\\']).on('all', (event, itemPath) => { 134 | const normalizedPath = path.normalize(itemPath).replace(/\\/g, '/').replace('..', ''); 135 | // https://www.npmjs.com/package/anymatch 136 | if (!anymatch(ignored, normalizedPath)) { 137 | // console.log(event, normalizedPath); 138 | if (buildTimer) { 139 | clearTimeout(buildTimer); 140 | } 141 | buildTimer = setTimeout(runBuildAll, 200); 142 | } 143 | }); 144 | }; 145 | 146 | if (watchMode) { 147 | runWatch(); 148 | } else { 149 | runBuild(); 150 | } 151 | -------------------------------------------------------------------------------- /viewi-app/js/modules/main/index.ts: -------------------------------------------------------------------------------- 1 | export const modules = {}; -------------------------------------------------------------------------------- /viewi-app/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "viewi-app", 3 | "version": "1.0.0", 4 | "description": "Reactive web application with Viewi", 5 | "license": "MIT", 6 | "engines": { 7 | "node": ">=12" 8 | }, 9 | "scripts": { 10 | "build": "node ./build.mjs", 11 | "watch": "node ./build.mjs --watch" 12 | }, 13 | "dependencies": { 14 | "anymatch": "^3.1.3", 15 | "chokidar": "^3.5.3", 16 | "esbuild": "0.18.17", 17 | "purgecss": "^5.0.0" 18 | } 19 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/anchor.ts: -------------------------------------------------------------------------------- 1 | import { HtmlNodeType } from "../node/htmlNodeType" 2 | 3 | export type Anchor = { 4 | target: HtmlNodeType, 5 | current: number, 6 | added: number, 7 | invalid: number[] 8 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/anchors.ts: -------------------------------------------------------------------------------- 1 | import { Anchor } from "./anchor"; 2 | 3 | export const anchors: { [key: string]: Anchor } = {}; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/createAnchorNode.ts: -------------------------------------------------------------------------------- 1 | import { Anchor } from "./anchor"; 2 | import { NodeAnchor } from "./nodeAnchor"; 3 | import { TextAnchor } from "./textAnchor"; 4 | 5 | let anchorNodeId = 0; 6 | 7 | export function nextAnchorNodeId(): number { 8 | return ++anchorNodeId; 9 | } 10 | 11 | export function createAnchorNode(target: NodeAnchor, insert: boolean = false, anchor?: Anchor, name?: string): TextAnchor { 12 | const anchorNode = document.createTextNode('') as TextAnchor; 13 | anchorNode._anchor = name ?? ('#' + ++anchorNodeId); 14 | if (anchor) { 15 | anchor.current++; 16 | } 17 | (insert || (anchor && target.childNodes.length > anchor.current)) 18 | ? (anchor ? target : target.parentElement)!.insertBefore(anchorNode, anchor ? target.childNodes[anchor.current] : target) 19 | : target.appendChild(anchorNode); 20 | return anchorNode; 21 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/foreachAnchorEnum.ts: -------------------------------------------------------------------------------- 1 | export enum ForeachAnchorEnum { 2 | BeginAnchor = 'b', 3 | EndAnchor = 'e' 4 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/getAnchor.ts: -------------------------------------------------------------------------------- 1 | import { Anchor } from "./anchor"; 2 | import { anchors } from "./anchors"; 3 | import { NodeAnchor } from "./nodeAnchor"; 4 | 5 | let anchorId = 0; 6 | 7 | export function getAnchor(target: NodeAnchor): Anchor { 8 | if (!target.__aid) { 9 | target.__aid = ++anchorId; 10 | anchors[target.__aid] = { current: -1, target, invalid: [], added: 0 }; 11 | } 12 | return anchors[target.__aid]; 13 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/nodeAnchor.ts: -------------------------------------------------------------------------------- 1 | export type NodeAnchor = Node & { __aid?: number }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/anchor/textAnchor.ts: -------------------------------------------------------------------------------- 1 | export type TextAnchor = Text & { _anchor?: string, previousSibling: (ChildNode & TextAnchor), nextSibling: (ChildNode & TextAnchor) }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/baseComponent.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveProxy } from "../reactivity/makeProxy"; 2 | 3 | export abstract class BaseComponent { 4 | __id: string = ''; 5 | _props: { [key: string]: any } = {}; 6 | $_callbacks: { [key: string]: Function } = {}; 7 | _refs: { [key: string]: Node | BaseComponent } = {}; 8 | _slots: { [key: string]: any } = {}; 9 | _element: Node | null = null; 10 | $$t: Function[] = []; // template inline expressions 11 | $$r: { [key: string]: { [key: string]: [Function, any[]] } } = {}; // reactivity callbacks 12 | $$p: [trackerId: string, activated: ReactiveProxy][] = []; // shared reactivity track ids 13 | $: T; 14 | _provides: Object; 15 | _parent: null | BaseComponent = null; 16 | _name: string = 'BaseComponent'; 17 | 18 | emitEvent(name: string, event?: any) { 19 | if (name in this.$_callbacks) { 20 | this.$_callbacks[name](event); 21 | } 22 | } 23 | 24 | provide(key: string, value: any): void { 25 | if (this._provides === this._parent?._provides) { 26 | this._provides = Object.create(this._provides); 27 | } 28 | this._provides[key] = value; 29 | } 30 | 31 | inject(key: string): any { 32 | return this._provides[key] || null; 33 | } 34 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/componentMetaData.ts: -------------------------------------------------------------------------------- 1 | import { ScopeType } from "../di/scopeType" 2 | import { TemplateNode } from "../node/templateNode" 3 | 4 | export type ComponentMetaData = { 5 | nodes?: TemplateNode, 6 | dependencies?: any[], 7 | di?: ScopeType, 8 | diProps?: { 9 | [key: string]: { 10 | name: string, 11 | di: ScopeType 12 | } 13 | } 14 | base?: boolean, 15 | custom?: boolean, 16 | renderer?: boolean, 17 | refs?: { [key: string]: boolean }, 18 | parent?: string, 19 | lazy?: string, 20 | middleware?: string[], 21 | hooks?: { 22 | init?: boolean, 23 | mount?: boolean, 24 | mounted?: boolean, 25 | rendered?: boolean, 26 | destroy?: boolean 27 | } 28 | } 29 | 30 | export type ComponentMetaDataList = { 31 | [key: string]: ComponentMetaData 32 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/componentsJson.ts: -------------------------------------------------------------------------------- 1 | import { RouteItem } from "../router/routeItem" 2 | import { ComponentMetaDataList } from "./componentMetaData" 3 | 4 | export type ComponentsJson = { 5 | _meta: { boolean: string }, 6 | _routes: RouteItem[], 7 | _config: { [key: string]: any }, 8 | _startup: string[], 9 | _globals: { [key: string]: string } 10 | } & ComponentMetaDataList; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/componentsMeta.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "../router/router"; 2 | import { ComponentMetaDataList } from "./componentMetaData"; 3 | 4 | export const componentsMeta: { 5 | list: ComponentMetaDataList, 6 | router: Router, 7 | config: { [key: string]: any }, 8 | booleanAttributes: {}, 9 | globals: { [key: string]: string } 10 | } = { 11 | list: {}, 12 | config: {}, 13 | globals: {}, 14 | booleanAttributes: {}, 15 | router: new Router() 16 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/iStartUp.ts: -------------------------------------------------------------------------------- 1 | export interface IStartUp { 2 | setUp(): void; 3 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/isComponent.ts: -------------------------------------------------------------------------------- 1 | import { componentsMeta } from "./componentsMeta"; 2 | 3 | export function isComponent(name: string) { 4 | return (name in componentsMeta.list); 5 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/makeGlobal.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "./baseComponent"; 2 | import { componentsMeta } from "./componentsMeta"; 3 | import { makeGlobalMethod } from "./makeGlobalMethod"; 4 | 5 | export function makeGlobal() { 6 | for (let key in componentsMeta.globals) { 7 | BaseComponent.prototype[key] = function () { 8 | BaseComponent.prototype[key] = makeGlobalMethod(key, componentsMeta.globals[key]); 9 | return BaseComponent.prototype[key].apply(null, arguments); 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/makeGlobalMethod.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "../di/resolve"; 2 | 3 | export function makeGlobalMethod(method: string, typeName: string) { 4 | const instance = resolve(typeName); 5 | return function () { 6 | return instance[method].apply(instance, arguments); 7 | } 8 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/component/reserverProps.ts: -------------------------------------------------------------------------------- 1 | export const ReserverProps = { 2 | _props: true, 3 | $_callbacks: true, 4 | _refs: true, 5 | _slots: true, 6 | _element: true, 7 | $$t: true, 8 | $$r: true, 9 | $: true, 10 | $$p: true, 11 | _name: true, 12 | emitEvent: true, 13 | _provides: true, 14 | _parent: true, 15 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/delay.ts: -------------------------------------------------------------------------------- 1 | const delayedQueue = {}; 2 | 3 | export const delay = { 4 | postpone: function (name: string, callback: Function) { 5 | delayedQueue[name] = callback; 6 | }, 7 | ready: function (name: string) { 8 | if (!(name in delayedQueue)) { 9 | throw new Error("There is no postponed action for " + name); 10 | } 11 | delayedQueue[name](); 12 | delete delayedQueue[name]; 13 | }, 14 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/diContainer.ts: -------------------------------------------------------------------------------- 1 | export type DIContainer = { [key: string]: any }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/factory.ts: -------------------------------------------------------------------------------- 1 | import { components } from "../../../app/main/components"; 2 | import { register } from "./register"; 3 | 4 | type Constructor = new (...args: any[]) => any; 5 | 6 | export const factoryContainer: { [name: string]: () => T } = {}; 7 | 8 | export function factory(name: string, implementation: T, factory: () => InstanceType) { 9 | register[name] = implementation; 10 | components[name] = implementation; 11 | factoryContainer[name] = factory; 12 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/globalScope.ts: -------------------------------------------------------------------------------- 1 | import { TextAnchor } from "../anchor/textAnchor" 2 | import { BaseComponent } from "../component/baseComponent" 3 | import { ContextScope } from "../lifecycle/contextScope" 4 | import { DIContainer } from "./diContainer" 5 | 6 | type RenderIteration = { 7 | instance: BaseComponent, 8 | scope: ContextScope, 9 | slots: { [key: string]: TextAnchor } 10 | } 11 | 12 | type GlobalScope = { 13 | hydrate: boolean, 14 | rootScope: ContextScope | false, 15 | scopedContainer: DIContainer, 16 | located: { [key: string]: boolean }, 17 | iteration: { [key: string]: RenderIteration }, 18 | lastIteration: { [key: string]: RenderIteration }, 19 | layout: string, 20 | cancel: boolean 21 | } 22 | 23 | export const globalScope: GlobalScope = { 24 | hydrate: true, // first time hydrate, TODO: configurable, MFE won't need hydration 25 | rootScope: false, 26 | scopedContainer: {}, 27 | located: {}, 28 | iteration: {}, 29 | lastIteration: {}, 30 | layout: '', 31 | cancel: false 32 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/register.ts: -------------------------------------------------------------------------------- 1 | import { resources } from "../../../app/main/resources"; 2 | 3 | export const register: { [name: string]: any } = window.ViewiApp ? window.ViewiApp[resources.name].register : {}; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/resolve.ts: -------------------------------------------------------------------------------- 1 | import { components } from "../../../app/main/components"; 2 | import { BaseComponent } from "../component/baseComponent"; 3 | import { componentsMeta } from "../component/componentsMeta"; 4 | import { getScopeState } from "../lifecycle/scopeState"; 5 | import { DIContainer } from "./diContainer"; 6 | import { factoryContainer } from "./factory"; 7 | import { globalScope } from "./globalScope"; 8 | import { ScopeType } from "./scopeType"; 9 | 10 | const singletonContainer: DIContainer = {}; 11 | let nextInstanceId = 0; 12 | const rootProvides = {}; 13 | 14 | export function resolve(name: string, params: { [key: string]: any } = {}, canBeNull: boolean = false, parent: BaseComponent | null = null) { 15 | if (!(name in componentsMeta.list)) { 16 | if (canBeNull) { 17 | return null; 18 | } 19 | throw new Error("Can't resolve " + name); 20 | } 21 | const info = componentsMeta.list[name]; 22 | let instance: any = null; 23 | let container: boolean | DIContainer = false; 24 | if (info.di === "SINGLETON") { 25 | container = singletonContainer; 26 | } else if (info.di === "SCOPED") { 27 | container = globalScope.scopedContainer; 28 | } 29 | if (container && (name in container)) { 30 | // console.log('Returning from cache', name, container[name]); 31 | return container[name]; 32 | } 33 | const toProvide = {}; 34 | if (info.custom) { 35 | instance = factoryContainer[name](); 36 | } else if (!info.dependencies) { 37 | instance = new components[name](); 38 | } else { 39 | const constructArguments: any[] = []; 40 | for (let i in info.dependencies) { 41 | const dependency = info.dependencies[i]; 42 | const diType = dependency['di'] || false; 43 | const argCanBeNull = !!dependency.null; 44 | var argument: any = null; // d.null 45 | if (diType === 'PARENT') { 46 | argument = parent ? parent.inject(dependency.name) : (rootProvides[dependency.name] || null); 47 | } else if (params && (dependency.argName in params)) { 48 | argument = params[dependency.argName]; 49 | } 50 | else if (dependency.default) { 51 | argument = dependency.default; // TODO: copy object or array 52 | } else if (dependency.builtIn) { 53 | argument = dependency.name === 'string' ? '' : 0; 54 | } else { 55 | argument = resolve(dependency.name, {}, argCanBeNull, parent); 56 | } 57 | if (diType === 'COMPONENT') { 58 | toProvide[dependency.name] = argument; 59 | } 60 | constructArguments.push(argument); 61 | } 62 | instance = new components[name](...constructArguments); 63 | } 64 | if (info.base) { 65 | const baseComponent = instance as BaseComponent; 66 | baseComponent.__id = ++nextInstanceId + ''; 67 | if (parent !== null) { 68 | baseComponent._provides = parent._provides; 69 | baseComponent._parent = parent; 70 | } else { 71 | baseComponent._provides = rootProvides; 72 | } 73 | for (let p in toProvide) { 74 | baseComponent.provide(p, toProvide[p]); 75 | } 76 | } 77 | 78 | // DI Props 79 | if (info.diProps) { 80 | for (let prop in info.diProps) { 81 | const dependency = info.diProps[prop]; 82 | const diType = dependency.di; 83 | let propInstance = null; 84 | if (diType === 'PARENT') { 85 | propInstance = parent ? parent.inject(dependency.name) : (rootProvides[dependency.name] || null); 86 | } else if (diType === 'SINGLETON') { 87 | if (!(dependency.name in singletonContainer)) { 88 | propInstance = resolve(dependency.name, {}, false, parent); 89 | singletonContainer[dependency.name] = propInstance; 90 | } 91 | propInstance = singletonContainer[dependency.name]; 92 | } else if (diType === 'SCOPED') { 93 | if (!(dependency.name in globalScope.scopedContainer)) { 94 | propInstance = resolve(dependency.name, {}, false, parent); 95 | globalScope.scopedContainer[dependency.name] = propInstance; 96 | } 97 | propInstance = globalScope.scopedContainer[dependency.name]; 98 | } else { 99 | propInstance = resolve(dependency.name, {}, false, parent); 100 | } 101 | instance[prop] = propInstance; 102 | if (diType === 'COMPONENT') { 103 | (instance as BaseComponent).provide(dependency.name, propInstance); 104 | } 105 | } 106 | } 107 | 108 | const scopeState = getScopeState(); 109 | if (scopeState.state[name]) { 110 | for (let prop in scopeState.state[name]) { 111 | instance[prop] = scopeState.state[name][prop]; 112 | } 113 | } 114 | if (container) { 115 | container[name] = instance; 116 | } 117 | return instance; 118 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/scopeType.ts: -------------------------------------------------------------------------------- 1 | export type ScopeType = 'SINGLETON' | 'SCOPED' | 'TRANSIENT' | 'COMPONENT' | 'PARENT'; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/di/setUp.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { IStartUp } from "../component/iStartUp"; 3 | import { Platform } from "../environment/platform"; 4 | import { HttpClient } from "../http/httpClient"; 5 | import { Portal } from "../portal/portal"; 6 | import { factory } from "./factory"; 7 | import { register } from "./register"; 8 | import { resolve } from "./resolve"; 9 | 10 | export function setUp(startUpItems: string[]) { 11 | register['BaseComponent'] = BaseComponent; 12 | factory('HttpClient', HttpClient, () => new HttpClient()); 13 | factory('Platform', Platform, () => new Platform()); 14 | factory('Portal', Portal, () => new Portal()); 15 | for (let i = 0; i < startUpItems.length; i++) { 16 | const stratUpInstance = resolve(startUpItems[i]) as IStartUp; 17 | stratUpInstance.setUp(); 18 | } 19 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/directive/conditionalDirective.ts: -------------------------------------------------------------------------------- 1 | export type ConditionalDirective = { 2 | values: boolean[], 3 | subs: string[], 4 | index: number 5 | } 6 | -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/directive/directive.ts: -------------------------------------------------------------------------------- 1 | import { DirectiveType } from "./directiveType" 2 | 3 | export type Directive = { 4 | type: DirectiveType 5 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/directive/directiveMap.ts: -------------------------------------------------------------------------------- 1 | export type DirectiveMap = { 2 | map: { [key: number]: boolean }, 3 | storage: { [key: string]: any } 4 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/directive/directiveStorageType.ts: -------------------------------------------------------------------------------- 1 | export enum DirectiveStorageType { 2 | Condition = 'conditions', 3 | Foreach = 'foreach' 4 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/directive/directiveType.ts: -------------------------------------------------------------------------------- 1 | export type DirectiveType = 'if' | 'else-if' | 'else' | 'foreach'; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/environment/platform.ts: -------------------------------------------------------------------------------- 1 | import { componentsMeta } from "../component/componentsMeta"; 2 | import { handleUrl, onUrlUpdate } from "../router/handleUrl"; 3 | 4 | class Platform { 5 | browser: true = true; 6 | server: false = false; 7 | 8 | constructor() { 9 | } 10 | 11 | getConfig() { 12 | return componentsMeta.config; 13 | } 14 | 15 | redirect(url: string) { 16 | handleUrl(url); 17 | } 18 | 19 | navigateBack() { 20 | history.back(); 21 | } 22 | 23 | getCurrentUrl(): string { 24 | return location.pathname + location.search; 25 | } 26 | 27 | setResponseStatus(status: number): void { 28 | // server side only 29 | } 30 | 31 | getCurrentUrlPath(): string { 32 | return location.pathname; 33 | } 34 | 35 | getQueryParams() { 36 | return Object.fromEntries(new URLSearchParams(location.search)); 37 | } 38 | 39 | onUrlUpdate(callback: Function) { 40 | onUrlUpdate.callback = callback; 41 | } 42 | } 43 | 44 | export { Platform } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/events/resolver.ts: -------------------------------------------------------------------------------- 1 | export type ResolverAction = (callback: (result: any, error?: any) => void) => void; 2 | 3 | class Resolver { 4 | onSuccess: CallableFunction; 5 | onError: CallableFunction | null = null; 6 | onAlways: CallableFunction | null = null; 7 | result = null; 8 | lastError = null; 9 | action: ResolverAction; 10 | 11 | constructor(action: ResolverAction) { 12 | this.action = action; 13 | } 14 | 15 | error(onError: CallableFunction) { 16 | this.onError = onError; 17 | } 18 | 19 | success(onSuccess: CallableFunction) { 20 | this.onSuccess = onSuccess; 21 | } 22 | 23 | always(always: CallableFunction) { 24 | this.onAlways = always; 25 | } 26 | 27 | run() { 28 | const $this = this; 29 | this.action(function (result: any, error: any) { 30 | $this.result = result; 31 | let throwError = false; 32 | if (error) { 33 | $this.lastError = error; 34 | if ($this.onError !== null) { 35 | $this.onError(error); 36 | } 37 | else { 38 | throwError = true; 39 | } 40 | } else { 41 | $this.onSuccess($this.result); 42 | } 43 | if ($this.onAlways != null) { 44 | $this.onAlways(); 45 | } 46 | if (throwError) { 47 | throw $this.lastError; 48 | } 49 | }); 50 | } 51 | 52 | then(onSuccess: CallableFunction, onError?: CallableFunction, always?: CallableFunction) { 53 | this.onSuccess = onSuccess; 54 | if (onError) { 55 | this.onError = onError; 56 | } 57 | if (always) { 58 | this.onAlways = always; 59 | } 60 | this.run(); 61 | } 62 | } 63 | 64 | export { Resolver } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/helpers/ensureType.ts: -------------------------------------------------------------------------------- 1 | export const ensureTypeOptions: { 2 | enable: boolean 3 | } = { 4 | enable: false 5 | } 6 | 7 | export function ensureType(targetType: Function, obj: object) { 8 | if (ensureTypeOptions.enable && Object.getPrototypeOf(obj) !== targetType.prototype) { 9 | ensureTypeOptions.enable = false; 10 | Object.setPrototypeOf(obj, targetType.prototype); 11 | } 12 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/helpers/isBlob.ts: -------------------------------------------------------------------------------- 1 | export function isBlob(data: any) { 2 | if ('Blob' in window && data instanceof Blob) { 3 | return true; 4 | } 5 | return false; 6 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/helpers/isSvg.ts: -------------------------------------------------------------------------------- 1 | const svgMap = {}; 2 | const svgTagsString = 3 | 'svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,' + 4 | 'defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,' + 5 | 'feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,' + 6 | 'feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,' + 7 | 'feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,' + 8 | 'fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,' + 9 | 'foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,' + 10 | 'mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,' + 11 | 'polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,' + 12 | 'text,textPath,title,tspan,unknown,use,view'; 13 | 14 | const svgTagsList = svgTagsString.split(','); 15 | 16 | for (let i = 0; i < svgTagsList.length; i++) { 17 | svgMap[svgTagsList[i]] = true; 18 | } 19 | 20 | export function isSvg(tag: string) { 21 | return (tag.toLowerCase() in svgMap); 22 | } 23 | 24 | export const xLinkNs = "http://www.w3.org/1999/xlink"; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/helpers/svgNameSpace.ts: -------------------------------------------------------------------------------- 1 | export const svgNameSpace = 'http://www.w3.org/2000/svg'; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/httpClient.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../events/resolver"; 2 | import { getScopeState } from "../lifecycle/scopeState"; 3 | import { MethodType } from "./methodType"; 4 | import { runRequest } from "./runRequest"; 5 | import { Response } from "./response"; 6 | import { Request } from "./request"; 7 | import { resolve } from "../di/resolve"; 8 | import { IHttpInterceptor } from "./iHttpInterceptor"; 9 | import { IRequestHandler } from "./iRequestHandler"; 10 | import { IResponseHandler } from "./iResponseHandler"; 11 | 12 | const interceptResponses = function (response: Response, callback, interceptorInstances: IHttpInterceptor[]) { 13 | const total = interceptorInstances.length; 14 | let current = total; 15 | 16 | const lastCall = function (response: Response, keepGoing: boolean) { 17 | if (keepGoing && response.status >= 200 && response.status < 300) { 18 | callback(response.body); 19 | } else { 20 | callback(undefined, response); 21 | } 22 | }; 23 | 24 | const run = function (response: Response, keepGoing: boolean) { 25 | if (keepGoing) { 26 | if (current > -1) { 27 | const interceptor: IHttpInterceptor = interceptorInstances[current]; 28 | interceptor.response(response, responseHandler); 29 | } else { 30 | lastCall(response, keepGoing); 31 | } 32 | } else { 33 | lastCall(response, keepGoing); 34 | } 35 | }; 36 | 37 | const responseHandler: IResponseHandler = { 38 | next: function (response: Response) { 39 | current--; 40 | run(response, true); 41 | }, 42 | reject: function (response: Response) { 43 | current--; 44 | run(response, false); 45 | } 46 | }; 47 | responseHandler.next(response); 48 | } 49 | 50 | class HttpClient { 51 | interceptors: string[] = []; 52 | 53 | request(method: MethodType, url: string, body?: any, headers?: { [name: string]: string }) { 54 | const $this = this; 55 | const resolver = new Resolver(function (callback) { 56 | try { 57 | const state = getScopeState(); 58 | const request = new Request(url, method, headers, body); 59 | let current = -1; 60 | const total = $this.interceptors.length; 61 | const interceptorInstances: IHttpInterceptor[] = []; 62 | const lastCall = function (request: Request, keepGoing: boolean) { 63 | if (keepGoing) { 64 | const requestKey = request.method + '_' + request.url + '_' + JSON.stringify(request.body); 65 | if (requestKey in state.http) { 66 | const responseData: { status: number, data: any } = JSON.parse(state.http[requestKey]); 67 | delete state.http[requestKey]; 68 | const response = new Response(request.url, responseData.status, 'OK', {}, responseData.data); 69 | interceptResponses(response, callback, interceptorInstances); 70 | return; 71 | } else { 72 | runRequest(function (response: Response) { 73 | interceptResponses(response, callback, interceptorInstances); 74 | }, request.method, request.url, request.body, request.headers); 75 | } 76 | } else { 77 | const response = new Response(request.url, 0, 'Rejected', {}, null); 78 | interceptResponses(response, callback, interceptorInstances); 79 | } 80 | } 81 | const run = function (request: Request, keepGoing: boolean) { 82 | if (!keepGoing) { 83 | lastCall(request, keepGoing); 84 | return; 85 | } 86 | if (current < total) { 87 | const interceptor: IHttpInterceptor = resolve($this.interceptors[current]); 88 | interceptorInstances.push(interceptor); 89 | interceptor.request(request, requestHandler); 90 | } else { 91 | lastCall(request, keepGoing); 92 | } 93 | }; 94 | const requestHandler: IRequestHandler = { 95 | next: function (request: Request): void { 96 | current++; 97 | run(request, true); 98 | }, 99 | reject: function (request: Request): void { 100 | current++; 101 | run(request, false); 102 | } 103 | }; 104 | requestHandler.next(request); 105 | } catch (ex) { 106 | console.error(ex); 107 | callback(undefined, ex); 108 | } 109 | }); 110 | 111 | return resolver; 112 | } 113 | 114 | get(url: string, headers?: { [name: string]: string }) { 115 | return this.request("get", url, null, headers); 116 | } 117 | 118 | post(url: string, body?: any, headers?: { [name: string]: string }) { 119 | return this.request("post", url, body, headers); 120 | } 121 | 122 | put(url: string, body?: any, headers?: { [name: string]: string }) { 123 | return this.request("put", url, body, headers); 124 | } 125 | 126 | delete(url: string, body?: any, headers?: { [name: string]: string }) { 127 | return this.request("delete", url, body, headers); 128 | } 129 | 130 | patch(url: string, body?: any, headers?: { [name: string]: string }) { 131 | return this.request("patch", url, body, headers); 132 | } 133 | 134 | withInterceptor(interceptor: string) { 135 | const http = new HttpClient(); 136 | http.interceptors = [...this.interceptors, interceptor]; 137 | return http; 138 | } 139 | } 140 | 141 | export { HttpClient } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/iHttpInterceptor.ts: -------------------------------------------------------------------------------- 1 | import { IRequestHandler } from "./iRequestHandler"; 2 | import { IResponseHandler } from "./iResponseHandler"; 3 | import { Request } from "./request"; 4 | import { Response } from "./response"; 5 | 6 | export type IHttpInterceptor = { 7 | request(request: Request, handler: IRequestHandler): void; 8 | response(response: Response, handler: IResponseHandler): void; 9 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/iRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "./request"; 2 | 3 | export type IRequestHandler = { 4 | next(request: Request): void; 5 | reject(request: Request): void; 6 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/iResponseHandler.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "./response"; 2 | 3 | export type IResponseHandler = { 4 | next(response: Response): void; 5 | reject(response: Response): void; 6 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/injectScript.ts: -------------------------------------------------------------------------------- 1 | export function injectScript(url: string) { 2 | const head = document.head; 3 | const script = document.createElement('script'); 4 | script.type = 'text/javascript'; 5 | script.src = url; 6 | head.appendChild(script); 7 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/methodType.ts: -------------------------------------------------------------------------------- 1 | export type MethodType = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head' -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/request.ts: -------------------------------------------------------------------------------- 1 | import { MethodType } from "./methodType"; 2 | 3 | export class Request { 4 | url: string; 5 | method: MethodType; 6 | headers: { [name: string]: string } = {}; 7 | body: any = null; 8 | isExternal: boolean; 9 | 10 | constructor(url: string, method: MethodType, headers: { [name: string]: string; } = {}, body: any = null) { 11 | this.url = url; 12 | this.method = method; 13 | this.headers = headers; 14 | this.body = body; 15 | } 16 | 17 | withMethod(method: MethodType) { 18 | var clone = this.clone(); 19 | clone.method = method; 20 | return clone; 21 | } 22 | 23 | withUrl(url: string) { 24 | var clone = this.clone(); 25 | clone.url = url; 26 | return clone; 27 | } 28 | 29 | withHeaders(headers: { [name: string]: string; }) { 30 | var clone = this.clone(); 31 | clone.headers = { ...clone.headers, ...headers }; 32 | return clone; 33 | } 34 | 35 | withHeader(name: string, value: any) { 36 | var clone = this.clone(); 37 | clone.headers[name] = value; 38 | return clone; 39 | } 40 | 41 | withBody(body: any = null) { 42 | var clone = this.clone(); 43 | clone.body = body; 44 | return clone; 45 | } 46 | 47 | clone() { 48 | var clone = new Request(this.url, this.method, this.headers, this.body); 49 | return clone; 50 | } 51 | 52 | // server-side only, makes no difference on front end 53 | markAsExternal() { 54 | return this; 55 | } 56 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/response.ts: -------------------------------------------------------------------------------- 1 | export class Response { 2 | url: string; 3 | status: number; 4 | statusText: string; 5 | headers: { [name: string]: string } = {}; 6 | body: any = null; 7 | 8 | constructor(url: string, status: number, statusText: string, headers: { [name: string]: string } = {}, body: any = null) { 9 | this.url = url; 10 | this.status = status; 11 | this.statusText = statusText; 12 | this.headers = headers; 13 | this.body = body; 14 | } 15 | 16 | withUrl(url: any) { 17 | var clone = this.clone(); 18 | clone.url = url; 19 | return clone; 20 | } 21 | 22 | withStatus(status: number, statusText: string | null = null) { 23 | var clone = this.clone(); 24 | clone.status = status; 25 | if (statusText !== null) { 26 | clone.statusText = statusText; 27 | } 28 | return clone; 29 | } 30 | 31 | withHeaders(headers: { [name: string]: string }) { 32 | var clone = this.clone(); 33 | clone.headers = { ...clone.headers, ...headers }; 34 | return clone; 35 | } 36 | 37 | withHeader(name: string | number, value: any) { 38 | var clone = this.clone(); 39 | clone.headers[name] = value; 40 | return clone; 41 | } 42 | 43 | withBody(body: any = null) { 44 | var clone = this.clone(); 45 | clone.body = body; 46 | return clone; 47 | } 48 | 49 | ok() { 50 | return this.status >= 200 && this.status < 300; 51 | } 52 | 53 | clone() { 54 | var clone = new Response(this.url, this.status, this.statusText, this.headers, this.body); 55 | return clone; 56 | } 57 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/http/runRequest.ts: -------------------------------------------------------------------------------- 1 | import { isBlob } from "../helpers/isBlob"; 2 | import { MethodType } from "./methodType"; 3 | import { Response } from "./response"; 4 | 5 | export function runRequest( 6 | callback: (response: Response) => void, 7 | type: MethodType, 8 | url: string, 9 | data?: any, 10 | headers?: { [name: string]: string | string[] } 11 | ) { 12 | const request = new XMLHttpRequest(); 13 | request.onreadystatechange = function () { 14 | if (request.readyState === 4) { 15 | const status = request.status; 16 | const contentType = request.getResponseHeader("Content-Type"); 17 | const itsJson = contentType && contentType.indexOf('application/json') === 0; 18 | const raw = request.responseText; 19 | let content = raw; 20 | if (itsJson) { 21 | content = JSON.parse(request.responseText); 22 | } 23 | const headers = {}; 24 | const headersString = request.getAllResponseHeaders(); 25 | if (headersString) { 26 | const headersArray = headersString.trim().split(/[\r\n]+/); 27 | for (let i = 0; i < headersArray.length; i++) { 28 | const line = headersArray[i]; 29 | const parts = line.split(": "); 30 | const header = parts.shift(); 31 | if (header) { 32 | const value = parts.join(": "); 33 | headers[header] = value; 34 | } 35 | }; 36 | } 37 | const response = new Response(url, status, '', headers, content); 38 | callback(response); 39 | } 40 | } 41 | const isJson = data !== null && typeof data === 'object' && !isBlob(data); 42 | request.open(type.toUpperCase(), url, true); 43 | if (isJson) { 44 | request.setRequestHeader('Content-Type', 'application/json'); 45 | } 46 | if (headers) { 47 | for (const h in headers) { 48 | if (Array.isArray(headers[h])) { 49 | for (let i = 0; i < headers[h].length; i++) { 50 | request.setRequestHeader(h, headers[h][i]); 51 | } 52 | } else { 53 | request.setRequestHeader(h, headers[h]); 54 | } 55 | } 56 | } 57 | data !== null ? 58 | request.send(isJson ? JSON.stringify(data) : data) 59 | : request.send(); 60 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/hydrate/hydrateComment.ts: -------------------------------------------------------------------------------- 1 | import { getAnchor } from "../anchor/getAnchor"; 2 | import { HtmlNodeType } from "../node/htmlNodeType"; 3 | 4 | export function hydrateComment(target: HtmlNodeType, content: string): Comment { 5 | const anchor = getAnchor(target); 6 | const max = target.childNodes.length; 7 | let end = anchor.current + 3; 8 | end = end > max ? max : end; 9 | const invalid: number[] = []; 10 | for (let i = anchor.current + 1; i < end; i++) { 11 | const potentialNode = target.childNodes[i]; 12 | if ( 13 | potentialNode.nodeType === 8 14 | ) { 15 | anchor.current = i; 16 | anchor.invalid = anchor.invalid.concat(invalid); 17 | // console.log('Hydrate match', potentialNode); 18 | return potentialNode as Comment; 19 | } 20 | invalid.push(i); 21 | } 22 | anchor.added++; 23 | anchor.invalid = anchor.invalid.concat(invalid); 24 | console.log('Hydrate comment not found', content); 25 | const element = document.createComment(content); 26 | anchor.current = anchor.current + invalid.length + 1; 27 | return max > anchor.current 28 | ? target.insertBefore(element, target.childNodes[anchor.current]) 29 | : target.appendChild(element); 30 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/hydrate/hydrateRaw.ts: -------------------------------------------------------------------------------- 1 | import { Anchor } from "../anchor/anchor"; 2 | import { HtmlNodeType } from "../node/htmlNodeType"; 3 | 4 | export function hydrateRaw(vdom: HTMLElement, anchor: Anchor, target: HtmlNodeType) { 5 | if (vdom.childNodes.length > 0) { 6 | const invalid: number[] = []; 7 | const rawNodes: HTMLElement[] = Array.prototype.slice.call(vdom.childNodes); 8 | for (let rawNodeI = 0; rawNodeI < rawNodes.length; rawNodeI++) { 9 | const rawNode = rawNodes[rawNodeI]; 10 | const rawNodeType = rawNode.nodeType; 11 | if (rawNodeType === 3) { 12 | // text 13 | const currentTargetNode = target.childNodes[anchor.current]; 14 | if (currentTargetNode && currentTargetNode.nodeType === rawNodeType) { 15 | currentTargetNode.nodeValue = rawNode.nodeValue; 16 | } else { 17 | anchor.added++; 18 | target.childNodes.length > anchor.current && invalid.push(anchor.current); 19 | target.childNodes.length > anchor.current + 1 20 | ? target.insertBefore(rawNode, target.childNodes[anchor.current + 1]) 21 | : target.appendChild(rawNode); 22 | anchor.current++; 23 | // insert 24 | // ? target.parentElement!.insertBefore(rawNode, target) 25 | // : target.appendChild(rawNode); 26 | } 27 | } else { 28 | // other 29 | const currentTargetNode = target.childNodes[anchor.current]; 30 | if ( 31 | !currentTargetNode 32 | || currentTargetNode.nodeType !== rawNodeType 33 | || (rawNodeType === 1 && currentTargetNode.nodeName !== rawNode.nodeName) 34 | ) { 35 | anchor.added++; 36 | target.childNodes.length > anchor.current && invalid.push(anchor.current); 37 | target.childNodes.length > anchor.current + 1 38 | ? target.insertBefore(rawNode, target.childNodes[anchor.current + 1]) 39 | : target.appendChild(rawNode); 40 | anchor.current++; 41 | // mismatch by type 42 | // insert 43 | // ? target.parentElement!.insertBefore(rawNode, target) 44 | // : target.appendChild(rawNode); 45 | } else if (rawNodeType === 1) { 46 | if (currentTargetNode.nodeName !== rawNode.nodeName || (currentTargetNode).outerHTML !== rawNode.outerHTML) { 47 | const keepKey = (currentTargetNode).getAttribute('data-keep'); 48 | if (!keepKey || keepKey !== rawNode.getAttribute('data-keep')) { // keep server-side version 49 | (currentTargetNode).outerHTML = rawNode.outerHTML; 50 | } 51 | } 52 | } 53 | // matched, continue 54 | } 55 | anchor.current++; 56 | } 57 | if (invalid.length > 0) { 58 | anchor.invalid = anchor.invalid.concat(invalid); 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/hydrate/hydrateTag.ts: -------------------------------------------------------------------------------- 1 | import { getAnchor } from "../anchor/getAnchor"; 2 | import { HtmlNodeType } from "../node/htmlNodeType"; 3 | 4 | const specialTags = { body: true, head: true, html: true }; 5 | 6 | export function hydrateTag(target: HtmlNodeType, tag: string): HtmlNodeType { 7 | const anchor = getAnchor(target); 8 | const max = target.childNodes.length; 9 | let end = anchor.current + 3; 10 | end = end > max ? max : end; 11 | const invalid: number[] = []; 12 | for (let i = anchor.current + 1; i < end; i++) { 13 | const potentialNode = target.childNodes[i]; 14 | if ( 15 | potentialNode.nodeType === 1 16 | && potentialNode.nodeName.toLowerCase() === tag.toLowerCase() 17 | ) { 18 | anchor.current = i; 19 | anchor.invalid = anchor.invalid.concat(invalid); 20 | // console.log('Hydrate match', potentialNode); 21 | return potentialNode as Node; 22 | } 23 | invalid.push(i); 24 | } 25 | if (tag in specialTags) { 26 | const nodes = document.getElementsByTagName(tag); 27 | if (nodes.length > 0) { 28 | anchor.invalid = []; 29 | return nodes[0]; 30 | } 31 | } 32 | anchor.added++; 33 | anchor.invalid = anchor.invalid.concat(invalid); 34 | console.warn('Hydrate not found', tag); 35 | const element = document.createElement(tag); 36 | anchor.current = anchor.current + invalid.length + 1; 37 | return max > anchor.current 38 | ? target.insertBefore(element, target.childNodes[anchor.current]) 39 | : target.appendChild(element); 40 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/hydrate/hydrateText.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { TemplateNode } from "../node/templateNode"; 3 | import { getAnchor } from "../anchor/getAnchor"; 4 | import { renderText } from "../render/renderText"; 5 | import { ContextScope } from "../lifecycle/contextScope"; 6 | import { HtmlNodeType } from "../node/htmlNodeType"; 7 | 8 | export function hydrateText(target: HtmlNodeType, instance: BaseComponent, node: TemplateNode, scope: ContextScope): Text { 9 | const anchor = getAnchor(target); 10 | const max = target.childNodes.length; 11 | let end = anchor.current + 3; 12 | end = end > max ? max : end; 13 | const invalid: number[] = []; 14 | const start = anchor.current > -1 ? anchor.current : anchor.current + 1; 15 | for (let i = start; i < end; i++) { 16 | const potentialNode = target.childNodes[i]; 17 | if ( 18 | potentialNode.nodeType === 3 19 | ) { 20 | if (i === anchor.current) { 21 | // text after text, no shift 22 | break; 23 | } 24 | anchor.current = i; 25 | anchor.invalid = anchor.invalid.concat(invalid); 26 | renderText(instance, node, potentialNode as Text, scope); 27 | // console.log('Hydrate match', potentialNode); 28 | return potentialNode as Text; 29 | } 30 | i !== anchor.current && invalid.push(i); 31 | } 32 | anchor.added++; 33 | anchor.invalid = anchor.invalid.concat(invalid); 34 | const textNode = document.createTextNode(''); 35 | renderText(instance, node, textNode, scope); 36 | anchor.current = anchor.current + invalid.length + 1; 37 | // console.log('Hydrate not found', textNode); 38 | return max > anchor.current 39 | ? target.insertBefore(textNode, target.childNodes[anchor.current]) 40 | : target.appendChild(textNode); 41 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/arrayScope.ts: -------------------------------------------------------------------------------- 1 | import { TextAnchor } from "../anchor/textAnchor"; 2 | import { ContextScope } from "./contextScope"; 3 | 4 | export type ArrayScope = { 5 | data: { [key: string | number]: { key: string | number, value: any, begin: TextAnchor, end: TextAnchor, scope: ContextScope } } 6 | }; 7 | -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/contextScope.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { Slots } from "../node/slots"; 3 | 4 | export type ContextScope = { 5 | counter: number, // current id counter 6 | id: number, // unique per parent scope 7 | why: ContextReason, 8 | instance: BaseComponent, 9 | lastComponent: { instance: BaseComponent | null }, 10 | main?: boolean, // first scope of the instance and should be disposed 11 | arguments: any[], // array (foreach directive) arguments 12 | map: { [key: string]: number }, // array (foreach directive) arguments positions 13 | track: { path: string, id: number }[], // disposable reactivity items 14 | parent?: ContextScope, 15 | children: { [key: string]: ContextScope }, // all nested scopes from directives and components, tree disposal 16 | slots?: Slots, 17 | refs?: { [key: string]: boolean }, 18 | keep?: boolean // do not dispose 19 | } 20 | 21 | export type ContextReason = 'if' | 'elseif' | 'else' | 'foreach' | 'forItem' | 'dynamic' | 'slot' | 'component' | string -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/dispose.ts: -------------------------------------------------------------------------------- 1 | import { ContextScope } from "./contextScope"; 2 | 3 | export function dispose(scope: ContextScope) { 4 | // dispose reactivity tracker for removed html/template nodes 5 | if (scope.keep) return; 6 | for (let reactivityIndex in scope.track) { 7 | const reactivityItem = scope.track[reactivityIndex]; 8 | delete scope.instance.$$r[reactivityItem.path][reactivityItem.id]; 9 | } 10 | scope.track = []; 11 | if (scope.children) { 12 | for (let i in scope.children) { 13 | dispose(scope.children[i]); 14 | } 15 | scope.children = {}; 16 | } 17 | if (scope.main) { 18 | // dispose instance 19 | for (let i = 0; i < scope.instance.$$p.length; i++) { 20 | const trackGroup = scope.instance.$$p[i]; 21 | delete trackGroup[1].$$r[trackGroup[0]]; 22 | } 23 | // TODO: call dispose hook 24 | const instance = scope.instance as any; 25 | if (instance.destroy) { 26 | instance.destroy(); 27 | } 28 | } 29 | if (scope.parent) { 30 | delete scope.parent.children[scope.id]; 31 | delete scope.parent; 32 | } 33 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/iDestroyable.ts: -------------------------------------------------------------------------------- 1 | export interface IDestroyable { 2 | destroy(): void; 3 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/imiddleware.ts: -------------------------------------------------------------------------------- 1 | export type IMiddleware = { 2 | run(context: { next: (allow: boolean) => void }): void; 3 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/propsContext.ts: -------------------------------------------------------------------------------- 1 | import { ContextScope } from "./contextScope" 2 | import { TemplateNode } from "../node/templateNode" 3 | 4 | export type PropsContext = { 5 | attributes: TemplateNode[] 6 | scope: ContextScope 7 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/lifecycle/scopeState.ts: -------------------------------------------------------------------------------- 1 | type ScopeState = { http: { [key: string]: any }, state: { [component: string]: { [prop: string]: any } } }; 2 | 3 | export function getScopeState(): ScopeState { 4 | const scopedResponseData: undefined | ScopeState = (window).viewiScopeState; 5 | return scopedResponseData ?? { http: {}, state: {} }; 6 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/node/htmlNodeType.ts: -------------------------------------------------------------------------------- 1 | export type HtmlNodeType = Node & { isSvg?: boolean } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/node/inputType.ts: -------------------------------------------------------------------------------- 1 | export type InputType = 'text' | 'checkbox' | 'radio' | 'select' -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/node/nodeType.ts: -------------------------------------------------------------------------------- 1 | export type NodeType = 'tag' | 'attr' | 'value' | 'component' | 'text' | 'comment' | 'root' | 'doctype' | undefined; 2 | export type NodeTypePacked = 't' | 'a' | 'v' | 'x' | 'm' | 'c' | 'r' | 'd' | undefined; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/node/slots.ts: -------------------------------------------------------------------------------- 1 | import { ContextScope } from "../lifecycle/contextScope"; 2 | import { TemplateNode } from "./templateNode"; 3 | 4 | export type Slots = { [key: string]: { node: TemplateNode, scope: ContextScope } } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/node/templateNode.ts: -------------------------------------------------------------------------------- 1 | import { NodeType, NodeTypePacked } from "./nodeType" 2 | 3 | export type TemplateNode = { 4 | t: NodeTypePacked, 5 | type: NodeType, 6 | c?: string, 7 | content?: string, 8 | code?: number, 9 | subs?: string[], 10 | e?: boolean, 11 | expression?: boolean, 12 | raw?: boolean, 13 | h?: TemplateNode[], 14 | children?: TemplateNode[], 15 | a?: TemplateNode[], 16 | attributes?: TemplateNode[], 17 | i?: TemplateNode[], 18 | slots?: TemplateNode[], 19 | directives?: TemplateNode[], 20 | unpacked?: boolean, 21 | dynamic?: TemplateNode, 22 | forData?: number, 23 | forItem?: string, 24 | forKey?: string, 25 | forKeyAuto?: boolean, 26 | func: Function, 27 | first?: boolean 28 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/node/unpack.ts: -------------------------------------------------------------------------------- 1 | import { NodeType } from "./nodeType"; 2 | import { TemplateNode } from "./templateNode"; 3 | 4 | export function unpack(item: TemplateNode) { 5 | let nodeType: NodeType = 'value'; 6 | switch (item.t) { 7 | case 't': { 8 | nodeType = 'tag'; 9 | break; 10 | } 11 | case 'a': { 12 | nodeType = 'attr'; 13 | break; 14 | } 15 | case undefined: 16 | case 'v': { 17 | nodeType = 'value'; 18 | break; 19 | } 20 | case 'c': { 21 | nodeType = 'component'; 22 | break; 23 | } 24 | case 'x': { 25 | nodeType = 'text'; 26 | break; 27 | } 28 | case 'm': { 29 | nodeType = 'comment'; 30 | break; 31 | } 32 | case 'd': { 33 | nodeType = 'doctype'; 34 | break; 35 | } 36 | case 'r': { 37 | nodeType = 'root'; 38 | // doctype node 10 39 | if (item.h && item.h[0].t === "x" && item.h[0].c?.substring(0, 9) === ' implements IRenderable, IDestroyable { 13 | to?: string; 14 | name?: string; 15 | _name: string = 'Portal'; 16 | anchorNode?: TextAnchor; 17 | 18 | destroy(): void { 19 | if (this.to && this.anchorNode && this.anchorNode.previousSibling) { 20 | while (this.anchorNode.previousSibling._anchor !== this.anchorNode._anchor) { 21 | this.anchorNode.previousSibling!.remove(); 22 | } 23 | this.anchorNode.previousSibling!.remove(); 24 | this.anchorNode.remove(); 25 | } 26 | } 27 | 28 | render(target: HtmlNodeType, name: string, scope: ContextScope, props?: PropsContext, hydrate = false, insert = false, params: { [key: string]: any } = {}): void { 29 | if (this.name) { 30 | const idEnd = 'portal_' + this.name + '_end'; 31 | if (hydrate) { 32 | const portalEndMark = document.getElementById(idEnd); 33 | if (portalEndMark) { 34 | const portalPositionIndex = Array.prototype.indexOf.call(target.childNodes, portalEndMark); 35 | if (portalPositionIndex > 0) { 36 | const anchor = getAnchor(target); 37 | if (!(this.name in portals)) { 38 | portals[this.name] = {}; 39 | } 40 | portals[this.name].current = anchor.current + 1; 41 | anchor.current = portalPositionIndex; 42 | if (portals[this.name].queue) { 43 | const queue = portals[this.name].queue; 44 | for (let i = 0; i < queue.length; i++) { 45 | queue[i][0].apply(null, queue[i][1]); 46 | } 47 | delete portals[this.name].queue; 48 | } 49 | } 50 | } 51 | } else { 52 | const idBegin = 'portal_' + this.name; 53 | const portalBeginElement = document.createElement('i'); 54 | const portalEndElement = document.createElement('i'); 55 | portalBeginElement.setAttribute('id', idBegin); 56 | portalEndElement.setAttribute('id', idEnd); 57 | const style = 'display: none !important;'; 58 | portalBeginElement.setAttribute('style', style); 59 | portalEndElement.setAttribute('style', style); 60 | insert 61 | ? target.parentElement!.insertBefore(portalBeginElement, target) 62 | : target.appendChild(portalBeginElement); 63 | insert 64 | ? target.parentElement!.insertBefore(portalEndElement, target) 65 | : target.appendChild(portalEndElement); 66 | } 67 | } else if (this.to) { 68 | if (hydrate) { 69 | if (this.to in portals && portals[this.to].current) { 70 | renderPortal(this, scope, hydrate, insert); 71 | } else { 72 | const delayedRender = [renderPortal, [this, scope, hydrate, insert]]; 73 | if (this.to in portals) { 74 | portals[this.to].queue.push(delayedRender); 75 | } else { 76 | portals[this.to] = { 77 | queue: [delayedRender] 78 | }; 79 | } 80 | } 81 | } else { 82 | renderPortal(this, scope, false, true); 83 | } 84 | } else { 85 | throw new Error("Portal component should have either 'name' or 'to' attribute."); 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/portal/portals.ts: -------------------------------------------------------------------------------- 1 | export const portals = {}; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/portal/renderPortal.ts: -------------------------------------------------------------------------------- 1 | import { createAnchorNode } from "../anchor/createAnchorNode"; 2 | import { getAnchor } from "../anchor/getAnchor"; 3 | import { globalScope } from "../di/globalScope"; 4 | import { ContextScope } from "../lifecycle/contextScope"; 5 | import { unpack } from "../node/unpack"; 6 | import { render } from "../render/render"; 7 | import { Portal } from "./portal"; 8 | import { portals } from "./portals"; 9 | 10 | export function renderPortal(portal: Portal, scope: ContextScope, hydrate = false, insert = false) { 11 | const portalEndMark = document.getElementById('portal_' + portal.to! + '_end'); 12 | if (portalEndMark) { 13 | const portalAnchorCurrent = portals[portal.to!].current; 14 | const renderTarget = insert ? portalEndMark : portalEndMark.parentElement!; 15 | const anchor = hydrate ? getAnchor(renderTarget) : undefined; 16 | const anchorCurrent = hydrate ? anchor!.current : 0; 17 | const portalPositionIndexBefore = Array.prototype.indexOf.call(renderTarget.childNodes, portalEndMark); 18 | hydrate && (anchor!.current = portalAnchorCurrent); 19 | // render 20 | let slotName: string = 'default'; 21 | const anchorSlotBegin = createAnchorNode(renderTarget, insert, anchor); // begin slot 22 | if (slotName in scope.slots!) { // slot from parent 23 | const slot = scope.slots![slotName]; 24 | if (!slot.node.unpacked) { 25 | unpack(slot.node); 26 | slot.node.unpacked = true; 27 | } 28 | render(renderTarget, slot.scope.instance, slot.node.children!, slot.scope, undefined, hydrate, insert); 29 | } 30 | const anchorSlotNode = createAnchorNode(renderTarget, insert, anchor, anchorSlotBegin!._anchor); // end slot 31 | if (scope.instance._name in globalScope.iteration) { 32 | globalScope.iteration[scope.instance._name].slots[slotName] = anchorSlotNode; 33 | } 34 | portal.anchorNode = anchorSlotNode; 35 | // restore anchor position 36 | if (hydrate) { 37 | portals[portal.to!].current = anchor!.current; 38 | const portalPositionIndexAfter = Array.prototype.indexOf.call(renderTarget.childNodes, portalEndMark); 39 | anchor!.current = anchorCurrent + portalPositionIndexAfter - portalPositionIndexBefore; 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/getComponentModelHandler.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent"; 2 | 3 | export function getComponentModelHandler(instance: BaseComponent, setter: (instance: BaseComponent, value: any) => void) { 4 | return function (event: any) { 5 | setter(instance, event); 6 | } 7 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/getModelHandler.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent"; 2 | import { ModelHandler } from "./modelHandler"; 3 | import { HTMLModelInputElement } from "./updateModelValue"; 4 | 5 | export function getModelHandler( 6 | instance: BaseComponent, 7 | options: ModelHandler 8 | ): EventListener { 9 | return function (event: Event & { 10 | target: HTMLModelInputElement 11 | }) { 12 | if (options.inputType === "checkbox") { 13 | const currentValue = options.getter(instance); 14 | const inputValue = event.target.value; 15 | if (Array.isArray(currentValue)) { 16 | const newValue = currentValue.slice(); 17 | const valuePosition = newValue.indexOf(inputValue); 18 | if (valuePosition === -1) { 19 | if (event.target.checked) { 20 | newValue.push(inputValue); 21 | } 22 | } else { 23 | if (!event.target.checked) { 24 | newValue.splice(valuePosition, 1); 25 | } 26 | } 27 | options.setter(instance, newValue); 28 | } else { 29 | options.setter(instance, event.target.checked); 30 | } 31 | } else if (options.inputType === "radio") { 32 | const inputValue = event.target.value; 33 | options.setter(instance, inputValue); 34 | } else if (options.isMultiple || event.target.multiple) { 35 | const inputOptions = event.target.options; 36 | const newValue: string[] = []; 37 | for (let i = 0; i < inputOptions.length; i++) { 38 | const currentOption = inputOptions[i]; 39 | if (currentOption.selected) { 40 | newValue.push(currentOption.value); 41 | } 42 | } 43 | options.setter(instance, newValue); 44 | } else { 45 | options.setter(instance, event.target.value); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/modelHandler.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent" 2 | import { InputType } from "../../node/inputType" 3 | 4 | export type ModelHandler = { 5 | getter: (instance: BaseComponent) => any, 6 | setter: (instance: BaseComponent, value: any) => void, 7 | inputType: InputType, 8 | isMultiple: boolean 9 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/updateComment.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent"; 2 | import { TemplateNode } from "../../node/templateNode"; 3 | 4 | export function updateComment(instance: BaseComponent, node: TemplateNode, commentNode: Comment) { 5 | const content = node.expression 6 | ? instance.$$t[node.code as number](instance) 7 | : (node.content ?? ''); 8 | commentNode.nodeValue !== content && (commentNode.nodeValue = content); 9 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/updateComponentModel.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent"; 2 | 3 | export function updateComponentModel( 4 | instance: BaseComponent, 5 | attrName: string, 6 | getter: (instance: BaseComponent) => any, 7 | parentInstance: BaseComponent 8 | ) { 9 | instance[attrName] = getter(parentInstance); 10 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/updateModelValue.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent"; 2 | import { ModelHandler } from "./modelHandler"; 3 | 4 | export type HTMLModelInputElement = HTMLInputElement & HTMLSelectElement; 5 | 6 | export function updateModelValue( 7 | target: HTMLModelInputElement, 8 | instance: BaseComponent, 9 | options: ModelHandler 10 | ): void { 11 | if (options.inputType === "checkbox") { 12 | const currentValue = options.getter(instance); 13 | if (Array.isArray(currentValue)) { 14 | const inputValue = target.value; 15 | const valuePosition = currentValue.indexOf(inputValue); 16 | if (valuePosition === -1) { 17 | target.removeAttribute('checked'); 18 | target.checked = false; 19 | } else { 20 | target.setAttribute('checked', 'checked'); 21 | target.checked = true; 22 | } 23 | } else { 24 | if (currentValue) { 25 | target.setAttribute('checked', 'checked'); 26 | target.checked = true; 27 | } else { 28 | target.removeAttribute('checked'); 29 | target.checked = false; 30 | } 31 | } 32 | } else if (options.inputType === "radio") { 33 | const currentValue = options.getter(instance); 34 | const inputValue = target.value; 35 | if (currentValue === inputValue) { 36 | target.setAttribute('checked', 'checked'); 37 | target.checked = true; 38 | } else { 39 | target.removeAttribute('checked'); 40 | target.checked = false; 41 | } 42 | } else if (options.isMultiple || target.multiple) { 43 | const inputOptions = target.options; 44 | const currentValue = options.getter(instance); 45 | for (let i = 0; i < inputOptions.length; i++) { 46 | const currentOption = inputOptions[i]; 47 | const index = currentValue.indexOf(currentOption.value); 48 | if (index === -1) { 49 | currentOption.selected = false; 50 | } else { 51 | currentOption.selected = true; 52 | } 53 | } 54 | } else { 55 | target.value = options.getter(instance); 56 | } 57 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/handlers/updateProp.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../../component/baseComponent"; 2 | import { TemplateNode } from "../../node/templateNode"; 3 | import { PropsContext } from "../../lifecycle/propsContext"; 4 | 5 | export function updateProp(instance: BaseComponent, attribute: TemplateNode, props: PropsContext) { 6 | const parentInstance = props.scope.instance; 7 | const attrName = attribute.expression 8 | ? parentInstance.$$t[attribute.code!](parentInstance) // TODO: arguments 9 | : (attribute.content ?? ''); 10 | if (attrName[0] === '(') { 11 | // TODO: event 12 | } else { 13 | let valueContent: any = null; 14 | let valueSubs = []; // TODO: on backend, pass attribute value subs in attribute 15 | if (attribute.children) { 16 | for (let av = 0; av < attribute.children.length; av++) { 17 | const attributeValue = attribute.children[av]; 18 | let callArguments = [parentInstance]; 19 | if (props.scope.arguments) { 20 | callArguments = callArguments.concat(props.scope.arguments); 21 | } 22 | const childContent = attributeValue.expression 23 | ? parentInstance.$$t[attributeValue.code as number].apply(null, callArguments) 24 | : (attributeValue.content ?? ''); 25 | valueContent = av === 0 ? childContent : valueContent + (childContent ?? ''); 26 | if (attributeValue.subs) { 27 | valueSubs = valueSubs.concat(attributeValue.subs as never[]); 28 | } 29 | } 30 | } 31 | if (attrName === '_props' && valueContent) { 32 | for (let propName in valueContent) { 33 | instance[propName] = valueContent[propName]; 34 | instance._props[propName] = valueContent[propName]; 35 | } 36 | } else { 37 | instance[attrName] = valueContent; 38 | instance._props[attrName] = valueContent; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/makeProxy.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { ReserverProps } from "../component/reserverProps"; 3 | 4 | let reactiveId = 0; 5 | 6 | let queue = {}; 7 | 8 | let timeoutId: number = 0; 9 | 10 | function executeQueue() { 11 | timeoutId = 0; 12 | const currentQueue = queue; 13 | queue = {}; 14 | for (let uid in currentQueue) { 15 | const callbackFunc = currentQueue[uid]; 16 | try { 17 | callbackFunc[0].apply(null, callbackFunc[1]); 18 | } catch (err) { 19 | console.error(err); 20 | } 21 | } 22 | } 23 | 24 | function schedule(path: string, i: string, callbackFunc: any) { 25 | queue[path + '-' + i] = callbackFunc; 26 | if (timeoutId === 0) { 27 | timeoutId = setTimeout(executeQueue, 0); 28 | } 29 | } 30 | 31 | export type ReactiveProxy = object & { $: ReactiveProxy, $$r?: { [key: string]: [path: string, instance: BaseComponent] } }; 32 | 33 | export function activateTarget(component: T & BaseComponent, mainPath: string, prop: string, target: any) { 34 | let val = target[prop]; 35 | if (!Object.getOwnPropertyDescriptor(target, prop)?.set) { 36 | Object.defineProperty(target, prop, { 37 | enumerable: true, 38 | configurable: true, 39 | get: function () { 40 | return val; 41 | }, 42 | set: function (value) { 43 | const react = val !== value; 44 | val = value; 45 | deepProxy(mainPath, component, val); 46 | if (react) { 47 | for (let id in target.$$r) { 48 | const path = target.$$r[id][0]; 49 | const component = target.$$r[id][1]; 50 | // const propertyPath = path + '.' + prop; 51 | // if (propertyPath in component.$$r) { 52 | // for (let i in component.$$r[propertyPath]) { 53 | // const callbackFunc = component.$$r[propertyPath][i]; 54 | // // TODO: schedule queue and react only once 55 | // callbackFunc[0].apply(null, callbackFunc[1]); 56 | // } 57 | // } 58 | // All root path dependencies should trigger updates, no need for sub path updates 59 | if (path in component.$$r) { 60 | for (let i in component.$$r[path]) { 61 | const callbackFunc = component.$$r[path][i]; 62 | schedule(path, i, callbackFunc); 63 | } 64 | } 65 | } 66 | } 67 | } 68 | }); 69 | deepProxy(mainPath, component, val); 70 | } 71 | } 72 | 73 | 74 | function deepProxy(prop: string, component: T & BaseComponent, targetObject: ReactiveProxy) { 75 | if (!(prop in ReserverProps)) { 76 | if (Array.isArray(targetObject)) { 77 | // TODO: 78 | } 79 | else if (targetObject !== null && typeof targetObject === 'object' && typeof targetObject !== 'function' && !(targetObject instanceof EventTarget)) { 80 | if (!('$$r' in targetObject)) { 81 | Object.defineProperty(targetObject, "$$r", { 82 | enumerable: false, 83 | writable: true, 84 | value: {} 85 | }); 86 | } 87 | let keys = Object.keys(targetObject); 88 | for (let i = 0; i < keys.length; i++) { 89 | const valueProp = keys[i]; 90 | if (!(valueProp in ReserverProps)) { 91 | activateTarget(component, prop, valueProp, targetObject); 92 | } 93 | } 94 | const trackerId = ++reactiveId + ''; 95 | targetObject.$$r![trackerId] = [prop, component]; 96 | component.$$p.push([trackerId, targetObject]); 97 | } 98 | } 99 | } 100 | 101 | export function defineReactive(component: T & BaseComponent, prop: string) { 102 | let val = component[prop]; 103 | deepProxy(prop, component, val); 104 | Object.defineProperty(component, prop, { 105 | enumerable: true, 106 | configurable: true, 107 | get: function () { 108 | return val; 109 | }, 110 | set: function (value) { 111 | const react = val !== value; 112 | val = value; 113 | deepProxy(prop, component, val); 114 | if (react && (prop in component.$$r)) { 115 | for (let i in component.$$r[prop]) { 116 | const callbackFunc = component.$$r[prop][i]; 117 | schedule(prop, i, callbackFunc); 118 | } 119 | } 120 | } 121 | }); 122 | } 123 | 124 | export function makeProxy(component: T & BaseComponent): T { 125 | let keys = Object.keys(component); 126 | for (let i = 0; i < keys.length; i++) { 127 | const prop = keys[i]; 128 | if (!(prop in ReserverProps)) { 129 | defineReactive(component, prop); 130 | } 131 | } 132 | return component; 133 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/makeProxyOrigin.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { ReserverProps } from "../component/reserverProps"; 3 | 4 | let reactiveId = 0; 5 | 6 | export type ReactiveProxy = object & { $: ReactiveProxy, $$r: { [key: string]: [path: string, instance: BaseComponent] } }; 7 | 8 | export function makeReactive(componentProperty: ReactiveProxy, component: BaseComponent, path: string): ReactiveProxy { 9 | const targetObject = componentProperty.$ ?? componentProperty; 10 | if (!targetObject.$) { 11 | Object.defineProperty(targetObject, "$", { 12 | enumerable: false, 13 | writable: true, 14 | value: targetObject 15 | }); 16 | Object.defineProperty(targetObject, "$$r", { 17 | enumerable: false, 18 | writable: true, 19 | value: {} 20 | }); 21 | } 22 | const proxy = new Proxy(targetObject, { 23 | set(obj, prop: string, value: any) { 24 | const react = obj[prop] !== value; 25 | const ret = Reflect.set(obj, prop, value); 26 | if (react) { 27 | for (let id in obj.$$r) { 28 | const path = obj.$$r[id][0]; 29 | const component = obj.$$r[id][1]; 30 | // const propertyPath = path + '.' + prop; 31 | // if (propertyPath in component.$$r) { 32 | // for (let i in component.$$r[propertyPath]) { 33 | // const callbackFunc = component.$$r[propertyPath][i]; 34 | // // TODO: schedule queue and react only once 35 | // callbackFunc[0].apply(null, callbackFunc[1]); 36 | // } 37 | // } 38 | // All root path dependencies should trigger updates, no need for sub path updates 39 | if (path in component.$$r) { 40 | for (let i in component.$$r[path]) { 41 | const callbackFunc = component.$$r[path][i]; 42 | // TODO: schedule queue and react only once 43 | callbackFunc[0].apply(null, callbackFunc[1]); 44 | } 45 | } 46 | } 47 | } 48 | return ret; 49 | } 50 | }); 51 | return proxy; 52 | } 53 | 54 | function deepProxy(prop: string, component: T & BaseComponent, value: any) { 55 | if (!(prop in ReserverProps) && value !== null && typeof value === 'object' && !Array.isArray(value)) { 56 | const activated = makeReactive(value, component, prop); 57 | component[prop] = activated; 58 | const trackerId = ++reactiveId + ''; 59 | activated.$$r[trackerId] = [prop, component]; 60 | component.$$p.push([trackerId, activated]); 61 | } 62 | } 63 | 64 | export function makeProxy(component: T & BaseComponent): T { 65 | let keys = Object.keys(component); 66 | for (let i = 0; i < keys.length; i++) { 67 | const key = keys[i]; 68 | const val = component[key]; 69 | deepProxy(key, component, val); 70 | } 71 | const proxy = new Proxy(component, { 72 | set(obj, prop: string, value) { 73 | const react = obj[prop] !== value; 74 | const ret = Reflect.set(obj, prop, value); 75 | deepProxy(prop, component, value); 76 | if (react && (prop in obj.$$r)) { 77 | for (let i in obj.$$r[prop]) { 78 | const callbackFunc = obj.$$r[prop][i]; 79 | // TODO: schedule queue and react only once 80 | callbackFunc[0].apply(null, callbackFunc[1]); 81 | } 82 | } 83 | return ret; 84 | } 85 | }); 86 | component.$ = component; 87 | return proxy; 88 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/reactivity/track.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { ContextScope } from "../lifecycle/contextScope"; 3 | 4 | let trackingId = 0; 5 | export function nextTrackingId() { 6 | return ++trackingId; 7 | } 8 | 9 | export function track(instance: BaseComponent, trackingPath: string, scope: ContextScope, action: [Function, any[]]) { 10 | if (!instance.$$r[trackingPath]) { 11 | instance.$$r[trackingPath] = {}; 12 | } 13 | const trackId = ++trackingId; 14 | scope.track.push({ id: trackId, path: trackingPath }); 15 | instance.$$r[trackingPath][trackId] = action; 16 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/iRenderable.ts: -------------------------------------------------------------------------------- 1 | import { ContextScope } from "../lifecycle/contextScope"; 2 | import { PropsContext } from "../lifecycle/propsContext"; 3 | import { HtmlNodeType } from "../node/htmlNodeType"; 4 | 5 | export interface IRenderable { 6 | render(target: HtmlNodeType, name: string, scope: ContextScope, props: PropsContext | undefined, hydrate: boolean, insert: boolean, params: { [key: string]: any }): void; 7 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderApp.ts: -------------------------------------------------------------------------------- 1 | import { resources } from "../../../app/main/resources"; 2 | import { anchors } from "../anchor/anchors"; 3 | import { componentsMeta } from "../component/componentsMeta"; 4 | import { delay } from "../di/delay"; 5 | import { globalScope } from "../di/globalScope"; 6 | import { resolve } from "../di/resolve"; 7 | import { injectScript } from "../http/injectScript"; 8 | import { dispose } from "../lifecycle/dispose"; 9 | import { IMiddleware } from "../lifecycle/imiddleware"; 10 | import { HtmlNodeType } from "../node/htmlNodeType"; 11 | import { renderComponent } from "./renderComponent"; 12 | 13 | const lazyRecords = {}; 14 | 15 | export function renderApp( 16 | name: string, 17 | params: { [key: string]: any }, 18 | target?: HtmlNodeType, 19 | onAccept?: { func: (href: string, forward: boolean) => void, href: string, forward: boolean }, 20 | skipMiddleware?: boolean 21 | ) { 22 | globalScope.cancel = false; 23 | // console.time('renderApp'); 24 | if (!(name in componentsMeta.list)) { 25 | throw new Error(`Component ${name} not found.`); 26 | } 27 | const info = componentsMeta.list[name]; 28 | if (info.lazy && !(info.lazy in lazyRecords)) { 29 | const baseName = 'viewi' + (resources.name === 'default' ? '' : '.' + resources.name); 30 | const scriptUrl = resources.publicPath + baseName + '.' + info.lazy + (resources.minify ? '.min' : '') + '.js' 31 | + (resources.appendVersion ? '?' + resources.build : ''); 32 | injectScript(scriptUrl); 33 | delay.postpone(info.lazy, function () { 34 | lazyRecords[info.lazy!] = true; 35 | renderApp(name, params, target, onAccept, skipMiddleware); 36 | }); 37 | return; 38 | } 39 | const hydrate = globalScope.hydrate; 40 | const lastScope = globalScope.rootScope; 41 | if (onAccept) { 42 | if (lastScope && info.parent !== globalScope.layout) { 43 | // new html root, can't render, request from server 44 | location.href = onAccept.href; 45 | return; 46 | } 47 | } 48 | 49 | if (info.middleware && !skipMiddleware) { 50 | const total = info.middleware.length; 51 | let globalAllow = true; 52 | let current = -1; 53 | const context = { 54 | next: function (allow: boolean = true) { 55 | globalAllow = allow; 56 | current++; 57 | if (globalAllow && current < total) { 58 | // run next middleware 59 | const middleware: IMiddleware = resolve(info.middleware![current]); 60 | middleware.run(context); 61 | } else { 62 | // render app 63 | if (globalAllow) { 64 | renderApp(name, params, target, onAccept, true); 65 | } else { 66 | // keep! 67 | } 68 | } 69 | } 70 | }; 71 | context.next(true); 72 | return; 73 | } 74 | if (onAccept) { 75 | onAccept.func(onAccept.href, onAccept.forward); 76 | } 77 | globalScope.layout = info.parent!; 78 | globalScope.lastIteration = globalScope.iteration; 79 | globalScope.iteration = {}; 80 | globalScope.scopedContainer = {}; 81 | globalScope.located = {}; 82 | globalScope.rootScope = renderComponent(target ?? document, name, undefined, {}, hydrate, false, params); 83 | globalScope.hydrate = false; // TODO: scope managment function 84 | for (let name in globalScope.lastIteration) { 85 | if (!(name in globalScope.iteration)) { 86 | globalScope.lastIteration[name].scope.keep = false; 87 | } 88 | } 89 | lastScope && dispose(lastScope); 90 | // console.log(anchors); 91 | // return; 92 | 93 | // Clean up unhydrated content 94 | if (hydrate) { 95 | for (let a in anchors) { 96 | const anchor = anchors[a]; 97 | // clean up what's left 98 | if (anchor.target.nodeName !== 'HEAD' && anchor.target.nodeName !== 'BODY') { 99 | for (let i = anchor.target.childNodes.length - 1; i >= anchor.current + 1; i--) { 100 | anchor.target.childNodes[i].remove(); 101 | } 102 | } 103 | // clean up not matched 104 | for (let i = anchor.invalid.length - 1; i >= 0; i--) { 105 | anchor.target.childNodes[anchor.invalid[i]].remove(); 106 | } 107 | } 108 | } 109 | // console.timeEnd('renderApp'); 110 | // console.timeLog('renderApp'); 111 | // console.timeEnd('renderApp'); 112 | // console.log(globalScope); 113 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderAttributeValue.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { TemplateNode } from "../node/templateNode"; 3 | import { componentsMeta } from "../component/componentsMeta"; 4 | import { ContextScope } from "../lifecycle/contextScope"; 5 | import { HtmlNodeType } from "../node/htmlNodeType"; 6 | import { xLinkNs } from "../helpers/isSvg"; 7 | 8 | export function renderAttributeValue( 9 | instance: BaseComponent, 10 | attribute: TemplateNode, 11 | element: HTMLElement & HtmlNodeType, 12 | attrName: string, 13 | scope: ContextScope 14 | ) { 15 | let valueContent: string | boolean | null = null; 16 | if (attribute.children) { 17 | valueContent = ''; 18 | for (let av = 0; av < attribute.children.length; av++) { 19 | const attributeValue = attribute.children[av]; 20 | let callArguments = [instance]; 21 | if (scope.arguments) { 22 | callArguments = callArguments.concat(scope.arguments); 23 | } 24 | const childContent = attributeValue.expression 25 | ? instance.$$t[attributeValue.code as number].apply(null, callArguments) 26 | : (attributeValue.content ?? ''); 27 | valueContent = av === 0 ? childContent : valueContent + (childContent ?? ''); 28 | } 29 | } 30 | if (attrName.toLowerCase() in componentsMeta.booleanAttributes) { 31 | if (valueContent === true || valueContent === null) { 32 | attrName !== element.getAttribute(attrName) && element.setAttribute(attrName, attrName); 33 | } else { 34 | element.removeAttribute(attrName); 35 | } 36 | } else { 37 | if (element.isSvg && attrName.startsWith('xlink:')) { 38 | if (valueContent !== null) { 39 | valueContent !== element.getAttribute(attrName) && element.setAttributeNS(xLinkNs, attrName, valueContent); 40 | } else { 41 | element.removeAttributeNS(xLinkNs, attrName.slice(6, attrName.length)); 42 | } 43 | } else { 44 | if (valueContent !== null) { 45 | valueContent !== element.getAttribute(attrName) && element.setAttribute(attrName, valueContent); 46 | } else { 47 | element.removeAttribute(attrName); 48 | } 49 | } 50 | } 51 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderComponent.ts: -------------------------------------------------------------------------------- 1 | import { components } from "../../../app/main/components"; 2 | import { BaseComponent } from "../component/baseComponent"; 3 | import { componentsMeta } from "../component/componentsMeta"; 4 | import { getComponentModelHandler } from "../reactivity/handlers/getComponentModelHandler"; 5 | import { makeProxy } from "../reactivity/makeProxy"; 6 | import { PropsContext } from "../lifecycle/propsContext"; 7 | import { render } from "./render"; 8 | import { resolve } from "../di/resolve"; 9 | import { Slots } from "../node/slots"; 10 | import { track } from "../reactivity/track"; 11 | import { unpack } from "../node/unpack"; 12 | import { updateComponentModel } from "../reactivity/handlers/updateComponentModel"; 13 | import { updateProp } from "../reactivity/handlers/updateProp"; 14 | import { ContextScope } from "../lifecycle/contextScope"; 15 | import { globalScope } from "../di/globalScope"; 16 | import { HtmlNodeType } from "../node/htmlNodeType"; 17 | import { IRenderable } from "./iRenderable"; 18 | 19 | export function renderComponent(target: HtmlNodeType, name: string, props?: PropsContext, slots?: Slots, hydrate = false, insert = false, params: { [key: string]: any } = {}): ContextScope { 20 | if (!(name in componentsMeta.list)) { 21 | throw new Error(`Component ${name} not found.`); 22 | } 23 | if (!(name in components)) { 24 | throw new Error(`Component ${name} not found.`); 25 | } 26 | const info = componentsMeta.list[name]; 27 | const root = info.nodes; 28 | // 'Reuse' is the concept of reusing same layout(s) to avoid rerendering the whole page and side-effects, including visual. 29 | // Top level component tag should be reused along with their rendered content, except slots 30 | // Duplicates are not allowed, otherwise the architecture is wrong and you must reconsider it 31 | const lastIteration = globalScope.lastIteration; 32 | const reuse = name in lastIteration; 33 | if (reuse) { 34 | // clean up previous page slots 35 | const slotHolders = lastIteration[name].slots; 36 | for (let slotName in slotHolders) { 37 | const anchorNode = slotHolders[slotName]; 38 | while (anchorNode.previousSibling._anchor !== anchorNode._anchor) { 39 | anchorNode.previousSibling!.remove(); 40 | } 41 | } 42 | lastIteration[name].scope.keep = true; 43 | } 44 | const instance: BaseComponent & IRenderable = reuse ? lastIteration[name].instance : makeProxy(resolve(name, params, false, props?.scope.lastComponent.instance || props?.scope.instance || null)); 45 | // console.log(name, instance._parent?._name, latestComponent?._name); 46 | if (!reuse) { 47 | if (info.hooks && info.hooks.init) { 48 | (instance as any).init(); 49 | } 50 | } 51 | const inlineExpressions = name + '_x'; 52 | if (!reuse && inlineExpressions in components) { 53 | instance.$$t = components[inlineExpressions]; 54 | } 55 | const scopeId = props ? ++props.scope.counter : 0; 56 | // TODO: on reuse - attach scope to a new parent 57 | const scope: ContextScope = reuse ? lastIteration[name].scope : { 58 | id: scopeId, 59 | why: name, 60 | arguments: [], // props ? [...props.scope.arguments] : [], 61 | instance: instance, 62 | main: true, 63 | map: props ? { ...props.scope.map } : {}, 64 | track: [], 65 | children: {}, 66 | lastComponent: { instance },//props ? props.scope.lastComponent : null, 67 | counter: 0, 68 | parent: props ? props.scope : undefined, 69 | slots: slots 70 | }; 71 | 72 | props && (props.scope.children[scopeId] = scope); 73 | if (info.refs) { 74 | scope.refs = info.refs; 75 | } 76 | 77 | // set props 78 | if (props && props.attributes) { 79 | const parentInstance = props.scope.instance; 80 | for (let a in props.attributes) { 81 | let callArguments = [parentInstance]; 82 | if (props.scope.arguments) { 83 | callArguments = callArguments.concat(props.scope.arguments); 84 | } 85 | const attribute = props.attributes[a]; 86 | const attrName = attribute.expression 87 | ? parentInstance.$$t[attribute.code!].apply(null, callArguments) 88 | : (attribute.content ?? ''); 89 | if (attrName[0] === '(') { 90 | const eventName = attrName.substring(1, attrName.length - 1); 91 | if (attribute.children) { 92 | const eventHandler = 93 | parentInstance.$$t[ 94 | attribute.dynamic 95 | ? attribute.dynamic.code! 96 | : attribute.children[0].code! 97 | ].apply(null, callArguments) as EventListener; 98 | instance.$_callbacks[eventName] = eventHandler; 99 | // console.log('Event', attribute, eventName, eventHandler); 100 | } 101 | } else if (attrName[0] === '#') { 102 | const refName = attrName.substring(1, attrName.length); 103 | parentInstance._refs[refName] = instance; 104 | if (refName in parentInstance) { 105 | parentInstance[refName] = instance; 106 | } 107 | } else { 108 | const isModel = attrName === 'model'; 109 | let valueContent: any = null; 110 | let valueSubs = []; // TODO: on backend, pass attribute value subs in attribute 111 | if (isModel) { 112 | const attributeValue = attribute.children![0]; 113 | const getterSetter = parentInstance.$$t[attributeValue.code as number].apply(null, callArguments); 114 | valueContent = getterSetter[0](parentInstance); 115 | instance.$_callbacks[attrName] = getComponentModelHandler(parentInstance, getterSetter[1]); 116 | for (let subI in attributeValue.subs!) { 117 | const trackingPath = attributeValue.subs[subI]; 118 | track(parentInstance, trackingPath, props.scope, [updateComponentModel, [instance, attrName, getterSetter[0], parentInstance]]); 119 | } 120 | } else { 121 | if (attribute.children) { 122 | for (let av = 0; av < attribute.children.length; av++) { 123 | const attributeValue = attribute.children[av]; 124 | let callArguments = [parentInstance]; 125 | if (props.scope.arguments) { 126 | callArguments = callArguments.concat(props.scope.arguments); 127 | } 128 | const childContent = attributeValue.expression 129 | ? parentInstance.$$t[attributeValue.code as number].apply(null, callArguments) 130 | : (attributeValue.content ?? ''); 131 | valueContent = av === 0 ? childContent : valueContent + (childContent ?? ''); 132 | if (attributeValue.subs) { 133 | valueSubs = valueSubs.concat(attributeValue.subs as never[]); 134 | } 135 | } 136 | } else { 137 | valueContent = true; // empty property conosidered bollean true 138 | } 139 | } 140 | if (attrName === '_props' && valueContent) { 141 | for (let propName in valueContent) { 142 | instance[propName] = valueContent[propName]; 143 | instance._props[propName] = valueContent[propName]; 144 | } 145 | } else { 146 | if (attribute.children?.length === 1 && attribute.children[0].content === 'false') { 147 | valueContent = false; 148 | } 149 | instance[attrName] = valueContent; 150 | instance._props[attrName] = valueContent; 151 | } 152 | // TODO: model 153 | // track 154 | if (valueSubs) { 155 | for (let subI in valueSubs) { 156 | const trackingPath = valueSubs[subI]; 157 | track(parentInstance, trackingPath, props.scope, [updateProp, [instance, attribute, props]]); 158 | } 159 | } 160 | } 161 | } 162 | } 163 | if (!globalScope.cancel && info.hooks && info.hooks.mounted) { 164 | (instance as any).mounted(); 165 | } 166 | 167 | // reuse && console.log(`Reusing component: ${name}`); 168 | if (name in globalScope.located) { 169 | globalScope.iteration[name] = { instance, scope, slots: {} }; 170 | } 171 | if (reuse) { 172 | // console.log('Resue: Rendering slots'); 173 | const slotHolders = lastIteration[name].slots; 174 | for (let slotName in slotHolders) { 175 | const anchorNode = slotHolders[slotName]; 176 | if (anchorNode.parentNode && document.body.contains(anchorNode)) { // slot is visible on page 177 | if (slots && slotName in slots) { // slot has been passed 178 | const slot = slots[slotName]; 179 | if (!slot.node.unpacked) { 180 | unpack(slot.node); 181 | slot.node.unpacked = true; 182 | } 183 | render(anchorNode, slot.scope.instance, slot.node.children!, slot.scope, undefined, false, true); 184 | } else { 185 | // TODO: render default slot content 186 | } 187 | globalScope.iteration[name].slots[slotName] = anchorNode; 188 | } 189 | } 190 | let componentName: string | false = name; 191 | while (componentName) { 192 | const componentInfo = componentsMeta.list[componentName]; 193 | componentName = false; 194 | const componentRoot = componentInfo.nodes; 195 | if (componentRoot) { 196 | const rootChildren = componentRoot.children; 197 | if (rootChildren) { 198 | if (rootChildren[0].type === 'component' && rootChildren[0].content! in lastIteration) { 199 | globalScope.iteration[rootChildren[0].content!] = lastIteration[rootChildren[0].content!]; 200 | componentName = rootChildren[0].content!; 201 | } 202 | } 203 | } 204 | } 205 | return scope; 206 | } 207 | // render 208 | if (info.renderer) { 209 | instance.render(target, name, scope, props, hydrate, insert, params); 210 | } 211 | 212 | if ( 213 | target 214 | && root 215 | ) { 216 | if (!root.unpacked) { 217 | unpack(root); 218 | root.unpacked = true; 219 | } 220 | const rootChildren = root.children; 221 | if (rootChildren) { 222 | if (rootChildren[0].type === 'component') { 223 | globalScope.located[rootChildren[0].content!] = true; 224 | } 225 | // console.log(target, instance, rootChildren); 226 | rootChildren[0].first = true; 227 | render(target, instance, rootChildren, scope, undefined, hydrate, insert); 228 | // console.log(name, instance, rootChildren); 229 | // console.log(name, instance); 230 | } 231 | } 232 | if (info.hooks && info.hooks.rendered) { 233 | setTimeout(function () { (instance as any).rendered(); }, 0); 234 | } 235 | return scope; 236 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderDynamic.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { TemplateNode } from "../node/templateNode"; 3 | import { PropsContext } from "../lifecycle/propsContext"; 4 | import { render } from "./render"; 5 | import { renderAttributeValue } from "./renderAttributeValue"; 6 | import { renderComponent } from "./renderComponent"; 7 | import { track } from "../reactivity/track"; 8 | import { isComponent } from "../component/isComponent"; 9 | import { TextAnchor } from "../anchor/textAnchor"; 10 | import { ContextScope } from "../lifecycle/contextScope"; 11 | import { dispose } from "../lifecycle/dispose"; 12 | 13 | export function renderDynamic(instance: BaseComponent, node: TemplateNode, scopeContainer: { scope: ContextScope, anchorNode: TextAnchor }) { 14 | const content = node.expression 15 | ? instance.$$t[node.code as number](instance) 16 | : (node.content ?? ''); 17 | const componentTag = node.type === "component" 18 | || (node.expression && isComponent(content)); 19 | const anchorNode = scopeContainer.anchorNode; 20 | const scope = scopeContainer.scope.parent!; 21 | dispose(scopeContainer.scope); 22 | while (anchorNode.previousSibling._anchor !== anchorNode._anchor) { 23 | anchorNode.previousSibling!.remove(); 24 | } 25 | const scopeId = ++scope.counter; 26 | const nextScope: ContextScope = { 27 | id: scopeId, 28 | why: 'dynamic', 29 | arguments: [...scope.arguments], 30 | map: { ...scope.map }, 31 | track: [], 32 | instance: instance, 33 | lastComponent: scope.lastComponent, 34 | parent: scope, 35 | children: {}, 36 | counter: 0, 37 | slots: scope.slots 38 | }; 39 | if (scope.refs) { 40 | nextScope.refs = scope.refs; 41 | } 42 | scopeContainer.scope = nextScope; 43 | scope.children[scopeId] = nextScope; 44 | // component 45 | if (componentTag) { 46 | const slots = {}; 47 | if (node.slots) { 48 | const scopeId = ++nextScope!.counter; 49 | const slotScope: ContextScope= { 50 | id: scopeId, 51 | why: 'slot', 52 | arguments: [...scope.arguments], 53 | map: { ...scope.map }, 54 | track: [], 55 | instance: instance, 56 | lastComponent: scope.lastComponent, 57 | parent: nextScope, 58 | children: {}, 59 | counter: 0, 60 | slots: scope.slots 61 | }; 62 | for (let slotName in node.slots) { 63 | slots[slotName] = { 64 | node: node.slots[slotName], 65 | scope: slotScope 66 | }; 67 | } 68 | } 69 | renderComponent(anchorNode, content, { attributes: node.attributes, scope: scope, instance: instance }, slots, false, true); 70 | return; 71 | } else { 72 | const element = anchorNode.parentElement!.insertBefore(document.createElement(content), anchorNode); 73 | 74 | if (node.attributes) { 75 | for (let a in node.attributes) { 76 | const attribute = node.attributes[a]; 77 | const attrName = attribute.expression 78 | ? instance.$$t[attribute.code!](instance) 79 | : (attribute.content ?? ''); 80 | if (attrName[0] === '(') { 81 | // event 82 | const eventName = attrName.substring(1, attrName.length - 1); 83 | if (attribute.children) { 84 | const eventHandler = 85 | instance.$$t[ 86 | attribute.dynamic 87 | ? attribute.dynamic.code! 88 | : attribute.children[0].code! 89 | ](instance) as EventListener; 90 | element.addEventListener(eventName, eventHandler); 91 | // console.log('Event', attribute, eventName, eventHandler); 92 | } 93 | } else { 94 | renderAttributeValue(instance, attribute, element, attrName, nextScope); 95 | let valueSubs = []; // TODO: on backend, pass attribute value subs in attribute 96 | if (attribute.children) { 97 | for (let av in attribute.children) { 98 | const attributeValue = attribute.children[av]; 99 | if (attributeValue.subs) { 100 | valueSubs = valueSubs.concat(attributeValue.subs as never[]); 101 | } 102 | } 103 | } 104 | if (valueSubs) { 105 | for (let subI in valueSubs) { 106 | const trackingPath = valueSubs[subI]; 107 | track(instance, trackingPath, nextScope, [renderAttributeValue, [instance, attribute, element, attrName, nextScope]]); 108 | } 109 | } 110 | } 111 | } 112 | } 113 | if (node.children) { 114 | render(element, instance, node.children, nextScope, undefined, false, false); 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderForeach.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { TemplateNode } from "../node/templateNode"; 3 | import { render } from "./render"; 4 | import { DirectiveMap } from "../directive/directiveMap"; 5 | import { createAnchorNode, nextAnchorNodeId } from "../anchor/createAnchorNode"; 6 | import { TextAnchor } from "../anchor/textAnchor"; 7 | import { ForeachAnchorEnum } from "../anchor/foreachAnchorEnum"; 8 | import { ContextScope } from "../lifecycle/contextScope"; 9 | import { ArrayScope } from "../lifecycle/arrayScope"; 10 | import { dispose } from "../lifecycle/dispose"; 11 | 12 | export function renderForeach( 13 | instance: BaseComponent, 14 | node: TemplateNode, 15 | directive: TemplateNode, 16 | anchors: { anchorBegin: TextAnchor, anchorNode: TextAnchor }, 17 | currentArrayScope: ArrayScope, 18 | localDirectiveMap: DirectiveMap, 19 | scope: ContextScope 20 | ) { 21 | let callArguments = [instance]; 22 | if (scope.arguments) { 23 | callArguments = callArguments.concat(scope.arguments); 24 | } 25 | const forMeta = directive.children![0]; 26 | const noKey = !!forMeta.forKeyAuto; 27 | const data = instance.$$t[ 28 | forMeta.forData! 29 | ].apply(null, callArguments); 30 | 31 | const isNumeric = Array.isArray(data); 32 | const usedMap = {}; 33 | let positionIndex = -1; 34 | let moveBefore = anchors.anchorBegin.nextSibling; 35 | const nextArrayScope: ArrayScope = { data: {} }; 36 | for (let forKey in data) { 37 | let found = false; 38 | positionIndex++; 39 | const dataKey = isNumeric ? +forKey : forKey; 40 | const dataItem = data[dataKey]; 41 | let foundIndex = -1; 42 | for (let di in currentArrayScope.data) { 43 | foundIndex++; 44 | if (di in usedMap) { 45 | continue; 46 | } 47 | const currentScopeItem = currentArrayScope.data[di]; 48 | if (currentScopeItem.value === dataItem && (noKey || currentScopeItem.key === dataKey)) { 49 | found = true; 50 | usedMap[di] = true; 51 | nextArrayScope.data[dataKey] = currentScopeItem; 52 | if (foundIndex !== positionIndex && moveBefore !== currentScopeItem.begin) { 53 | // move html 54 | const beginAnchor = currentScopeItem.begin; 55 | let nextToMove = beginAnchor.nextSibling; 56 | moveBefore.before(beginAnchor); 57 | while (nextToMove._anchor !== beginAnchor._anchor) { 58 | nextToMove = nextToMove.nextSibling; 59 | moveBefore.before(nextToMove.previousSibling); 60 | } 61 | moveBefore.before(nextToMove); 62 | } 63 | moveBefore = currentScopeItem.end.nextSibling; 64 | break; 65 | } 66 | } 67 | if (!found) { 68 | const scopeId = ++scope.counter; 69 | const nextScope: ContextScope = { 70 | id: scopeId, 71 | why: 'forItem', 72 | instance: instance, 73 | lastComponent: scope.lastComponent, 74 | arguments: [...scope.arguments], 75 | map: { ...scope.map }, 76 | track: [], 77 | parent: scope, 78 | children: {}, 79 | counter: 0 80 | }; 81 | if (scope.refs) { 82 | nextScope.refs = scope.refs; 83 | } 84 | scope.children[scopeId] = nextScope; 85 | nextScope.map[directive.children![0].forKey!] = nextScope.arguments.length; 86 | nextScope.arguments.push(dataKey); 87 | nextScope.map[directive.children![0].forItem!] = nextScope.arguments.length; 88 | nextScope.arguments.push(dataItem); 89 | const nextDirectives: DirectiveMap = { map: { ...localDirectiveMap.map }, storage: { ...localDirectiveMap.storage } }; 90 | const itemBeginAnchor = createAnchorNode(moveBefore, true, undefined, ForeachAnchorEnum.BeginAnchor + nextAnchorNodeId()); // begin foreach item 91 | render(moveBefore, instance, [node], nextScope, nextDirectives, false, true); 92 | const itemEndAnchor = createAnchorNode(moveBefore, true, undefined, itemBeginAnchor._anchor); // end foreach item 93 | moveBefore = itemEndAnchor.nextSibling; 94 | nextArrayScope.data[dataKey] = { 95 | key: dataKey, 96 | value: dataItem, 97 | begin: itemBeginAnchor, 98 | end: itemEndAnchor, 99 | scope: nextScope 100 | }; 101 | } 102 | } 103 | // removing what's missing 104 | for (let di in currentArrayScope.data) { 105 | if (!(di in usedMap)) { 106 | const endAnchor = currentArrayScope.data[di].end; 107 | while (endAnchor.previousSibling._anchor !== endAnchor._anchor) { 108 | endAnchor.previousSibling!.remove(); 109 | } 110 | currentArrayScope.data[di].begin.remove(); 111 | endAnchor.remove(); 112 | dispose(currentArrayScope.data[di].scope); 113 | delete currentArrayScope.data[di]; 114 | } 115 | } 116 | currentArrayScope.data = nextArrayScope.data; 117 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderIf.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { TemplateNode } from "../node/templateNode"; 3 | import { render } from "./render"; 4 | import { ConditionalDirective } from "../directive/conditionalDirective"; 5 | import { DirectiveMap } from "../directive/directiveMap"; 6 | import { TextAnchor } from "../anchor/textAnchor"; 7 | import { ContextScope } from "../lifecycle/contextScope"; 8 | import { dispose } from "../lifecycle/dispose"; 9 | 10 | export function renderIf( 11 | instance: BaseComponent, 12 | node: TemplateNode, 13 | scopeContainer: { scope: ContextScope, anchorNode: TextAnchor }, 14 | directive: TemplateNode, 15 | ifConditions: ConditionalDirective, 16 | localDirectiveMap: DirectiveMap, 17 | index: number 18 | ) { 19 | let nextValue = true; 20 | for (let i = 0; i < index; i++) { 21 | nextValue = nextValue && !ifConditions.values[i]; 22 | } 23 | const scope = scopeContainer.scope.parent!; 24 | if (directive.children) { 25 | let callArguments = [instance]; 26 | if (scope.arguments) { 27 | callArguments = callArguments.concat(scope.arguments); 28 | } 29 | nextValue = nextValue && !!(instance.$$t[ 30 | directive.children[0].code! 31 | ].apply(null, callArguments)); 32 | } 33 | const anchorNode = scopeContainer.anchorNode; 34 | const nextDirectives: DirectiveMap = { map: { ...localDirectiveMap.map }, storage: { ...localDirectiveMap.storage } }; 35 | if (ifConditions.values[index] !== nextValue) { 36 | ifConditions.values[index] = nextValue; 37 | if (nextValue) { 38 | // render 39 | const scopeId = ++scope.counter; 40 | const nextScope: ContextScope = { 41 | id: scopeId, 42 | why: index === 0 ? 'if' : (directive.children ? 'elseif' : 'else'), 43 | instance: instance, 44 | lastComponent: scope.lastComponent, 45 | arguments: [...scope.arguments], 46 | map: { ...scope.map }, 47 | track: [], 48 | parent: scope, 49 | children: {}, 50 | counter: 0, 51 | slots: scope.slots 52 | }; 53 | if (scope.refs) { 54 | nextScope.refs = scope.refs; 55 | } 56 | scopeContainer.scope = nextScope; 57 | scope.children[scopeId] = nextScope; 58 | render(anchorNode, instance, [node], nextScope, nextDirectives, false, true); 59 | } else { 60 | // remove and dispose 61 | dispose(scopeContainer.scope); 62 | scopeContainer.scope = { 63 | id: -1, 64 | why: 'if', 65 | instance: instance, 66 | lastComponent: scope.lastComponent, 67 | arguments: [], 68 | map: {}, 69 | track: [], 70 | parent: scope, 71 | children: {}, 72 | counter: 0 73 | }; 74 | while (anchorNode.previousSibling._anchor !== anchorNode._anchor) { 75 | anchorNode.previousSibling!.remove(); 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderRaw.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { TextAnchor } from "../anchor/textAnchor"; 3 | import { TemplateNode } from "../node/templateNode"; 4 | import { ContextScope } from "../lifecycle/contextScope"; 5 | 6 | export function renderRaw(instance: BaseComponent, node: TemplateNode, scope: ContextScope, anchorNode: TextAnchor) { 7 | // remove 8 | while (anchorNode.previousSibling._anchor !== anchorNode._anchor) { 9 | anchorNode.previousSibling!.remove(); 10 | } 11 | // insert new content 12 | const parentTagNode = anchorNode.parentElement!; 13 | const vdom = document.createElement(parentTagNode.nodeName); 14 | let callArguments = [instance]; 15 | if (scope.arguments) { 16 | callArguments = callArguments.concat(scope.arguments); 17 | } 18 | const content = (node.expression 19 | ? instance.$$t[node.code as number].apply(null, callArguments) 20 | : node.content) ?? ''; 21 | vdom.innerHTML = content; 22 | const rawNodes = Array.prototype.slice.call(vdom.childNodes); 23 | for (let rawNodeI = 0; rawNodeI < rawNodes.length; rawNodeI++) { 24 | const rawNode = rawNodes[rawNodeI]; 25 | parentTagNode.insertBefore(rawNode, anchorNode); 26 | } 27 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/render/renderText.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from "../component/baseComponent"; 2 | import { ContextScope } from "../lifecycle/contextScope"; 3 | import { TemplateNode } from "../node/templateNode"; 4 | 5 | export function renderText(instance: BaseComponent, node: TemplateNode, textNode: Text, scope: ContextScope) { 6 | let callArguments = [instance]; 7 | if (scope.arguments) { 8 | callArguments = callArguments.concat(scope.arguments); 9 | } 10 | const content = (node.expression 11 | ? instance.$$t[node.code as number].apply(null, callArguments) 12 | : node.content) ?? ''; 13 | textNode.nodeValue !== content && (textNode.nodeValue = content); 14 | // debug purposes, TODO: debug/dev mode logs 15 | // if (textNode.parentNode && !document.body.contains(textNode)) { 16 | // console.log('Element is missing from the page', textNode); 17 | // } 18 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/router/handleUrl.ts: -------------------------------------------------------------------------------- 1 | import { componentsMeta } from "../component/componentsMeta"; 2 | import { globalScope } from "../di/globalScope"; 3 | import { renderApp } from "../render/renderApp"; 4 | import { locationScope } from "./locationScope"; 5 | 6 | const getPathName = function (href: string) { 7 | locationScope.link.href = href; 8 | return locationScope.link.pathname; 9 | }; 10 | 11 | export const onUrlUpdate: { 12 | callback?: Function 13 | } = {}; 14 | 15 | const updateHistory = function (href: string, forward: boolean = true) { 16 | if (forward) { 17 | window.history.pushState({ href: href }, '', href); 18 | } 19 | window.scrollTo(0, 0); 20 | onUrlUpdate.callback?.(); 21 | } 22 | 23 | export function handleUrl(href: string, forward: boolean = true) { 24 | globalScope.cancel = true; 25 | const urlPath = getPathName(href); 26 | const routeItem = componentsMeta.router.resolve(urlPath); 27 | if (routeItem == null) { 28 | throw 'Can\'t resolve route for uri: ' + urlPath; 29 | } 30 | setTimeout(function () { 31 | renderApp(routeItem.item.action, routeItem.params, undefined, { func: updateHistory, href, forward }); 32 | }, 0); 33 | } 34 | -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/router/locationScope.ts: -------------------------------------------------------------------------------- 1 | const htmlElementA = document.createElement('a'); 2 | 3 | export const locationScope: { link: HTMLAnchorElement, scrollTo: string | null } = { link: htmlElementA, scrollTo: null }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/router/routeItem.ts: -------------------------------------------------------------------------------- 1 | export class RouteItem { 2 | method: string; 3 | url: string; 4 | action: string; 5 | wheres: { [key: string]: any }; 6 | defaults: { [key: string]: any } | null = null; 7 | 8 | constructor(method: string, url: string, action: string, defaults: { [key: string]: any } | null = null, wheres?: { [key: string]: any }) { 9 | this.method = method; 10 | this.url = url; 11 | this.action = action; 12 | this.wheres = {}; 13 | this.defaults = defaults; 14 | if (wheres) { 15 | this.wheres = wheres; 16 | } 17 | } 18 | 19 | where(wheresOrName: string | { [key: string]: any }, expr: any) { 20 | if (wheresOrName !== null && typeof wheresOrName === 'object') { 21 | this.wheres = Object.assign(this.where, wheresOrName); 22 | } else if (expr) { 23 | this.wheres[wheresOrName] = expr; 24 | } 25 | return this; 26 | }; 27 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/router/routeRecord.ts: -------------------------------------------------------------------------------- 1 | import { RouteItem } from "./routeItem"; 2 | 3 | export type RouteRecord = { item: RouteItem, params: { [key: string]: any } }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/router/router.ts: -------------------------------------------------------------------------------- 1 | import { RouteItem } from "./routeItem"; 2 | import { RouteRecord } from "./routeRecord"; 3 | 4 | export class Router { 5 | routes: RouteItem[]; 6 | trimExpr: RegExp = /^\/|\/$/g; 7 | 8 | setRoutes(routeList: RouteItem[]) { 9 | this.routes = routeList; 10 | }; 11 | 12 | getRoutes(): RouteItem[] { 13 | return this.routes; 14 | }; 15 | 16 | register( 17 | method: string, 18 | url: string, 19 | action: string, 20 | defaults: { [key: string]: any } | null = null, 21 | wheres?: { [key: string]: any } 22 | ): RouteItem { 23 | const item = new RouteItem( 24 | method.toLowerCase(), 25 | url, 26 | action, 27 | defaults, 28 | wheres 29 | ); 30 | this.routes.push(item); 31 | return item; 32 | }; 33 | 34 | get(url: string, action: string): RouteItem { 35 | return this.register('get', url, action); 36 | }; 37 | 38 | resolve(url: string): RouteRecord | null { 39 | url = url.replace(this.trimExpr, ''); 40 | const parts = url.split('/'); 41 | for (let k in this.routes) { 42 | const params = {}; 43 | let valid = true; 44 | const item = this.routes[k]; 45 | const targetUrl = item.url.replace(this.trimExpr, ''); 46 | const targetParts = targetUrl.split('/'); 47 | let pi = 0; 48 | let skipAll = false; 49 | for (pi; pi < targetParts.length; pi++) { 50 | const urlExpr = targetParts[pi]; 51 | const hasWildCard = urlExpr.indexOf('*') !== -1; 52 | if (hasWildCard) { 53 | const beginning = urlExpr.slice(0, -1); 54 | if (!beginning || parts[pi].indexOf(beginning) === 0) { 55 | skipAll = true; 56 | break; 57 | } 58 | } 59 | const hasParams = urlExpr.indexOf('{') !== -1; 60 | if (urlExpr !== parts[pi] && !hasParams) { 61 | valid = false; 62 | break; 63 | } 64 | if (hasParams) { 65 | // has {***} parameter 66 | const bracketParts = urlExpr.split(/[{}]+/); 67 | // console.log(urlExpr, bracketParts); 68 | let paramName = bracketParts[1]; 69 | if (paramName[paramName.length - 1] === '?') { 70 | // optional 71 | paramName = paramName.slice(0, -1); 72 | } else if (pi >= parts.length) { 73 | valid = false; 74 | break; 75 | } 76 | if (paramName.indexOf('<') !== -1) { // has 77 | const matches = /<([^>]+)>/.exec(paramName); 78 | if (matches) { 79 | paramName = paramName.replace(/<([^>]+)>/g, ''); 80 | item.wheres[paramName] = matches[1]; 81 | } 82 | } 83 | if (item.wheres[paramName]) { 84 | const regex = new RegExp(item.wheres[paramName], 'g'); 85 | if (!regex.test(parts[pi])) { 86 | valid = false; 87 | break; 88 | } 89 | regex.lastIndex = 0; 90 | // test for "/" 91 | if (regex.test('/')) { // skip to the end 92 | skipAll = true; 93 | } 94 | } 95 | let paramValue = pi < parts.length ? parts[pi] : null; 96 | if (paramValue && bracketParts[0]) { 97 | if (paramValue.indexOf(bracketParts[0]) !== 0) { 98 | valid = false; 99 | break; 100 | } else { 101 | paramValue = paramValue.slice(bracketParts[0].length); 102 | } 103 | } 104 | params[paramName] = paramValue; 105 | if (skipAll) { 106 | params[paramName] = parts.slice(pi).join('/'); 107 | break; 108 | } 109 | } 110 | } 111 | if (pi < parts.length && !skipAll) { 112 | valid = false; 113 | } 114 | if (valid) { 115 | return { item: item, params: params }; 116 | } 117 | } 118 | return null; 119 | }; 120 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/router/watchLinks.ts: -------------------------------------------------------------------------------- 1 | import { handleUrl } from "./handleUrl"; 2 | import { locationScope } from "./locationScope"; 3 | 4 | export function watchLinks() { 5 | document.addEventListener('click', function (event: MouseEvent) { 6 | if (event.defaultPrevented) { 7 | return; 8 | } 9 | if (!event.target) { 10 | console.warn('Can not aquire event target at "watchLinks".'); 11 | } 12 | const target = event.target!; 13 | let nextTarget: HTMLLinkElement = target; 14 | while (nextTarget.parentElement && nextTarget.tagName !== 'A') { 15 | nextTarget = nextTarget.parentElement; 16 | } 17 | if ( 18 | nextTarget.tagName === 'A' 19 | && nextTarget.href 20 | && nextTarget.href.indexOf(location.origin) === 0 21 | && (nextTarget.target === "_self" || !nextTarget.target) 22 | ) { 23 | locationScope.scrollTo = null; 24 | if ( 25 | !locationScope.link.hash 26 | || locationScope.link.pathname !== location.pathname 27 | ) { 28 | event.preventDefault(); // Cancel native event 29 | // e.stopPropagation(); // Don't bubble/capture the event 30 | if (locationScope.link.hash) { 31 | locationScope.scrollTo = locationScope.link.hash; 32 | } 33 | handleUrl(nextTarget.href, true); 34 | } 35 | } 36 | }, false); 37 | 38 | // handle back button 39 | window.addEventListener('popstate', function (event) { 40 | if (event.state) 41 | handleUrl(event.state.href, false); 42 | else 43 | handleUrl(location.href, false); 44 | }); 45 | } -------------------------------------------------------------------------------- /viewi-app/js/viewi/core/viewi.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { ViewiApp: { [name: string]: Viewi } } 3 | } 4 | 5 | export type Viewi = { 6 | register: { [name: string]: any }, 7 | version: string, 8 | build: string, 9 | name: string, 10 | publish: (group: string, importComponents: {}) => void 11 | }; -------------------------------------------------------------------------------- /viewi-app/js/viewi/index.ts: -------------------------------------------------------------------------------- 1 | import { components, templates } from "../app/main/components"; 2 | import { functions } from "../app/main/functions"; 3 | import { resources } from "../app/main/resources"; 4 | import "../modules/main"; 5 | import { ComponentsJson } from "./core/component/componentsJson"; 6 | import { componentsMeta } from "./core/component/componentsMeta"; 7 | import { makeGlobal } from "./core/component/makeGlobal"; 8 | import { delay } from "./core/di/delay"; 9 | import { register } from "./core/di/register"; 10 | import { setUp } from "./core/di/setUp"; 11 | import { handleUrl } from "./core/router/handleUrl"; 12 | import { watchLinks } from "./core/router/watchLinks"; 13 | import { Viewi as ViewiApp } from "./core/viewi"; 14 | 15 | const ViewiApp: ViewiApp = { 16 | register: {}, 17 | version: resources.version, 18 | build: resources.build, 19 | name: resources.name, 20 | publish(group: string, importComponents: { [name: string]: any }) { 21 | for (let name in importComponents) { 22 | if (!(name in components)) { 23 | const imortItem = importComponents[name]; 24 | if (imortItem._t === 'template') { 25 | componentsMeta.list[imortItem.name] = JSON.parse(imortItem.data); 26 | } else { 27 | components[name] = imortItem; 28 | } 29 | } 30 | } 31 | delay.ready(group); 32 | }, 33 | }; 34 | 35 | window.ViewiApp = window.ViewiApp || {}; 36 | window.ViewiApp[resources.name] = ViewiApp; 37 | 38 | (async () => { 39 | let data: ComponentsJson = JSON.parse(templates); 40 | if (!resources.combine) { 41 | data = await (await fetch(resources.componentsPath)).json() as ComponentsJson; 42 | } 43 | componentsMeta.list = data; 44 | componentsMeta.router.setRoutes(data._routes); 45 | componentsMeta.config = data._config; 46 | componentsMeta.globals = data._globals; 47 | const booleanArray = data._meta['boolean'].split(','); 48 | for (let i = 0; i < booleanArray.length; i++) { 49 | componentsMeta.booleanAttributes[booleanArray[i]] = true; 50 | } 51 | setUp(data._startup); 52 | makeGlobal(); 53 | ViewiApp.register = { ...components, ...register, ...functions }; 54 | watchLinks(); 55 | handleUrl(location.href); 56 | //setTimeout(() => renderApp('TestComponent'), 500); 57 | })(); -------------------------------------------------------------------------------- /viewi-app/routes.php: -------------------------------------------------------------------------------- 1 | router(); 20 | 21 | $router->get('/', HomePage::class); 22 | $router->get('/counter', CounterPage::class); 23 | $router->get('/counter/{page}', CounterPage::class); 24 | $router->get('/todo', TodoAppPage::class); 25 | $router->get('/redirect-test', RedirectTestComponent::class); 26 | $router->get('/current-url', CurrentUrlTestPage::class); 27 | $router->get('/async-ssr-test/{id}', AsyncTestComponent::class); 28 | $router->get('/interceptors-test/{id}', InterceptorsTestComponent::class); 29 | $router->get('/middleware-test/{id}', MiddlewareTestComponent::class); 30 | $router->get('/middleware-fail-test/{id}', MiddlewareFailTestComponent::class); 31 | $router 32 | ->get('*', NotFoundPage::class) 33 | ->transform(function (Response $response) { 34 | return $response->withStatus(404, 'Not Found'); 35 | }); 36 | -------------------------------------------------------------------------------- /viewi-app/viewi.php: -------------------------------------------------------------------------------- 1 |