├── .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 | `;
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 elements should expose a "defs" property...`, async function() {
14 |
15 | const head = `
16 |
17 | Hello world Export
18 | Hellort
19 | `;
20 | const body = `
21 |
22 | Hello world Export
23 | Hellort
24 | `;
25 | const { document } = createDocument( head, body );
26 | await delay( 200 );
27 | // -------
28 | expect( document ).to.have.property( 'import' );
29 | document.import( 'temp0', temp0 => {
30 | expect( temp0 ).to.have.property( 'defs' );
31 | expect( temp0.def ).to.eq( 'temp0' );
32 | } );
33 | // -------
34 | expect( document.body ).to.have.property( 'import' );
35 | document.body.import( 'temp1', temp1 => {
36 | expect( temp1 ).to.have.property( 'defs' );
37 | expect( temp1.def ).to.eq( 'temp1' );
38 | } );
39 | } );
40 |
41 | it ( `The document object and elements should expose a "defs" property...`, async function() {
42 |
43 | const body = '', head = `
44 |
45 | Hello world Export
46 | Hellort
47 |
48 |
49 | Hello world Export
50 | Hellort
51 |
52 | `;
53 | const { document, window } = createDocument( head, body );
54 | await delay( 20 );
55 | const { webqit: { Observer } } = window;
56 | // -------
57 | document.import( 'temp0', temp0 => {
58 | expect( temp0 ).to.have.property( 'defs' );
59 | expect( temp0.defs[ '#' ] ).to.have.length( 2 );
60 | const temp2 = Observer.reduce( temp0.defs, getQueryPath( 'temp2' ), Observer.get );
61 | expect( temp2 ).to.have.property( 'defs' );
62 | // -------
63 | const temp1Inherited = Observer.reduce( temp0.defs, getQueryPath( 'temp2/temp1' ), Observer.get );
64 | expect( temp1Inherited ).to.have.property( 'defs' );
65 | // -------
66 | const temp3Observed = [];
67 | Observer.reduce( temp0.defs, getQueryPath( 'temp2/temp3' ), Observer.observe, record => {
68 | temp3Observed.push( record.value );
69 | } );
70 | // -------
71 | const temp3 = document.createElement( 'template' );
72 | temp3.setAttribute( 'def', 'temp3' );
73 | temp0.content.appendChild( temp3 );
74 | // -------
75 | expect( temp3Observed ).to.be.an( 'array' ).with.length( 1 );
76 | expect( temp3Observed[ 0 ] ).to.have.property( 'defs' );
77 | // -------
78 | const temp3Inherited = Observer.reduce( temp0.defs, getQueryPath( 'temp2/temp3' ), Observer.get );
79 | expect( temp3Inherited ).to.have.property( 'defs' );
80 | // -------
81 | } );
82 | } );
83 |
84 | } );
85 |
86 | describe( `Remote...`, function() {
87 | this.timeout( 10000 );
88 |
89 | it( `Add remote lazy module, with a nested remote lazy module, then resolve.`, async function() {
90 |
91 | const head = ``, body = ``;
92 | const { document, window } = createDocument( head, body, window => {
93 | // Define remote responses
94 | const contents0 = `
95 | `;
96 | const contents1 = `
97 | `;
98 | const contents2 = `
99 |
100 | Hello world Export
101 | Hellort
`;
102 | const timeout = 1000;
103 | mockRemoteFetch( window, { '/temp0.html': contents0, '/temp1.html': contents1, '/temp2.html': contents2 }, timeout );
104 | } );
105 | await delay( 50 );
106 | const { webqit: { Observer } } = window;
107 | // -------
108 | // Add a remote module
109 | const templateEl = document.createElement( 'template' );
110 | templateEl.setAttribute( 'def', 'temp0' );
111 | templateEl.setAttribute( 'loading', 'lazy' );
112 | templateEl.setAttribute( 'src', '/temp0.html' );
113 | document.head.appendChild( templateEl );
114 | // -------
115 | // Add the import element to with a view to waiting for the remote module
116 | document.import( 'temp0', async temp0 => {
117 | expect( temp0 ).to.have.property( 'defs' );
118 | await delay( 2100 );
119 | // temp1 shouldn't have been automatically loaded still
120 | const hasTemp1 = Observer.reduce( temp0.defs, getQueryPath( 'temp1' ), Observer.has );
121 | expect( hasTemp1 ).to.be.false;
122 | // Try access temp1 to trigger loading and await
123 | const _temp1 = await Observer.reduce( temp0.defs, getQueryPath( 'temp1' ), Observer.get );
124 | expect( _temp1 ).to.have.property( 'defs' );
125 | // -------
126 | // Receive updates
127 | const temp3Observed = [];
128 | Observer.reduce( temp0.defs, getQueryPath( 'temp1/temp2/temp3' ), Observer.observe, ( record, lifecycle ) => {
129 | temp3Observed.push( record.value );
130 | } );
131 | await delay( 2100 );
132 | // -------
133 | // temp2 should be loaded by now
134 | expect( temp3Observed ).to.be.an( 'array' ).with.length( 1 );
135 | expect( temp3Observed[ 0 ] ).to.have.property( 'defs' );
136 | expect( temp3Observed[ 0 ].getAttribute( 'def' ) ).to.eq( 'temp3' );
137 | } );
138 | } );
139 | } );
140 |
141 | describe( `Context...`, function() {
142 | this.timeout( 10000 );
143 |
144 | it( `Use the context API to fire a scoped-request that is imitially resolved from the document and then from a scoped context.`, async function() {
145 |
146 | const head = `
147 |
148 |
149 | Hello world Export
150 | Hellort
151 |
152 | `;
153 | const body = `
154 |
`;
155 | const { document, window } = createDocument( head, body );
156 | await delay( 50 );
157 |
158 | // -------
159 | const addScopedModules = () => {
160 | const templateEl = document.createElement( 'template' );
161 | templateEl.setAttribute( 'def', 'temp0' );
162 | templateEl.toggleAttribute( 'scoped', true );
163 | const scoped = document.body.appendChild( templateEl );
164 | document.body.setAttribute( 'importscontext', '/' );
165 | return scoped;
166 | };
167 | // -------
168 | const contextRequest = ( el, params, callback ) => {
169 | const request = { kind: 'html-imports', live: true, ...params };
170 | const event = new window.webqit.DOMContextRequestEvent( request, callback );
171 | return el.dispatchEvent( event );
172 | };
173 | // -------
174 | await 'For some reason, the element in the head needs to show up in the document defsObj';
175 | const defsObjs = [], div = document.querySelector( 'div' );
176 | // -------
177 | contextRequest( div, { detail: '/temp0', diff: false }, response => {
178 | defsObjs.push( response );
179 | } );
180 | expect( defsObjs ).to.have.length( 1 );
181 | expect( defsObjs[ 0 ] ).to.have.property( 'scoped', false );
182 | // -------
183 | const scoped = addScopedModules();
184 | expect( defsObjs ).to.have.length( 2 );
185 | expect( defsObjs[ 1 ] ).to.have.property( 'scoped', true );
186 | // -------
187 | scoped.remove();
188 | expect( defsObjs ).to.have.length( 3 );
189 |
190 | expect( defsObjs[ 2 ] ).to.have.property( 'scoped', false );
191 | // -------
192 | document.body.appendChild( scoped );
193 | expect( defsObjs ).to.have.length( 4 );
194 | expect( defsObjs[ 3 ] ).to.have.property( 'scoped', true );
195 | // -------
196 | document.import( 'temp0', temp0 => {
197 | const unscoped = temp0;
198 | unscoped.remove();
199 | document.head.appendChild( unscoped );
200 | document.body.remove();
201 | expect( defsObjs ).to.have.length( 4 );
202 | } );
203 | } );
204 | } );
205 |
206 | } );
207 |
--------------------------------------------------------------------------------
/src/html-imports/index.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * @imports
4 | */
5 | import HTMLModule from './HTMLModule.js';
6 | import HTMLImportsContext from './HTMLImportsContext.js';
7 | import _HTMLImportElement from './_HTMLImportElement.js';
8 | import { _wq, _init } from '../util.js';
9 |
10 | /**
11 | * Initializes HTML Modules.
12 | *
13 | * @param $config Object
14 | *
15 | * @return Void
16 | */
17 | export default function init($config = {}) {
18 | const { config, window } = _init.call(this, 'html-imports', $config, {
19 | elements: { import: 'import', },
20 | attr: { def: 'def', extends: 'extends', inherits: 'inherits', ref: 'ref', importscontext: 'importscontext', },
21 | api: { def: 'def', defs: 'defs', import: 'import' },
22 | });
23 | if (!config.attr.fragmentdef) { config.attr.fragmentdef = config.attr.def; }
24 | config.templateSelector = `template[${window.CSS.escape(config.attr.def)}]`;
25 | config.importsContextSelector = `[${window.CSS.escape(config.attr.importscontext)}]`;
26 | config.slottedElementsSelector = `[${window.CSS.escape(config.attr.fragmentdef)}]:not(template)`;
27 | const anchorNodeMatch = (start, end) => {
28 | const starting = `starts-with(., "${start}")`;
29 | const ending = `substring(., string-length(.) - string-length("${end}") + 1) = "${end}"`;
30 | return `${starting} and ${ending}`;
31 | }
32 | config.anchorNodeSelector = `comment()[${anchorNodeMatch(`<${config.elements.import}`, `</${config.elements.import}>`)}]`;
33 | window.webqit.HTMLImportsContext = HTMLImportsContext;
34 | window.webqit.HTMLImportElement = _HTMLImportElement();
35 | exposeAPIs.call(window, config);
36 | realtime.call(window, config);
37 | }
38 |
39 | /**
40 | * Returns the "defs" object associated with the given node.
41 | *
42 | * @param Element node
43 | * @param Bool autoCreate
44 | *
45 | * @return Object
46 | */
47 | export function getDefs(node, autoCreate = true) {
48 | if (!_wq(node).has('defs') && autoCreate) {
49 | const defs = Object.create(null);
50 | _wq(node).set('defs', defs);
51 | }
52 | return _wq(node).get('defs');
53 | }
54 |
55 | /**
56 | * Exposes HTML Modules with native APIs.
57 | *
58 | * @param Object config
59 | *
60 | * @return Void
61 | */
62 | function exposeAPIs(config) {
63 | const window = this, { webqit: { oohtml: { configs } } } = window;
64 | // The "def" & "defs" properties
65 | if (config.api.def in window.HTMLTemplateElement.prototype) { throw new Error(`The "HTMLTemplateElement" prototype already has a "${config.api.def}" API!`); }
66 | if (config.api.defs in window.HTMLTemplateElement.prototype) { throw new Error(`The "HTMLTemplateElement" prototype already has a "${config.api.defs}" API!`); }
67 | // No-conflict assertions
68 | Object.defineProperty(window.HTMLTemplateElement.prototype, config.api.def, {
69 | get: function () {
70 | return this.getAttribute(config.attr.def);
71 | }
72 | });
73 | Object.defineProperty(window.HTMLTemplateElement.prototype, config.api.defs, {
74 | get: function () {
75 | return getDefs(this);
76 | }
77 | });
78 | // The "scoped" property
79 | Object.defineProperty(window.HTMLTemplateElement.prototype, 'scoped', {
80 | configurable: true,
81 | get() { return this.hasAttribute('scoped'); },
82 | set(value) { this.toggleAttribute('scoped', value); },
83 | });
84 | // The Import API
85 | [window.Document.prototype, window.Element.prototype, window.ShadowRoot.prototype].forEach(prototype => {
86 | // No-conflict assertions
87 | const type = prototype === window.Document.prototype ? 'Document' : (prototype === window.ShadowRoot.prototype ? 'ShadowRoot' : 'Element');
88 | if (config.api.import in prototype) { throw new Error(`The ${type} prototype already has a "${config.api.import}" API!`); }
89 | if (config.api.defs in prototype) { throw new Error(`The ${type} prototype already has a "${config.api.defs}" API!`); }
90 | // Definitions
91 | Object.defineProperty(prototype, config.api.defs, {
92 | get: function () {
93 | return getDefs(this);
94 | }
95 | });
96 | Object.defineProperty(prototype, config.api.import, {
97 | value: function (ref, live = false, callback = null) {
98 | return importRequest(this, ...arguments);
99 | }
100 | });
101 | });
102 | function importRequest(context, ref, live = false, callback = null) {
103 | let options = {};
104 | if (typeof live === 'function') {
105 | callback = live;
106 | live = false;
107 | } else if (typeof live === 'object' && live) {
108 | options = { ...live, ...options };
109 | } else { options = { live }; }
110 | const request = { ...HTMLImportsContext.createRequest(ref), ...options };
111 | return context[configs.CONTEXT_API.api.contexts].request(request, callback);
112 | }
113 | }
114 |
115 | /**
116 | * Performs realtime capture of elements and their attributes
117 | * and their module query results; then resolves the respective import elements.
118 | *
119 | * @param Object config
120 | *
121 | * @return Void
122 | */
123 | function realtime(config) {
124 | const window = this, { webqit: { Observer, realdom, oohtml: { configs }, HTMLImportElement, HTMLImportsContext } } = window;
125 |
126 | // ------------
127 | // MODULES
128 | // ------------
129 | const attachImportsContext = host => {
130 | const contextsApi = host[configs.CONTEXT_API.api.contexts];
131 | if (!contextsApi.find(HTMLImportsContext.kind)) {
132 | contextsApi.attach(new HTMLImportsContext);
133 | }
134 | };
135 | const detachImportsContext = host => {
136 | const contextsApi = host[configs.CONTEXT_API.api.contexts];
137 | const ctx = contextsApi.find(HTMLImportsContext.kind);
138 | if (ctx && ( /* disconnect? */!host.isConnected || /* not inheriting && no localModules? */(
139 | !host.matches?.(config.importsContextSelector) && !Object.keys(ctx.localModules).length
140 | ))) {
141 | contextsApi.detach(ctx);
142 | }
143 | };
144 | // ------------
145 | realdom.realtime(window.document).query([config.templateSelector, config.importsContextSelector], record => {
146 | record.entrants.forEach(entry => {
147 | if (entry.matches(config.templateSelector)) {
148 | const htmlModule = HTMLModule.instance(entry);
149 | htmlModule.ownerContext = entry.scoped ? entry.parentNode : entry.getRootNode();
150 | const ownerContextModulesObj = getDefs(htmlModule.ownerContext);
151 | if (htmlModule.defId) { Observer.set(ownerContextModulesObj, htmlModule.defId, entry); }
152 | // The ownerContext's defs - ownerContextModulesObj - has to be populated
153 | // Before attaching a context instance to it, to give the just created context something to use for
154 | // fullfiling reclaimed requests.
155 | attachImportsContext(htmlModule.ownerContext);
156 | } else {
157 | attachImportsContext(entry);
158 | }
159 | });
160 | record.exits.forEach(entry => {
161 | if (entry.matches(config.templateSelector)) {
162 | const htmlModule = HTMLModule.instance(entry);
163 | //if (!htmlModule.ownerContext) return; // JSDOM sometimes
164 | const ownerContextModulesObj = getDefs(htmlModule.ownerContext);
165 | if (htmlModule.defId && htmlModule.ownerContext.isConnected) { Observer.deleteProperty(ownerContextModulesObj, htmlModule.defId); }
166 | detachImportsContext(htmlModule.ownerContext);
167 | } else {
168 | detachImportsContext(entry);
169 | }
170 | });
171 | }, { id: 'imports:template/importscontext', live: true, subtree: 'cross-roots', timing: 'sync', staticSensitivity: true, eventDetails: true });
172 |
173 | // ------------
174 | // IMPORTS
175 | // ------------
176 | realdom.realtime(window.document).query(config.elements.import, record => {
177 | record.entrants.forEach(node => handleRealtime(node, true, record));
178 | record.exits.forEach(node => handleRealtime(node, false, record));
179 | }, { id: 'imports:import', live: true, subtree: 'cross-roots', timing: 'sync', deferred: true });
180 | function handleRealtime(entry, connectedState) {
181 | const elInstance = HTMLImportElement.instance(entry);
182 | if (connectedState) { elInstance['#'].connectedCallback(); }
183 | else { elInstance['#'].disconnectedCallback(); }
184 | }
185 | // Hydration
186 | if (window.webqit.env === 'server') return;
187 | realdom.realtime(window.document).query(`(${config.anchorNodeSelector})`, record => {
188 | record.entrants.forEach(anchorNode => {
189 | if (_wq(anchorNode).get('isAnchorNode')) return; // Doubling up on the early return above! Ignoring every just created anchorNode
190 | const reviver = window.document.createElement('div');
191 | reviver.innerHTML = anchorNode.nodeValue;
192 | reviver.innerHTML = reviver.firstChild.textContent;
193 | const importEl = reviver.firstChild;
194 | let nodecount = parseInt(importEl.getAttribute('data-nodecount'));
195 | const slottedElements = new Set;
196 | let slottedElement = anchorNode;
197 | while ((slottedElement = slottedElement.previousElementSibling) && slottedElement.matches(config.slottedElementsSelector) && nodecount--) {
198 | slottedElements.add(slottedElement);
199 | }
200 | HTMLImportElement.instance(importEl)['#'].hydrate(anchorNode, slottedElements);
201 | });
202 | }, { id: 'imports:hydration', live: true, subtree: 'cross-roots', timing: 'sync' });
203 | }
--------------------------------------------------------------------------------
/test/imports.test.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * @imports
4 | */
5 | import { expect } from 'chai';
6 | import { createDocument, createDocumentPrefixed, mockRemoteFetch, delay } from './index.js';
7 |
8 | describe(`HTML Imports`, function() {
9 |
10 | describe( `Basic...`, function() {
11 |
12 | const head = `
13 |
14 | Hello world Export
15 | Hellort
16 | `;
17 | const body = `
18 | `;
19 | const { document } = createDocument( head, body );
20 |
21 | it ( `The document object and elements should expose an "import" property`, async function() {
22 | expect( document ).to.have.property( 'import' );
23 | } );
24 |
25 | it ( ` element be automatically resolved: import default export...`, async function() {
26 | expect( document.body.children ).to.have.length( 2 );
27 | expect( document.body.firstElementChild.nodeName ).to.eq( 'P' );
28 | } );
29 |
30 | it( ` element be resolved again: after having mutated an export right at its module.`, async function() {
31 | const templateEl = document.querySelector( 'template' );
32 | let added = document.createElement( 'div' );
33 | templateEl.content.appendChild( added );
34 | console.log('\n\n\n\n', document.body.outerHTML);
35 | expect( document.body.children ).to.have.length( 3 );
36 | } );
37 | } );
38 |
39 | describe( `Dynamic...`, function() {
40 |
41 | const head = `
42 |
43 |
44 | Hello world Export
45 | Hellort
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | a
56 |
57 | b
58 |
59 | c
60 |
61 |
62 |
63 |
64 |
65 | 1
66 |
67 | 2
68 |
69 | 3
70 |
71 |
72 |
73 |
74 | `;
75 | const body = `
76 | `;
77 | const { document } = createDocumentPrefixed( 'wq', head, body );
78 | const importEl = document.querySelector( 'wq-import' );
79 |
80 | it ( ` element should not be resolved: no match for given import ID...`, async function() {
81 | expect( document.body.firstElementChild.nodeName ).to.eq( 'WQ-IMPORT' );
82 | } );
83 |
84 | it ( ` element should be automatically resolved: new import ID is set...`, async function() {
85 | importEl.setAttribute( 'wq-ref', 'temp0#input' );
86 | expect( document.body.firstElementChild.nodeName ).to.eq( 'INPUT' );
87 | } );
88 |
89 | it ( ` element should be automatically resolved: new moduleref is set - nested...`, async function() {
90 | importEl.setAttribute( 'wq-ref', 'temp0/temp1#input' );
91 | expect( document.body.firstElementChild.nodeName ).to.eq( 'TEXTAREA' );
92 | } );
93 |
94 | it ( ` element should be automatically resolved: moduleref is unset - should now be inherited from ...`, async function() {
95 | importEl.setAttribute( 'wq-ref', '#input' );
96 | expect( document.body.firstElementChild.nodeName ).to.eq( 'WQ-IMPORT' );
97 | document.body.setAttribute( 'wq-importscontext', 'temp0/temp1/temp2' );
98 | expect( document.body.firstElementChild.nodeName ).to.eq( 'SELECT' );
99 | } );
100 |
101 | it ( ` element should be automatically resolved: moduleref at is changed...`, async function() {
102 | document.body.setAttribute( 'wq-importscontext', 'temp0' );
103 | expect( document.body.firstElementChild.nodeName ).to.eq( 'INPUT' );
104 | } );
105 |
106 | it ( ` element should be automatically RESTORED: slotted element is removed from DOM...`, async function() {
107 | document.body.querySelector( 'input' ).remove();
108 | expect( document.body.firstElementChild.nodeName ).to.eq( 'WQ-IMPORT' );
109 | } );
110 |
111 | } );
112 |
113 | describe( `Remote...`, function() {
114 |
115 | it( ` element from nested remote modules.`, async function() {
116 | this.timeout( 10000 );
117 |
118 | const head = ``, body = ``, timeout = 2000;
119 | const window = createDocument( head, body, window => {
120 | // Define a remote response
121 | const contents0 = `
122 | `;
123 | const contents1 = `
124 | Hello world Export
125 | Hellort
126 |
127 | `;
128 | mockRemoteFetch( window, { '/temp0.html': contents0, '/temp1.html': contents1 }, timeout );
129 | } ), document = window.document;
130 |
131 | // Add a remote module
132 | const templateEl = document.createElement( 'template' );
133 | templateEl.setAttribute( 'def', 'temp0' );
134 | templateEl.setAttribute( 'src', '/temp0.html' );
135 | document.head.appendChild( templateEl );
136 | // Add the import element to with a view to waiting for the remote module
137 | const importEl = document.createElement( 'import' );
138 | importEl.setAttribute( 'ref', 'temp0/temp1' );
139 | document.body.appendChild( importEl );
140 | // Should stil be waiting...
141 | expect( document.body.firstElementChild.nodeName ).to.eq( 'IMPORT' );
142 | // When remote request must have completed
143 | await delay( ( timeout * 2 ) + 150 );
144 | expect( document.body.firstElementChild.nodeName ).to.eq( 'P' );
145 | expect( document.body.lastElementChild.nodeName ).to.eq( 'P' );
146 | } );
147 | } );
148 |
149 | describe( `Hydration...`, function() {
150 |
151 | it ( `Server-resolved element should maintain relationship with slotted elements...`, async function() {
152 |
153 | const head = `
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 | `;
163 | const body = `
164 |
165 |
166 |
167 |
`;
168 | const { document } = createDocument( head, body, window => window.webqit.env = 'client' );
169 | await delay( 20 );
170 |
171 | const routingElement = document.body.firstElementChild;
172 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'TEXTAREA' );
173 | document.import( 'temp0/temp1', temp1 => {
174 | const textarea = temp1.defs[ '#input' ];
175 | textarea.remove();
176 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'IMPORT' );
177 | temp1.content.prepend( textarea );
178 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'TEXTAREA' );
179 | routingElement.firstElementChild.remove();
180 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'IMPORT' );
181 | } );
182 | } );
183 |
184 | it ( `Server-resolved element should maintain relationship with slotted elements...`, async function() {
185 |
186 | const head = `
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 | `;
196 | const body = `
197 |
198 |
199 |
200 |
`;
201 | const { document } = createDocument( head, body, window => window.webqit.env = 'client' );
202 | await delay( 20 );
203 |
204 | const routingElement = document.body.firstElementChild;
205 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'TEXTAREA' );
206 | routingElement.setAttribute( 'importscontext', 'temp0/temp1/temp2' );
207 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'SELECT' );
208 | routingElement.removeChild( routingElement.firstElementChild );
209 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'IMPORT' );
210 | routingElement.setAttribute( 'importscontext', 'temp0' );
211 | expect( routingElement.firstElementChild.nodeName ).to.eq( 'INPUT' );
212 | } );
213 |
214 | } );
215 |
216 | } );
217 |
--------------------------------------------------------------------------------
/src/html-imports/_HTMLImportElement.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * @imports
4 | */
5 | import HTMLImportsContext from './HTMLImportsContext.js';
6 | import { _wq, env } from '../util.js';
7 |
8 | /**
9 | * Creates the HTMLImportElement class.
10 | *
11 | * @param Object config
12 | *
13 | * @return HTMLImportElement
14 | */
15 | export default function () {
16 | const { window } = env, { webqit } = window, { realdom, oohtml: { configs } } = webqit;
17 | if (webqit.HTMLImportElement) return webqit.HTMLImportElement;
18 | const BaseElement = configs.HTML_IMPORTS.elements.import.includes('-') ? window.HTMLElement : class { };
19 | class HTMLImportElement extends BaseElement {
20 |
21 | /**
22 | * @instance
23 | *
24 | * @param HTMLElement node
25 | *
26 | * @returns
27 | */
28 | static instance(node) {
29 | if (configs.HTML_IMPORTS.elements.import.includes('-') && (node instanceof this)) return node;
30 | return _wq(node).get('import::instance') || new this(node);
31 | }
32 |
33 | /**
34 | * @constructor
35 | */
36 | constructor(...args) {
37 | super();
38 | // --------
39 | const el = args[0] || this;
40 | _wq(el).set('import::instance', this);
41 | Object.defineProperty(this, 'el', { get: () => el, configurable: false });
42 |
43 | const priv = {};
44 | Object.defineProperty(this, '#', { get: () => priv, configurable: false });
45 | priv.slottedElements = new Set;
46 |
47 | priv.setAnchorNode = anchorNode => {
48 | priv.anchorNode = anchorNode;
49 | return anchorNode;
50 | };
51 |
52 | priv.live = callback => {
53 | if (priv.liveImportsRealtime) throw new Error(`Import element already in live mode.`);
54 | const parentNode = this.el.isConnected ? this.el.parentNode : priv.anchorNode.parentNode;
55 | priv.liveImportsRealtime = realdom.realtime(this.el).attr(configs.HTML_IMPORTS.attr.ref, (record, { signal }) => {
56 | priv.moduleRef = record.value;
57 | const moduleRef = priv.moduleRef.includes('#') ? priv.moduleRef : `${priv.moduleRef}#`/* for live children */;
58 | const request = { ...HTMLImportsContext.createRequest(moduleRef), live: signal && true, signal, diff: !moduleRef.endsWith('#') };
59 | parentNode[configs.CONTEXT_API.api.contexts].request(request, response => {
60 | callback((response instanceof window.HTMLTemplateElement ? [...response.content.children] : (
61 | Array.isArray(response) ? response : response && [response]
62 | )) || []);
63 | });
64 | }, { live: true, timing: 'sync', lifecycleSignals: true });
65 | priv.autoDestroyRealtime = realdom.realtime(window.document).track(parentNode, () => {
66 | priv.die();
67 | }, { subtree: 'cross-roots', timing: 'sync', generation: 'exits' });
68 | };
69 |
70 | priv.die = () => {
71 | priv.autoDestroyRealtime?.disconnect();
72 | priv.liveImportsRealtime?.disconnect();
73 | priv.liveImportsRealtime = null;
74 | };
75 |
76 | priv.hydrate = (anchorNode, slottedElements) => {
77 | anchorNode.replaceWith(priv.setAnchorNode(this.createAnchorNode()));
78 | priv.live(fragments => {
79 | // The default action
80 | if (priv.originalsRemapped) return this.fill(fragments);
81 | // Initial remap action
82 | const identifiersMap = fragments.map((fragment, i) => ({ el: fragment, fragmentDef: fragment.getAttribute(configs.HTML_IMPORTS.attr.fragmentdef) || '', tagName: fragment.tagName, i }));
83 | slottedElements.forEach((slottedElement, i) => {
84 | const tagName = slottedElement.tagName, fragmentDef = slottedElement.getAttribute(configs.HTML_IMPORTS.attr.fragmentdef) || '';
85 | const originalsMatch = (i++, identifiersMap.find(fragmentIdentifiers => fragmentIdentifiers.tagName === tagName && fragmentIdentifiers.fragmentDef === fragmentDef && fragmentIdentifiers.i === i));
86 | if (originalsMatch) _wq(slottedElement).set('original@imports', originalsMatch.el); // Or should we throw integrity error here?
87 | _wq(slottedElement).set('slot@imports', this.el);
88 | priv.slottedElements.add(slottedElement);
89 | });
90 | priv.originalsRemapped = true;
91 | });
92 | };
93 |
94 | priv.autoRestore = (callback = null) => {
95 | priv.autoRestoreRealtime?.disconnect();
96 | if (callback) callback();
97 | const restore = () => {
98 | if (this.el.isConnected) return;
99 | this.el.setAttribute('data-nodecount', 0);
100 | priv.internalMutation = true;
101 | priv.anchorNode.replaceWith(this.el);
102 | priv.internalMutation = false;
103 | priv.setAnchorNode(null);
104 | };
105 | if (!priv.slottedElements.size) return restore();
106 | const autoRestoreRealtime = realdom.realtime(priv.anchorNode.parentNode).observe([...priv.slottedElements], record => {
107 | record.exits.forEach(outgoingNode => {
108 | _wq(outgoingNode).delete('slot@imports');
109 | priv.slottedElements.delete(outgoingNode);
110 | });
111 | if (!priv.slottedElements.size) {
112 | autoRestoreRealtime.disconnect();
113 | // At this point, ignore if this is a removal involving the whole parent node
114 | if (!record.target.isConnected) return;
115 | restore();
116 | }
117 | }, { subtree: 'cross-roots', timing: 'sync', generation: 'exits' });
118 | priv.autoRestoreRealtime = autoRestoreRealtime;
119 | };
120 |
121 | priv.connectedCallback = () => {
122 | if (priv.internalMutation) return;
123 | priv.live(fragments => this.fill(fragments));
124 | };
125 |
126 | priv.disconnectedCallback = () => {
127 | if (priv.internalMutation) return;
128 | priv.die();
129 | };
130 | }
131 |
132 | /**
133 | * Creates the slot's anchor node.
134 | *
135 | * @return Element
136 | */
137 | createAnchorNode() {
138 | if (window.webqit.env !== 'server') { return window.document.createTextNode('') }
139 | const escapeElement = window.document.createElement('div');
140 | escapeElement.textContent = this.el.outerHTML;
141 | const anchorNode = window.document.createComment(escapeElement.innerHTML);
142 | _wq(anchorNode).set('isAnchorNode', true);
143 | return anchorNode;
144 | }
145 |
146 | /**
147 | * Fills the slot with slottableElements
148 | *
149 | * @param Iterable slottableElements
150 | *
151 | * @return void
152 | */
153 | fill(slottableElements, r) {
154 | if (!this.el.isConnected && (!this['#'].anchorNode || !this['#'].anchorNode.isConnected)) {
155 | // LiveImports must be responding to an event that just removed the subtree from DOM
156 | return;
157 | }
158 | if (Array.isArray(slottableElements)) { slottableElements = new Set(slottableElements) }
159 | // This state must be set before the diffing below and the serialization done at createAnchorNode()
160 | this.el.setAttribute('data-nodecount', slottableElements.size);
161 | this['#'].autoRestore(() => {
162 | this['#'].slottedElements.forEach(slottedElement => {
163 | const slottedElementOriginal = _wq(slottedElement).get('original@imports');
164 | // If still available in source, simply leave unchanged
165 | // otherwise remove it from slot... to reflect this change
166 | if (slottableElements.has(slottedElementOriginal)) {
167 | slottableElements.delete(slottedElementOriginal);
168 | } else {
169 | this['#'].slottedElements.delete(slottedElement);
170 | // This removal will not be caught
171 | slottedElement.remove();
172 | }
173 | });
174 | // Make sure anchor node is what's in place...
175 | // not the import element itslef - but all only when we have slottableElements.size
176 | if (slottableElements.size && this.el.isConnected) {
177 | const newAnchorNode = this['#'].setAnchorNode(this.createAnchorNode());
178 | this['#'].internalMutation = true;
179 | this.el.replaceWith(newAnchorNode);
180 | this['#'].internalMutation = false;
181 | }
182 | // Insert slottables now
183 | slottableElements.forEach(slottableElement => {
184 | // Clone each slottable element and give it a reference to its original
185 | const slottableElementClone = slottableElement.cloneNode(true);
186 | // The folllowing references must be set before adding to DODM
187 | if (!slottableElementClone.hasAttribute(configs.HTML_IMPORTS.attr.fragmentdef)) {
188 | slottableElementClone.toggleAttribute(configs.HTML_IMPORTS.attr.fragmentdef, true);
189 | }
190 | _wq(slottableElementClone).set('original@imports', slottableElement);
191 | _wq(slottableElementClone).set('slot@imports', this.el);
192 | this['#'].slottedElements.add(slottableElementClone);
193 | this['#'].anchorNode.before(slottableElementClone);
194 | });
195 | });
196 | }
197 |
198 | /**
199 | * Empty slot.
200 | *
201 | * @return void
202 | */
203 | empty() { this['#'].slottedElements.forEach(slottedElement => slottedElement.remove()); }
204 |
205 | /**
206 | * Returns the slot's anchorNode.
207 | *
208 | * @return array
209 | */
210 | get anchorNode() { return this['#'].anchorNode; }
211 |
212 | /**
213 | * Returns the slot's module reference, if any.
214 | *
215 | * @return string
216 | */
217 | get moduleRef() { return this['#'].moduleRef; }
218 |
219 | /**
220 | * Returns the slot's slotted elements.
221 | *
222 | * @return array
223 | */
224 | get slottedElements() { return this['#'].slottedElements; }
225 | }
226 | if (configs.HTML_IMPORTS.elements.import.includes('-')) { customElements.define(configs.HTML_IMPORTS.elements.import, HTMLImportElement); }
227 | webqit.HTMLImportElement = HTMLImportElement;
228 | return HTMLImportElement;
229 | }
--------------------------------------------------------------------------------
/src/data-binding/index.js:
--------------------------------------------------------------------------------
1 | import { resolveParams } from '@webqit/use-live/params';
2 | import { xpathQuery } from '@webqit/realdom/src/realtime/Util.js';
3 | import { _wq, _init, _splitOuter } from '../util.js';
4 |
5 | export default function init( $config = {} ) {
6 | const { config, window } = _init.call( this, 'data-binding', $config, {
7 | attr: { render: 'render', itemIndex: 'data-key' },
8 | tokens: { nodeType: 'processing-instruction', tagStart: '?{', tagEnd: '}?', stateStart: '; [=', stateEnd: ']' },
9 | advanced: resolveParams({ runtimeParams: { spec: { handler: e => {} } } }),
10 | } );
11 | ( { CONTEXT_API: config.CONTEXT_API, BINDINGS_API: config.BINDINGS_API, HTML_IMPORTS: config.HTML_IMPORTS } = window.webqit.oohtml.configs );
12 | config.attrSelector = `[${ window.CSS.escape( config.attr.render ) }]`;
13 | const discreteBindingsMatch = ( start, end ) => {
14 | const starting = `starts-with(., "${ start }")`;
15 | const ending = `substring(., string-length(.) - string-length("${ end }") + 1) = "${ end }"`;
16 | return `${ starting } and ${ ending }`;
17 | }
18 | config.discreteBindingsSelector = `comment()[${ discreteBindingsMatch( config.tokens.tagStart, config.tokens.tagEnd ) }]`;
19 | realtime.call( window, config );
20 | }
21 |
22 | function realtime( config ) {
23 | const window = this, { webqit: { realdom } } = window;
24 | // ----------------
25 | realdom.realtime( window.document ).query( config.attrSelector, record => {
26 | cleanup.call( this, ...record.exits );
27 | mountInlineBindings.call( window, config, ...record.entrants );
28 | queueMicrotask(() => {
29 | });
30 | }, { id: 'data-binding:attr', live: true, subtree: 'cross-roots', timing: 'sync', eventDetails: true, staticSensitivity: true } );
31 | realdom.realtime( window.document ).query( `(${ config.discreteBindingsSelector })`, record => {
32 | cleanup.call( this, ...record.exits );
33 | mountDiscreteBindings.call( window, config, ...record.entrants );
34 | queueMicrotask(() => {
35 | });
36 | }, { id: 'data-binding:descrete', live: true, subtree: 'cross-roots', timing: 'sync' } );
37 | }
38 |
39 | function createDynamicScope( config, root ) {
40 | const { webqit: { realdom, Observer, DOMBindingsContext } } = this;
41 | if ( _wq( root ).has( 'data-binding' ) ) return _wq( root ).get( 'data-binding' );
42 | const scope = Object.create( null ), abortController = new AbortController;
43 | scope[ '$exec__' ] = ( target, prop, ...args ) => {
44 | const exec = () => {
45 | try { target[ prop ]( ...args ); }
46 | catch( e ) { throw new Error( `Error executing "${ prop }()": ${ e.message } at ${ e.cause }` ); }
47 | };
48 | exec();
49 | };
50 | scope[ '$assign__' ] = ( target, prop, val ) => {
51 | const exec = () => {
52 | try { target[ prop ] = val; }
53 | catch( e ) { throw new Error( `Error executing "${ prop } = ${ val }": ${ e.message } at ${ e.cause }` ); }
54 | };
55 | exec();
56 | };
57 | Observer.intercept( scope, {
58 | get: ( e, recieved, next ) => {
59 | if ( !( e.key in scope ) ) {
60 | const request = { ...DOMBindingsContext.createRequest( e.key ), live: true, signal: abortController.signal };
61 | root[ config.CONTEXT_API.api.contexts ].request( request, value => {
62 | Observer.set( scope, e.key, value );
63 | } );
64 | }
65 | return next( scope[ e.key ] ?? ( e.key in globalThis ? globalThis[ e.key ] : undefined ) );
66 | },
67 | has: ( e, recieved, next ) => { return next( true ); }
68 | } );
69 | const instance = { scope, abortController, bindings: new Map };
70 | _wq( root ).set( 'data-binding', instance );
71 | return instance;
72 | }
73 |
74 | function cleanup( ...entries ) {
75 | for ( const node of entries ) {
76 | const root = node.nodeName === '#text' ? node.parentNode : node;
77 | const { bindings, abortController } = _wq( root ).get( 'data-binding' ) || {};
78 | if ( !bindings?.has( node ) ) return;
79 | bindings.get( node ).liveProgramHandle.abort();
80 | bindings.get( node ).signals?.forEach( s => s.abort() );
81 | bindings.delete( node );
82 | if ( !bindings.size ) {
83 | abortController.abort();
84 | _wq( root ).delete( 'data-binding' );
85 | }
86 | }
87 | }
88 |
89 | function patternMatch( config, str ) {
90 | const tagStart = config.tokens.tagStart.split( '' ).map( x => `\\${ x }` ).join( '' );
91 | const tagEnd = config.tokens.tagEnd.split( '' ).map( x => `\\${ x }` ).join( '' );
92 | const stateStart = config.tokens.stateStart.split( '' ).map( x => x === ' ' ? `(?:\\s+)?` : `\\${ x }` ).join( '' );
93 | const stateEnd = config.tokens.stateEnd.split( '' ).map( x => `\\${ x }` ).join( '' );
94 | const pattern = `^${ tagStart }(.*?)(?:${ stateStart }(\\d+)${ stateEnd }(?:\\s+)?)?${ tagEnd }$`;
95 | const [ /*raw*/, expr, span ] = str.match( new RegExp( pattern ) );
96 | return { raw: str, expr, span: parseInt( span ?? 0 ) };
97 | }
98 |
99 | async function mountDiscreteBindings( config, ...entries ) {
100 | const window = this;
101 | const instances = entries.reduce( ( instances, node ) => {
102 | if ( node.isBound ) return instances;
103 | const template = patternMatch( config, node.nodeValue );
104 | let textNode = node;
105 | if ( template.span ) {
106 | textNode = node.nextSibling;
107 | if ( textNode?.nodeName !== '#text' || textNode.nodeValue.length < template.span ) return instances;
108 | if ( textNode.nodeValue.length > template.span ) { textNode.splitText( template.span ); }
109 | } else {
110 | textNode = node.ownerDocument.createTextNode( '' );
111 | node.after( textNode );
112 | }
113 | textNode.isBound = true;
114 | let anchorNode = node;
115 | if ( window.webqit.env !== 'server' ) {
116 | anchorNode.remove();
117 | anchorNode = null;
118 | }
119 | return instances.concat( { textNode, template, anchorNode } );
120 | }, [] );
121 |
122 | for ( const { textNode, template, anchorNode } of instances ) {
123 | const compiled = compileDiscreteBindings.call( window, config, template.expr );
124 | const { scope, bindings } = createDynamicScope.call( this, config, textNode.parentNode );
125 | Object.defineProperty( textNode, '$oohtml_internal_databinding_anchorNode', { value: anchorNode, configurable: true } );
126 | try {
127 | bindings.set( textNode, { liveProgramHandle: await ( await compiled.bind( textNode, scope ) ).execute(), } );
128 | } catch( e ) {
129 | console.log(e);
130 | }
131 | }
132 | }
133 |
134 | const discreteParseCache = new Map;
135 | function compileDiscreteBindings( config, str ) {
136 | if ( discreteParseCache.has( str ) ) return discreteParseCache.get( str );
137 | let source = `let content = ((${ str }) ?? '') + '';`;
138 | source += `$assign__(this, 'nodeValue', content);`;
139 | source += `if ( this.$oohtml_internal_databinding_anchorNode ) { $assign__(this.$oohtml_internal_databinding_anchorNode, 'nodeValue', "${ config.tokens.tagStart }${ escDouble( str ) }${ config.tokens.stateStart }" + content.length + "${ config.tokens.stateEnd } ${ config.tokens.tagEnd }"); }`;
140 | const { webqit: { LiveScript, AsyncLiveScript } } = this;
141 | const { parserParams, compilerParams, runtimeParams } = config.advanced;
142 | const compiled = new (LiveScript || AsyncLiveScript)( source, { parserParams, compilerParams, runtimeParams } );
143 | discreteParseCache.set( str, compiled );
144 | return compiled;
145 | }
146 |
147 | async function mountInlineBindings( config, ...entries ) {
148 | for ( const node of entries ) {
149 | const compiled = compileInlineBindings.call( this, config, node.getAttribute( config.attr.render ) );
150 | const { scope, bindings } = createDynamicScope.call( this, config, node );
151 | const signals = [];
152 | Object.defineProperty( node, '$oohtml_internal_databinding_signals', { value: signals, configurable: true } );
153 | try {
154 | bindings.set( node, { signals, liveProgramHandle: await ( await compiled.bind( node, scope ) ).execute(), } );
155 | } catch( e ) {
156 | console.log(e);
157 | }
158 | }
159 | }
160 |
161 | const inlineParseCache = new Map;
162 | function compileInlineBindings( config, str ) {
163 | if ( inlineParseCache.has( str ) ) return inlineParseCache.get( str );
164 | const validation = {};
165 | let $event_i = -1;
166 | const source = _splitOuter( str, ';' ).map( str => {
167 | const [ left, right ] = _splitOuter( str, ':' ).map( x => x.trim() );
168 | const directive = left[ 0 ], param = left.slice( 1 ).trim();
169 | const arg = `(${ right })`, $arg = `(${ arg } ?? '')`;
170 | // CSS
171 | if ( directive === '&' ) {
172 | if ( param.startsWith( '--' ) ) return `$exec__(this.style, 'setProperty', "${ escDouble( param ) }", ${ $arg });`;
173 | return `$assign__(this.style, "${ escDouble( param ) }", ${ $arg });`;
174 | }
175 | // Class list
176 | if ( directive === '%' ) return `$exec__(this.classList, 'toggle', "${ escDouble( param ) }", !!${ arg });`;
177 | // Attribute
178 | if ( directive === '~' ) {
179 | if ( param.startsWith( '?' ) ) return `$exec__(this, 'toggleAttribute', "${ escDouble( param.substring( 1 ).trim() ) }", !!${ arg });`;
180 | return `$exec__(this, 'setAttribute', "${ escDouble( param ) }", ${ $arg });`;
181 | }
182 | // Structure
183 | if ( directive === '#' ) {
184 | if ( validation[ param ] ) throw new Error( `Duplicate binding: ${ left }.` );
185 | validation[ param ] = true;
186 | if ( param === 'text' ) return `$assign__(this, 'textContent', ${ $arg });`;
187 | if ( param === 'html' ) return `$assign__(this, 'innerHTML', ${ $arg });`;
188 | if ( param === 'items' ) {
189 | const [ iterationSpec, importSpec ] = _splitOuter( right, '/' );
190 | if ( !importSpec ) throw new Error( `Invalid ${ directive }items spec: ${ str }; no import specifier.` );
191 | let [ raw, production, kind, iteratee ] = iterationSpec.trim().match( /(.*?[\)\s+])(of|in)([\(\{\[\s+].*)/i ) || [];
192 | if ( !raw ) throw new Error( `Invalid ${ directive }items spec: ${ str }.` );
193 | if ( production.startsWith( '(' ) ) {
194 | production = production.trim().slice( 1, -1 ).split( ',' ).map( x => x.trim() );
195 | } else { production = [ production ]; }
196 | if ( production.length > ( kind === 'in' ? 3 : 2 ) ) throw new Error( `Invalid ${ directive }items spec: ${ str }.` );
197 | const indices = kind === 'in' ? production[ 2 ] : ( production[ 1 ] || '$index__' );
198 | const src = `
199 | let $iteratee__ = ${ iteratee };
200 | let $import__ = this.${ config.HTML_IMPORTS.api.import }( ${ importSpec.trim() }, true );
201 | this.$oohtml_internal_databinding_signals?.push( $import__ );
202 | if ( $import__.value && $iteratee__ ) {
203 | let $existing__ = new Map;
204 | [ ...this.children ].filter( el => el.matches( '[${ config.attr.itemIndex }]' ) ).forEach( x => {
205 | $existing__.set( x.getAttribute( '${ config.attr.itemIndex }' ), x );
206 | } );
207 | ${ indices ? `let ${ indices } = -1;` : '' }
208 | for ( let ${ production[ 0 ] } ${ kind } $iteratee__ ) {
209 | ${ indices ? `${ indices } ++;` : '' }
210 | ${ kind === 'in' && production[ 1 ] ? `let /*value*/${ production[ 1 ] } = $iteratee__[ ${ production[ 0 ] } ];` : '' }
211 | let $itemBinding__ = { ${ production.join( ', ' ) } };
212 |
213 | const $key___ = ( ${ kind === 'in' ? production[ 0 ] : indices } ) + '';
214 | let $itemNode__ = $existing__.get( $key___ );
215 | if ( $itemNode__ ) {
216 | $existing__.delete( $key___ );
217 | $exec__($itemNode__, '${ config.BINDINGS_API.api.bind }', $itemBinding__ );
218 | } else {
219 | $itemNode__ = ( Array.isArray( $import__.value ) ? $import__.value[ 0 ] : ( $import__.value instanceof window.HTMLTemplateElement ? $import__.value.content.firstElementChild : $import__.value ) ).cloneNode( true );
220 | $itemNode__.setAttribute( "${ config.attr.itemIndex }", $key___ );
221 | $exec__($itemNode__, '${ config.BINDINGS_API.api.bind }', $itemBinding__ );
222 | $exec__(this, 'appendChild', $itemNode__ );
223 | }
224 |
225 | if ( ${ kind === 'in' ? `!( ${ production[ 0 ] } in $iteratee__ )` : `typeof ${ production[ 0 ] } === 'undefined'` } ) { $itemNode__.remove(); }
226 | }
227 | $existing__.forEach( x => x.remove() );
228 | $existing__.clear();
229 | }`;
230 | return src;
231 | }
232 | }
233 | // Events
234 | if ( directive === '@' ) {
235 | $event_i++;
236 | return `
237 | const handler${ $event_i } = event => ${ right.startsWith('{') ? right : arg };
238 | this.addEventListener( '${ param }', handler${ $event_i } );
239 | const abort${ $event_i } = () => this.removeEventListener( '${ param }', handler${ $event_i } );
240 | this.$oohtml_internal_databinding_signals?.push( { abort: abort${ $event_i } } );
241 | `;
242 | }
243 | // Functions
244 | if ( directive === '$' ) {
245 | return `$exec__(this, '${ param }', ${ arg });`;
246 | }
247 | if ( str.trim() ) throw new Error( `Invalid binding: ${ str }.` );
248 | } ).join( `\n` );
249 | const { webqit: { LiveScript, AsyncLiveScript } } = this;
250 | const { parserParams, compilerParams, runtimeParams } = config.advanced;
251 | const compiled = new (LiveScript || AsyncLiveScript)( source, { parserParams, compilerParams, runtimeParams } );
252 | inlineParseCache.set( str, compiled );
253 | return compiled;
254 | }
255 |
256 | const escDouble = str => str.replace(/"/g, '\\"');
257 |
258 | export function idleCompiler( node ) {
259 | const window = this, { webqit: { oohtml: { configs: { DATA_BINDING: config } } } } = window;
260 | // Attr selector must also come first, as in above
261 | ( node?.matches( config.attrSelector ) ? [ node ] : [] ).concat([ ...( node?.querySelectorAll( config.attrSelector ) || [] ) ]).forEach( node => {
262 | compileInlineBindings.call( window, config, node.getAttribute( config.attr.render ) );
263 | } );
264 | xpathQuery( window, node, `(${ config.discreteBindingsSelector })` ).forEach( node => {
265 | const template = patternMatch( config, node.nodeValue );
266 | compileDiscreteBindings.call( window, config, template.expr );
267 | } );
268 | }
--------------------------------------------------------------------------------
/src/namespaced-html/index.js:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * @imports
4 | */
5 | import DOMNamingContext from './DOMNamingContext.js';
6 | import { _wq, _init, _splitOuter, _fromHash, _toHash, getInternalAttrInteraction, internalAttrInteraction } 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, 'namespaced-html', $config, {
15 | attr: { namespace: 'namespace', lid: 'id', },
16 | api: { namespace: 'namespace', },
17 | tokens: { lidrefPrefix: '~', lidrefSeparator: ':' },
18 | target: { className: ':target', eventName: ':target', scrolling: true },
19 | });
20 | config.lidSelector = `[${window.CSS.escape(config.attr.lid)}]`;
21 | config.namespaceSelector = `[${window.CSS.escape(config.attr.namespace)}]`;
22 | window.webqit.DOMNamingContext = DOMNamingContext;
23 | exposeAPIs.call(window, config);
24 | realtime.call(window, config);
25 | }
26 |
27 | /**
28 | * @init
29 | *
30 | * @param Object config
31 | *
32 | * @return String
33 | */
34 | function lidUtil(config) {
35 | const { lidrefPrefix, lidrefSeparator, } = config.tokens;
36 | return {
37 | escape(str, mode = 1) { return [...str].map(x => !/\w/.test(x) ? (mode === 2 ? `\\\\${x}` : `\\${x}`) : x).join(''); },
38 | lidrefPrefix(escapeMode = 0) { return escapeMode ? this.escape(lidrefPrefix, escapeMode) : lidrefPrefix; },
39 | lidrefSeparator(escapeMode = 0) { return escapeMode ? this.escape(lidrefSeparator, escapeMode) : lidrefSeparator; },
40 | isUuid(str, escapeMode = 0) { return str.startsWith(this.lidrefPrefix(escapeMode)) && str.includes(this.lidrefSeparator(escapeMode)); },
41 | //isLidref( str, escapeMode = 0 ) { return str.startsWith( this.lidrefPrefix( escapeMode ) ) && !str.includes( this.lidrefSeparator( escapeMode ) ); },
42 | toUuid(hash, lid, escapeMode = 0) { return hash.endsWith('-root') ? lid : `${this.lidrefPrefix(escapeMode)}${hash}${this.lidrefSeparator(escapeMode)}${lid}`; },
43 | uuidToId(str, escapeMode = 0) { return this.isUuid(str) ? str.split(this.lidrefSeparator(escapeMode))[1] : str; },
44 | uuidToLidref(str, escapeMode = 0) { return this.isUuid(str) ? `${this.lidrefPrefix(escapeMode)}${str.split(this.lidrefSeparator(escapeMode))[1]}` : str; },
45 | }
46 | }
47 |
48 | /**
49 | * @rewriteSelector
50 | *
51 | * @param String selectorText
52 | * @param String namespaceUUID
53 | * @param String scopeSelector
54 | * @param Bool escapeMode
55 | *
56 | * @return String
57 | */
58 | export function rewriteSelector(selectorText, namespaceUUID, scopeSelector = null, escapeMode = 0) {
59 | const window = this, { webqit: { oohtml: { configs: { NAMESPACED_HTML: config } } } } = window;
60 | const $lidUtil = lidUtil(config);
61 | // Match :scope and relative ID selector
62 | const regex = new RegExp(`${scopeSelector ? `:scope|` : ''}#(${$lidUtil.lidrefPrefix(escapeMode + 1)})?([\\w]+${$lidUtil.lidrefSeparator(escapeMode + 1)})?((?:[\\w-]|\\\\.)+)`, 'g');
63 | // Parse potentially combined selectors individually and categorise into categories per whether they have :scope or not
64 | const [cat1, cat2] = _splitOuter(selectorText, ',').reduce(([cat1, cat2], selector) => {
65 | // The deal: match and replace
66 | let quotesMatch, hadScopeSelector;
67 | selector = selector.replace(regex, (match, lidrefPrefixMatch, lidrefSeparatorMatch, id, index) => {
68 | if (!quotesMatch) { // Lazy: stuff
69 | // Match strings between quotes (single or double) and use that qualify matches above
70 | // The string: String.raw`She said, "Hello, John. I\"m your friend." or "you're he're" 'f\'"j\'"f'jfjf`;
71 | // Should yield: `"Hello, John. I\\"m your friend."`, `"you're he're"`, `'f\\'"j\\'"f'`
72 | quotesMatch = [...selector.matchAll(/(["'])(?:(?=(\\?))\2.)*?\1/g)];
73 | }
74 | // Qualify match
75 | if (quotesMatch.some(q => index > q.index && index + match.length < q.index + q[0].length)) return match;
76 | // Replace :scope
77 | if (match === ':scope') {
78 | hadScopeSelector = true;
79 | return scopeSelector;
80 | }
81 | const isLidref = lidrefPrefixMatch && !lidrefSeparatorMatch;
82 | const isUuid = lidrefPrefixMatch && lidrefSeparatorMatch;
83 | if (isUuid) {
84 | return `#${$lidUtil.escape(match.replace('#', ''), 1)}`;
85 | }
86 | // Rewrite relative ID selector
87 | if (isLidref) {
88 | if (config.attr.lid === 'id' && namespaceUUID && !namespaceUUID.endsWith('-root')) {
89 | return `#${$lidUtil.toUuid(namespaceUUID, id, 1)}`;
90 | }
91 | // Fallback to attr-based
92 | }
93 | // Rewrite absolute ID selector
94 | let rewrite;
95 | if (config.attr.lid === 'id') {
96 | rewrite = `:is(#${id},[id^="${$lidUtil.lidrefPrefix(escapeMode)}"][id$="${$lidUtil.lidrefSeparator(escapeMode)}${id}"])`;
97 | } else {
98 | rewrite = `:is(#${id},[${window.CSS.escape(config.attr.lid)}="${id}"])`;
99 | }
100 | return isLidref ? `:is(${rewrite}):not(${scopeSelector ? scopeSelector + ' ' : ''}${config.namespaceSelector} *)` : rewrite;
101 | });
102 | // Category 2 has :scope and category 1 does not
103 | return hadScopeSelector ? [cat1, cat2.concat(selector)] : [cat1.concat(selector), cat2];
104 | }, [[], []]);
105 | // Do the upgrade
106 | let newSelectorText;
107 | if (scopeSelector && cat1.length) {
108 | newSelectorText = [cat1.length > 1 ? `${scopeSelector} :is(${cat1.join(', ')})` : `${scopeSelector} ${cat1[0]}`, cat2.join(', ')].filter(x => x).join(', ');
109 | } else {
110 | newSelectorText = [...cat1, ...cat2].join(', ');
111 | }
112 | return newSelectorText;
113 | }
114 |
115 | /**
116 | * @param Element node
117 | *
118 | * @return Object
119 | */
120 | export function getOwnNamespaceObject(node) {
121 | const window = this;
122 | if (!_wq(node).has('namespace')) {
123 | const namespaceObj = Object.create(null);
124 | _wq(node).set('namespace', namespaceObj);
125 | const isDocumentRoot = [window.Document, window.ShadowRoot].some(x => node instanceof x);
126 | Object.defineProperty(namespaceObj, Symbol.toStringTag, {
127 | get() {
128 | return isDocumentRoot ? 'RootNamespaceRegistry' : 'NamespaceRegistry';
129 | }
130 | });
131 | }
132 | return _wq(node).get('namespace');
133 | }
134 |
135 | /**
136 | * @param Element node
137 | * @param Bool forID
138 | *
139 | * @return Object
140 | */
141 | export function getOwnerNamespaceObject(node, forID = false) {
142 | const window = this, { webqit: { oohtml: { configs: { NAMESPACED_HTML: config } } } } = window;
143 | const isDocumentRoot = [window.Document, window.ShadowRoot].some(x => node instanceof x);
144 | return getOwnNamespaceObject.call(window, isDocumentRoot ? node : ((forID ? node.parentNode : node)?.closest/*can be documentFragment when Shadow DOM*/?.(config.namespaceSelector) || node.getRootNode()));
145 | }
146 |
147 | /**
148 | * @param Object namespaceObj
149 | *
150 | * @return String
151 | */
152 | export function getNamespaceUUID(namespaceObj) {
153 | const isDocumentRoot = Object.prototype.toString.call(namespaceObj) === '[object RootNamespaceRegistry]';
154 | return (_fromHash(namespaceObj) || _toHash(namespaceObj)) + (isDocumentRoot ? '-root' : '');
155 | }
156 |
157 | /**
158 | * Exposes Namespaced HTML with native APIs.
159 | *
160 | * @param Object config
161 | *
162 | * @return Void
163 | */
164 | function exposeAPIs(config) {
165 | const window = this, { webqit: { Observer } } = window;
166 | // The Namespace API
167 | [window.Document.prototype, window.Element.prototype, window.ShadowRoot.prototype].forEach(prototype => {
168 | // No-conflict assertions
169 | const type = prototype === window.Document.prototype ? 'Document' : (prototype === window.ShadowRoot.prototype ? 'ShadowRoot' : 'Element');
170 | if (config.api.namespace in prototype) { throw new Error(`The ${type} prototype already has a "${config.api.namespace}" API!`); }
171 | // Definitions
172 | Object.defineProperty(prototype, config.api.namespace, {
173 | get: function () {
174 | return Observer.proxy(getOwnNamespaceObject.call(window, this));
175 | }
176 | });
177 | });
178 | }
179 |
180 | /**
181 | * Performs realtime capture of elements and builds their relationships.
182 | *
183 | * @param Object config
184 | *
185 | * @return Void
186 | */
187 | function realtime(config) {
188 | const window = this, { webqit: { Observer, realdom, oohtml: { configs }, DOMNamingContext } } = window;
189 |
190 | // ------------
191 | // APIS
192 | // ------------
193 | // See https://wicg.github.io/aom/aria-reflection-explainer.html & https://github.com/whatwg/html/issues/3515 for the ARIA refelction properties idea
194 | // See https://www.w3.org/TR/wai-aria-1.1/#attrs_relationships for the relational ARIA attributes
195 | const idRefsAttrs = ['aria-owns', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'aria-flowto',];
196 | const idRefAttrs = ['for', 'list', 'form', 'aria-activedescendant', 'aria-details', 'aria-errormessage', 'popovertarget'];
197 | const attrList = [config.attr.lid, ...idRefsAttrs, ...idRefAttrs];
198 | const relMap = { id: 'id'/* just in case it's in attrList */, for: 'htmlFor', 'aria-owns': 'ariaOwns', 'aria-controls': 'ariaControls', 'aria-labelledby': 'ariaLabelledBy', 'aria-describedby': 'ariaDescribedBy', 'aria-flowto': 'ariaFlowto', 'aria-activedescendant': 'ariaActiveDescendant', 'aria-details': 'ariaDetails', 'aria-errormessage': 'ariaErrorMessage', 'popovertarget': 'popoverTargetElement' };
199 | const $lidUtil = lidUtil(config);
200 | const uuidsToLidrefs = (node, attrName, getter) => {
201 | if (!getInternalAttrInteraction(node, attrName) && _wq(node, 'attrOriginals').has(attrName)) {
202 | return _wq(node, 'attrOriginals').get(attrName);
203 | }
204 | const value = getter();
205 | if (getInternalAttrInteraction(node, attrName)) return value;
206 | return value && value.split(' ').map(x => (x = x.trim()) && (attrName === config.attr.lid ? $lidUtil.uuidToId : $lidUtil.uuidToLidref).call($lidUtil, x)).join(' ');
207 | };
208 |
209 | // Intercept getElementById()
210 | const getElementByIdDescr = Object.getOwnPropertyDescriptor(window.Document.prototype, 'getElementById');
211 | Object.defineProperty(window.Document.prototype, 'getElementById', {
212 | ...getElementByIdDescr, value(id) {
213 | return this.querySelector(`#${id}`); // To be rewritten at querySelector()
214 | }
215 | });
216 | // Intercept querySelector() and querySelectorAll()
217 | for (const queryApi of ['querySelector', 'querySelectorAll']) {
218 | for (const nodeApi of [window.Document, window.Element]) {
219 | const querySelectorDescr = Object.getOwnPropertyDescriptor(nodeApi.prototype, queryApi);
220 | Object.defineProperty(nodeApi.prototype, queryApi, {
221 | ...querySelectorDescr, value(selector) {
222 | return querySelectorDescr.value.call(this, rewriteSelector.call(window, selector, getNamespaceUUID(getOwnNamespaceObject.call(window, this))));
223 | }
224 | });
225 | }
226 | }
227 | // Intercept getAttribute()
228 | const getAttributeDescr = Object.getOwnPropertyDescriptor(window.Element.prototype, 'getAttribute');
229 | Object.defineProperty(window.Element.prototype, 'getAttribute', {
230 | ...getAttributeDescr, value(attrName) {
231 | const getter = () => getAttributeDescr.value.call(this, attrName);
232 | return attrList.includes(attrName) && !_wq(this, 'lock').get(attrName) ? uuidsToLidrefs(this, attrName, getter) : getter();
233 | }
234 | });
235 | // Hide implementation details on the Attr node too.
236 | const propertyDescr = Object.getOwnPropertyDescriptor(window.Attr.prototype, 'value');
237 | Object.defineProperty(window.Attr.prototype, 'value', {
238 | ...propertyDescr, get() {
239 | const getter = () => propertyDescr.get.call(this);
240 | return attrList.includes(this.name) ? uuidsToLidrefs(this.ownerElement, this.name, getter) : getter();
241 | }
242 | });
243 | const propertyDescr2 = Object.getOwnPropertyDescriptor(window.Node.prototype, 'nodeValue');
244 | Object.defineProperty(window.Node.prototype, 'nodeValue', {
245 | ...propertyDescr2, get() {
246 | const getter = () => propertyDescr2.get.call(this);
247 | return this instanceof window.Attr && attrList.includes(this.name) ? uuidsToLidrefs(this.ownerElement, this.name, getter) : getter();
248 | }
249 | });
250 | // These APIs should return LIDREFS minus the hash part
251 | for (const attrName of attrList) {
252 | if (!(attrName in relMap)) continue;
253 | const domApis = attrName === 'for' ? [window.HTMLLabelElement, window.HTMLOutputElement]
254 | : (attrName === 'popovertarget' ? [window.HTMLButtonElement, window.HTMLInputElement] : [window.Element]);
255 | for (const domApi of domApis) {
256 | const propertyDescr = Object.getOwnPropertyDescriptor(domApi.prototype, relMap[attrName]);
257 | if (!propertyDescr) continue;
258 | Object.defineProperty(domApi.prototype, relMap[attrName], {
259 | ...propertyDescr, get() {
260 | const getter = () => propertyDescr.get.call(this, attrName);
261 | return uuidsToLidrefs(this, attrName, getter);
262 | }
263 | });
264 | }
265 | }
266 | if (config.attr.lid !== 'id') {
267 | // Reflect the custom [config.attr.lid] attribute
268 | Object.defineProperty(window.Element.prototype, config.attr.lid, {
269 | configurable: true, enumerable: true, get() {
270 | return this.getAttribute(config.attr.lid);
271 | }, set(value) {
272 | return this.setAttribute(config.attr.lid, value);
273 | }
274 | });
275 | }
276 |
277 | // ------------
278 | // LOCAL IDS & IDREFS
279 | // ------------
280 | const attrChange = (entry, attrName, value, callback) => {
281 | return internalAttrInteraction(entry, attrName, () => {
282 | if (typeof value === 'function') value = value();
283 | return callback(value);
284 | });
285 | };
286 | const setupBinding = (entry, attrName, value, newNamespaceObj = null) => {
287 | attrChange(entry, attrName, value, value => {
288 | const isLidAttr = attrName === config.attr.lid;
289 | const namespaceObj = newNamespaceObj || getOwnerNamespaceObject.call(window, entry, isLidAttr);
290 | const namespaceUUID = getNamespaceUUID(namespaceObj);
291 | if (isLidAttr) {
292 | const id = $lidUtil.uuidToId(value);
293 | if (Observer.get(namespaceObj, id) !== entry) {
294 | const uuid = $lidUtil.toUuid(namespaceUUID, id);
295 | if (uuid !== value) { entry.setAttribute('id', uuid); }
296 | Observer.set(namespaceObj, id, entry);
297 | }
298 | } else {
299 | _wq(entry, 'attrOriginals').set(attrName, value); // Save original before rewrite
300 | const newAttrValue = value.split(' ').map(idref => (idref = idref.trim()) && $lidUtil.isUuid(idref) ? idref : $lidUtil.toUuid(namespaceUUID, idref)).join(' ');
301 | entry.setAttribute(attrName, newAttrValue);
302 | _wq(namespaceObj).set('idrefs', _wq(namespaceObj).get('idrefs') || new Set);
303 | _wq(namespaceObj).get('idrefs').add(entry);
304 | }
305 | });
306 | };
307 | const cleanupBinding = (entry, attrName, oldValue, prevNamespaceObj = null) => {
308 | attrChange(entry, attrName, oldValue, oldValue => {
309 | const isLidAttr = attrName === config.attr.lid;
310 | const namespaceObj = prevNamespaceObj || getOwnerNamespaceObject.call(window, entry, isLidAttr);
311 | if (isLidAttr) {
312 | const id = $lidUtil.uuidToId(oldValue);
313 | if (Observer.get(namespaceObj, id) === entry) {
314 | Observer.deleteProperty(namespaceObj, id);
315 | }
316 | } else {
317 | const newAttrValue = _wq(entry, 'attrOriginals').get(attrName);// oldValue.split( ' ' ).map( lid => ( lid = lid.trim() ) && $lidUtil.uuidToLidref( lid ) ).join( ' ' );
318 | if (entry.hasAttribute(attrName)) entry.setAttribute(attrName, newAttrValue);
319 | _wq(namespaceObj).get('idrefs')?.delete(entry);
320 | }
321 | });
322 | };
323 |
324 | // ------------
325 | // NAMESPACE
326 | // ------------
327 | realdom.realtime(window.document).query(config.namespaceSelector, record => {
328 | const reAssociate = (entry, attrName, oldNamespaceObj, newNamespaceObj) => {
329 | if (!entry.hasAttribute(attrName)) return;
330 | const attrValue = () => entry.getAttribute(attrName);
331 | cleanupBinding(entry, attrName, attrValue/* Current resolved value as-is */, oldNamespaceObj);
332 | if (entry.isConnected) { setupBinding(entry, attrName, _wq(entry, 'attrOriginals').get(attrName)/* Saved original value */ || attrValue/* Lest it's ID */, newNamespaceObj); }
333 | };
334 | record.exits.forEach(entry => {
335 | if (entry.isConnected) {
336 | const namespaceObj = getOwnNamespaceObject.call(window, entry);
337 | // Detach ID and IDREF associations
338 | for (const node of new Set([...Object.values(namespaceObj), ...(_wq(namespaceObj).get('idrefs') || [])])) {
339 | for (const attrName of attrList) { reAssociate(node, attrName, namespaceObj); }
340 | }
341 | }
342 | // Detach ID associations
343 | const contextsApi = entry[configs.CONTEXT_API.api.contexts];
344 | const ctx = contextsApi.find(DOMNamingContext.kind);
345 | // Detach namespace instance
346 | if (ctx) { contextsApi.detach(ctx); }
347 | });
348 | record.entrants.forEach(entry => {
349 | // Claim ID and IDREF associations
350 | let newSuperNamespaceObj;
351 | const superNamespaceObj = getOwnerNamespaceObject.call(window, entry, true);
352 | for (const node of new Set([...Object.values(superNamespaceObj), ...(_wq(superNamespaceObj).get('idrefs') || [])])) {
353 | if ((newSuperNamespaceObj = getOwnerNamespaceObject.call(window, node, true)) === superNamespaceObj) continue;
354 | for (const attrName of attrList) { reAssociate(node, attrName, superNamespaceObj, newSuperNamespaceObj); }
355 | }
356 | // Attach namespace instance
357 | const contextsApi = entry[configs.CONTEXT_API.api.contexts];
358 | if (!contextsApi.find(DOMNamingContext.kind)) { contextsApi.attach(new DOMNamingContext); }
359 | });
360 | }, { id: 'namespace-html:namespace', live: true, subtree: 'cross-roots', timing: 'sync', staticSensitivity: true, eventDetails: true });
361 |
362 | // DOM realtime
363 | realdom.realtime(window.document).query(`[${attrList.map(attrName => window.CSS.escape(attrName)).join('],[')}]`, record => {
364 | // We do some caching to prevent redundanct lookups
365 | const namespaceNodesToTest = { forID: new Map, forOther: new Map, };
366 | for (const attrName of attrList) {
367 | // Point to the right cache
368 | const _namespaceNodesToTest = attrName === config.attr.lid ? namespaceNodesToTest.forID : namespaceNodesToTest.forOther;
369 | record.exits.forEach(entry => {
370 | if (!entry.hasAttribute(attrName)) return;
371 | // Point to the right namespace node
372 | let namespaceNodeToTest = _namespaceNodesToTest.get(entry);
373 | if (typeof namespaceNodeToTest === 'undefined') {
374 | namespaceNodeToTest = (attrName === config.attr.lid ? entry.parentNode : entry)?.closest/*can be documentFragment when Shadow DOM*/?.(config.namespaceSelector) || entry.getRootNode().host;
375 | _namespaceNodesToTest.set(entry, namespaceNodeToTest);
376 | }
377 | if (namespaceNodeToTest && !namespaceNodeToTest.isConnected) return;
378 | cleanupBinding(entry, attrName, () => entry.getAttribute(attrName)/* Current resolved value as-is */);
379 | });
380 | record.entrants.forEach(entry => {
381 | if (!entry.hasAttribute(attrName)) return;
382 | setupBinding(entry, attrName, () => entry.getAttribute(attrName)/* Raw value (as-is) that will be saved as original */);
383 | });
384 | }
385 | namespaceNodesToTest.forID.clear();
386 | namespaceNodesToTest.forOther.clear();
387 | }, { id: 'namespace-html:attrs', live: true, subtree: 'cross-roots', timing: 'sync' });
388 | // Attr realtime
389 | realdom.realtime(window.document, 'attr').observe(attrList, records => {
390 | for (const record of records) {
391 | if (record.oldValue && record.value !== record.oldValue) {
392 | cleanupBinding(record.target, record.name, record.oldValue/* Current resolved value as-is */);
393 | }
394 | if (record.value && record.value !== record.oldValue) {
395 | setupBinding(record.target, record.name, record.value/* Raw value (as-is) that will be saved as original */);
396 | }
397 | }
398 | }, { id: 'namespace-html:attr(attrs)', subtree: 'cross-roots', timing: 'sync', newValue: true, oldValue: true });
399 |
400 | // ------------
401 | // TARGETS
402 | // ------------
403 | let prevTarget;
404 | const activateTarget = () => {
405 | if (!window.location.hash?.startsWith(`#${$lidUtil.lidrefPrefix()}`)) return;
406 | const path = window.location.hash?.substring(`#${$lidUtil.lidrefPrefix()}`.length).split('/').map(s => s.trim()).filter(s => s) || [];
407 | const currTarget = path.reduce((prev, segment) => prev && prev[config.api.namespace][segment], window.document);
408 | if (prevTarget && config.target.className) { prevTarget.classList.toggle(config.target.className, false); }
409 | if (currTarget && currTarget !== window.document) {
410 | if (config.target.className) { currTarget.classList.toggle(config.target.className, true); }
411 | if (config.target.eventName) { currTarget.dispatchEvent(new window.CustomEvent(config.target.eventName)); }
412 | if (config.target.scrolling && path.length > 1) { currTarget.scrollIntoView(); }
413 | prevTarget = currTarget;
414 | }
415 | };
416 | // "hash" realtime
417 | window.addEventListener('hashchange', activateTarget);
418 | realdom.ready(activateTarget);
419 | // ----------------
420 | }
421 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OOHTML — _HTML for the Modern UI_
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![bundle][bundle-src]][bundle-href]
5 | [![License][license-src]][license-href]
6 |
7 | HTML lacks the component system, reactivity, and other paradigms that underpin modern UI development. OOHTML brings these capabilities to HTML and makes it possible to directly author user interfaces in HTML.
8 |
9 | OOHTML is _Object-Oriented HTML_. It is a semantic layer over standard HTML that adds new behaviours to the DOM — including reactivity and a declarative component system.
10 |
11 | It comes as a script that plugs diretly into the DOM and have new semantics take effect. No compile step or setup is required. And that makes it especially convenient to work with.
12 |
13 | ```html
14 |
15 | ```
16 |
17 | ## Capabilities
18 |
19 | ### `1 | ` A component system
20 |
21 | OOHTML enables a simple "define-and-use" system in HTML that is based on two complementary elements — the `` and `` elements. It makes it really easy to share repeating structures and stay organized.
22 |
23 | ### `2 | ` Data-binding and reactivity
24 |
25 | OOHTML gives HTML the concept of data-binding (`{ expression }`) and reactivity that lets you embed application data in markup and have them stay in sync with application state. You get framework-grade reactivity without the overhead.
26 |
27 | ### `3 | ` New scoping behaviours
28 |
29 | OOHTML extends the existing CSS scoping system to support the familiar `
211 |
212 |
213 |
222 |
223 |
224 |
225 |
226 |
227 | { globalTitle }?>
228 | { body }?>
229 | Global count: { count }?>
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
249 |
250 |
251 | ```
252 |
253 | It resolves to the following, at runtime:
254 |
255 | ```html
256 |
257 |
258 |
259 |
260 |
261 |
262 | Card Demo
263 | Rendered outside the component.
264 | Global count: 0
265 |
266 |
267 | Card Title – Card Demo
268 | Rendered inside the component.
269 | Local count: 0
270 |
273 |
276 |
277 |
278 |
279 | Card Title – Card Demo
280 | Rendered inside the component.
281 | Local count: 0
282 |
285 |
288 |
289 |
290 |
291 |
292 |
295 |
296 |
297 | ```
298 |
299 | #### Up there…
300 |
301 | - We have a single component imported twice.
302 | - Style, script, and IDs scoped to the component so that repeating the structure in the DOM don't create collisions.
303 | - Bindings resolve seamlessly inside and outside the component.
304 | - The template ↔ import relationship hold live as before.
305 | - Such that if you located the original ` → ` element in the browser's console and deleted the node, all imports would dissolve; restored, all imports would resolve.
306 | - Similarly, the namespace ↔ ID relationship, and the data ↔ binding relationship, all hold live.
307 |
308 | ## HTML for the Modern UI
309 |
310 | By simply enhancing HTML, OOHTML makes it possible to directly author modern user interfaces in HTML and effectively removes the tooling tax traditionally associated with UI development. In place of a compile step, you get a back-to-the-basics experience and an edit-in-the-browser workflow.
311 |
312 | > [!TIP]
313 | > In addition to inline components, OOHTML also supports file-based components. It's companion CLI tool – [OOHTML CLI](https://github.com/webqit/oohtml-cli) – lets you define your components in files and have them come together into a single file that you can directly import into your page.
314 |
315 | > [!TIP]
316 | > OOHTML solves the UI side of your application. You would need a framework to build a complete app with OOHTML. [Webflo](https://github.com/webqit/webflo) is a modern fullstack framework that converges on OOHTML for the UI. You even get Hot Module Replacement (HMR) on top as you edit your HTML components.
317 |
318 | ## Not a Replacement for Shadow DOM
319 |
320 | OOHTML comes as its own addition to the DOM – alongside Web Components and Shadow DOM. Far from an Anti-Shadow DOM effort, OOHTML complements the HTML authoring experience inside the Shadow DOM itself – as it does outside of it. When used in the Shadow DOM, the Shadow DOM simply becomes the document that OOHTML sees – the Shadow Root itself (`#shadow-root`) being the new `document` root that OOHTML works with.
321 |
322 | Leveraging OOHTML in the Shadow DOM requires no additional step. Simply have the OOHTML script loaded in the main document as before and write.
323 |
324 | For a quick way to see OOHTML in the Shadow DOM, we could suppose the whole of [example 3](#3---more-typical-usage-patterns) above as the Shadow DOM of a certain Web Component. It would look like this:
325 |
326 | Web Component + Shadow DOM + OOHTML – (Click to show)
327 |
328 | ```html
329 |
330 |
331 |
332 |
333 |
401 |
402 |
403 |
404 |
405 |
406 |
407 | ```
408 |
409 | > [!IMPORTANT]
410 | > The example above follows the pattern that OOHTML currently supports: injecting the shadow DOM markup in the `connectedCallback()` phase. Doing so in the constructor currently doesn't work. Declarative Shadow DOM is also not supported yet. We hope to overcome this limitation in future versions.
411 |
412 |
413 |
414 | ## Documentation
415 |
416 | OOHTML adds a coherent set of capabilities to HTML: a declarative component system, data-binding and reactivity, scoped styles and scripts, namespaces for IDs, and the underlying context model that ties them together.
417 |
418 | This README introduces the ideas.
419 | The full reference each lives in the Wiki.
420 |
421 | | Capability | Description | Reference |
422 | | :-------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------- |
423 | | **HTML Imports** | Declarative & imperative module imports (`` · ``) including remote modules, inheritance, contexts, and live resolution. | [HTML Imports](https://github.com/webqit/oohtml/wiki/HTML-Imports) |
424 | | **Data Binding** | Comment-based bindings (`{ }?>`), attribute-based bindings (`render="…”`), list rendering, and runtime updates. | [Data Binding](https://github.com/webqit/oohtml/wiki/Data-Binding) |
425 | | **DOM Scoping** | Style and script scoping (`