diff --git a/doc/reference/app.md b/doc/reference/app.md index c168bc36..af5337fc 100644 --- a/doc/reference/app.md +++ b/doc/reference/app.md @@ -66,6 +66,8 @@ The `config` object is an object with some of the following keys: needs a template. If undefined is returned, owl looks into the app templates. - **`warnIfNoStaticProps (boolean, default=false)`**: if true, Owl will log a warning whenever it encounters a component that does not provide a [static props description](props.md#props-validation). +- **`customDirectives (object)`**: if given, the corresponding function on the object will be called + on the template custom directives: `t-custom-*` (see [Custom Directives](templates.md#custom-directives)). ## `mount` helper diff --git a/doc/reference/templates.md b/doc/reference/templates.md index 705728a8..1e88a94b 100644 --- a/doc/reference/templates.md +++ b/doc/reference/templates.md @@ -18,6 +18,7 @@ - [Sub Templates](#sub-templates) - [Dynamic Sub Templates](#dynamic-sub-templates) - [Debugging](#debugging) + - [Custom Directives](#custom-directives) - [Fragments](#fragments) - [Inline templates](#inline-templates) - [Rendering svg](#rendering-svg) @@ -80,6 +81,7 @@ needs. Here is a list of all Owl specific directives: | `t-slot`, `t-set-slot`, `t-slot-scope` | [Rendering a slot](slots.md) | | `t-model` | [Form input bindings](input_bindings.md) | | `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) | +| `t-custom-*` | [Rendering nodes with custom directives](#custom-directives) | ## QWeb Template Reference @@ -588,6 +590,35 @@ will stop execution if the browser dev tools are open. will print 42 to the console. +### Custom Directives + +Owl 2 supports the declaration of custom directives. To use them, an Object of functions needs to be configured on the owl APP: + +```js + new App(..., { + customDirectives: { + test_directive: function (el, value) { + el.setAttribute("t-on-click", value); + } + } + }); +``` + +The functions will be called when a custom directive with the name of the +function is found. The original element will be replaced with the one +modified by the function. +This : + +```xml +
+``` + +will be replaced by : + +```xml +
+``` + ## Fragments Owl 2 supports templates with an arbitrary number of root elements, or even just diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 00000000..0a900bdb --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,4 @@ +export type customDirectives = Record< + string, + (node: Element, value: string, modifier?: string) => void +>; diff --git a/src/compiler/index.ts b/src/compiler/index.ts index 16683bc1..ba513de9 100644 --- a/src/compiler/index.ts +++ b/src/compiler/index.ts @@ -1,3 +1,4 @@ +import type { customDirectives } from "../common/types"; import type { TemplateSet } from "../runtime/template_set"; import type { BDom } from "../runtime/blockdom"; import { CodeGenerator, Config } from "./code_generator"; @@ -10,13 +11,14 @@ export type TemplateFunction = (app: TemplateSet, bdom: any, helpers: any) => Te interface CompileOptions extends Config { name?: string; + customDirectives?: customDirectives; } export function compile( template: string | Element, options: CompileOptions = {} ): TemplateFunction { // parsing - const ast = parse(template); + const ast = parse(template, options.customDirectives); // some work const hasSafeContext = diff --git a/src/compiler/parser.ts b/src/compiler/parser.ts index 11c9bdb7..dc5c3ee7 100644 --- a/src/compiler/parser.ts +++ b/src/compiler/parser.ts @@ -1,4 +1,5 @@ import { OwlError } from "../common/owl_error"; +import type { customDirectives } from "../common/types"; import { parseXML } from "../common/utils"; // ----------------------------------------------------------------------------- @@ -198,23 +199,26 @@ export type AST = // ----------------------------------------------------------------------------- const cache: WeakMap = new WeakMap(); -export function parse(xml: string | Element): AST { +export function parse(xml: string | Element, customDir?: customDirectives): AST { + const ctx = { + inPreTag: false, + customDirectives: customDir, + }; if (typeof xml === "string") { const elem = parseXML(`${xml}`).firstChild as Element; - return _parse(elem); + return _parse(elem, ctx); } let ast = cache.get(xml); if (!ast) { // we clone here the xml to prevent modifying it in place - ast = _parse(xml.cloneNode(true) as Element); + ast = _parse(xml.cloneNode(true) as Element, ctx); cache.set(xml, ast); } return ast; } -function _parse(xml: Element): AST { +function _parse(xml: Element, ctx: ParsingContext): AST { normalizeXML(xml); - const ctx = { inPreTag: false }; return parseNode(xml, ctx) || { type: ASTType.Text, value: "" }; } @@ -222,6 +226,7 @@ interface ParsingContext { tModelInfo?: TModelInfo | null; nameSpace?: string; inPreTag: boolean; + customDirectives?: customDirectives; } function parseNode(node: Node, ctx: ParsingContext): AST | null { @@ -229,6 +234,7 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null { return parseTextCommentNode(node, ctx); } return ( + parseTCustom(node, ctx) || parseTDebugLog(node, ctx) || parseTForEach(node, ctx) || parseTIf(node, ctx) || @@ -277,6 +283,37 @@ function parseTextCommentNode(node: Node, ctx: ParsingContext): AST | null { return null; } +function parseTCustom(node: Element, ctx: ParsingContext): AST | null { + if (!ctx.customDirectives) { + return null; + } + const nodeAttrsNames = node.getAttributeNames(); + for (let attr of nodeAttrsNames) { + if (attr === "t-custom" || attr === "t-custom-") { + throw new OwlError("Missing custom directive name with t-custom directive"); + } + if (attr.startsWith("t-custom-")) { + const directiveName = attr.split(".")[0].slice(9); + const customDirective = ctx.customDirectives[directiveName]; + if (!customDirective) { + throw new OwlError(`Custom directive "${directiveName}" is not defined`); + } + const value = node.getAttribute(attr)!; + const modifier = attr.split(".").length > 1 ? attr.split(".")[1] : undefined; + node.removeAttribute(attr); + try { + customDirective(node, value, modifier); + } catch (error) { + throw new OwlError( + `Custom directive "${directiveName}" throw the following error: ${error}` + ); + } + return parseNode(node, ctx); + } + } + return null; +} + // ----------------------------------------------------------------------------- // debugging // ----------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index 83d84e89..3e4063f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,6 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate( dev: this.dev, translateFn: this.translateFn, translatableAttributes: this.translatableAttributes, + customDirectives: this.customDirectives, }); }; diff --git a/src/runtime/template_set.ts b/src/runtime/template_set.ts index cdb7e16b..3a13c365 100644 --- a/src/runtime/template_set.ts +++ b/src/runtime/template_set.ts @@ -5,6 +5,7 @@ import { Portal, portalTemplate } from "./portal"; import { helpers } from "./template_helpers"; import { OwlError } from "../common/owl_error"; import { parseXML } from "../common/utils"; +import type { customDirectives } from "../common/types"; const bdom = { text, createBlock, list, multi, html, toggler, comment }; @@ -14,6 +15,7 @@ export interface TemplateSetConfig { translateFn?: (s: string) => string; templates?: string | Document | Record; getTemplate?: (s: string) => Element | Function | string | void; + customDirectives?: customDirectives; } export class TemplateSet { @@ -27,6 +29,7 @@ export class TemplateSet { translateFn?: (s: string) => string; translatableAttributes?: string[]; Portal = Portal; + customDirectives: customDirectives; constructor(config: TemplateSetConfig = {}) { this.dev = config.dev || false; @@ -42,6 +45,7 @@ export class TemplateSet { } } this.getRawTemplate = config.getTemplate; + this.customDirectives = config.customDirectives || {}; } addTemplate(name: string, template: string | Element) { diff --git a/tests/compiler/__snapshots__/t_custom.test.ts.snap b/tests/compiler/__snapshots__/t_custom.test.ts.snap new file mode 100644 index 00000000..4d16e476 --- /dev/null +++ b/tests/compiler/__snapshots__/t_custom.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`t-custom can use t-custom directive on a node 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let hdlr1 = [ctx['click'], ctx]; + return block1([hdlr1]); + } +}" +`; + +exports[`t-custom can use t-custom directive with modifier on a node 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let hdlr1 = [ctx['click'], ctx]; + return block1([hdlr1]); + } +}" +`; diff --git a/tests/compiler/t_custom.test.ts b/tests/compiler/t_custom.test.ts new file mode 100644 index 00000000..abfb745f --- /dev/null +++ b/tests/compiler/t_custom.test.ts @@ -0,0 +1,55 @@ +import { App, Component, xml } from "../../src"; +import { makeTestFixture, snapshotEverything } from "../helpers"; + +let fixture: HTMLElement; + +snapshotEverything(); + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +describe("t-custom", () => { + test("can use t-custom directive on a node", async () => { + const steps: string[] = []; + class SomeComponent extends Component { + static template = xml`
`; + click() { + steps.push("clicked"); + } + } + const app = new App(SomeComponent, { + customDirectives: { + plop: (node, value) => { + node.setAttribute("t-on-click", value); + }, + }, + }); + await app.mount(fixture); + expect(fixture.innerHTML).toBe(`
`); + fixture.querySelector("div")!.click(); + expect(steps).toEqual(["clicked"]); + }); + + test("can use t-custom directive with modifier on a node", async () => { + const steps: string[] = []; + class SomeComponent extends Component { + static template = xml`
`; + click() { + steps.push("clicked"); + } + } + const app = new App(SomeComponent, { + customDirectives: { + plop: (node, value, modifier) => { + node.setAttribute("t-on-click", value); + steps.push(modifier || ""); + }, + }, + }); + await app.mount(fixture); + expect(fixture.innerHTML).toBe(`
`); + fixture.querySelector("div")!.click(); + expect(steps).toEqual(["mouse", "clicked"]); + }); +});