@ti-engine/web-framework 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.env +4 -0
  2. package/CHANGELOG.md +274 -0
  3. package/README.md +26 -0
  4. package/bin/build/post-install.js +18 -0
  5. package/bin/localization/web-server-labels.json +28 -0
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
  7. package/bin/static/favicon.ico +0 -0
  8. package/bin/static/fragments/components/component-notification-bar.html +21 -0
  9. package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
  10. package/bin/static/fragments/components/component-sidebar.html +33 -0
  11. package/bin/static/fragments/components/component-tooltip.html +11 -0
  12. package/bin/static/fragments/components/component-topbar.html +6 -0
  13. package/bin/static/fragments/frame-administration.html +3 -0
  14. package/bin/static/fragments/frame-application.html +19 -0
  15. package/bin/static/fragments/frame-dashboard.html +3 -0
  16. package/bin/static/fragments/frame-login.html +105 -0
  17. package/bin/static/fragments/frame-not-found.html +3 -0
  18. package/bin/static/fragments/frame-profile.html +3 -0
  19. package/bin/static/index.html +23 -0
  20. package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
  21. package/bin/static/scripts/lib/htmx.min.js +1 -0
  22. package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
  23. package/bin/static/scripts/ti-charts.js +1591 -0
  24. package/bin/static/scripts/ti-framework.css +3195 -0
  25. package/bin/static/scripts/ti-framework.js +1427 -0
  26. package/bin/static/scripts/ti-theme-black-glass.css +216 -0
  27. package/bin/static/scripts/ti-theme-daylight.css +87 -0
  28. package/bin/web-app-manager.js +564 -0
  29. package/bin/web-server.js +604 -0
  30. package/bin/web-server.json +49 -0
  31. package/components/admin-config-handlers.js +92 -0
  32. package/components/auth-manager.js +344 -0
  33. package/components/authorization.js +135 -0
  34. package/components/config-change-notifier.js +98 -0
  35. package/components/config-registry.js +246 -0
  36. package/components/config-service.js +349 -0
  37. package/components/config-store.js +246 -0
  38. package/components/definitions.types.js +26 -0
  39. package/components/session-store.js +111 -0
  40. package/components/user.js +133 -0
  41. package/components/web-handlers.js +765 -0
  42. package/package.json +66 -0
@@ -0,0 +1,564 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2025 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ const exceptions = require( "@ti-engine/core/exceptions" );
10
+ const tools = require( "@ti-engine/core/tools" );
11
+ const localization = require( "@ti-engine/core/localization" );
12
+ const path = require( "node:path" );
13
+ const fs = require( "node:fs" );
14
+ const configRegistry = require( "#config-registry" );
15
+ const configService = require( "#config-service" );
16
+ const authorization = require( "#authorization" );
17
+
18
+ const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
19
+ const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
20
+ const RE_HTMX_CONFIG = /\{ti-htmx-config-placeholder}/g;
21
+ const RE_CSP_NONCE = /^[A-Za-z0-9+/=_-]{16,}$/;
22
+ const TI_NESTED_FRAME_PLACEHOLDER = "ti-nested-frame-placeholder";
23
+
24
+ /**
25
+ * A generic web application manager that handles the rendering and behavior of web application views. It is designed to be extended by specific web application
26
+ * managers for each web application you want to implement with the ti-engine web framework.
27
+ * <br/>
28
+ * NOTE: You should not instantiate this class directly. Instead, extend it and override the abstract methods as needed. Additionally, you should configure your
29
+ * ti-engine web server 'TiWebApplicationConfig' settings by specifying the 'classPath' that corresponds to your web application manager. The path should be
30
+ * relative to the intended process's working directory.
31
+ *
32
+ * @class TiWebAppManager
33
+ * @abstract
34
+ * @public
35
+ */
36
+ class TiWebAppManager {
37
+
38
+ #webAppIdentifier;
39
+ #fragments = {};
40
+ #staticFileCache = {};
41
+ #staticFileCacheEnabled;
42
+
43
+ /**
44
+ * @constructor
45
+ * @param {string} identifier The identifier for this web application. Should be unique and recognizable.
46
+ * @throws {TiException.E_GEN_ABSTRACT_CLASS_INIT} If this class is instantiated directly.
47
+ */
48
+ constructor( identifier ) {
49
+ // Make sure this abstract class cannot be instantiated:
50
+ if ( new.target === TiWebAppManager ) {
51
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_ABSTRACT_CLASS_INIT, { name: this.constructor.name } );
52
+ }
53
+
54
+ this.#webAppIdentifier = identifier;
55
+ this.#staticFileCacheEnabled = ( process.env.TI_WEB_APP_STATIC_CACHE_DISABLED !== "true" );
56
+
57
+ // Define the default HTML fragments for the application:
58
+ this.#fragments[ 'home' ] = {
59
+ path: "index.html",
60
+ components: [ "component-notification-bar" ]
61
+ };
62
+ this.#fragments[ 'application-main' ] = {
63
+ title: "Application",
64
+ path: "fragments/frame-application.html",
65
+ components: [ "component-topbar", "component-sidebar", "component-notification-bar", "component-sidebar-flyout" ]
66
+ };
67
+ this.#fragments[ 'login' ] = {
68
+ title: "Login",
69
+ path: "fragments/frame-login.html"
70
+ };
71
+ this.#fragments[ 'dashboard' ] = {
72
+ title: "Dashboard",
73
+ path: "fragments/frame-dashboard.html"
74
+ };
75
+ this.#fragments[ 'administration' ] = {
76
+ title: "Administration",
77
+ path: "fragments/frame-administration.html"
78
+ };
79
+ this.#fragments[ 'profile' ] = {
80
+ title: "Profile",
81
+ path: "fragments/frame-profile.html"
82
+ };
83
+ this.#fragments[ 'not-found' ] = {
84
+ title: "Not Found",
85
+ path: "fragments/frame-not-found.html"
86
+ };
87
+ }
88
+
89
+ /* Public interface */
90
+
91
+ /**
92
+ * Returns the identifier for this web application.
93
+ *
94
+ * @property
95
+ * @returns {string}
96
+ * @public
97
+ */
98
+ get webAppIdentifier() {
99
+ return this.#webAppIdentifier;
100
+ }
101
+
102
+ /**
103
+ * Adds a new HTML fragment to the web application.
104
+ * <br/>
105
+ * NOTE: This method should only be called during the initialization phase of your web application manager.
106
+ *
107
+ * @method
108
+ * @param {string} identifier
109
+ * @param {Object} fragment The fragment descriptor (`{ title, path, components }`). May also carry an optional
110
+ * `roles` array (`Array<string|number>`): when present, the default {@link TiWebAppManager#verifyAccess} serves
111
+ * the fragment only to sessions holding at least one of those roles; omit it (or leave empty) for a public screen.
112
+ * @throws {TiException.E_GEN_UNALLOWED_OVERRIDE} If a fragment with the same identifier already exists.
113
+ * @public
114
+ */
115
+ addFragment( identifier, fragment ) {
116
+ if ( this.#fragments[ identifier ] === undefined ) {
117
+ this.#fragments[ identifier ] = fragment;
118
+ } else {
119
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_UNALLOWED_OVERRIDE, { identifier: identifier } );
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Registers an editable configuration document with the framework config registry (JSON Schema + semantic
125
+ * validators + default value + editor metadata). Call during initialization. See {@link ConfigRegistry#register}.
126
+ *
127
+ * @method
128
+ * @param {string} configKey
129
+ * @param {Object} definition
130
+ * @returns {TiWebAppManager} this (chainable)
131
+ * @public
132
+ */
133
+ registerConfigDocument( configKey, definition ) {
134
+ configRegistry.instance.register( configKey, definition );
135
+ return this;
136
+ }
137
+
138
+ /**
139
+ * Registers a JSON Schema that is referenced (via `$ref`) by config-document schemas but is not itself a document.
140
+ *
141
+ * @method
142
+ * @param {Object} schema
143
+ * @returns {TiWebAppManager} this (chainable)
144
+ * @public
145
+ */
146
+ registerConfigSchema( schema ) {
147
+ configRegistry.instance.addSchema( schema );
148
+ return this;
149
+ }
150
+
151
+ /**
152
+ * Registers a composite (entity) editor with the framework config service — a `compose(docs)`/`decompose(edited,docs)`
153
+ * pair over one or more documents. Call during initialization. See {@link ConfigService#registerEditor}.
154
+ *
155
+ * @method
156
+ * @param {string} editorKey
157
+ * @param {Object} definition
158
+ * @returns {TiWebAppManager} this (chainable)
159
+ * @public
160
+ */
161
+ registerConfigEditor( editorKey, definition ) {
162
+ configService.instance.registerEditor( editorKey, definition );
163
+ return this;
164
+ }
165
+
166
+ /**
167
+ * Used to clear the static file cache. This is useful for testing purposes to ensure that the web server is always serving fresh content.
168
+ *
169
+ * @method
170
+ * @public
171
+ */
172
+ clearStaticFileCache() {
173
+ this.#staticFileCache = {};
174
+ }
175
+
176
+ /**
177
+ * Optional HTML transformation hook.
178
+ * <br/>
179
+ * NOTE: Override in subclasses to add nonces or other dynamic data to outgoing HTML.
180
+ *
181
+ * @method
182
+ * @param {string} html
183
+ * @param {Object} [options]
184
+ * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
185
+ * @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
186
+ * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
187
+ * @param {string} [options.title] Optional title to replace the placeholder in the HTML.
188
+ * @returns {Promise<string>}
189
+ * @virtual
190
+ * @public
191
+ */
192
+ transformHtml( html, options = {} ) {
193
+ return new Promise( ( resolve, reject ) => {
194
+ let transformedHtml = String( html );
195
+
196
+ // Insert nonce in all placeholder locations. If nonce is not provided or is invalid, this will use an empty string instead to remove the placeholder:
197
+ const nonce = ( typeof options?.nonce === "string" && RE_CSP_NONCE.test( options?.nonce ) ) ? options?.nonce : "";
198
+ transformedHtml = transformedHtml.replaceAll( RE_NONCE_ATTR, nonce );
199
+ if ( options.isHome ) {
200
+ let htmxConfig = {
201
+ inlineScriptNonce: nonce,
202
+ inlineStyleNonce: nonce,
203
+ allowEval: false,
204
+ refreshOnHistoryMiss: true,
205
+ historyCacheSize: 0
206
+ };
207
+ transformedHtml = transformedHtml.replace( RE_HTMX_CONFIG, JSON.stringify( htmxConfig ) );
208
+ }
209
+
210
+ const csrfToken = ( typeof options?.csrfToken === "string" ) ? options?.csrfToken : "";
211
+ transformedHtml = transformedHtml.replaceAll( RE_CSRF_ATTR, csrfToken );
212
+
213
+ transformedHtml = transformedHtml.replace( "{ti-title-placeholder}", options.title || "" );
214
+
215
+ resolve( transformedHtml );
216
+ } );
217
+ }
218
+
219
+ /**
220
+ * Used to assemble the complete HTML view for the requested route, including nested HTML fragments.
221
+ *
222
+ * @method
223
+ * @param {TiSession} session
224
+ * @param {string[]} staticContentPaths
225
+ * @param {string} route
226
+ * @param {Object} [options]
227
+ * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
228
+ * @param {boolean} [options.isPartial] Optional flag to indicate whether the requested route is a partial load of a fragment.
229
+ * @param {string} [options.view] Optional view name to load within this route.
230
+ * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
231
+ * @returns {Promise<string>}
232
+ * @public
233
+ */
234
+ assembleHtmlView( session, staticContentPaths, route, options = {} ) {
235
+ return new Promise( ( resolve, reject ) => {
236
+ let fragment;
237
+ let getHtmlPromises = [];
238
+ let localOptions = ( options && typeof options === "object" ) ? { ...options } : {};
239
+
240
+ if ( route === "/" ) {
241
+ fragment = this.#fragments[ 'home' ];
242
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, { ...localOptions, isHome: true } ) );
243
+ } else if ( route === "/app/error" ) {
244
+ // TODO: This endpoint is for testing purposes only. Remove later.
245
+ return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_METHOD ) );
246
+ } else if ( route === "/app" || route === "/app/enter" ) {
247
+ fragment = ( session && session.user ) ? this.#fragments[ 'application-main' ] : this.#fragments[ 'login' ];
248
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
249
+ } else if ( route === "/not-found" ) {
250
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], { ...localOptions, isHome: true, title: this.#fragments[ 'not-found' ].title } ) );
251
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'not-found' ], localOptions ) );
252
+ } else {
253
+ fragment = this.#fragments[ options.view ];
254
+ if ( !fragment ) {
255
+ // Abort execution if the requested view is not found:
256
+ return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
257
+ } else {
258
+ // This handles application refreshes from nested frames:
259
+ if ( options.isPartial !== true ) {
260
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], {
261
+ ...localOptions,
262
+ isHome: true,
263
+ title: fragment.title
264
+ } ) );
265
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'application-main' ], localOptions ) );
266
+ }
267
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
268
+ }
269
+ }
270
+
271
+ Promise.all( getHtmlPromises ).then( ( filesData ) => {
272
+ let assembledHtml = undefined;
273
+ filesData.forEach( ( fileData ) => {
274
+ // There should always be at most one ti-nested-frame-placeholder element in each HTML fragment:
275
+ assembledHtml = ( assembledHtml ) ? this.#replacePlaceholderElement( assembledHtml, TI_NESTED_FRAME_PLACEHOLDER, fileData ) : fileData;
276
+ } );
277
+ resolve( assembledHtml );
278
+ } ).catch( ( error ) => {
279
+ reject( exceptions.raise( error ) );
280
+ } );
281
+ } );
282
+ }
283
+
284
+ /**
285
+ * Used to process a request for a data resource.
286
+ *
287
+ * @method
288
+ * @param {TiSession} session
289
+ * @param {string} view
290
+ * @param {Object} [options]
291
+ * @returns {Promise<Object>}
292
+ * @virtual
293
+ * @public
294
+ */
295
+ processDataRequest( session, view, options = {} ) {
296
+ return new Promise( ( resolve, reject ) => {
297
+ if ( view === "config" ) {
298
+ resolve( {
299
+ labels: localization.getAllLabels( session?.language ),
300
+ auth: {
301
+ isAuthenticated: Boolean( session && session.user )
302
+ }
303
+ } );
304
+ } else {
305
+ reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
306
+ }
307
+ } );
308
+ }
309
+
310
+ /**
311
+ * Used to process an application service request.
312
+ *
313
+ * @method
314
+ * @param {TiSession} session
315
+ * @param {string} service
316
+ * @param {Object} params
317
+ * @returns {Promise<Object>}
318
+ * @virtual
319
+ * @public
320
+ */
321
+ processServiceRequest( session, service, params ) {
322
+ return new Promise( ( resolve, reject ) => {
323
+ reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { service: service } ) );
324
+ } );
325
+ }
326
+
327
+ /**
328
+ * Used to verify whether the current user has access to the requested resource. The default implementation gates
329
+ * HTML fragments by their declared `roles`: a fragment registered via {@link TiWebAppManager#addFragment} with a
330
+ * `roles` array is served only to sessions holding at least one of those roles (see {@link addFragment}); a
331
+ * fragment with no `roles` is public to any authenticated user. This makes role-restricted screens unreachable by
332
+ * direct URL, not merely hidden in the UI. Override in subclasses only to implement additional/alternative checks.
333
+ *
334
+ * @method
335
+ * @virtual
336
+ * @param {TiSession} session
337
+ * @param {Object} resource The fragment descriptor; its optional `resource.roles` lists the roles permitted to load it.
338
+ * @returns {Promise}
339
+ * @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (403) When the session holds none of the fragment's required roles.
340
+ * @public
341
+ */
342
+ verifyAccess( session, resource ) {
343
+ return new Promise( ( resolve, reject ) => {
344
+ const requiredRoles = ( resource && resource.roles ) ? resource.roles : null;
345
+ const userRoles = ( session && session.user && session.user.roles ) || [];
346
+ if ( authorization.isAccessAllowed( requiredRoles, userRoles ) ) {
347
+ return resolve();
348
+ }
349
+ reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_403 ) );
350
+ } );
351
+ }
352
+
353
+ /* Private interface */
354
+
355
+ /**
356
+ * Returns the HTML fragment for the requested route.
357
+ *
358
+ * @method
359
+ * @param {TiSession} session
360
+ * @param {string[]} staticContentPaths
361
+ * @param {Object} fragment
362
+ * @param {Object} [options]
363
+ * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
364
+ * @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
365
+ * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
366
+ * @returns {Promise<string>}
367
+ * @private
368
+ */
369
+ #getHtmlFragment( session, staticContentPaths, fragment, options = {} ) {
370
+ return new Promise( ( resolve, reject ) => {
371
+ this.verifyAccess( session, fragment ).then( () => {
372
+ return this.#locateStaticFile( staticContentPaths, fragment.path );
373
+ } ).then( ( fileData ) => {
374
+ return this.#replaceComponentPlaceholders( fileData, staticContentPaths, fragment.components );
375
+ } ).then( ( fileData ) => {
376
+ return this.transformHtml( fileData, { ...options, title: fragment.title || options.title } );
377
+ } ).then( ( fileData ) => {
378
+ resolve( fileData );
379
+ } ).catch( ( error ) => {
380
+ reject( exceptions.raise( error ) );
381
+ } );
382
+ } );
383
+ }
384
+
385
+ /**
386
+ * Attempts to locate the requested static file in the provided static content paths.
387
+ *
388
+ * @method
389
+ * @param {string[]} staticContentPaths A list of directories to search for static content. If sent as expected by the web server, the first item in the array should be the system default path.
390
+ * @param {string} filePath Relative path to the static file to locate.
391
+ * @returns {Promise<string>}
392
+ * @private
393
+ */
394
+ #locateStaticFile( staticContentPaths, filePath ) {
395
+ return new Promise( ( resolve, reject ) => {
396
+ if ( this.#staticFileCacheEnabled === true && this.#staticFileCache[ filePath ] !== undefined ) {
397
+ resolve( this.#staticFileCache[ filePath ] );
398
+ } else {
399
+ let fullFilePath;
400
+ // Search for the file in the static content paths in reverse order so that the default system path is checked last. This will ensure that
401
+ // any fragment overrides are loaded first (i.e., fragments with the same relative path):
402
+ for ( let idx = staticContentPaths.length - 1; idx >= 0; idx-- ) {
403
+ const staticContentPath = staticContentPaths[ idx ];
404
+ let potentialFilePath = path.join( staticContentPath, filePath );
405
+ if ( fs.existsSync( potentialFilePath ) ) {
406
+ fullFilePath = potentialFilePath;
407
+ break;
408
+ }
409
+ }
410
+
411
+ if ( fullFilePath !== undefined ) {
412
+ fs.promises.readFile( fullFilePath, "utf8" ).then( ( fileData ) => {
413
+ if ( this.#staticFileCacheEnabled === true ) {
414
+ this.#staticFileCache[ filePath ] = fileData;
415
+ }
416
+ resolve( fileData );
417
+ } ).catch( ( error ) => {
418
+ reject( exceptions.raise( error ) );
419
+ } );
420
+ } else {
421
+ reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
422
+ }
423
+ }
424
+ } );
425
+ }
426
+
427
+ /**
428
+ * Used to replace the component placeholders in the HTML with the actual component HTML.
429
+ *
430
+ * @method
431
+ * @param {string} html
432
+ * @param {string[]} staticContentPaths
433
+ * @param {Array<string>} components
434
+ * @returns {Promise<string>}
435
+ * @private
436
+ */
437
+ #replaceComponentPlaceholders( html, staticContentPaths, components ) {
438
+ return new Promise( ( resolve, reject ) => {
439
+ if ( components === undefined || components.length === 0 ) {
440
+ resolve( html );
441
+ } else {
442
+ let transformedHtml = String( html );
443
+ let promises = [];
444
+ const componentData = {};
445
+ tools.arrayUniques( components ).forEach( ( component ) => {
446
+ promises.push( this.#locateStaticFile( staticContentPaths, `fragments/components/${ component }.html` ).then( ( fileData ) => {
447
+ componentData[ component ] = fileData;
448
+ } ) );
449
+ } );
450
+
451
+ Promise.all( promises ).then( () => {
452
+ components.forEach( ( component ) => {
453
+ transformedHtml = this.#replacePlaceholderElement( transformedHtml, `ti-${ component }-placeholder`, componentData[ component ] );
454
+ } );
455
+ resolve( transformedHtml );
456
+ } ).catch( ( error ) => {
457
+ reject( exceptions.raise( error ) );
458
+ } );
459
+ }
460
+ } );
461
+ }
462
+
463
+ /**
464
+ * Used to replace a placeholder element in the HTML with the provided replacement.
465
+ * <br/>
466
+ * The placeholder's attributes are exposed to the replacement HTML as `{ti-<attr-name>}` tokens, allowing the
467
+ * component template to consume initial data without changing its `x-data` factory.
468
+ * <br/>
469
+ * If the replacement HTML contains a `<ti-slot></ti-slot>` (or self-closing `<ti-slot/>`) marker, the placeholder's
470
+ * inner content replaces it precisely; otherwise, inner content is appended before the replacement's last closing
471
+ * tag (legacy behaviour) so existing components keep working.
472
+ *
473
+ * @method
474
+ * @param {string} html
475
+ * @param {string} tagName
476
+ * @param {string} replacement
477
+ * @returns {string}
478
+ * @private
479
+ */
480
+ #replacePlaceholderElement( html, tagName, replacement ) {
481
+ const start = html.indexOf( `<${ tagName }` );
482
+ if ( start === -1 ) {
483
+ return html;
484
+ }
485
+ const gt = html.indexOf( ">", start );
486
+ if ( gt === -1 ) {
487
+ return html;
488
+ }
489
+ // Tolerate whitespace(s) before '/>' and attributes on the tag:
490
+ let p = gt - 1;
491
+ while ( p > start && /\s/.test( html[ p ] ) ) p--;
492
+ const isSelfClosing = html[ p ] === "/";
493
+ let end;
494
+ let inner = "";
495
+ if ( isSelfClosing ) {
496
+ end = gt + 1;
497
+ } else {
498
+ const close = `</${ tagName }>`;
499
+ end = html.indexOf( close, gt + 1 );
500
+ if ( end === -1 ) {
501
+ return html;
502
+ }
503
+ inner = html.slice( gt + 1, end );
504
+ end += close.length;
505
+ }
506
+
507
+ // Substitute the placeholder's attributes as `{ti-<name>}` tokens inside the replacement HTML:
508
+ const placeholderAttributes = this.#parsePlaceholderAttributes( html.slice( start, gt + 1 ) );
509
+ let processedReplacement = replacement;
510
+ Object.keys( placeholderAttributes ).forEach( ( name ) => {
511
+ const value = placeholderAttributes[ name ];
512
+ const token = `{ti-${ name }}`;
513
+ // Use split/join for a literal replaceAll without regex escaping concerns:
514
+ processedReplacement = processedReplacement.split( token ).join( value );
515
+ } );
516
+
517
+ let replacementWithInner = processedReplacement;
518
+ const slotMatch = processedReplacement.match( /<ti-slot\b[^>]*>[\s\S]*?<\/ti-slot>|<ti-slot\b[^>]*\/>/ );
519
+ if ( slotMatch ) {
520
+ // If a slot marker exists, the placeholder's inner content (when present) replaces it. When inner is
521
+ // empty, the slot's own default content (between <ti-slot> and </ti-slot>) is kept by unwrapping it:
522
+ if ( inner ) {
523
+ replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], inner );
524
+ } else {
525
+ replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], ( match ) => {
526
+ const defaultMatch = match.match( /<ti-slot\b[^>]*>([\s\S]*?)<\/ti-slot>/ );
527
+ return defaultMatch ? defaultMatch[ 1 ] : "";
528
+ } );
529
+ }
530
+ } else if ( inner ) {
531
+ const insertAt = processedReplacement.lastIndexOf( "</" );
532
+ if ( insertAt !== -1 ) {
533
+ replacementWithInner = processedReplacement.slice( 0, insertAt ) + inner + processedReplacement.slice( insertAt );
534
+ } else {
535
+ replacementWithInner = processedReplacement + inner;
536
+ }
537
+ }
538
+
539
+ return html.slice( 0, start ) + replacementWithInner + html.slice( end );
540
+ }
541
+
542
+ /**
543
+ * Parses the attribute name/value pairs declared on a placeholder element's opening tag.
544
+ *
545
+ * @method
546
+ * @param {string} openingTag The full opening tag text, e.g. `<ti-foo-placeholder bar="baz">`.
547
+ * @returns {Object<string, string>}
548
+ * @private
549
+ */
550
+ #parsePlaceholderAttributes( openingTag ) {
551
+ const attributes = {};
552
+ // Strip the element name and the surrounding angle brackets so only the attribute string remains:
553
+ const trimmed = openingTag.replace( /^<[^\s>/]+/, "" ).replace( /\/?>\s*$/, "" );
554
+ const regex = /([a-zA-Z_][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
555
+ let match;
556
+ while ( ( match = regex.exec( trimmed ) ) !== null ) {
557
+ attributes[ match[ 1 ] ] = match[ 2 ] ?? match[ 3 ] ?? match[ 4 ] ?? "";
558
+ }
559
+ return attributes;
560
+ }
561
+
562
+ }
563
+
564
+ module.exports = TiWebAppManager;