Commit cbe3e785 authored by David Sveningsson's avatar David Sveningsson

feat(dom): allow plugins to modify element metadata

refs #62
parent 999a4af1
export { Source } from "./source";
export { Source, ProcessElementContext } from "./source";
export { Location, sliceLocation } from "./location";
export { Context, ContentModel } from "./context";
import { HtmlElement } from "../dom";
import { MetaElement } from "../meta";
import { AttributeData } from "../parser";
export type ProcessAttributeCallback = (
this: {},
attr: AttributeData
) => Iterable<AttributeData>;
export type ProcessElementCallback = (node: HtmlElement) => void;
export interface ProcessElementContext {
getMetaFor(tagName: string): MetaElement;
}
export type ProcessElementCallback = (
this: ProcessElementContext,
node: HtmlElement
) => void;
export interface SourceHooks {
/**
......
......@@ -2,7 +2,7 @@ import { Attribute, DOMTree, HtmlElement, NodeClosed, NodeType } from ".";
import { Config } from "../config";
import { Location, Source } from "../context";
import { Token, TokenType } from "../lexer";
import { MetaData, MetaTable } from "../meta";
import { MetaData, MetaElement, MetaTable } from "../meta";
import { Parser } from "../parser";
import { processAttribute } from "../transform/mocks/attribute";
import { DynamicValue } from "./dynamic-value";
......@@ -385,6 +385,43 @@ describe("HtmlElement", () => {
});
});
describe("loadMeta()", () => {
let node: HtmlElement;
const original = {
inherit: "foo",
flow: true,
} as MetaElement;
beforeEach(() => {
node = new HtmlElement("my-element", null, null, original);
});
it("should overwrite copyable properties", () => {
expect.assertions(1);
node.loadMeta({ flow: false } as MetaElement);
expect(node.meta.flow).toEqual(false);
});
it("should not overwrite non-copyable properties", () => {
expect.assertions(1);
node.loadMeta({ inherit: "bar" } as MetaElement);
expect(node.meta.inherit).toEqual("foo");
});
it("should remove missing properties", () => {
expect.assertions(1);
node.loadMeta({} as MetaElement);
expect(node.meta.flow).toBeUndefined();
});
it("should handle when original meta is null", () => {
expect.assertions(1);
const node = new HtmlElement("my-element");
node.loadMeta({ flow: false } as MetaElement);
expect(node.meta.flow).toEqual(false);
});
});
describe("getElementsByTagName()", () => {
it("should find elements", () => {
const nodes = root.getElementsByTagName("li");
......
import { Location, sliceLocation } from "../context";
import { Token } from "../lexer";
import { MetaElement, MetaTable } from "../meta";
import { MetaCopyableProperty, MetaElement, MetaTable } from "../meta";
import { Attribute } from "./attribute";
import { DOMNode } from "./domnode";
import { DOMTokenList } from "./domtokenlist";
......@@ -19,12 +19,12 @@ export enum NodeClosed {
export class HtmlElement extends DOMNode {
public readonly tagName: string;
public readonly meta: MetaElement;
public readonly parent: HtmlElement;
public readonly voidElement: boolean;
public readonly depth: number;
public closed: NodeClosed;
protected readonly attr: { [key: string]: Attribute[] };
private metaElement: MetaElement;
public constructor(
tagName: string,
......@@ -39,9 +39,9 @@ export class HtmlElement extends DOMNode {
this.tagName = tagName;
this.parent = parent;
this.attr = {};
this.meta = meta;
this.metaElement = meta;
this.closed = closed;
this.voidElement = this.meta ? this.meta.void : false;
this.voidElement = meta ? meta.void : false;
this.depth = 0;
if (parent) {
......@@ -128,6 +128,47 @@ export class HtmlElement extends DOMNode {
return (this.tagName && tagName === "*") || this.tagName === tagName;
}
/**
* Load new element metadata onto this element.
*
* Do note that semantics such as `void` cannot be changed (as the element has
* already been created). In addition the element will still "be" the same
* element, i.e. even if loading meta for a `<p>` tag upon a `<div>` tag it
* will still be a `<div>` as far as the rest of the validator is concerned.
*
* In fact only certain properties will be copied onto the element:
*
* - content categories (flow, phrasing, etc)
* - required attributes
* - attribute allowed values
* - permitted/required elements
*
* Properties *not* loaded:
*
* - inherit
* - deprecated
* - foreign
* - void
* - implicitClosed
* - scriptSupporting
* - deprecatedAttributes
*
* Changes to element metadata will only be visible after `element:ready` (and
* the subsequent `dom:ready` event).
*/
public loadMeta(meta: MetaElement): void {
if (!this.metaElement) {
this.metaElement = {} as MetaElement;
}
for (const key of MetaCopyableProperty) {
if (typeof meta[key] !== "undefined") {
this.metaElement[key] = meta[key];
} else {
delete this.metaElement[key];
}
}
}
/**
* Match this element against given selectors. Returns true if any selector
* matches.
......@@ -155,6 +196,10 @@ export class HtmlElement extends DOMNode {
return false;
}
public get meta(): MetaElement {
return this.metaElement;
}
public setAttribute(
key: string,
value: string | DynamicValue,
......
......@@ -49,6 +49,10 @@ export interface MetaData {
requiredContent?: RequiredContent;
}
/**
* Properties listed here can be used to reverse search elements with the given
* property enabled. See [[MetaTable.getTagsWithProperty]].
*/
export type MetaLookupableProperty =
| "metadata"
| "flow"
......@@ -64,6 +68,29 @@ export type MetaLookupableProperty =
| "scriptSupporting"
| "form";
/**
* Properties listed here can be copied (loaded) onto another element using
* [[HtmlElement.loadMeta]].
*/
export const MetaCopyableProperty = [
"metadata",
"flow",
"sectioning",
"heading",
"phrasing",
"embedded",
"interactive",
"transparent",
"form",
"requiredAttributes",
"attributes",
"permittedContent",
"permittedDescendants",
"permittedOrder",
"requiredAncestors",
"requiredContent",
];
export interface MetaElement extends MetaData {
/* filled internally for reverse lookup */
tagName: string;
......
......@@ -3,6 +3,7 @@ export {
MetaData,
MetaElement,
MetaLookupableProperty,
MetaCopyableProperty,
PropertyExpression,
} from "./element";
export { Validator } from "./validator";
......@@ -111,9 +111,12 @@ export class MetaTable {
this.loadFromObject(clone(json), filename);
}
/**
* Get [[MetaElement]] for the given tag or null if the element doesn't exist.
*
* @returns A shallow copy of metadata.
*/
public getMetaFor(tagName: string): MetaElement {
/* @TODO Only entries with dynamic properties has to be copied, static
* entries could be shared */
tagName = tagName.toLowerCase();
return this.elements[tagName]
? Object.assign({}, this.elements[tagName])
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`parser should postprocess elements allow modifiy element metadata 1`] = `
Object {
"attributes": Object {
"contenteditable": Array [
"",
"true",
"false",
],
"dir": Array [
"ltr",
"rtl",
"auto",
],
"draggable": Array [
"true",
"false",
],
"hidden": Array [],
"tabindex": Array [
/-\\?\\\\d\\+/,
],
},
"deprecatedAttributes": Array [
"contextmenu",
],
"flow": true,
"permittedContent": Array [
"@flow",
"dt",
"dd",
],
"tagName": "i",
"void": false,
}
`;
exports[`parser should postprocess elements allow modifiy element metadata 2`] = `
Object {
"attributes": Object {
"contenteditable": Array [
"",
"true",
"false",
],
"dir": Array [
"ltr",
"rtl",
"auto",
],
"draggable": Array [
"true",
"false",
],
"hidden": Array [],
"tabindex": Array [
/-\\?\\\\d\\+/,
],
},
"deprecatedAttributes": Array [
"contextmenu",
],
"flow": true,
"permittedContent": Array [
"@phrasing",
],
"phrasing": true,
"tagName": "u",
"void": false,
}
`;
import { Config } from "../config";
import { Location, Source } from "../context";
import { Location, ProcessElementContext, Source } from "../context";
import { DOMTree, HtmlElement, TextNode } from "../dom";
import { EventCallback } from "../event";
import HtmlValidate from "../htmlvalidate";
......@@ -999,20 +999,55 @@ describe("parser", () => {
expect(events.shift()).toBeUndefined();
});
it("elements", () => {
const processElement = jest.fn();
const source: Source = {
data: "<input>",
filename: "inline",
line: 1,
column: 1,
offset: 0,
hooks: {
processElement,
},
};
parser.parseHtml(source);
expect(processElement).toHaveBeenCalledWith(expect.any(HtmlElement));
describe("elements", () => {
it("by calling hook", () => {
let context: any;
const processElement = jest.fn(function(this: any) {
context = this;
});
const source: Source = {
data: "<input>",
filename: "inline",
line: 1,
column: 1,
offset: 0,
hooks: {
processElement,
},
};
parser.parseHtml(source);
expect(processElement).toHaveBeenCalledWith(expect.any(HtmlElement));
expect(context).toEqual({
getMetaFor: expect.any(Function),
});
});
it("allow modifiy element metadata", () => {
expect.assertions(2);
function processElement(
this: ProcessElementContext,
node: HtmlElement
): void {
if (node.tagName === "i") {
node.loadMeta(this.getMetaFor("div"));
}
}
const source: Source = {
data: "<i></i><u></u>",
filename: "inline",
line: 1,
column: 1,
offset: 0,
hooks: {
processElement,
},
};
const doc = parser.parseHtml(source);
const i = doc.querySelector("i");
const u = doc.querySelector("u");
expect(i.meta).toMatchSnapshot();
expect(u.meta).toMatchSnapshot();
});
});
});
......
import { Config } from "../config";
import { Location, sliceLocation, Source } from "../context";
import { ProcessAttributeCallback } from "../context/source";
import {
ProcessAttributeCallback,
ProcessElementContext,
} from "../context/source";
import { DOMTree, HtmlElement, NodeClosed } from "../dom";
import {
AttributeEvent,
......@@ -200,6 +203,8 @@ export class Parser {
const close = !open || node.closed !== NodeClosed.Open;
const foreign = node.meta && node.meta.foreign;
/* if the previous tag to be implicitly closed by the current tag we close
* it and pop it from the stack before continuing processing this tag */
if (closeOptional) {
const active = this.dom.getActive();
active.closed = NodeClosed.ImplicitClosed;
......@@ -264,10 +269,7 @@ export class Parser {
location: Location
): void {
/* call processElement hook */
if (source.hooks && source.hooks.processElement) {
const processElement = source.hooks.processElement;
processElement(active);
}
this.processElement(active, source);
/* trigger event for the closing of the element (the </> tag)*/
this.trigger("tag:close", {
......@@ -286,6 +288,19 @@ export class Parser {
}
}
private processElement(node: HtmlElement, source: Source): void {
if (source.hooks && source.hooks.processElement) {
const processElement = source.hooks.processElement;
const metaTable = this.metaTable;
const context: ProcessElementContext = {
getMetaFor(tagName: string) {
return metaTable.getMetaFor(tagName);
},
};
processElement.call(context, node);
}
}
/**
* Discard tokens until the end tag for the foreign element is found.
*/
......@@ -371,7 +386,7 @@ export class Parser {
/* handle deprecated callbacks */
let iterator: Iterable<AttributeData>;
const legacy = processAttribute(attrData);
const legacy = processAttribute.call({}, attrData);
if (typeof (legacy as any)[Symbol.iterator] !== "function") {
/* AttributeData */
iterator = [attrData];
......
......@@ -6,7 +6,7 @@ export { CLI } from "./cli/cli";
export { Config, ConfigData, ConfigLoader, Severity } from "./config";
export { DynamicValue, HtmlElement } from "./dom";
export { Rule } from "./rule";
export { Source, Location } from "./context";
export { Source, Location, ProcessElementContext } from "./context";
export { Reporter, Message, Result } from "./reporter";
export { Transformer, TemplateExtractor } from "./transform";
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment