├── .gitignore ├── dist ├── d.js ├── d.js.map └── scoped-js.js ├── src ├── context-api │ ├── DuplicateContextError.js │ ├── targets.browser.js │ ├── DOMContextResponse.js │ ├── _DOMContextRequestEvent.js │ ├── DOMContexts.js │ ├── index.js │ └── DOMContext.js ├── index.js ├── index.lite.js ├── data-binding │ ├── targets.browser.js │ └── index.js ├── html-imports │ ├── targets.browser.js │ ├── HTMLImportsContext.js │ ├── HTMLModule.js │ ├── index.js │ └── _HTMLImportElement.js ├── bindings-api │ ├── targets.browser.js │ ├── DOMBindingsContext.js │ └── index.js ├── scoped-css │ ├── targets.browser.js │ └── index.js ├── scoped-js │ ├── targets.browser.js │ └── index.js ├── namespaced-html │ ├── targets.browser.js │ ├── DOMNamingContext.js │ └── index.js ├── init.js └── util.js ├── .github ├── FUNDING.yml └── workflows │ └── publish.yml ├── test ├── scoped-css.test.js ├── scoped-js.test.js ├── bindings-api.test.js ├── index.js ├── namespace-api.test.js ├── modules.test.js └── imports.test.js ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !/.github 3 | !/.gitignore 4 | node_modules 5 | -------------------------------------------------------------------------------- /dist/d.js: -------------------------------------------------------------------------------- 1 | console.log("hello",import.meta.url); 2 | //# sourceMappingURL=d.js.map 3 | -------------------------------------------------------------------------------- /src/context-api/DuplicateContextError.js: -------------------------------------------------------------------------------- 1 | 2 | export default class DuplicateContextError extends Error {} -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as UseLive from '@webqit/use-live'; 2 | import init from './init.js'; 3 | 4 | init.call( window, UseLive ); 5 | -------------------------------------------------------------------------------- /src/index.lite.js: -------------------------------------------------------------------------------- 1 | import * as UseLive from '@webqit/use-live/lite'; 2 | import init from './init.js'; 3 | 4 | init.call( window, UseLive ); 5 | -------------------------------------------------------------------------------- /src/context-api/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); -------------------------------------------------------------------------------- /src/data-binding/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); -------------------------------------------------------------------------------- /src/html-imports/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); -------------------------------------------------------------------------------- /src/bindings-api/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); 11 | -------------------------------------------------------------------------------- /src/scoped-css/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); 11 | -------------------------------------------------------------------------------- /src/scoped-js/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); 11 | -------------------------------------------------------------------------------- /src/namespaced-html/targets.browser.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import init from './index.js'; 6 | 7 | /** 8 | * @init 9 | */ 10 | init.call( window ); 11 | -------------------------------------------------------------------------------- /dist/d.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": ["../d.js"], 4 | "sourcesContent": ["console.log('hello', import.meta.url);"], 5 | "mappings": "AAAA,QAAQ,IAAI,QAAS,YAAY,GAAG", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /src/context-api/DOMContextResponse.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { env } from '../util.js'; 6 | 7 | export default class DOMContextResponse extends AbortController { 8 | constructor( callback ) { 9 | super(); 10 | callback( response => { 11 | const { window: { webqit: { Observer } } } = env; 12 | Observer.defineProperty( this, 'value', { value: response, configurable: true, enumerable: true } ); 13 | }, this ); 14 | } 15 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ox-harris # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: webqit # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /test/scoped-css.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { expect } from 'chai'; 6 | import { createDocument, delay } from './index.js'; 7 | 8 | describe(`Test: Scoped CSS`, function() { 9 | 10 | describe(`Styles`, function() { 11 | 12 | it(`Should do basic rewrite`, async function() { 13 | const head = '', body = ` 14 |
15 |

Hello World!

16 | 21 |
`; 22 | 23 | const window = createDocument( head, body ); 24 | await delay( 160 ); // Takes time to dynamically load Reflex compiler 25 | const styleElement = window.document.querySelector( 'style' ); 26 | 27 | //expect( styleElement.textContent.substring( 0, 13 ) ).to.eq( '@scope (' ); 28 | }); 29 | 30 | }); 31 | 32 | }); -------------------------------------------------------------------------------- /test/scoped-js.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { expect } from 'chai'; 6 | import { createDocument, delay } from './index.js'; 7 | 8 | describe(`Test: Scoped JS`, function() { 9 | 10 | describe(`Scripts`, function() { 11 | 12 | it(`Should do basic observe`, async function() { 13 | const head = ``; 14 | const body = ` 15 |

Hello World!

