Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@quilted/typescript": "^0.4.2",
"@quilted/vite": "^0.1.27",
"@types/node": "~20.11.0",
"jest-extended": "^4.0.2",
"jsdom": "^25.0.0",
"prettier": "^3.3.3",
"rollup": "^4.21.0",
Expand Down
3 changes: 1 addition & 2 deletions packages/polyfill/source/DocumentFragment.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {NAME, OWNER_DOCUMENT, NodeType} from './constants.ts';
import {NAME, NodeType} from './constants.ts';
import {ParentNode} from './ParentNode.ts';

export class DocumentFragment extends ParentNode {
nodeType = NodeType.DOCUMENT_FRAGMENT_NODE;
[NAME] = '#document-fragment';
[OWNER_DOCUMENT] = window.document as any;
}
4 changes: 3 additions & 1 deletion packages/polyfill/source/ParentNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export class ParentNode extends ChildNode {
}

removeChild(child: Node) {
if (child.parentNode !== this) throw Error(`not a child of this node`);
if (child[PARENT] !== this) throw Error(`not a child of this node`);
child[PARENT] = null;

const prev = child[PREV];
const next = child[NEXT];
if (prev) prev[NEXT] = next;
Expand Down
35 changes: 24 additions & 11 deletions packages/polyfill/source/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,26 @@ import type {Comment} from './Comment.ts';
import type {ParentNode} from './ParentNode.ts';
import type {Element} from './Element.ts';

// const voidElements = {
// img: true,
// image: true,
// };
// const elementTokenizer =
// /(?:<([a-z][a-z0-9-:]*)( [^<>'"\n=\s]+=(['"])[^>'"\n]*\3)*\s*(\/?)\s*>|<\/([a-z][a-z0-9-:]*)>|([^&<>]+))/gi;
// const attributeTokenizer = / ([^<>'"\n=\s]+)=(['"])([^>'"\n]*)\2/g;
const ENTITIES = {
amp: '&',
quot: '"',
apos: "'",
lt: '<',
gt: '>',
} as const;

function decode(str: string) {
return str.replace(
/&(?:(amp|quot|apos|lt|gt)|#(\d+));/gi,
(s, e: keyof typeof ENTITIES, d) =>
d ? String.fromCharCode(d) : ENTITIES[e] || s,
);
}

const elementTokenizer =
/(?:<([a-z][a-z0-9-:]*)((?:\s[^<>'"=\n\s]+(?:=(['"])[^\n]*?\3|=[^>'"\n\s]*|))*)\s*(\/?)\s*>|<\/([a-z][a-z0-9-:]*)>|<!--(.*?)-->|([^&<>]+))/gi;
/(?:<([a-z][a-z0-9-:]*)((?:\s+[^<>'"=\s]+(?:=(['"]).*?\3|=[^>'"\s]*|))*)\s*(\/?)\s*>|<\/([a-z][a-z0-9-:]*)>|<!--(.*?)-->|([^<>]+))/gis;

const attributeTokenizer =
/\s([^<>'"=\n\s]+)(?:=(['"])([^\n]*?)\2|=([^>'"\n\s]*)|)/g;
const attributeTokenizer = /\s+([^<>'"=\s]+)(?:=(['"])(.*?)\2|=([^>'"\s]*)|)/gs;

export function parseHtml(html: string, contextNode: Node) {
const document = contextNode.ownerDocument;
Expand Down Expand Up @@ -53,7 +60,13 @@ export function parseHtml(html: string, contextNode: Node) {
} else if (token[6]) {
parent.append(document.createComment(token[6]!));
} else {
parent.append(token[7]!);
const lastChild = parent.lastChild;
const text = decode(token[7]!);
if (lastChild && lastChild.nodeType === NodeType.TEXT_NODE) {
(lastChild as Text).data += text;
} else {
parent.append(text);
}
}
}
return root;
Expand Down
25 changes: 25 additions & 0 deletions packages/polyfill/source/tests/Node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {describe, it, expect} from 'vitest';

