Skip to content

Commit

Permalink
feat: add support for q:base (#93)
Browse files Browse the repository at this point in the history
* feat: add support for `q:base`

* fixup! feat: add support for `q:base`

Co-authored-by: Misko Hevery <>
  • Loading branch information
mhevery authored Nov 30, 2021
1 parent 490b902 commit 98eef9c
Show file tree
Hide file tree
Showing 15 changed files with 231 additions and 118 deletions.
2 changes: 1 addition & 1 deletion WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ check_rules_nodejs_version(minimum_version_string = "2.2.0")

# Setup the Node.js toolchain
node_repositories(
node_version = "15.0.1",
package_json = ["//:package.json"],
node_version = "16.6.2",
)

yarn_install(
Expand Down
11 changes: 4 additions & 7 deletions integration/specs/qwikloader_spec.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
<html>
<head>
<script src="/qwikloader.js" type="module"></script>
<link rel="q.protocol.base" href="./" />
</head>
<body>
<h1>it should register all events</h1>
Expand All @@ -13,9 +12,7 @@ <h1>it should register all events</h1>
- it can listen on all events an
- and it can process protocol urls.
</pre>
<button
on:click="base:qwikloader_spec#updateElement?selector=%23click_test>pre&content=PASSED"
>
<button on:click="./qwikloader_spec#updateElement?selector=%23click_test>pre&content=PASSED">
click
</button>
</test>
Expand All @@ -26,7 +23,7 @@ <h1>it should listen on non-bubbling event</h1>
Verify that non-bubbling events are correctly captured.
</pre>
<button
on:mouseenter="base:qwikloader_spec#updateElement?selector=%23non_bubbling_event>pre&content=PASSED"
on:mouseenter="./qwikloader_spec#updateElement?selector=%23non_bubbling_event>pre&content=PASSED"
>
mouseenter
</button>
Expand All @@ -35,7 +32,7 @@ <h1>it should listen on non-bubbling event</h1>
<h1>it should set up `$init` event</h1>
<test id="autofire_$init">
<pre
on:q-init="base:qwikloader_spec#updateElement?selector=%23autofire_\$init>pre&content=PASSED"
on:q-init="./qwikloader_spec#updateElement?selector=%23autofire_\$init>pre&content=PASSED"
>
Verify qwikloader correctly fires `$init` event on start.
</pre>
Expand All @@ -44,7 +41,7 @@ <h1>it should set up `$init` event</h1>
<h1>it should broadcast document events</h1>
<test id="broadcast_scroll">
<pre
on:document:scroll="base:qwikloader_spec#updateElement?selector=%23broadcast_scroll>pre&content=PASSED"
on:document:scroll="./qwikloader_spec#updateElement?selector=%23broadcast_scroll>pre&content=PASSED"
>
Verify qwikloader correctly fires `scroll` event on scroll.
</pre>
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@
"link.dist": "cd dist-dev/@builder.io-qwik && npm link"
},
"devDependencies": {
"@bazel/typescript": "^3.5.0",
"@bazel/ibazel": "^0.15.10",
"@bazel/bazelisk": "^1.7.5",
"@microsoft/api-extractor": "^7.18.19",
"@napi-rs/cli": "^1.3.5",
"@napi-rs/triples": "^1.0.3",
"@node-rs/helper": "^1.2.1",
"@octokit/action": "^3.18.0",
"@types/cross-spawn": "^6.0.2",
Expand Down
3 changes: 3 additions & 0 deletions scripts/package_build.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def package_build(
"@npm//rollup",
"@npm//source-map-support",
"@npm//terser",
"@npm//@octokit/action",
"@npm//typescript",
"@npm//@types/cross-spawn",
"@npm//cross-spawn",
Expand All @@ -34,6 +35,8 @@ def package_build(
"@npm//semver",
"@npm//path-browserify",
"@npm//@types/path-browserify",
"@npm//@types/prompts",
"@npm//prompts",
"//scripts:all_build_source",
],
outs = [
Expand Down
41 changes: 22 additions & 19 deletions src/bootloader-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,26 @@
*/
export const qrlResolver = (
doc: Document,
eventUrl: string | null | undefined,
linkElm?: HTMLLinkElement,
href?: string,
url?: URL
element: Element | null,
eventUrl?: string | null,
_url?: string,
_base?: string | URL
): URL | undefined => {
if (eventUrl) {
url = new URL(
eventUrl.replace(/^(\w+):(\/)?/, (str, protocol, slash) => {
linkElm = doc.querySelector(`[rel="q.protocol.${protocol}"]`) as HTMLLinkElement;
href = linkElm && linkElm.href;
if (!href) error(protocol + ' not defined');
return href + (href!.endsWith('/') ? '' : slash || '');
}),
doc.baseURI
);
url.pathname += '.js';
if (eventUrl === undefined) {
// recursive call
if (element) {
_url = element.getAttribute('q:base')!;
_base = qrlResolver(
doc,
element.parentNode && (element.parentNode as HTMLElement).closest('[q\\:base]')
);
} else {
_url = doc.baseURI;
}
} else if (eventUrl) {
(_url = eventUrl + '.js'), (_base = qrlResolver(doc, element!.closest('[q\\:base]')));
}
return url;
return _url ? new URL(_url, _base) : undefined;
};

const error = (msg: string) => {
Expand All @@ -67,7 +69,7 @@ export const qwikLoader = (doc: Document, hasInitialized?: boolean | number) =>
};

const dispatch = async (element: Element, eventName: string, ev: Event, url?: URL) => {
url = qrlResolver(doc, element.getAttribute('on:' + eventName));
url = qrlResolver(doc, element, element.getAttribute('on:' + eventName));
if (url) {
const handler = getModuleExport(
url,
Expand Down Expand Up @@ -172,13 +174,14 @@ export const setupPrefetching = (
const intersectionObserverCallback = (items: IntersectionObserverEntry[]) => {
items.forEach((item) => {
if (item.intersectionRatio > 0) {
const attrs = item.target.attributes;
const element = item.target;
const attrs = element.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
const name = attr.name;
const value = attr.value;
if (name.startsWith('on:') && value) {
const url = qrlResolver(doc, value)!;
const url = qrlResolver(doc, element, value)!;
url.hash = url.search = '';
const key = url.toString();
if (!qrlCache[key]) {
Expand Down
42 changes: 25 additions & 17 deletions src/bootloader-shared.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,30 +98,38 @@ describe('qwikloader', () => {
doc = createDocument();
});

it('get protocol, trim ending slash', () => {
const link = doc.createElement('link');
link.setAttribute('rel', 'q.protocol.test');
link.href = 'http://qwik.dev/test/';
doc.head.appendChild(link);
it('should resolve full URL', () => {
const div = doc.createElement('div');
expect(String(qrlResolver(doc, div, 'http://foo.bar/baz'))).toEqual('http://foo.bar/baz.js');
});

const url = qrlResolver(doc, 'test:/hi')!;
expect(url.pathname).toBe('/test/hi.js');
it('should resolve relative URL against base', () => {
const div = doc.createElement('div');
expect(String(qrlResolver(doc, div, './bar'))).toEqual('http://document.qwik.dev/bar.js');
});

it('get protocol', () => {
const link = doc.createElement('link');
link.setAttribute('rel', 'q.protocol.test');
link.href = 'http://qwik.dev/test';
doc.head.appendChild(link);
it('should resolve relative URL against q:base', () => {
const div = doc.createElement('div');
div.setAttribute('q:base', '../baz/');
expect(String(qrlResolver(doc, div, './bar'))).toEqual('http://document.qwik.dev/baz/bar.js');
});

const url = qrlResolver(doc, 'test:/hi')!;
expect(url.pathname).toBe('/test/hi.js');
it('should resolve relative URL against nested q:base', () => {
const div = doc.createElement('div');
const parent = doc.createElement('parent');
doc.body.appendChild(parent);
parent.appendChild(div);
parent.setAttribute('q:base', './parent/');
div.setAttribute('q:base', './child/');
expect(String(qrlResolver(doc, div, './bar'))).toEqual(
'http://document.qwik.dev/parent/child/bar.js'
);
});

it('do nothing for null/undefined/empty string', () => {
expect(qrlResolver(doc, null)).toBeFalsy();
expect(qrlResolver(doc, undefined)).toBeFalsy();
expect(qrlResolver(doc, '')).toBeFalsy();
const div = doc.createElement('div');
expect(qrlResolver(doc, null, null)).toBeFalsy();
expect(qrlResolver(doc, div, '')).toBeFalsy();
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/core/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export function qHook<PROPS extends {}, STATE extends {} | undefined | unknown,
export function qHook<COMP extends QComponent, ARGS extends {} | unknown = unknown, RET = unknown>(hook: (props: PropsOf<COMP>, state: StateOf<COMP>, args: ARGS) => ValueOrPromise<RET>): QHook<PropsOf<COMP>, StateOf<COMP>, any, RET>;

// @public
export function qImport<T>(node: Node | Document, url: string | QRL<T> | URL): T | Promise<T>;
export function qImport<T>(element: Element, url: string | QRL<T> | URL): T | Promise<T>;

// @public (undocumented)
export type QObject<T extends {}> = T & {
Expand Down
5 changes: 3 additions & 2 deletions src/core/component/q-component-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ export function getHostElement(element: Element): HTMLElement | null {
function insertStyleIfNeeded(ctx: QComponentCtx, style: string | null) {
if (style) {
const styleId = styleKey(style as any)!;
const document = ctx.hostElement.ownerDocument;
const host = ctx.hostElement;
const document = host.ownerDocument;
const head = document.querySelector('head')!;
if (!head.querySelector(`style[q\\:style="${styleId}"]`)) {
const styleImport = Promise.resolve(qImport<string>(document, style));
const styleImport = Promise.resolve(qImport<string>(host, style));
styleImport.then((styles: string) => {
const style = document.createElement('style');
style.setAttribute('q:style', styleId);
Expand Down
7 changes: 3 additions & 4 deletions src/core/component/q-component.unit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { Fragment, h, qHook, qObject } from '@builder.io/qwik';
import { ElementFixture, trigger } from '../../testing/element_fixture';
import { expectDOM } from '../../testing/expect-dom.unit';
import { qRender } from '../render/q-render.public';
import { TEST_CONFIG } from '../util/test_config';
import { qComponent } from './q-component.public';
import { qStyles } from './qrl-styles';

describe('q-component', () => {
it('should declare and render basic component', async () => {
const fixture = new ElementFixture(TEST_CONFIG);
const fixture = new ElementFixture();
await qRender(fixture.host, <HelloWorld></HelloWorld>);
expectDOM(
fixture.host,
Expand All @@ -21,7 +20,7 @@ describe('q-component', () => {
});

it('should render Counter and accept events', async () => {
const fixture = new ElementFixture(TEST_CONFIG);
const fixture = new ElementFixture();
await qRender(fixture.host, <MyCounter step={5} value={15} />);
expectDOM(
fixture.host,
Expand Down Expand Up @@ -51,7 +50,7 @@ describe('q-component', () => {
});

it('should render a collection of todo items', async () => {
const host = new ElementFixture(TEST_CONFIG).host;
const host = new ElementFixture().host;
const items = qObject({
items: [
qObject({
Expand Down
64 changes: 23 additions & 41 deletions src/core/import/qImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ import { assertDefined } from '../assert/assert';
/**
* Lazy load a `QRL` symbol and returns the resulting value.
*
* @param base -`QRL`s are relative, and therefore they need a base for resolution.
* - `Element` use `base.ownerDocument.baseURI`
* - `Document` use `base.baseURI`
* @param element - Location of the URL to resolve against.
* @param url - A relative URL (as `string` or `QRL`) or fully qualified `URL`
* @returns A cached value synchronously or promise of imported value.
* @public
*/
export function qImport<T>(node: Node | Document, url: string | QRL<T> | URL): T | Promise<T> {
export function qImport<T>(element: Element, url: string | QRL<T> | URL): T | Promise<T> {
if (isParsedQRL(url)) {
assertDefined(url._serialized);
url = Array.isArray(url._serialized) ? url._serialized[0] : url._serialized!;
Expand All @@ -35,9 +33,9 @@ export function qImport<T>(node: Node | Document, url: string | QRL<T> | URL): T
return Promise.resolve<T>(testSymbol);
}
}
const doc: QDocument = node.ownerDocument || (node as Document);
const doc: QDocument = element.ownerDocument!;
const corePlatform = getPlatform(doc);
const normalizedUrl = toUrl(doc, url);
const normalizedUrl = toUrl(doc, element, url);
const importPath = corePlatform.toPath(normalizedUrl);
const exportName = qExport(normalizedUrl);
const cacheKey = importPath + '#' + exportName;
Expand Down Expand Up @@ -82,43 +80,27 @@ export function qImportSet(doc: QDocument, cacheKey: string, value: any): void {
* @param url - relative URL
* @returns fully qualified URL.
*/
export function toUrl(doc: Document, url: string | QRL | URL): URL {
if (typeof url === 'string') {
const baseURI = getConfig(doc, `baseURI`) || doc.baseURI;
return new URL(adjustProtocol(doc, url), baseURI);
} else {
return url as URL;
}
}
export function toUrl(doc: Document, element: Element | null, url?: string | QRL | URL): URL {
let _url: string | QRL | URL;
let _base: string | URL | undefined = undefined;

/**
* Convert custom protocol to path by looking it up in `QConfig`
*
* Paths such as
* ```
* QRL`foo:/bar`
* ```
*
* The `QRL` looks up `foo` in the document's `<link ref="q.protocol.foo" href="somePath">`
* resulting in `somePath/bar`
*
* @param doc
* @param qrl
* @returns URL where the custom protocol has been resolved.
*/
function adjustProtocol(doc: Document, qrl: string | QRL): string {
return String(qrl).replace(/(^\w+):\/?/, (all, protocol) => {
let value = getConfig(doc, `protocol.` + protocol);
if (value && !value.endsWith('/')) {
value = value + '/';
if (url === undefined) {
// recursive call
if (element) {
_url = element.getAttribute('q:base')!;
_base = toUrl(
doc,
element.parentNode && (element.parentNode as HTMLElement).closest('[q\\:base]')
);
} else {
_url = doc.baseURI;
}
return value || all;
});
}

function getConfig(doc: Document, configKey: string) {
const linkElm = doc.querySelector(`link[rel="q.${configKey}"]`) as HTMLLinkElement;
return linkElm && linkElm.getAttribute('href');
} else if (url) {
(_url = url), (_base = toUrl(doc, element!.closest('[q\\:base]')));
} else {
throw new Error('INTERNAL ERROR');
}
return new URL(String(_url), _base);
}

/**
Expand Down
12 changes: 7 additions & 5 deletions src/core/import/qImport.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@
* found in the LICENSE file at https://github.com/BuilderIO/qwik/blob/main/LICENSE
*/

import { TEST_CONFIG } from '../util/test_config';
import { qExport, qImport, qParams } from '../import/qImport';
import { ElementFixture, isPromise } from '@builder.io/qwik/testing';
import { getBaseUri } from '../util/base_uri';

describe('qImport', () => {
it('should import default symbol', async () => {
const fixture = new ElementFixture(TEST_CONFIG);
const valuePromise = qImport(fixture.host, 'import:qImport_default_unit');
const fixture = new ElementFixture();
fixture.host.setAttribute('q:base', 'file://' + getBaseUri());
const valuePromise = qImport(fixture.host, './qImport_default_unit');
expect(isPromise(valuePromise)).toBe(true);
expect(await valuePromise).toEqual('DEFAULT_VALUE');
// second read is direct.
expect(qImport(fixture.host, 'import:qImport_default_unit')).toEqual('DEFAULT_VALUE');
expect(qImport(fixture.host, './qImport_default_unit')).toEqual('DEFAULT_VALUE');
});

it('should import symbol from extension', async () => {
const fixture = new ElementFixture(TEST_CONFIG);
const fixture = new ElementFixture();
fixture.host.setAttribute('q:base', 'file://' + getBaseUri());
const valuePromise = qImport(fixture.host, '../import/qImport_symbol_unit#mySymbol');
expect(isPromise(valuePromise)).toBe(true);
expect(await valuePromise).toEqual('MY_SYMBOL_VALUE');
Expand Down
Loading

0 comments on commit 98eef9c

Please sign in to comment.