16 | `; 21 | 22 | const window = createDocument( head, body ); 23 | window.testRecords = []; 24 | await delay( 500 ); // Takes time to dynamically load Reflex compiler 25 | 26 | expect( window.testRecords ).to.have.length( 1 ); 27 | expect( window.testRecords[ 0 ] ).to.eql( window.document.body ); 28 | }); 29 | 30 | }); 31 | 32 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present, WebQit, Inc. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | registry-url: 'https://registry.npmjs.org/' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Build 26 | run: npm run build 27 | 28 | - name: Determine npm tag 29 | id: tag 30 | run: | 31 | # Extract tag name without "refs/tags/" 32 | TAG_REF=${GITHUB_REF#refs/tags/} 33 | echo "Detected Git tag: $TAG_REF" 34 | 35 | # Determine npm tag 36 | if [[ "$TAG_REF" == *-* ]]; then 37 | # prerelease (contains a hyphen) 38 | NPM_TAG=$(echo "$TAG_REF" | sed -E 's/^v[0-9]+\.[0-9]+\.[0-9]+-([a-zA-Z0-9]+).*/\1/') 39 | else 40 | NPM_TAG="latest" 41 | fi 42 | echo "npm publish will use tag: $NPM_TAG" 43 | echo "tag=$NPM_TAG" >> $GITHUB_OUTPUT 44 | 45 | - name: Publish to npm 46 | env: 47 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | run: npm publish --tag ${{ steps.tag.outputs.tag }} 49 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import NamespacedHTML from './namespaced-html/index.js'; 6 | import ScopedJS, { idleCompiler as idleCompiler1 } from './scoped-js/index.js'; 7 | import DataBinding, { idleCompiler as idleCompiler2 } from './data-binding/index.js'; 8 | import BindingsAPI from './bindings-api/index.js'; 9 | import HTMLImports from './html-imports/index.js'; 10 | import ContextAPI from './context-api/index.js'; 11 | import ScopedCSS from './scoped-css/index.js'; 12 | 13 | /** 14 | * @init 15 | */ 16 | export default function init( UseLive, configs = {} ) { 17 | if ( !this.webqit ) { this.webqit = {}; } 18 | Object.assign( this.webqit, UseLive ); 19 | // -------------- 20 | ContextAPI.call( this, ( configs.CONTEXT_API || {} ) ); 21 | BindingsAPI.call( this, ( configs.BINDINGS_API || {} ) ); // Depends on ContextAPI 22 | // Imports must happen before the rest... structure must be flattened before the other things below which query the DOM 23 | HTMLImports.call( this, { ...( configs.HTML_IMPORTS || {} ), idleCompilers: [ idleCompiler1, idleCompiler2 ] } ); // Depends on ContextAPI 24 | NamespacedHTML.call( this, ( configs.NAMESPACED_HTML || {} ) ); // Depends on ContextAPI 25 | DataBinding.call( this, ( configs.DATA_BINDING || {} ) ); // Depends on ContextAPI, BindingsAPI, HTMLImports 26 | ScopedCSS.call( this, ( configs.SCOPED_CSS || {} ) ); // Depends on NamespacedHTML 27 | ScopedJS.call( this, ( configs.SCOPED_JS || {} ) ); 28 | } 29 | -------------------------------------------------------------------------------- /test/bindings-api.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { expect } from 'chai'; 6 | import { createDocument } from './index.js'; 7 | 8 | describe(`Bindings API`, function() { 9 | 10 | describe( `Basic...`, function() { 11 | 12 | const head = ``; 13 | const body = ``; 14 | const { document, window } = createDocument( head, body ); 15 | 16 | it ( `The document object and elements should expose a "bindings" property each...`, async function() { 17 | expect( document ).to.have.property( 'bindings' ); 18 | expect( document.body ).to.have.property( 'bindings' ); 19 | } ); 20 | 21 | it ( `Bindings objects should be observable...`, async function() { 22 | const { webqit: { Observer } } = window; 23 | let idReceived = null; 24 | Observer.observe( document.bindings, records => { 25 | idReceived = records[ 0 ].key; 26 | } ); 27 | document.bindings.someKey = 'someValue'; // new 28 | expect( idReceived ).to.eq( 'someKey' ); 29 | // ------------- 30 | let changes = []; 31 | Observer.observe( document.bindings, records => { 32 | changes.push( ...records ); 33 | } ); 34 | document.bindings.someKey2 = 'someValue2'; // new 35 | document.bind( { someKey: 'someValue'/* update */, someKey3: 'someValue3'/* new */ } ); 36 | expect( changes ).to.an( 'array' ).with.length( 4 ); // (1) sets: someKey2, (2) deletes: someKey2, (3) updates: someKey, (4) sets: someKey3; 37 | } ); 38 | 39 | } ); 40 | 41 | } ); 42 | 43 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { createWindow } from '@webqit/oohtml-ssr'; 6 | 7 | /** 8 | * ------- 9 | * HELPERS 10 | * ------- 11 | */ 12 | 13 | export function delay( duration, callback = undefined ) { 14 | return new Promise( res => { 15 | setTimeout( () => res( callback && callback() ), duration ); 16 | } ); 17 | } 18 | 19 | export function createDocument( head = '', body = '', callback = null, ) { 20 | return createDocumentPrefixed( '', ...arguments ); 21 | } 22 | 23 | export function createDocumentPrefixed( prefix, head = '', body = '', callback = null, ) { 24 | const skeletonDoc = ` 25 | 26 | 27 | 28 | 29 | ${ prefix ? `` : `` } 30 | ${ head } 31 | 32 | 33 | ${ body } 34 | `; 35 | return createWindow( skeletonDoc, { 36 | // Notice we do not want to use the Path utility here. 37 | // Destroys the file:/// url convention especially in windows 38 | url: import.meta.url.substring( 0, import.meta.url.lastIndexOf( '/test/index.js' ) ), 39 | beforeParse( window ) { 40 | if ( callback ) callback( window ); 41 | } 42 | } ); 43 | } 44 | 45 | export function mockRemoteFetch( window, contents, delay = 1000 ) { 46 | window.fetch = url => { 47 | console.info( 'Fetching .......... ', url ); 48 | const successResponse = () => ( { ok: true, text: () => Promise.resolve( contents[ url ] ), } ); 49 | return new Promise( ( res, rej ) => { 50 | setTimeout( () => { 51 | if ( contents[ url ] ) res( successResponse() ) 52 | else rej( { message: 'Not found.' } ); 53 | }, delay ); 54 | } ); 55 | }; 56 | } -------------------------------------------------------------------------------- /src/namespaced-html/DOMNamingContext.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import DOMContext from '../context-api/DOMContext.js'; 6 | import { env } from '../util.js'; 7 | 8 | export default class DOMNamingContext extends DOMContext { 9 | 10 | static kind = 'namespace'; 11 | 12 | /** 13 | * @createRequest 14 | */ 15 | static createRequest( detail = null ) { 16 | const request = super.createRequest(); 17 | if ( detail?.startsWith( '@' ) ) { 18 | const [ targetContext, ...detail ] = detail.slice( 1 ).split( '/' ).map( s => s.trim() ); 19 | request.targetContext = targetContext; 20 | request.detail = detail.join( '/' ); 21 | } else { request.detail = detail; } 22 | return request; 23 | } 24 | 25 | /** 26 | * @namespaceObj 27 | */ 28 | get namespaceObj() { return this.host[ this.configs.NAMESPACED_HTML.api.namespace ]; } 29 | 30 | /** 31 | * @handle() 32 | */ 33 | handle( event ) { 34 | const { window: { webqit: { Observer } } } = env; 35 | // Any existing event.meta.controller? Abort! 36 | event.meta.controller?.abort(); 37 | 38 | // Parse and translate detail 39 | if ( !( event.detail || '' ).trim() ) return event.respondWith( Observer.unproxy( this.namespaceObj ) ); 40 | let path = ( event.detail || '' ).split( '/' ).map( x => x.trim() ).filter( x => x ); 41 | if ( !path.length ) return event.respondWith(); 42 | path = path.join( `/${ this.configs.NAMESPACED_HTML.api.namespace }/` )?.split( '/' ) || []; 43 | 44 | event.meta.controller = Observer.reduce( this.namespaceObj, path, Observer.get, descriptor => { 45 | if ( this.disposed ) return; // If already scheduled but aborted as in provider unmounting 46 | event.respondWith( descriptor.value ); 47 | }, { live: event.live, signal: event.signal, descripted: true } ); 48 | } 49 | 50 | /** 51 | * @unsubscribed() 52 | */ 53 | unsubscribed( event ) { event.meta.controller?.abort(); } 54 | } 55 | -------------------------------------------------------------------------------- /test/namespace-api.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { expect } from 'chai'; 6 | import { createDocument, delay } from './index.js'; 7 | 8 | describe(`Namespaced HTML`, function() { 9 | 10 | describe( `Basic...`, async function() { 11 | 12 | const head = ``; 13 | const body = ` 14 |
15 |
16 |
`; 17 | const { document, window } = createDocument( head, body ); 18 | await delay( 60 ); 19 | const { webqit: { Observer } } = window; 20 | 21 | it ( `The document object and elements should expose a "namespace" property each...`, async function() { 22 | expect( document ).to.have.property( 'namespace' ); 23 | expect( document.namespace.main ).to.have.property( 'namespace' ).that.have.property( 'child' ); 24 | } ); 25 | 26 | it ( `Namespace objects should be observable...`, async function() { 27 | let idReceived = null; 28 | Observer.observe( document.namespace, records => { 29 | idReceived = records[ 0 ].key; 30 | } ); 31 | const item = document.createElement( 'div' ); 32 | item.setAttribute( 'id', 'some-id' ); 33 | document.body.appendChild( item ); 34 | expect( idReceived ).to.eq( 'some-id' ); 35 | } ); 36 | 37 | it ( `Namespace attributes should be applicable dynamically...`, async function() { 38 | expect( Object.keys( document.namespace ).length ).to.eq( 2 ); 39 | document.body.toggleAttribute( 'namespace', true ); 40 | expect( Object.keys( document.namespace ).length ).to.eq( 0 ); 41 | expect( Object.keys( document.body.namespace ).length ).to.eq( 2 ); 42 | document.body.toggleAttribute( 'namespace', false ); 43 | expect( Object.keys( document.namespace ).length ).to.eq( 2 ); 44 | expect( Object.keys( document.body.namespace ).length ).to.eq( 0 ); 45 | } ); 46 | 47 | } ); 48 | 49 | } ); 50 | 51 | -------------------------------------------------------------------------------- /src/bindings-api/DOMBindingsContext.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import DOMContext from '../context-api/DOMContext.js'; 6 | import { env } from '../util.js'; 7 | 8 | export default class DOMBindingsContext extends DOMContext { 9 | 10 | static kind = 'bindings'; 11 | 12 | /** 13 | * @createRequest 14 | */ 15 | static createRequest( detail = null ) { 16 | const request = super.createRequest(); 17 | if ( detail?.startsWith( '@' ) ) { 18 | const [ targetContext, ...detail ] = detail.slice( 1 ).split( '.' ).map( s => s.trim() ); 19 | request.targetContext = targetContext; 20 | request.detail = detail.join( '.' ); 21 | } else { request.detail = detail; } 22 | return request; 23 | } 24 | 25 | /** 26 | * @bindingsObj 27 | */ 28 | get bindingsObj() { return this.host[ this.configs.BINDINGS_API.api.bindings ]; } 29 | 30 | /** 31 | * @matchesEvent 32 | */ 33 | matchEvent( event ) { 34 | return super.matchEvent( event ) 35 | && ( !event.detail || !this.detail || ( Array.isArray( event.detail ) ? event.detail[ 0 ] === this.detail : event.detail === this.detail ) ); 36 | } 37 | 38 | /** 39 | * @handle() 40 | */ 41 | handle( event ) { 42 | // Any existing event.meta.controller? Abort! 43 | event.meta.controller?.abort(); 44 | if ( !( event.detail + '' ).trim() ) return event.respondWith( this.bindingsObj ); 45 | const { window: { webqit: { Observer } } } = env; 46 | event.meta.controller = Observer.reduce( this.bindingsObj, Array.isArray( event.detail ) ? event.detail : [ event.detail ], Observer.get, descriptor => { 47 | if ( this.disposed ) return; // If already scheduled but aborted as in provider unmounting 48 | event.respondWith( descriptor.value ); 49 | }, { live: event.live, signal: event.signal, descripted: true } ); 50 | } 51 | 52 | /** 53 | * @unsubscribed() 54 | */ 55 | unsubscribed( event ) { event.meta.controller?.abort(); } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webqit/oohtml", 3 | "title": "CHTML", 4 | "description": "A suite of new DOM features that brings language support for modern UI development paradigms: a component-based architecture, data binding, and reactivity.", 5 | "keywords": [ 6 | "namespaced-HTML", 7 | "html-modules", 8 | "ui-bindings", 9 | "html-imports", 10 | "reflex", 11 | "subscript", 12 | "scoped-js", 13 | "UI", 14 | "wicg-proposal" 15 | ], 16 | "homepage": "https://webqit.io/tooling/oohtml", 17 | "version": "5.0.2", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/webqit/oohtml.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/webqit/oohtml/issues" 25 | }, 26 | "type": "module", 27 | "sideEffects": true, 28 | "main": "./src/index.js", 29 | "exports": { 30 | ".": "./src/index.js", 31 | "./lite": "./src/index.lite.js" 32 | }, 33 | "scripts": { 34 | "test": "mocha --extension .test.js --exit", 35 | "build": "esbuild main=src/index.js main.lite=src/index.lite.js context-api=src/context-api/targets.browser.js bindings-api=src/bindings-api/targets.browser.js namespaced-html=src/namespaced-html/targets.browser.js html-imports=src/html-imports/targets.browser.js data-binding=src/data-binding/targets.browser.js scoped-css=src/scoped-css/targets.browser.js scoped-js=src/scoped-js/targets.browser.js --bundle --format=esm --minify --sourcemap --outdir=dist", 36 | "preversion": "npm run test && npm run build && git add -A dist", 37 | "postversion": "git push && git push --tags", 38 | "version:next": "npm version prerelease --preid=next" 39 | }, 40 | "dependencies": { 41 | "@webqit/realdom": "^2.1.35", 42 | "@webqit/use-live": "^0.5.42", 43 | "@webqit/util": "^0.8.16" 44 | }, 45 | "devDependencies": { 46 | "@webqit/oohtml-ssr": "^2.2.1", 47 | "chai": "^4.3.4", 48 | "coveralls": "^3.1.1", 49 | "esbuild": "^0.14.43", 50 | "mocha": "^10.0.0", 51 | "mocha-lcov-reporter": "^1.3.0" 52 | }, 53 | "author": "Oxford Harrison ", 54 | "maintainers": [ 55 | "Oxford Harrison " 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/context-api/_DOMContextRequestEvent.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { env } from '../util.js'; 6 | 7 | export default function() { 8 | const { window } = env, { webqit } = window; 9 | if ( webqit.DOMContextRequestEvent ) return webqit.DOMContextRequestEvent; 10 | class DOMContextRequestEvent extends window.Event { 11 | /** 12 | * @constructor 13 | */ 14 | constructor( ...args ) { 15 | const callback = args.pop(); 16 | if ( typeof callback !== 'function' ) throw new Error( `Callback must be provided.` ); 17 | const options = args.pop(); 18 | if ( !options?.kind ) throw new Error( `"options.kind" must be specified.` ); 19 | const eventNames = [ 'contextrequest', 'contextclaim' ]; 20 | const type = args.pop() || eventNames[ 0 ]; 21 | if ( !eventNames.includes( type ) ) throw new Error( `Invalid event type. Must be one of: ${ eventNames.join( ',' ) }` ); 22 | // ------------- 23 | const { kind, detail, targetContext, live, signal, diff, ...otherOpts } = options; 24 | super( type, { ...otherOpts, bubbles: otherOpts.bubbles !== false } ); 25 | // ------------- 26 | Object.defineProperty( this, 'callback', { get: () => callback } ); 27 | Object.defineProperty( this, 'kind', { get: () => kind } ); 28 | Object.defineProperty( this, 'targetContext', { get: () => targetContext } ); 29 | Object.defineProperty( this, 'detail', { get: () => detail } ); 30 | Object.defineProperty( this, 'live', { get: () => live } ); 31 | Object.defineProperty( this, 'signal', { get: () => signal } ); 32 | Object.defineProperty( this, 'diff', { get: () => diff } ); 33 | Object.defineProperty( this, 'options', { get: () => otherOpts } ); 34 | Object.defineProperty( this, 'meta', { value: {} } ); 35 | } 36 | 37 | get target() { return super.target || this.meta.target; } 38 | get answered() { return this.meta.answered || false; } 39 | 40 | /** 41 | * @respondWith 42 | */ 43 | respondWith( response ) { 44 | this.meta.answered = true; 45 | if ( this.diff ) { 46 | if ( '_prevValue' in this && this._prevValue === response ) return; 47 | Object.defineProperty( this, '_prevValue', { value: response, configurable: true } ); 48 | } 49 | return this.callback( response ); 50 | } 51 | } 52 | webqit.DOMContextRequestEvent = DOMContextRequestEvent; 53 | return DOMContextRequestEvent; 54 | } -------------------------------------------------------------------------------- /src/context-api/DOMContexts.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import _DOMContextRequestEvent from './_DOMContextRequestEvent.js'; 6 | import DOMContextResponse from './DOMContextResponse.js'; 7 | import DOMContext from './DOMContext.js'; 8 | import DuplicateContextError from './DuplicateContextError.js'; 9 | import { _wq } from '../util.js'; 10 | 11 | const waitListMappings = new Map; 12 | export default class DOMContexts { 13 | 14 | /** 15 | * @instance 16 | */ 17 | static instance( host ) { 18 | return _wq( host ).get( 'contexts::instance' ) || new this( host );; 19 | } 20 | 21 | /** 22 | * @constructor 23 | */ 24 | constructor( host ) { 25 | _wq( host ).get( `contexts::instance` )?.dispose(); 26 | _wq( host ).set( `contexts::instance`, this ); 27 | const priv = { host, contexts: new Set }; 28 | Object.defineProperty( this, '#', { get: () => priv } ); 29 | } 30 | 31 | /** 32 | * @Symbol.iterator 33 | */ 34 | [ Symbol.iterator ] () { return this[ '#' ].contexts[ Symbol.iterator ](); } 35 | 36 | /** 37 | * @length 38 | */ 39 | get length() { return this[ '#' ].contexts.size; } 40 | 41 | /** 42 | * @find() 43 | */ 44 | find( ...args ) { 45 | return [ ...this[ '#' ].contexts ].find( ctx => { 46 | if ( typeof args[ 0 ] === 'function' ) return args[ 0 ]( ctx ); 47 | return ctx.constructor.kind === args[ 0 ] && ( !args[ 1 ] || ctx.detail === args[ 1 ] ); 48 | } ); 49 | } 50 | 51 | /** 52 | * @attach() 53 | */ 54 | attach( ctx ) { 55 | if ( !( ctx instanceof DOMContext) ) throw new TypeError( `Context is not a valid DOMContext instance.` ); 56 | if ( this.find( ctx.constructor.kind, ctx.detail ) ) { 57 | throw new DuplicateContextError( `Context of same kind "${ ctx.constructor.kind }"${ ctx.detail ? ` and detail "${ ctx.detail }"` : '' } already exists.` ); 58 | } 59 | this[ '#' ].contexts.add( ctx ); 60 | ctx.initialize( this[ '#' ].host ); 61 | } 62 | 63 | /** 64 | * @detach() 65 | */ 66 | detach( ctx ) { 67 | ctx.dispose( this[ '#' ].host ); 68 | this[ '#' ].contexts.delete( ctx ); 69 | } 70 | 71 | /** 72 | * @request() 73 | */ 74 | request( ...args ) { 75 | return new DOMContextResponse( ( emitter, responseInstance ) => { 76 | if ( typeof args[ args.length - 1 ] !== 'function' ) { 77 | if ( !args[ args.length - 1 ] ) { args[ args.length - 1 ] = emitter; } 78 | else { args.push( emitter ); } 79 | } 80 | 81 | let options; 82 | if ( ( options = args.find( arg => typeof arg === 'object' && arg ) ) && options.live ) { 83 | if ( options.signal ) options.signal.addEventListener( 'abort', () => responseInstance.abort() ); 84 | args[ args.indexOf( options ) ] = { ...options, signal: responseInstance.signal }; 85 | } 86 | const event = new ( _DOMContextRequestEvent() )( ...args ); 87 | this[ '#' ].host.dispatchEvent( event ); 88 | } ); 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /src/context-api/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import DOMContexts from './DOMContexts.js'; 6 | import DOMContext from './DOMContext.js'; 7 | import _DOMContextRequestEvent from './_DOMContextRequestEvent.js'; 8 | import DOMContextResponse from './DOMContextResponse.js'; 9 | import DuplicateContextError from './DuplicateContextError.js'; 10 | import { _init } from '../util.js'; 11 | 12 | /** 13 | * Initializes HTML Modules. 14 | * 15 | * @param $config Object 16 | * 17 | * @return Void 18 | */ 19 | export default function init( $config = {} ) { 20 | const { config, window } = _init.call( this, 'context-api', $config, { 21 | elements: { roots: 'root,webflo-embedded', }, 22 | attr: { contextname: 'contextname', }, 23 | api: { contexts: 'contexts', }, 24 | } ); 25 | const waitListMappings = new Map, dispatchEvent = window.EventTarget.prototype.dispatchEvent; 26 | Object.defineProperty( window.EventTarget.prototype, 'dispatchEvent', { value: function( ...args ) { 27 | const event = args[0], rootNode = this?.closest?.(config.elements.roots) || this.getRootNode?.(); 28 | if ( [ 'contextclaim', 'contextrequest' ].includes( event.type ) && rootNode ) { 29 | if ( event.meta ) event.meta.target = this; 30 | const temp = (event) => { 31 | event.stopImmediatePropagation(); 32 | // Always set this whether answered or not 33 | if ( event.meta ) event.meta.target = event.target; 34 | if ( event.answered ) return; 35 | if ( !waitListMappings.get( rootNode ) ) waitListMappings.set( rootNode, new Set ); 36 | if ( event.type === 'contextrequest' && event.live ) { 37 | waitListMappings.get( rootNode ).add( event ); 38 | } else if ( event.type === 'contextclaim' ) { 39 | const claims = new Set; 40 | waitListMappings.get( rootNode ).forEach( subscriptionEvent => { 41 | if ( !event.target.contains( subscriptionEvent.target ) || !event.detail?.matchEvent?.( subscriptionEvent ) ) return; 42 | waitListMappings.get( rootNode ).delete( subscriptionEvent ); 43 | claims.add( subscriptionEvent ); 44 | } ); 45 | if ( !waitListMappings.get( rootNode ).size ) waitListMappings.delete( rootNode ); 46 | return event.respondWith?.( claims ); 47 | } 48 | }; 49 | rootNode.addEventListener( event.type, temp ); 50 | const returnValue = dispatchEvent.call( this, ...args ); 51 | rootNode.removeEventListener( event.type, temp ); 52 | return returnValue; 53 | } 54 | return dispatchEvent.call( this, ...args ); 55 | } } ); 56 | window.webqit.DOMContexts = DOMContexts; 57 | window.webqit.DOMContext = DOMContext; 58 | window.webqit.DOMContextRequestEvent = _DOMContextRequestEvent(); 59 | window.webqit.DOMContextResponse = DOMContextResponse; 60 | window.webqit.DuplicateContextError = DuplicateContextError; 61 | exposeAPIs.call( window, config ); 62 | } 63 | 64 | /** 65 | * Exposes HTML Modules with native APIs. 66 | * 67 | * @param Object config 68 | * 69 | * @return Void 70 | */ 71 | function exposeAPIs( config ) { 72 | const window = this; 73 | [ window.Document.prototype, window.Element.prototype, window.ShadowRoot.prototype ].forEach( prototype => { 74 | // No-conflict assertions 75 | const type = prototype === window.Document.prototype ? 'Document' : ( prototype === window.ShadowRoot.prototype ? 'ShadowRoot' : 'Element' ); 76 | if ( config.api.contexts in prototype ) { throw new Error( `The ${ type } prototype already has a "${ config.api.contexts }" API!` ); } 77 | // Definitions 78 | Object.defineProperty( prototype, config.api.contexts, { get: function() { 79 | return DOMContexts.instance( this ); 80 | } } ); 81 | } ); 82 | } 83 | -------------------------------------------------------------------------------- /src/context-api/DOMContext.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import DOMContexts from './DOMContexts.js'; 6 | import { env } from '../util.js'; 7 | 8 | export default class DOMContext { 9 | 10 | /** 11 | * To be implemented by subclasses 12 | */ 13 | static kind; 14 | 15 | /** 16 | * @createRequest 17 | */ 18 | static createRequest() { return { kind: this.kind }; } 19 | 20 | /** 21 | * @constructor 22 | */ 23 | constructor( detail = null ) { 24 | Object.defineProperty( this, 'detail', { get: () => detail } ); 25 | Object.defineProperty( this, 'subscriptions', { value: new Set } ); 26 | } 27 | 28 | /** 29 | * @configs 30 | */ 31 | get configs() { 32 | const { window: { webqit: { oohtml: { configs } } } } = env; 33 | return configs; 34 | } 35 | 36 | /** 37 | * @name 38 | */ 39 | get name() { return [ env.window.Document, env.window.ShadowRoot ].some( x => this.host instanceof x ) ? Infinity : this.host.getAttribute( this.configs.CONTEXT_API.attr.contextname ); } 40 | 41 | /** 42 | * @subscribed() 43 | */ 44 | subscribed( event ) {} 45 | 46 | /** 47 | * @handle() 48 | */ 49 | handle( event ) {} 50 | 51 | /** 52 | * @unsubscribed() 53 | */ 54 | unsubscribed( event ) {} 55 | 56 | /** 57 | * @matchEvent 58 | */ 59 | matchEvent( event ) { 60 | return event.kind === this.constructor.kind 61 | && ( !event.targetContext || event.targetContext === this.name ); 62 | } 63 | 64 | /** 65 | * @handleEvent() 66 | */ 67 | handleEvent( event ) { 68 | if ( this.disposed || typeof event.respondWith !== 'function' ) return; 69 | if ( event.type === 'contextclaim' ) { 70 | if ( !( event.detail instanceof DOMContext ) || event.target === this.host ) return; 71 | const claims = new Set; 72 | this.subscriptions.forEach( subscriptionEvent => { 73 | if ( !event.target.contains( subscriptionEvent.target ) || !event.detail.matchEvent( subscriptionEvent ) ) return; 74 | this.subscriptions.delete( subscriptionEvent ); 75 | this.unsubscribed( subscriptionEvent ); 76 | claims.add( subscriptionEvent ); 77 | } ); 78 | if ( claims.size ) { return event.respondWith( claims ); } 79 | } 80 | if ( event.type === 'contextrequest' ) { 81 | if ( !this.matchEvent( event ) ) return; 82 | if ( event.live ) { 83 | this.subscriptions.add( event ); 84 | this.subscribed( event ); 85 | event.signal?.addEventListener( 'abort', () => { 86 | this.subscriptions.delete( event ); 87 | this.unsubscribed( event ); 88 | } ); 89 | } 90 | event.stopPropagation(); 91 | return this.handle( event ); 92 | } 93 | } 94 | 95 | /** 96 | * @initialize() 97 | */ 98 | initialize( host ) { 99 | this.host = host; 100 | this.disposed = false; 101 | host.addEventListener( 'contextrequest', this ); 102 | host.addEventListener( 'contextclaim', this ); 103 | const { value: claims } = DOMContexts.instance( host ).request( 'contextclaim', { kind: this.constructor.kind, detail: this } ); 104 | claims?.forEach( subscriptionEvent => { 105 | this.subscriptions.add( subscriptionEvent ); 106 | this.subscribed( subscriptionEvent ); 107 | this.handle( subscriptionEvent ); 108 | } ); 109 | return this; 110 | } 111 | 112 | /** 113 | * @dispose() 114 | */ 115 | dispose( host ) { 116 | this.disposed = true; 117 | host.removeEventListener( 'contextrequest', this ); 118 | host.removeEventListener( 'contextclaim', this ); 119 | this.subscriptions.forEach( subscriptionEvent => { 120 | this.subscriptions.delete( subscriptionEvent ); 121 | this.unsubscribed( subscriptionEvent ); 122 | const { target } = subscriptionEvent; 123 | subscriptionEvent.meta.answered = false; 124 | target.dispatchEvent( subscriptionEvent ); 125 | } ); 126 | return this; 127 | } 128 | 129 | } -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import realdomInit from '@webqit/realdom'; 6 | import { _wq as __wq } from '@webqit/util/js/index.js'; 7 | import { _merge } from '@webqit/util/obj/index.js'; 8 | import { _toTitle } from '@webqit/util/str/index.js'; 9 | 10 | export const _wq = ( target, ...args ) => __wq( target, 'oohtml', ...args ); 11 | 12 | export const env = {}; 13 | 14 | export function _init( name, $config, $defaults ) { 15 | const window = this, realdom = realdomInit.call( window ); 16 | env.window = window; 17 | if ( !window.webqitConfig ) { 18 | window.webqitConfig = realdom.meta( 'webqit' ).json(); 19 | } 20 | window.webqit || ( window.webqit = {} ); 21 | window.webqit.oohtml || ( window.webqit.oohtml = {} ); 22 | window.webqit.oohtml.configs || ( window.webqit.oohtml.configs = {} ); 23 | // --------------------- 24 | const configKey = name.toUpperCase().replace( '-', '_' ); 25 | if ( !window.webqit.oohtml.configs[ configKey ] ) { 26 | window.webqit.oohtml.configs[ configKey ] = {}; 27 | const config = window.webqit.oohtml.configs[ configKey ]; 28 | _merge( 2, config, $defaults, $config, realdom.meta( name ).json() ); 29 | if ( window.webqitConfig.prefix ) { 30 | Object.keys( config ).forEach( main => { 31 | Object.keys( config[ main ] ).forEach( key => { 32 | if ( main === 'api' && typeof config[ main ][ key ] === 'string' ) { 33 | config[ main ][ key ] = `${ window.webqitConfig.prefix }${ _toTitle( config[ main ][ key ] ) }` 34 | } else if ( [ 'attr', 'elements' ].includes( main ) && config[ main ][ key ]?.startsWith( 'data-' ) === false ) { 35 | config[ main ][ key ] = `${ window.webqitConfig.prefix }-${ config[ main ][ key ] }` 36 | } 37 | } ); 38 | } ); 39 | } 40 | } 41 | // --------------------- 42 | return { config: window.webqit.oohtml.configs[ configKey ], realdom, window }; 43 | } 44 | 45 | export function getInternalAttrInteraction( node, attrName ) { 46 | return __wq( node, 'realdom', 'internalAttrInteractions' ).get( attrName ); 47 | } 48 | export function internalAttrInteraction( node, attrName, callback ) { 49 | const savedAttrLocking = __wq( node, 'realdom', 'internalAttrInteractions' ).get( attrName ); 50 | __wq( node, 'realdom', 'internalAttrInteractions' ).set( attrName, true ); 51 | const value = callback(); 52 | __wq( node, 'realdom', 'internalAttrInteractions' ).set( attrName, savedAttrLocking ); 53 | return value; 54 | } 55 | 56 | export function _compare( a, b, depth = 1, objectSizing = false ) { 57 | if ( depth && typeof a === 'object' && a && typeof b === 'object' && b && ( !objectSizing || Object.keys( a ).length === Object.keys( b ).length ) ) { 58 | for ( let key in a ) { 59 | if ( !_compare( a[ key ], b[ key ], depth - 1, objectSizing ) ) return false; 60 | } 61 | return true; 62 | } 63 | if ( Array.isArray( a ) && Array.isArray( b ) && a.length === b.length ) { 64 | return ( b = b.slice( 0 ).sort() ) && a.slice( 0 ).sort().every( ( valueA, i ) => valueA === b[ i ] ); 65 | } 66 | return a === b; 67 | } 68 | 69 | export function _splitOuter( str, delim ) { 70 | return [ ...str ].reduce( ( [ quote, depth, splits ], x ) => { 71 | if ( !quote && depth === 0 && ( Array.isArray( delim ) ? delim : [ delim ] ).includes( x ) ) { 72 | return [ quote, depth, [ '' ].concat( splits ) ]; 73 | } 74 | if ( !quote && [ '(', '[', '{' ].includes( x ) && !splits[ 0 ].endsWith( '\\' ) ) depth++; 75 | if ( !quote && [ ')', ']', '}' ].includes( x ) && !splits[ 0 ].endsWith( '\\' ) ) depth--; 76 | if ( [ '"', "'", '`' ].includes( x ) && !splits[ 0 ].endsWith( '\\' ) ) { 77 | quote = quote === x ? null : ( quote || x ); 78 | } 79 | splits[ 0 ] += x; 80 | return [ quote, depth, splits ] 81 | }, [ null, 0, [ '' ] ] )[ 2 ].reverse(); 82 | } 83 | 84 | // Unique ID generator 85 | export const _uniqId = () => ( 0 | Math.random() * 9e6 ).toString( 36 ); 86 | 87 | // Hash of anything generator 88 | const hashTable = new Map; 89 | export function _toHash( val ) { 90 | let hash; 91 | if ( !( hash = hashTable.get( val ) ) ) { 92 | hash = _uniqId(); 93 | hashTable.set( val, hash ); 94 | } 95 | return hash; 96 | } 97 | 98 | // Value of any hash 99 | export function _fromHash( hash ) { 100 | let val; 101 | hashTable.forEach( ( _hash, _val ) => { 102 | if ( _hash === hash ) val = _val; 103 | } ); 104 | return val; 105 | } -------------------------------------------------------------------------------- /src/html-imports/HTMLImportsContext.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import DOMContext from '../context-api/DOMContext.js'; 6 | import { getDefs } from './index.js'; 7 | import { env } from '../util.js'; 8 | 9 | export default class HTMLImportsContext extends DOMContext { 10 | 11 | /** 12 | * @kind 13 | */ 14 | static kind = 'html-imports'; 15 | 16 | /** 17 | * @createRequest 18 | */ 19 | static createRequest(detail = null) { 20 | const request = super.createRequest(); 21 | if (detail?.startsWith('/')) { 22 | request.detail = detail; 23 | request.targetContext = Infinity; 24 | } else if (detail?.startsWith('@')) { 25 | const [targetContext, ..._detail] = detail.slice(1).split(/(?<=\w)(?=\/|#)/).map(s => s.trim()); 26 | request.targetContext = targetContext; 27 | request.detail = _detail.join(''); 28 | } else { request.detail = detail; } 29 | return request; 30 | } 31 | 32 | /** 33 | * @localModules 34 | */ 35 | get localModules() { return getDefs(this.host); } 36 | get inheritedModules() { return this.#inheritedModules; } 37 | #inheritedModules = {}; 38 | 39 | /** 40 | * @handle() 41 | */ 42 | 43 | handle(event) { 44 | const { window: { webqit: { Observer } } } = env; 45 | // Any existing event.meta.controller? Abort! 46 | event.meta.controller?.abort(); 47 | // Parse and translate detail 48 | let path = (event.detail || '').split(/\/|(?<=\w)(?=#)/g).map(x => x.trim()).filter(x => x); 49 | if (!path.length) return event.respondWith(); 50 | path = path.join(`/${this.configs.HTML_IMPORTS.api.defs}/`)?.split('/').map(x => x === '*' ? Infinity : x) || []; 51 | // We'll now fulfill request 52 | const options = { live: event.live, sig_nal: event.signal, descripted: true }; 53 | event.meta.controller = Observer.reduce(this.#modules, path, Observer.get, (m) => { 54 | if (Array.isArray(m)) { 55 | if (!m.length) { 56 | event.respondWith(); 57 | return; 58 | } 59 | // Paths with wildcard 60 | for (const n of m) { 61 | event.respondWith(n); 62 | } 63 | } else { 64 | event.respondWith(m.value); 65 | } 66 | }, options); 67 | } 68 | 69 | /** 70 | * @unsubscribed() 71 | */ 72 | unsubscribed(event) { event.meta.controller?.abort(); } 73 | 74 | /** 75 | * @initialize() 76 | */ 77 | #modules; 78 | #controller1; 79 | #controller2; 80 | initialize(host) { 81 | this.host = host; 82 | const { window: { webqit: { Observer } } } = env; 83 | // ---------------- 84 | // Resolve 85 | const resolve = () => { 86 | for (const key of new Set([...Object.keys(this.localModules), ...Object.keys(this.inheritedModules), ...Object.keys(this.#modules)])) { 87 | if (!Observer.has(this.localModules, key) && !Observer.has(this.inheritedModules, key)) { 88 | Observer.deleteProperty(this.#modules, key); 89 | } else if (key === '#' && Observer.has(this.localModules, key) && Observer.has(this.inheritedModules, key)) { 90 | Observer.set(this.#modules, key, [...Observer.get(this.localModules, key), ...Observer.get(this.inheritedModules, key)]); 91 | } else { 92 | const _module = Observer.get(this.localModules, key) || Observer.get(this.inheritedModules, key); 93 | if (Observer.get(this.#modules, key) !== _module) { 94 | Observer.set(this.#modules, key, _module); 95 | } 96 | } 97 | } 98 | }; 99 | // ---------------- 100 | // Observe local 101 | this.#modules = { ...this.localModules }; 102 | this.#controller1?.abort(); 103 | this.#controller1 = Observer.observe(this.localModules, () => resolve('local'), { timing: 'sync' }); 104 | // ---------------- 105 | // If host has importscontext attr, compute that 106 | const $config = this.configs.HTML_IMPORTS; 107 | if (this.host.matches && $config.attr.importscontext) { 108 | const realdom = this.host.ownerDocument.defaultView.webqit.realdom; 109 | let prevRef; 110 | this.#controller2?.disconnect(); 111 | this.#controller2 = realdom.realtime(this.host).attr($config.attr.importscontext, (record, { signal }) => { 112 | const moduleRef = (record.value || '').trim(); 113 | prevRef = moduleRef; 114 | // This superModules contextrequest is automatically aborted by the injected signal below 115 | this.#inheritedModules = {}; 116 | const request = { ...this.constructor.createRequest(moduleRef ? `${moduleRef}/*` : '*'), live: true, signal, diff: true }; 117 | this.host.parentNode[this.configs.CONTEXT_API.api.contexts].request(request, (m) => { 118 | if (!m) { 119 | this.#inheritedModules = {}; 120 | resolve('inherited'); 121 | } else if (m.type === 'delete') { 122 | delete this.#inheritedModules[m.key]; 123 | if (!Reflect.has(this.localModules, m.key)) { 124 | Observer.deleteProperty(this.#modules, m.key); 125 | } 126 | } else { 127 | this.#inheritedModules[m.key] = m.value; 128 | if (!Reflect.has(this.localModules, m.key) && Reflect.get(this.#modules, m.key) !== m.value) { 129 | Observer.set(this.#modules, m.key, m.value); 130 | } 131 | } 132 | }); 133 | resolve('inherited'); 134 | }, { live: true, timing: 'sync', oldValue: true, lifecycleSignals: true }); 135 | } 136 | // ---------------- 137 | return super.initialize(host); 138 | } 139 | 140 | /** 141 | * @dispose() 142 | */ 143 | dispose(host) { 144 | // Stop listening for sources 145 | this.#controller1?.abort(); 146 | this.#controller2?.disconnect(); 147 | // Now, stop listening for contextrequest and contextclaim events 148 | // And relinquish own subscribers to owner context 149 | return super.dispose(host); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/scoped-js/index.js: -------------------------------------------------------------------------------- 1 | import { resolveParams } from '@webqit/use-live/params'; 2 | import { _wq, _init, _toHash, _fromHash } from '../util.js'; 3 | 4 | export default function init({ advanced = {}, ...$config }) { 5 | const { config, window } = _init.call( this, 'scoped-js', $config, { 6 | script: { retention: 'retain', mimeTypes: 'module|text/javascript|application/javascript', timing: 'auto' }, 7 | api: { scripts: 'scripts' }, 8 | advanced: resolveParams(advanced), 9 | } ); 10 | const customTypes = Array.isArray( config.script.mimeTypes ) ? config.script.mimeTypes : config.script.mimeTypes.split( '|' ).filter( t => t ); 11 | config.scriptSelector = customTypes.map( t => `script[type="${ window.CSS.escape( t ) }"]:not([oohtmlignore])` ).concat(`script:not([type])`).join( ',' ); 12 | window.webqit.oohtml.Script = { 13 | compileCache: [ new Map, new Map, ], 14 | execute: execute.bind( window, config ), 15 | }; 16 | exposeAPIs.call( window, config ); 17 | realtime.call( window, config ); 18 | } 19 | 20 | function exposeAPIs( config ) { 21 | const window = this, { webqit: { nextKeyword, matchPrologDirective } } = window; 22 | const scriptsMap = new Map; 23 | if ( config.api.scripts in window.Element.prototype ) { throw new Error( `The "Element" class already has a "${ config.api.scripts }" property!` ); } 24 | [ window.ShadowRoot.prototype, window.Element.prototype ].forEach( proto => { 25 | Object.defineProperty( proto, config.api.scripts, { get: function() { 26 | if ( !scriptsMap.has( this ) ) { scriptsMap.set( this, [] ); } 27 | return scriptsMap.get( this ); 28 | }, } ); 29 | } ); 30 | Object.defineProperties( window.HTMLScriptElement.prototype, { 31 | scoped: { 32 | configurable: true, 33 | get() { return this.hasAttribute( 'scoped' ); }, 34 | set( value ) { this.toggleAttribute( 'scoped', value ); }, 35 | }, 36 | live: { 37 | configurable: true, 38 | get() { 39 | if (this.liveProgramHandle) return true; 40 | const scriptContents = nextKeyword(this.oohtml__textContent || this.textContent || '', 0, 0); 41 | return matchPrologDirective(scriptContents, true); 42 | }, 43 | }, 44 | } ); 45 | } 46 | 47 | // Script runner 48 | async function execute( config, execHash ) { 49 | const window = this, { realdom } = window.webqit; 50 | const exec = _fromHash( execHash ); 51 | if ( !exec ) throw new Error( `Argument must be a valid exec hash.` ); 52 | const { script, compiledScript, thisContext } = exec; 53 | // Honour retention flag 54 | if ( config.script.retention === 'dispose' ) { 55 | script.remove(); 56 | } else if ( config.script.retention === 'hidden' ) { 57 | script.textContent = `"source hidden"`; 58 | } else { 59 | setTimeout(async () => { 60 | script.textContent = await compiledScript.toString(); 61 | }, 0); //Anti-eval hack 62 | } 63 | // Execute and save state 64 | const varScope = script.scoped ? thisContext : script.getRootNode(); 65 | if ( !_wq( varScope ).has( 'scriptEnv' ) ) { 66 | _wq( varScope ).set( 'scriptEnv', Object.create( null ) ); 67 | } 68 | const liveProgramHandle = await ( await compiledScript.bind( thisContext, _wq( varScope ).get( 'scriptEnv' ) ) ).execute(); 69 | if ( script.live ) { Object.defineProperty( script, 'liveProgramHandle', { value: liveProgramHandle } ); } 70 | realdom.realtime( window.document ).observe( script, () => { 71 | if ( script.live ) { liveProgramHandle.abort(); } 72 | if ( thisContext instanceof window.Element ) { thisContext[ config.api.scripts ]?.splice( thisContext[ config.api.scripts ].indexOf( script, 1 ) ); } 73 | }, { id: 'scoped-js:script-exits', subtree: 'cross-roots', timing: 'sync', generation: 'exits' } ); 74 | } 75 | 76 | function realtime( config ) { 77 | const inBrowser = Object.getOwnPropertyDescriptor( globalThis, 'window' )?.get?.toString().includes( '[native code]' ) ?? false; 78 | const window = this, { webqit: { oohtml, realdom } } = window; 79 | if ( !window.HTMLScriptElement.supports ) { window.HTMLScriptElement.supports = type => [ 'text/javascript', 'application/javascript' ].includes( type ); } 80 | const handled = new WeakSet; 81 | realdom.realtime( window.document ).query( config.scriptSelector, record => { 82 | record.entrants.forEach( script => { 83 | if ( handled.has( script ) || script.hasAttribute('oohtmlno') || (!inBrowser && !script.hasAttribute('ssr')) ) return; 84 | // Do compilation 85 | const compiledScript = compileScript.call( window, config, script ); 86 | if ( !compiledScript ) return; 87 | handled.add( script ); 88 | // Run now!!! 89 | const thisContext = script.scoped ? script.parentNode || record.target : ( script.type === 'module' ? undefined : window ); 90 | if ( script.scoped ) { thisContext[ config.api.scripts ].push( script ); } 91 | const execHash = _toHash( { script, compiledScript, thisContext } ); 92 | const manualHandling = record.type === 'query' || ( script.type && !window.HTMLScriptElement.supports( script.type ) ) || script.getAttribute('data-handling') === 'manual'; 93 | if ( manualHandling || config.script.timing === 'manual' ) { oohtml.Script.execute( execHash ); } else { 94 | script.textContent = `webqit.oohtml.Script.execute( '${ execHash }' );`; 95 | } 96 | } ); 97 | }, { id: 'scoped-js:script-entries', live: true, subtree: 'cross-roots', timing: 'intercept', generation: 'entrants', eventDetails: true } ); 98 | // --- 99 | } 100 | 101 | function compileScript( config, script ) { 102 | const window = this, { webqit: { oohtml, LiveScript, AsyncLiveScript, LiveModule } } = window; 103 | 104 | let textContent = script.textContent.trim(); 105 | if ( textContent.startsWith( '/*@oohtml*/if(false){' ) && textContent.endsWith( '}/*@oohtml*/' ) ) { 106 | textContent = textContent.slice( 21, -12 ); 107 | Object.defineProperty( script, 'oohtml__textContent', { value: textContent } ); 108 | } 109 | if ( !textContent.trim().length ) return; 110 | 111 | const sourceHash = _toHash( textContent ); 112 | const compileCache = oohtml.Script.compileCache[ script.live ? 0 : 1 ]; 113 | let compiledScript; 114 | if ( !( compiledScript = compileCache.get( sourceHash ) ) ) { 115 | const { parserParams, compilerParams, runtimeParams } = config.advanced; 116 | compiledScript = new ( script.type === 'module' ? LiveModule : ( LiveScript || AsyncLiveScript ) )( textContent, { 117 | liveMode: script.live, 118 | exportNamespace: `#${ script.id }`, 119 | fileName: `${ window.document.url?.split( '#' )?.[ 0 ] || '' }#${ script.id }`, 120 | parserParams, 121 | compilerParams, 122 | runtimeParams, 123 | } ); 124 | compileCache.set( sourceHash, compiledScript ); 125 | } 126 | return compiledScript; 127 | } 128 | 129 | export function idleCompiler( node ) { 130 | const window = this, { webqit: { oohtml: { configs: { SCOPED_JS: config } } } } = window; 131 | [ ...( node?.querySelectorAll( config.scriptSelector ) || [] ) ].forEach( script => { 132 | compileScript.call( window, config, script ); 133 | } ); 134 | } -------------------------------------------------------------------------------- /src/scoped-css/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { rewriteSelector, getOwnerNamespaceObject, getNamespaceUUID } from '../namespaced-html/index.js'; 6 | import { _init, _toHash } from '../util.js'; 7 | 8 | /** 9 | * @init 10 | * 11 | * @param Object $config 12 | */ 13 | export default function init({ advanced = {}, ...$config }) { 14 | const { config, window } = _init.call( this, 'scoped-css', $config, { 15 | api: { styleSheets: 'styleSheets' }, 16 | style: { retention: 'retain', mimeTypes: 'text/css', strategy: null }, 17 | } ); 18 | config.styleSelector = (Array.isArray( config.style.mimeTypes ) ? config.style.mimeTypes : config.style.mimeTypes.split( '|' ) ).concat( '' ).reduce( ( selector, mm ) => { 19 | const qualifier = mm ? `[type="${ window.CSS.escape( mm ) }"]` : ':not([type])'; 20 | return selector.concat( `style${ qualifier }` ); 21 | }, [] ).join( ',' ); 22 | window.webqit.oohtml.Style = { 23 | compileCache: new Map, 24 | }; 25 | exposeAPIs.call( window, config ); 26 | realtime.call( window, config ); 27 | } 28 | 29 | /** 30 | * Exposes Bindings with native APIs. 31 | * 32 | * @param Object config 33 | * 34 | * @return Void 35 | */ 36 | function exposeAPIs( config ) { 37 | const window = this, styleSheetsMap = new Map; 38 | // The "styleSheets" API 39 | [ window.Element.prototype ].forEach( prototype => { 40 | // No-conflict assertions 41 | const type = 'Element'; 42 | if ( config.api.styleSheets in prototype ) { throw new Error( `The ${ type } prototype already has a "${ config.api.styleSheets }" API!` ); } 43 | // Definitions 44 | Object.defineProperty( prototype, config.api.styleSheets, { get: function() { 45 | if ( !styleSheetsMap.has( this ) ) { styleSheetsMap.set( this, [] ); } 46 | return styleSheetsMap.get( this ); 47 | }, } ); 48 | } ); 49 | // The HTMLStyleElement "scoped" property 50 | Object.defineProperty( window.HTMLStyleElement.prototype, 'scoped', { 51 | configurable: true, 52 | get() { return this.hasAttribute( 'scoped' ); }, 53 | set( value ) { this.toggleAttribute( 'scoped', value ); }, 54 | } ); 55 | } 56 | 57 | /** 58 | * Performs realtime capture of elements and builds their relationships. 59 | * 60 | * @param Object config 61 | * 62 | * @return Void 63 | */ 64 | function realtime( config ) { 65 | const window = this, { webqit: { oohtml, realdom } } = window; 66 | const inBrowser = Object.getOwnPropertyDescriptor( globalThis, 'window' )?.get?.toString().includes( '[native code]' ) ?? false; 67 | if ( !window.CSS.supports ) { window.CSS.supports = () => false; } 68 | const handled = new WeakSet; 69 | realdom.realtime( window.document ).query( config.styleSelector, record => { 70 | record.entrants.forEach( style => { 71 | if ( handled.has( style ) ) return; 72 | handled.add( style ); 73 | // Do compilation 74 | const sourceHash = _toHash( style.textContent ); 75 | const supportsHAS = CSS.supports( 'selector(:has(a,b))' ); 76 | const scopeSelector = style.scoped && ( supportsHAS ? `:has(> style[rand-${ sourceHash }])` : `[rand-${ sourceHash }]` ); 77 | const supportsScope = style.scoped && window.CSSScopeRule && false/* Disabled for buggy behaviour: rewriting selectorText within an @scope block invalidates the scoping */; 78 | const scopeRoot = style.scoped && style.parentNode || style.getRootNode(); 79 | if ( scopeRoot instanceof window.Element ) { 80 | scopeRoot[ config.api.styleSheets ].push( style ); 81 | if ( !inBrowser ) return; 82 | ( supportsHAS ? style : scopeRoot ).toggleAttribute( `rand-${ sourceHash }`, true ); 83 | } 84 | if ( !inBrowser ) return; 85 | if ( style.scoped && style.hasAttribute( 'shared' ) ) { 86 | let compiledSheet; 87 | if ( !( compiledSheet = oohtml.Style.compileCache.get( sourceHash ) ) ) { 88 | compiledSheet = createAdoptableStylesheet.call( window, style, null, supportsScope, scopeSelector ); 89 | oohtml.Style.compileCache.set( sourceHash, compiledSheet ); 90 | } 91 | // Run now!!! 92 | Object.defineProperty( style, 'sheet', { value: compiledSheet, configurable: true } ); 93 | style.textContent = '\n/*[ Shared style sheet ]*/\n'; 94 | } else { 95 | const transform = () => { 96 | const namespaceUUID = getNamespaceUUID( getOwnerNamespaceObject.call( window, scopeRoot ) ); 97 | upgradeSheet.call( this, style.sheet, namespaceUUID, !supportsScope && scopeSelector ); 98 | }; 99 | if ( style.isConnected ) { transform(); } 100 | else { setTimeout( () => { transform(); }, 0 ); } 101 | } 102 | } ); 103 | }, { id: 'scoped-css', live: true, subtree: 'cross-roots', timing: 'intercept', generation: 'entrants' } ); 104 | // --- 105 | } 106 | 107 | function createAdoptableStylesheet( style, namespaceUUID, supportsScope, scopeSelector ) { 108 | const window = this, textContent = style.textContent; 109 | let styleSheet, cssText = supportsScope && scopeSelector ? `@scope (${ scopeSelector }) {\n${ textContent.trim() }\n}` : textContent.trim(); 110 | try { 111 | styleSheet = new window.CSSStyleSheet; 112 | styleSheet.replaceSync( cssText ); 113 | upgradeSheet.call( this, styleSheet, namespaceUUID, !supportsScope && scopeSelector ); 114 | const adopt = () => style.getRootNode().adoptedStyleSheets.push( styleSheet ); 115 | if ( style.isConnected ) { adopt(); } 116 | else { setTimeout( () => { adopt(); }, 0 ); } 117 | } catch( e ) { 118 | const styleCopy = window.document.createElement( 'style' ); 119 | style.after( styleCopy ); 120 | styleCopy.textContent = cssText; 121 | styleSheet = styleCopy.sheet; 122 | upgradeSheet.call( this, styleSheet, namespaceUUID, !supportsScope && scopeSelector ); 123 | } 124 | return styleSheet; 125 | } 126 | 127 | function upgradeSheet( styleSheet, namespaceUUID, scopeSelector = null ) { 128 | const l = styleSheet?.cssRules.length || -1; 129 | for ( let i = 0; i < l; ++i ) { 130 | const cssRule = styleSheet.cssRules[ i ]; 131 | if ( cssRule instanceof CSSImportRule ) { 132 | // Handle imported stylesheets 133 | //upgradeSheet( cssRule.styleSheet, namespaceUUID, scopeSelector ); 134 | continue; 135 | } 136 | upgradeRule.call( this, cssRule, namespaceUUID, scopeSelector ); 137 | } 138 | } 139 | 140 | function upgradeRule( cssRule, namespaceUUID, scopeSelector = null ) { 141 | if ( cssRule instanceof CSSStyleRule ) { 142 | // Resolve relative IDs and scoping (for non-@scope browsers) 143 | upgradeSelector.call( this, cssRule, namespaceUUID, scopeSelector ); 144 | return; 145 | } 146 | if ( [ window.CSSScopeRule, window.CSSMediaRule, window.CSSContainerRule, window.CSSSupportsRule, window.CSSLayerBlockRule ].some( type => type && cssRule instanceof type ) ) { 147 | // Parse @rule blocks 148 | const l = cssRule.cssRules.length; 149 | for ( let i = 0; i < l; ++i ) { 150 | upgradeRule.call( this, cssRule.cssRules[ i ], namespaceUUID, scopeSelector ); 151 | } 152 | } 153 | } 154 | 155 | function upgradeSelector( cssRule, namespaceUUID, scopeSelector = null ) { 156 | const newSelectorText = rewriteSelector.call( this, cssRule.selectorText, namespaceUUID, scopeSelector, 1 ); 157 | cssRule.selectorText = newSelectorText; 158 | // Parse nested blocks. (CSS nesting) 159 | if ( cssRule.cssRules ) { 160 | const l = cssRule.cssRules.length; 161 | for ( let i = 0; i < l; ++i ) { 162 | upgradeSelector.call( this, cssRule.cssRules[ i ], namespaceUUID, /* Nesting has nothing to do with scopeSelector */ ); 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /src/bindings-api/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import DOMBindingsContext from './DOMBindingsContext.js'; 6 | import { _wq, _init, _splitOuter } from '../util.js'; 7 | 8 | /** 9 | * @init 10 | * 11 | * @param Object $config 12 | */ 13 | export default function init( $config = {} ) { 14 | const { config, window } = _init.call( this, 'bindings-api', $config, { 15 | attr: { bindingsreflection: 'bindings' }, 16 | api: { bind: 'bind', bindings: 'bindings', }, 17 | } ); 18 | window.webqit.DOMBindingsContext = DOMBindingsContext; 19 | exposeAPIs.call( window, config ); 20 | realtime.call(window, config); 21 | } 22 | 23 | /** 24 | * @Defs 25 | * 26 | * The internal bindings object 27 | * within elements and the document object. 28 | */ 29 | function getBindings( config, node ) { 30 | const window = this, { webqit: { Observer, oohtml: { configs: { CONTEXT_API: ctxConfig } } } } = window; 31 | if ( !_wq( node ).has( 'bindings' ) ) { 32 | const bindingsObj = Object.create( null ); 33 | _wq( node ).set( 'bindings', bindingsObj ); 34 | Observer.observe( bindingsObj, mutations => { 35 | if ( node instanceof window.Element ) { 36 | const bindingsParse = parseBindingsAttr( node.getAttribute( config.attr.bindingsreflection ) || '' ); 37 | const bindingsParseBefore = new Map(bindingsParse); 38 | for ( const m of mutations ) { 39 | if ( m.detail?.publish !== false ) { 40 | if ( m.type === 'delete' ) bindingsParse.delete( m.key ); 41 | else bindingsParse.set( m.key, undefined ); 42 | } 43 | } 44 | if ( bindingsParse.size && bindingsParse.size !== bindingsParseBefore.size ) { 45 | node.setAttribute( config.attr.bindingsreflection, `{ ${ [ ...bindingsParse.entries() ].map(([ key, value ]) => value === undefined ? key : `${ key }: ${ value }` ).join( ', ' ) } }` ); 46 | } else if ( !bindingsParse.size ) node.toggleAttribute( config.attr.bindingsreflection, false ); 47 | } else { 48 | const contextsApi = node[ ctxConfig.api.contexts ]; 49 | for ( const m of mutations ) { 50 | if ( m.type === 'delete' ) { 51 | const ctx = contextsApi.find( DOMBindingsContext.kind, m.key ); 52 | if ( ctx ) contextsApi.detach( ctx ); 53 | } else if ( !contextsApi.find( DOMBindingsContext.kind, m.key ) ) { 54 | contextsApi.attach( new DOMBindingsContext( m.key ) ); 55 | } 56 | } 57 | } 58 | } ); 59 | } 60 | return _wq( node ).get( 'bindings' ); 61 | } 62 | 63 | /** 64 | * Exposes Bindings with native APIs. 65 | * 66 | * @param Object config 67 | * 68 | * @return Void 69 | */ 70 | function exposeAPIs( config ) { 71 | const window = this, { webqit: { Observer } } = window; 72 | // The Bindings APIs 73 | [ window.Document.prototype, window.Element.prototype, window.ShadowRoot.prototype ].forEach( prototype => { 74 | // No-conflict assertions 75 | const type = prototype === window.Document.prototype ? 'Document' : ( prototype === window.ShadowRoot.prototype ? 'ShadowRoot' : 'Element' ); 76 | if ( config.api.bind in prototype ) { throw new Error( `The ${ type } prototype already has a "${ config.api.bind }" API!` ); } 77 | if ( config.api.bindings in prototype ) { throw new Error( `The ${ type } prototype already has a "${ config.api.bindings }" API!` ); } 78 | // Definitions 79 | Object.defineProperty( prototype, config.api.bind, { value: function( bindings, options = {} ) { 80 | return applyBindings.call( window, config, this, bindings, options ); 81 | } }); 82 | Object.defineProperty( prototype, config.api.bindings, { get: function() { 83 | return Observer.proxy( getBindings.call( window, config, this ) ); 84 | } } ); 85 | } ); 86 | } 87 | 88 | /** 89 | * Exposes Bindings with native APIs. 90 | * 91 | * @param Object config 92 | * @param document|Element target 93 | * @param Object bindings 94 | * @param Object params 95 | * 96 | * @return Void 97 | */ 98 | function applyBindings( config, target, bindings, { merge, diff, publish, namespace } = {} ) { 99 | const window = this, { webqit: { Observer } } = window; 100 | const bindingsObj = getBindings.call( this, config, target ); 101 | const $params = { diff, namespace, detail: { publish } }; 102 | const exitingKeys = merge ? [] : Object.keys( bindingsObj ).filter( key => !( key in bindings ) ); 103 | return Observer.batch( bindingsObj, () => { 104 | if ( exitingKeys.length ) { Observer.deleteProperties( bindingsObj, exitingKeys, $params ); } 105 | return Observer.set( bindingsObj, bindings, $params ); 106 | }, $params ); 107 | } 108 | 109 | /** 110 | * Performs realtime capture of elements and their attributes 111 | * and their module query results; then resolves the respective import elements. 112 | * 113 | * @param Object config 114 | * 115 | * @return Void 116 | */ 117 | function realtime(config) { 118 | const window = this, { webqit: { realdom, Observer, oohtml: { configs } } } = window; 119 | // ------------ 120 | const attachBindingsContext = (host, key) => { 121 | const contextsApi = host[configs.CONTEXT_API.api.contexts]; 122 | if ( !contextsApi.find( DOMBindingsContext.kind, key ) ) { 123 | contextsApi.attach( new DOMBindingsContext( key ) ); 124 | } 125 | }; 126 | const detachBindingsContext = (host, key) => { 127 | let ctx, contextsApi = host[configs.CONTEXT_API.api.contexts]; 128 | while( ctx = contextsApi.find( DOMBindingsContext.kind, key ) ) contextsApi.detach(ctx); 129 | }; 130 | // ------------ 131 | realdom.realtime(window.document).query( `[${window.CSS.escape(config.attr.bindingsreflection)}]`, record => { 132 | record.exits.forEach( entry => detachBindingsContext( entry ) ); 133 | record.entrants.forEach(entry => { 134 | const bindingsParse = parseBindingsAttr( entry.getAttribute( config.attr.bindingsreflection ) || '' ); 135 | const newData = [ ...bindingsParse.entries() ].filter(([ k, v ]) => v !== undefined ); 136 | if ( newData.length ) entry[ config.api.bind ]( Object.fromEntries( newData ), { merge: true, publish: false } ); 137 | for ( const [ key ] of bindingsParse ) { 138 | attachBindingsContext( entry, key ); 139 | } 140 | } ); 141 | }, { id: 'bindings:dom', live: true, subtree: 'cross-roots', timing: 'sync', eventDetails: true }); 142 | realdom.realtime( window.document, 'attr' ).observe( config.attr.bindingsreflection, record => { 143 | const bindingsObj = getBindings.call( window, config, record.target ); 144 | const bindingsParse = parseBindingsAttr( record.value || '' ); 145 | const oldBindings = parseBindingsAttr( record.oldValue || '' ); 146 | for ( const key of new Set([ ...bindingsParse.keys(), ...oldBindings.keys() ]) ) { 147 | if ( !oldBindings.has( key ) ) { 148 | if ( bindingsParse.get( key ) !== undefined ) Observer.set( bindingsObj, key, bindingsParse.get( key ), { detail: { publish: false } } ); 149 | attachBindingsContext( record.target, key ); 150 | } else if ( !bindingsParse.has( key ) ) { 151 | if ( oldBindings.get( key ) !== undefined ) Observer.deleteProperty( bindingsObj, key, { detail: { publish: false } } ); 152 | detachBindingsContext( record.target, key ); 153 | } else if ( bindingsParse.get( key ) !== oldBindings.get( key ) ) { 154 | Observer.set( bindingsObj, key, bindingsParse.get( key ), { detail: { publish: false } } ); 155 | } 156 | } 157 | }, { id: 'bindings:attr', subtree: 'cross-roots', timing: 'sync', newValue: true, oldValue: true } ); 158 | } 159 | 160 | const parseBindingsAttr = str => { 161 | str = str.trim(); 162 | return new Map(_splitOuter( str.slice(1, -1), ',' ).filter( s => s.trim() ).map( _str => { 163 | return _splitOuter( _str, ':' ).map( s => s.trim() ); 164 | })); 165 | }; 166 | -------------------------------------------------------------------------------- /src/html-imports/HTMLModule.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { getDefs } from './index.js'; 6 | import { _wq, env } from '../util.js'; 7 | 8 | export default class HTMLModule { 9 | 10 | /** 11 | * @instance 12 | */ 13 | static instance(host) { 14 | return _wq(host).get('defsmanager::instance') || new this(host); 15 | } 16 | 17 | /** 18 | * @constructor 19 | */ 20 | constructor(host, parent = null, level = 0) { 21 | const { window } = env, { webqit: { realdom, oohtml: { configs } } } = window; 22 | _wq(host).get(`defsmanager::instance`)?.dispose(); 23 | _wq(host).set(`defsmanager::instance`, this); 24 | this.window = window; 25 | this.host = host; 26 | this.config = configs.HTML_IMPORTS; 27 | this.parent = parent; 28 | this.level = level; 29 | this.defs = getDefs(this.host); 30 | this.defId = (this.host.getAttribute(this.config.attr.def) || '').trim(); 31 | this.validateDefId(this.defId); 32 | // ---------- 33 | this.realtimeA = realdom.realtime(this.host.content).children(record => { 34 | this.expose(record.exits, false); // Must come first 35 | this.expose(record.entrants, true); 36 | }, { live: true, timing: 'sync' }); 37 | // ---------- 38 | this.realtimeB = realdom.realtime(this.host).attr(['src', 'loading'], (...args) => this.evaluateLoading(...args), { 39 | live: true, 40 | atomic: true, 41 | timing: 'sync', 42 | lifecycleSignals: true 43 | }); 44 | // ---------- 45 | this.realtimeC = this.evalInheritance(); 46 | // ---------- 47 | } 48 | 49 | /** 50 | * Validates export ID. 51 | * 52 | * @param String defId 53 | * 54 | * @returns Void 55 | */ 56 | validateDefId(defId) { 57 | if (['@', '/', '*', '#'].some(token => defId.includes(token))) { 58 | throw new Error(`The export ID "${defId}" contains an invalid character.`); 59 | } 60 | } 61 | 62 | /** 63 | * Maps module contents as defs. 64 | * 65 | * @param Array entries 66 | * @param Bool isConnected 67 | * 68 | * @returns Void 69 | */ 70 | expose(entries, isConnected) { 71 | const { window } = env, { webqit: { Observer } } = window; 72 | let dirty, allFragments = this.defs['#'] || []; 73 | entries.forEach(entry => { 74 | if (!entry || entry.nodeType !== 1) return; 75 | const isTemplate = entry.matches(this.config.templateSelector); 76 | const defId = (entry.getAttribute(isTemplate ? this.config.attr.def : this.config.attr.fragmentdef) || '').trim(); 77 | if (isConnected) { 78 | if (isTemplate && defId) { 79 | new HTMLModule(entry, this.host, this.level + 1); 80 | } else { 81 | allFragments.push(entry); 82 | dirty = true; 83 | if (typeof requestIdleCallback === 'function') { 84 | requestIdleCallback(() => { 85 | this.config.idleCompilers?.forEach(callback => callback.call(this.window, entry)); 86 | }); 87 | } 88 | } 89 | if (defId) { 90 | this.validateDefId(defId); 91 | Observer.set(this.defs, (!isTemplate && '#' || '') + defId, entry); 92 | } 93 | } else { 94 | if (isTemplate && defId) { HTMLModule.instance(entry).dispose(); } 95 | else { 96 | allFragments = allFragments.filter(x => x !== entry); 97 | dirty = true; 98 | } 99 | if (defId) Observer.deleteProperty(this.defs, (!isTemplate && '#' || '') + defId); 100 | } 101 | }); 102 | if (dirty) Observer.set(this.defs, '#', allFragments); 103 | } 104 | 105 | /** 106 | * Evaluates remote content loading. 107 | * 108 | * @param AbortSignal signal 109 | * 110 | * @returns Void 111 | */ 112 | evaluateLoading([record1, record2], { signal }) { 113 | const { window: { webqit: { Observer } } } = env; 114 | const src = (record1.value || '').trim(); 115 | if (!src) return; 116 | let $loadingPromise, loadingPromise = promise => { 117 | if (!promise) return $loadingPromise; // Get 118 | $loadingPromise = promise.then(() => interception.remove()); // Set 119 | }; 120 | const loading = (record2.value || '').trim(); 121 | const interception = Observer.intercept(this.defs, 'get', async (descriptor, recieved, next) => { 122 | if (loading === 'lazy') { loadingPromise(this.load(src, true)); } 123 | await loadingPromise(); 124 | return next(); 125 | }, { signal }); 126 | if (loading !== 'lazy') { loadingPromise(this.load(src)); } 127 | } 128 | 129 | /** 130 | * Fetches a module's "src". 131 | * 132 | * @param String src 133 | * 134 | * @return Promise 135 | */ 136 | #fetchedURLs = new Set; 137 | #fetchInFlight; 138 | load(src) { 139 | const { window } = env; 140 | if (this.#fetchedURLs.has(src)) { 141 | // Cache busting is needed to 142 | return Promise.resolve(); 143 | } 144 | this.#fetchedURLs.add(src); 145 | if (this.#fetchedURLs.size === 1 && this.host.content.children.length) { 146 | return Promise.resolve(); 147 | } 148 | // Ongoing request? 149 | this.#fetchInFlight?.controller.abort(); 150 | // The promise 151 | const controller = new AbortController(); 152 | const fire = (type, detail) => this.host.dispatchEvent(new window.CustomEvent(type, { detail })); 153 | const request = window.fetch(src, { signal: controller.signal, element: this.host }).then(response => { 154 | return response.ok ? response.text() : Promise.reject(response.statusText); 155 | }).then(content => { 156 | this.host.innerHTML = content.trim(); // IMPORTANT: .trim() 157 | fire('load'); 158 | return this.host; 159 | }).catch(e => { 160 | console.error(`Error fetching the bundle at "${src}": ${e.message}`); 161 | this.#fetchInFlight = null; 162 | fire('loaderror'); 163 | return this.host; 164 | }); 165 | this.#fetchInFlight = { request, controller }; 166 | return request; 167 | } 168 | 169 | /** 170 | * Evaluates module inheritance. 171 | * 172 | * @returns Void|AbortController 173 | */ 174 | evalInheritance() { 175 | if (!this.parent) return []; 176 | const { window: { webqit: { Observer } } } = env; 177 | let extendedId = (this.host.getAttribute(this.config.attr.extends) || '').trim(); 178 | let inheritedIds = (this.host.getAttribute(this.config.attr.inherits) || '').trim().split(' ').map(id => id.trim()).filter(x => x); 179 | const handleInherited = records => { 180 | records.forEach(record => { 181 | if (Observer.get(this.defs, record.key) !== record.oldValue) return; 182 | if (['get'/*initial get*/, 'set', 'def'].includes(record.type)) { 183 | Observer[record.type.replace('get', 'set')](this.defs, record.key, record.value); 184 | } else if (record.type === 'delete') { 185 | Observer.deleteProperty(this.defs, record.key); 186 | } 187 | }); 188 | }; 189 | const realtimes = []; 190 | const parentDefsObj = getDefs(this.parent); 191 | if (extendedId) { 192 | realtimes.push(Observer.reduce(parentDefsObj, [extendedId, this.config.api.defs, Infinity], Observer.get, handleInherited, { live: true })); 193 | } 194 | if (inheritedIds.length) { 195 | realtimes.push(Observer.get(parentDefsObj, inheritedIds.includes('*') ? Infinity : inheritedIds, handleInherited, { live: true })); 196 | } 197 | return realtimes; 198 | } 199 | 200 | /** 201 | * Disposes the instance and its processes. 202 | * 203 | * @returns Void 204 | */ 205 | dispose() { 206 | this.realtimeA.disconnect(); 207 | this.realtimeB.disconnect(); 208 | this.realtimeC.forEach(r => (r instanceof Promise ? r.then(r => r.abort()) : r.abort())); 209 | Object.entries(this.defs).forEach(([key, entry]) => { 210 | if (key.startsWith('#')) return; 211 | HTMLModule.instance(entry).dispose(); 212 | }); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /test/modules.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @imports 4 | */ 5 | import { expect } from 'chai'; 6 | import { createDocument, mockRemoteFetch, delay } from './index.js'; 7 | const getQueryPath = str => str.split( '/' ).join( '/defs/' ).split( '/' ); 8 | 9 | describe(`HTML Modules`, function() { 10 | 11 | describe( `APIs...`, function() { 12 | 13 | it ( `The document object and