Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] parser: add support for user-space directives #1651

Merged
merged 2 commits into from
Nov 25, 2024
Merged
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
3 changes: 3 additions & 0 deletions doc/reference/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ 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)).
- **`globalValues (object)`**: Global object of elements available at compilations.

## `mount` helper

Expand Down
31 changes: 31 additions & 0 deletions doc/reference/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
<div t-custom-test_directive="click" />
```

will be replaced by :

```xml
<div t-on-click="value"/>
```

## Fragments

Owl 2 supports templates with an arbitrary number of root elements, or even just
Expand Down
4 changes: 4 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type customDirectives = Record<
string,
(node: Element, value: string, modifier?: string) => void
>;
4 changes: 4 additions & 0 deletions src/compiler/code_generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Config {
export interface CodeGenOptions extends Config {
hasSafeContext?: boolean;
name?: string;
hasGlobalValues: boolean;
}

// using a non-html document so that <inner/outer>HTML serializes as XML instead
Expand Down Expand Up @@ -286,6 +287,9 @@ export class CodeGenerator {
this.dev = options.dev || false;
this.ast = ast;
this.templateName = options.name;
if (options.hasGlobalValues) {
this.helpers.add("__globals__");
}
}

generateCode(): string {
Expand Down
9 changes: 7 additions & 2 deletions src/compiler/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,13 +11,17 @@ export type TemplateFunction = (app: TemplateSet, bdom: any, helpers: any) => Te

interface CompileOptions extends Config {
name?: string;
customDirectives?: customDirectives;
hasGlobalValues: boolean;
}
export function compile(
template: string | Element,
options: CompileOptions = {}
options: CompileOptions = {
hasGlobalValues: false,
}
): TemplateFunction {
// parsing
const ast = parse(template);
const ast = parse(template, options.customDirectives);

// some work
const hasSafeContext =
Expand Down
2 changes: 1 addition & 1 deletion src/compiler/inline_expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { OwlError } from "../common/owl_error";
//------------------------------------------------------------------------------

const RESERVED_WORDS =
"true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date".split(
"true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date,__globals__".split(
","
);

Expand Down
47 changes: 42 additions & 5 deletions src/compiler/parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OwlError } from "../common/owl_error";
import type { customDirectives } from "../common/types";
import { parseXML } from "../common/utils";

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -198,37 +199,42 @@ export type AST =
// -----------------------------------------------------------------------------
const cache: WeakMap<Element, AST> = 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(`<t>${xml}</t>`).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: "" };
}

interface ParsingContext {
tModelInfo?: TModelInfo | null;
nameSpace?: string;
inPreTag: boolean;
customDirectives?: customDirectives;
}

function parseNode(node: Node, ctx: ParsingContext): AST | null {
if (!(node instanceof Element)) {
return parseTextCommentNode(node, ctx);
}
return (
parseTCustom(node, ctx) ||
parseTDebugLog(node, ctx) ||
parseTForEach(node, ctx) ||
parseTIf(node, ctx) ||
Expand Down Expand Up @@ -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
// -----------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(
dev: this.dev,
translateFn: this.translateFn,
translatableAttributes: this.translatableAttributes,
customDirectives: this.customDirectives,
hasGlobalValues: this.hasGlobalValues,
});
};
11 changes: 10 additions & 1 deletion src/runtime/template_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -14,6 +15,8 @@ export interface TemplateSetConfig {
translateFn?: (s: string) => string;
templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives;
globalValues?: object;
}

export class TemplateSet {
Expand All @@ -27,6 +30,9 @@ export class TemplateSet {
translateFn?: (s: string) => string;
translatableAttributes?: string[];
Portal = Portal;
customDirectives: customDirectives;
runtimeUtils: object;
hasGlobalValues: boolean;

constructor(config: TemplateSetConfig = {}) {
this.dev = config.dev || false;
Expand All @@ -42,6 +48,9 @@ export class TemplateSet {
}
}
this.getRawTemplate = config.getTemplate;
this.customDirectives = config.customDirectives || {};
this.runtimeUtils = { ...helpers, __globals__: config.globalValues || {} };
this.hasGlobalValues = Boolean(config.globalValues && Object.keys(config.globalValues).length);
}

addTemplate(name: string, template: string | Element) {
Expand Down Expand Up @@ -97,7 +106,7 @@ export class TemplateSet {
this.templates[name] = function (context, parent) {
return templates[name].call(this, context, parent);
};
const template = templateFn(this, bdom, helpers);
const template = templateFn(this, bdom, this.runtimeUtils);
this.templates[name] = template;
}
return this.templates[name];
Expand Down
15 changes: 15 additions & 0 deletions tests/app/__snapshots__/app.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ exports[`app app: clear scheduler tasks and destroy cancelled nodes immediately
}"
`;

exports[`app can add functions to the bdom 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { __globals__ } = helpers;

let block1 = createBlock(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);

return function template(ctx, node, key = \\"\\") {
let hdlr1 = [()=>__globals__.plop('click'), ctx];
return block1([hdlr1]);
}
}"
`;

exports[`app can call processTask twice in a row without crashing 1`] = `
"function anonymous(app, bdom, helpers
) {
Expand Down
18 changes: 18 additions & 0 deletions tests/app/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,22 @@ describe("app", () => {
await app.mount(fixture);
expect(fixture.innerHTML).toBe("parent<div></div>");
});

test("can add functions to the bdom", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-on-click="() => __globals__.plop('click')" class="my-div"/>`;
}
const app = new App(SomeComponent, {
globalValues: {
plop: (string: any) => {
steps.push(string);
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["click"]);
});
});
29 changes: 29 additions & 0 deletions tests/compiler/__snapshots__/t_custom.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);

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(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);

return function template(ctx, node, key = \\"\\") {
let hdlr1 = [ctx['click'], ctx];
return block1([hdlr1]);
}
}"
`;
55 changes: 55 additions & 0 deletions tests/compiler/t_custom.test.ts
Original file line number Diff line number Diff line change
@@ -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`<div t-custom-plop="click" class="my-div"/>`;
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(`<div class="my-div"></div>`);
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`<div t-custom-plop.mouse="click" class="my-div"/>`;
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(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["mouse", "clicked"]);
});
});
Loading