import {NAME} from '../constants';
import {Node} from '../Node';
import {createNode} from '../Document';
import {setupScratch} from './helpers';

describe('Node', () => {
const {document, hooks} = setupScratch();

it('can be constructed', () => {
const node = createNode(new Node(), document);
expect(node.ownerDocument).toBe(document);
expect(node.isConnected).toBe(false);
expect(hooks.createText).not.toHaveBeenCalled();
expect(hooks.insertChild).not.toHaveBeenCalled();
});

it('exposes localName & nodeName', () => {
const node = createNode(new Node(), document);
node[NAME] = '#text';
expect(node.localName).toBe('#text');
expect(node.nodeName).toBe('#TEXT');
});
});
177 changes: 177 additions & 0 deletions packages/polyfill/source/tests/ParentNode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {describe, beforeEach, it, expect} from 'vitest';

import {Node} from '../Node';
import {setupScratch} from './helpers';

describe('ParentNode', () => {
const ctx = setupScratch();
const {hooks} = ctx;

describe('append and remove', () => {
it('can be appended to a parent', () => {
const node = ctx.document.createTextNode('');
ctx.scratch.append(node);
expect(ctx.scratch.childNodes).toEqual([node]);
expect(ctx.scratch.children).toEqual([]);
expect(hooks.insertChild).toHaveBeenCalledOnce();
expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 0);
expect(node.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
});

it('can be removed from a parent', () => {
const node = ctx.document.createTextNode('');
ctx.scratch.append(node);
ctx.scratch.removeChild(node);
expect(ctx.scratch.childNodes).toEqual([]);
expect(hooks.insertChild).toHaveBeenCalledOnce();
expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 0);
expect(hooks.removeChild).toHaveBeenCalledOnce();
expect(hooks.removeChild).toHaveBeenCalledAfter(hooks.insertChild);
expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node, 0);
expect(node.parentNode).toBe(null);
expect(node.isConnected).toBe(false);
});

it('throws if removed from wrong parent', () => {
const node = ctx.document.createTextNode('');
ctx.scratch.append(node);
expect(() => {
ctx.document.body.removeChild(node);
}).toThrow();
expect(hooks.removeChild).not.toHaveBeenCalled();
expect(node.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
});

it('throws if removed twice', () => {
const node = ctx.document.createTextNode('');
ctx.scratch.append(node);
ctx.scratch.removeChild(node);
ctx.clearMocks();
expect(() => {
ctx.scratch.removeChild(node);
}).toThrow();
expect(hooks.removeChild).not.toHaveBeenCalled();
});
});

describe('re-insertion into current parent', () => {
let node: Node;
let node2: Node;
let node3: Node;
beforeEach(() => {
node = ctx.document.createElement('node');
node2 = ctx.document.createElement('node2');
node3 = ctx.document.createElement('node3');
ctx.scratch.append(node, node2, node3);
ctx.clearMocks();
});

it('move to end', () => {
ctx.scratch.insertBefore(node, null);
expect(ctx.scratch.childNodes).toEqual([node2, node3, node]);
expect(ctx.scratch.children).toEqual([node2, node3, node]);
expect(hooks.removeChild).toHaveBeenCalledOnce();
expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node, 0);
expect(hooks.insertChild).toHaveBeenCalledOnce();
expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 2);
expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild);
expect(node.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
});

it('move to start', () => {
ctx.scratch.insertBefore(node3, node);
expect(ctx.scratch.childNodes).toEqual([node3, node, node2]);
expect(ctx.scratch.children).toEqual([node3, node, node2]);
expect(hooks.removeChild).toHaveBeenCalledOnce();
expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node3, 2);
expect(hooks.insertChild).toHaveBeenCalledOnce();
expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node3, 0);
expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild);
expect(node.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
});

it('reinsert at end', () => {
ctx.scratch.appendChild(node3);
expect(ctx.scratch.childNodes).toEqual([node, node2, node3]);
expect(ctx.scratch.children).toEqual([node, node2, node3]);
expect(hooks.removeChild).toHaveBeenCalledOnce();
expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node3, 2);
expect(hooks.insertChild).toHaveBeenCalledOnce();
expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node3, 2);
expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild);
expect(node.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
});

it('reinsert at start', () => {
ctx.scratch.insertBefore(node, node2);
expect(ctx.scratch.childNodes).toEqual([node, node2, node3]);
expect(ctx.scratch.children).toEqual([node, node2, node3]);
expect(hooks.removeChild).toHaveBeenCalledOnce();
expect(hooks.removeChild).toHaveBeenCalledWith(ctx.scratch, node, 0);
expect(hooks.insertChild).toHaveBeenCalledOnce();
expect(hooks.insertChild).toHaveBeenCalledWith(ctx.scratch, node, 0);
expect(hooks.insertChild).toHaveBeenCalledAfter(hooks.removeChild);
expect(node.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
});

it('reverse children order', () => {
ctx.scratch.replaceChildren(node3, node2, node);
expect(ctx.scratch.childNodes).toEqual([node3, node2, node]);
expect(ctx.scratch.children).toEqual([node3, node2, node]);
// remove all nodes in document order
expect(hooks.removeChild).toHaveBeenCalledTimes(3);
expect(hooks.removeChild).toHaveBeenNthCalledWith(
1,
ctx.scratch,
node,
0,
);
expect(hooks.removeChild).toHaveBeenNthCalledWith(
2,
ctx.scratch,
node2,
0,
);
expect(hooks.removeChild).toHaveBeenNthCalledWith(
3,
ctx.scratch,
node3,
0,
);
// removes should all be called prior to inserts
expect(hooks.removeChild).toHaveBeenCalledBefore(hooks.insertChild);
// insert all nodes in new order
expect(hooks.insertChild).toHaveBeenCalledTimes(3);
expect(hooks.insertChild).toHaveBeenNthCalledWith(
1,
ctx.scratch,
node3,
0,
);
expect(hooks.insertChild).toHaveBeenNthCalledWith(
2,
ctx.scratch,
node2,
1,
);
expect(hooks.insertChild).toHaveBeenNthCalledWith(
3,
ctx.scratch,
node,
2,
);
expect(node.parentNode).toBe(ctx.scratch);
expect(node2.parentNode).toBe(ctx.scratch);
expect(node3.parentNode).toBe(ctx.scratch);
expect(node.isConnected).toBe(true);
expect(node2.isConnected).toBe(true);
expect(node3.isConnected).toBe(true);
});
});
});
61 changes: 61 additions & 0 deletions packages/polyfill/source/tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {beforeAll, beforeEach, afterEach, vi, type Mock} from 'vitest';
import {HOOKS} from '../constants';
import {Window} from '../Window';
import type {Element} from '../Element';
import type {Document} from '../Document';
import type {Hooks} from '../hooks';

export function setupScratch() {
let window!: Window;
let document!: Document;
let scratch!: Element;
const hooks = {
insertChild: vi.fn(),
createElement: vi.fn(),
createText: vi.fn(),
removeChild: vi.fn(),
setText: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setAttribute: vi.fn(),
removeAttribute: vi.fn(),
} satisfies {[key in keyof Hooks]: Mock};

function clearMocks() {
for (const key in hooks) {
hooks[key as keyof typeof hooks].mockClear();
}
}

beforeAll(() => {
window = new Window();
window[HOOKS] = hooks;
document = window.document;
});

beforeEach(() => {
scratch = document.createElement('scratch');
document.body.append(scratch);
clearMocks();
});

afterEach(() => {
scratch.replaceChildren();
scratch?.remove();
});

return {
// using getters here is required because things are recreated for each test
get window() {
return window;
},
get document() {
return document;
},
get scratch() {
return scratch;
},
hooks,
clearMocks,
};
}
8 changes: 8 additions & 0 deletions packages/polyfill/source/tests/jest-extended.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type CustomMatchers from 'jest-extended';
import 'vitest';

declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface AsymmetricMatchersContaining<T = any> extends CustomMatchers<T> {}
interface ExpectStatic extends CustomMatchers<T> {}
}
Loading