Compare commits

..

21 Commits

Author SHA1 Message Date
Romeo Fragomeli 66a801393f [REL] v2.2.11
# v2.2.11

 - [FIX] compiler: better support for arrow function and function call
 - [IMP] owl-vision: Autocomplete and added missing owl directives
 - [IMP] index: export batched utility function
 - [FIX] playground: correctly escape backslashes and interpolation sigils
 - [FIX] compiler: correctly escape special characters in template literals
 - [FIX] Typo docs
 - [FIX] runtime: don't emit async hook warnings when cancelled/destroyed
2024-06-17 15:31:21 +02:00
Romeo Fragomeli e7f405cc97 [FIX] compiler: better support for arrow function and function call
This commit fixes inline expressions when we have these conditions:
* inline expression that contains an arrow function
* inside the arrow function call a function with multiple arguments (3+)
* the second argument (not the first and not the last) must be a variable

In the `compileExprToArray` we have a code to handle missing tokens in an object
e.g.: {a} (equivalent to {a:a})

When OWL match all 3 conditions listed above we execute the code to handle
the missing tokens and so it alter the tokens and adds a new token:
`{ type: "COLON", value: ":" }`
This result in a Javascript compilation error:
OwlError: Failed to compile template "XXX": missing ) after argument list

To fix the error and avoid execute the code to handle the missing tokens, now,
we track also the parentheses in our local stack.
2024-06-17 12:51:59 +02:00
Bastien Fafchamps (bafa) 55c48b2b12 [IMP] owl-vision: Autocomplete and added missing owl directives
This commit adds basic autocomplete in  xml files. This includes autocompletion
for elements, components, props, attributes, and javascript expressions.

It also:
- Adds "Go To Definition" support for props and javascript expressions in xml
- Support for the following directives: t-att, t-model, t-tag, t-debug, t-log
- Fixes t-else syntax highlight to be non-dynamic as the attribute value
should be empty
2024-06-11 10:18:03 +02:00
Lucas Lefèvre (lul) 2eb151c92d [IMP] index: export batched utility function
'batched' will be used in odoo/o-spreadsheet.
There's also a copied version (slightly modified) in odoo/odoo.

It shows it could be useful outside owl.
2024-05-22 12:01:13 +02:00
Samuel Degueldre 7952f31e63 [FIX] playground: correctly escape backslashes and interpolation sigils
Previously, if you used a backslash in a template on the playground, it
would be interpreted as an escape sequence, and if you wrote "${" it
would likely crash, as it would be treated as an interpolation sigil in
the context of the script element injected inside the playground's
iframe.

A previous fix already escaped backticks, this commit completes the
escaping by escaping the two other things that have special meaning
within template literals.
2024-05-13 15:35:02 +02:00
Samuel Degueldre 11e4e67599 [FIX] compiler: correctly escape special characters in template literals
Previously, there were a few places where the compiler would create
strings from template content and emit them as template literals, but
didn't properly escape characters or character sequences with special
meanings, in particular: backslashes, backticks, and interpolation
sigils.

This commit fixes this in:
- block creation (interpolation sigils were not escaped)
- text node creation (no escaping was performed)
- comment not creation (no escaping was performed)
- default values for t-esc (no escaping was performed)
- body of a t-set (no escaping was performed)
2024-05-13 15:35:02 +02:00
Giorgio (gito) 7b7a6de373 [FIX] Typo docs
interplolation -> interpolation
2024-04-22 16:09:06 +02:00
Giorgio (gito) b63d1e28b2 [FIX] Typo docs
Fixed brackets on props validation example
2024-04-22 11:33:55 +02:00
Giorgio (gito) c0667a11c6 [FIX] Typo docs
Missing closing parenthesis in props validation code example
2024-04-22 10:54:37 +02:00
Samuel Degueldre fddb1ec924 [FIX] runtime: don't emit async hook warnings when cancelled/destroyed
Previously, the async hook warning that's emitted when async hooks take
too long to resolve would be emitted even if the component was
destroyed or cancelled. We used to check if the node's fiber was still
the current fiber as a proxy for cancellation, but this is insufficient
in the case where an app might be destroyed.

This commit also wraps the content of some of the warning tests into a
try/finally block so that a failure of these tests doesn't pollute
setTimeout and console.warn and cause other tests to timeout one after
another.
2024-04-15 10:01:54 +02:00
Samuel Degueldre e6c3b62ef0 [REL] v2.2.10
# v2.2.10

 - [IMP] runtime: allow using any class as a type in props validation
 - Update reactivity.md
 - [IMP] owl-vision: Better snippets
 - [FIX] docs: code in example cannot run
2024-04-02 12:25:50 +02:00
Samuel Degueldre 97b69f164f [IMP] runtime: allow using any class as a type in props validation
Previously, we had a fixed whitelist for types that were allowed during
props validation. The implementation however supports using arbitrary
classes, and in practice it's desirable to do so, and already done when
not using typescript (when using typescript, it will error if the class
is not whitelisted), eg in Odoo, we use "Element" for the arch in the
standard view props, but this causes all view controllers to fail type
checking because it's not whitelisted.

This commit simply replaces existing constructors by a generic
constructor type, and adds a test with a validation success and a test
with a validation failure.
2024-03-26 14:10:30 +01:00
Mohamed Alkobrosli 33dfeb1b41 Update reactivity.md
of a of a repeatition, it is fixed by removing "of a"
2024-03-21 23:33:00 +01:00
Arnaud Baes dd292472b9 [IMP] owl-vision: Better snippets
- Adds a basic XML owl template
- Make use of `$TM_FILENAME_BASE` and `$RELATIVE_FILEPATH` and attempt
  to predict the component and template names.

See: https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables
2024-03-14 12:40:37 +01:00
Trịnh Đức Độ 9b18b57fdf [FIX] docs: code in example cannot run 2024-03-14 08:10:42 +01:00
Samuel Degueldre 68f491cd32 [REL] v2.2.9
# v2.2.9

 - [IMP] reactivity: replace sets with small arrays for performance
2024-01-12 15:44:13 +01:00
Samuel Degueldre 7b3e39ba27 [IMP] reactivity: replace sets with small arrays for performance
While Sets have better lookup complexity than arrays, because of the
large constant factors, small arrays can perform better than small sets
when checking for inclusion.

In practice, replacing both of the raw types sets with arrays can
improve performance of reactive-heavy workloads by as much as 30%.

Considering the reactivity code is very hot when rendering data-heavy
components, and the low impact on readability of the fix, the
cost-benefit analysis is clearly in favour of making the fix.
2024-01-12 15:40:14 +01:00
Samuel Degueldre 61fc3f4fdc [REL] v2.2.8
# v2.2.8

 - [IMP] template set config: getTemplate function
 - [IMP] parser: .trim modifier implies .lazy modifier
 - [REF] parser, template_set: factor out parseXML function
2024-01-12 11:03:03 +01:00
Mathieu Duckerts-Antoine 7b454dae66 [IMP] template set config: getTemplate function
A template set can now obtain a template by calling the function
getTemplate (if any) received in the initial config.
The returned value can be an Element instance, a template string, a
function (i.e. a compiled template), or null. In the last case, owl will
look into the template set rawTemplates object.
2024-01-12 10:56:02 +01:00
FrancoisGe 70101e4c66 [IMP] parser: .trim modifier implies .lazy modifier
Before to this commit, the .trim modifier did not work correctly. The value
in the model is always trim at each input event but not the visual value
in the input. This difference between the visual and the model value cause
few strange bug.

After reflection, we think that we always want to be in .lazy when we use .trim.
Because we want to trim the final value during the onchange event and not
at each input event. If we do it at each input event, we can't write more then
one word easily.

So this commit change the behavior of the .trim modifier to always be in .lazy
2024-01-04 11:07:37 +01:00
Samuel Degueldre 5ef405293a [REF] parser, template_set: factor out parseXML function
For some reason the code of parseXML was duplicated, despite being
exactly the same except for some whitespace. Move it out into a common
utils file.

closes #1569
2023-12-22 11:01:19 +01:00
50 changed files with 7389 additions and 5800 deletions
+1
View File
@@ -47,3 +47,4 @@ Utility/helpers:
- [`status`](reference/component.md#status-helper): utility function to get the status of a component (new, mounted or destroyed)
- [`validate`](reference/utils.md#validate): validates if an object satisfies a specified schema
- [`whenReady`](reference/utils.md#whenready): utility function to execute code when DOM is ready
- [`batched`](reference/utils.md#batched): utility function to batch function calls
+2
View File
@@ -61,6 +61,8 @@ The `config` object is an object with some of the following keys:
templates (see [translations](translations.md))
- **`templates (string | xml document)`**: all the templates that will be used by
the components created by the application.
- **`getTemplate ((s: string) => Element | Function | string | void)`**: a function that will be called by owl when it
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).
+3 -2
View File
@@ -238,7 +238,7 @@ class ComponentB extends owl.Component {
count: {type: Number},
messages: {
type: Array,
element: {type: Object, shape: {id: Boolean, text: String }
element: {type: Object, shape: {id: Boolean, text: String }}
},
date: Date,
combinedVal: [Number, Boolean],
@@ -276,7 +276,8 @@ class ComponentB extends owl.Component {
id: Number,
name: {type: String, optional: true},
url: String
]}, // object, with keys id (number), name (string, optional) and url (string)
}
}, // object, with keys id (number), name (string, optional) and url (string)
someObj3: {
type: Object,
values: { type: Array, element: String },
+2 -2
View File
@@ -152,7 +152,7 @@ This may seem counter-intuitive, but it makes perfect sense in the context of co
```js
class DoubleCounter extends Component {
static template = xml`
<t t-esc="state.selected + ': ' + state[state.selected].value"/>
<t t-esc="'selected: ' + state.selected + ', value: ' + state[state.selected]"/>
<button t-on-click="() => this.state.count1++">increment count 1</button>
<button t-on-click="() => this.state.count2++">increment count 2</button>
<button t-on-click="changeCounter">Switch counter</button>
@@ -193,7 +193,7 @@ to be able to opt out of creating them in the first place. This is the purpose o
### `markRaw`
Marks an object so that it is ignored by the reactivity system, meaning that if this object is ever
part of a of a reactive object, it will be returned as is, and no keys in that object will be
part of a reactive object, it will be returned as is, and no keys in that object will be
observed.
```js
+1 -1
View File
@@ -133,7 +133,7 @@ Slots can define a default content, in case the parent did not define them:
## Dynamic Slots
The `t-slot` directive is actually able to use any expressions, using string
interplolation:
interpolation:
```xml
<t t-slot="{{current}}" />
+20
View File
@@ -9,6 +9,7 @@ functions are all available in the `owl.utils` namespace.
- [`loadFile`](#loadfile): loading a file (useful for templates)
- [`EventBus`](#eventbus): a simple EventBus
- [`validate`](#validate): a validation function
- [`batched`](#batched): batch function calls
## `whenReady`
@@ -78,3 +79,22 @@ validate(
// - 'id' is missing (should be a number),
// - 'url' is missing (should be a boolean or list of numbers),
```
## `batched`
The `batched` function creates a batched version of a callback so that multiple calls to it within the same microtick will only result in a single invocation of the original callback.
```js
function hello() {
console.log("hello");
}
const batchedHello = batched(hello);
batchedHello();
// Nothing is logged
batchedHello();
// Still not logged
await Promise.resolve(); // Await the next microtick
// "hello" is logged only once
```
+56 -72
View File
@@ -1850,8 +1850,9 @@ const NO_CALLBACK = () => {
};
const objectToString = Object.prototype.toString;
const objectHasOwnProperty = Object.prototype.hasOwnProperty;
const SUPPORTED_RAW_TYPES = new Set(["Object", "Array", "Set", "Map", "WeakMap"]);
const COLLECTION_RAWTYPES = new Set(["Set", "Map", "WeakMap"]);
// Use arrays because Array.includes is faster than Set.has for small arrays
const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"];
const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"];
/**
* extract "RawType" from strings like "[object RawType]" => this lets us ignore
* many native objects such as Promise (whose toString is [object Promise])
@@ -1874,7 +1875,7 @@ function canBeMadeReactive(value) {
if (typeof value !== "object") {
return false;
}
return SUPPORTED_RAW_TYPES.has(rawType(value));
return SUPPORTED_RAW_TYPES.includes(rawType(value));
}
/**
* Creates a reactive from the given object/callback if possible and returns it,
@@ -2044,7 +2045,7 @@ function reactive(target, callback = NO_CALLBACK) {
const reactivesForTarget = reactiveCache.get(target);
if (!reactivesForTarget.has(callback)) {
const targetRawType = rawType(target);
const handler = COLLECTION_RAWTYPES.has(targetRawType)
const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
? collectionsProxyHandler(target, callback, targetRawType)
: basicProxyHandler(callback);
const proxy = new Proxy(target, handler);
@@ -2621,7 +2622,7 @@ function wrapError(fn, hookName) {
result.catch(() => { }),
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)),
]).then((res) => {
if (res === TIMEOUT && node.fiber === fiber) {
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
console.warn(timeoutError);
}
});
@@ -3159,8 +3160,14 @@ const helpers = {
makeRefWrapper,
};
const bdom = { text, createBlock, list, multi, html, toggler, comment };
function parseXML$1(xml) {
/**
* Parses an XML string into an XML document, throwing errors on parser errors
* instead of returning an XML document containing the parseerror.
*
* @param xml the string to parse
* @returns an XML document corresponding to the content of the string
*/
function parseXML(xml) {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
@@ -3187,7 +3194,9 @@ function parseXML$1(xml) {
throw new OwlError(msg);
}
return doc;
}
}
const bdom = { text, createBlock, list, multi, html, toggler, comment };
class TemplateSet {
constructor(config = {}) {
this.rawTemplates = Object.create(globalTemplates);
@@ -3206,6 +3215,7 @@ class TemplateSet {
}
}
}
this.getRawTemplate = config.getTemplate;
}
static registerTemplate(name, fn) {
globalTemplates[name] = fn;
@@ -3235,15 +3245,16 @@ class TemplateSet {
// empty string
return;
}
xml = xml instanceof Document ? xml : parseXML$1(xml);
xml = xml instanceof Document ? xml : parseXML(xml);
for (const template of xml.querySelectorAll("[t-name]")) {
const name = template.getAttribute("t-name");
this.addTemplate(name, template);
}
}
getTemplate(name) {
var _a;
if (!(name in this.templates)) {
const rawTemplate = this.rawTemplates[name];
const rawTemplate = ((_a = this.getRawTemplate) === null || _a === void 0 ? void 0 : _a.call(this, name)) || this.rawTemplates[name];
if (rawTemplate === undefined) {
let extraInfo = "";
try {
@@ -3501,7 +3512,7 @@ function compileExprToArray(expr) {
const localVars = new Set();
const tokens = tokenize(expr);
let i = 0;
let stack = []; // to track last opening [ or {
let stack = []; // to track last opening (, [ or {
while (i < tokens.length) {
let token = tokens[i];
let prevToken = tokens[i - 1];
@@ -3510,10 +3521,12 @@ function compileExprToArray(expr) {
switch (token.type) {
case "LEFT_BRACE":
case "LEFT_BRACKET":
case "LEFT_PAREN":
stack.push(token.type);
break;
case "RIGHT_BRACE":
case "RIGHT_BRACKET":
case "RIGHT_PAREN":
stack.pop();
}
let isVar = token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value);
@@ -3625,6 +3638,13 @@ function isProp(tag, key) {
}
return false;
}
/**
* Returns a template literal that evaluates to str. You can add interpolation
* sigils into the string if required
*/
function toStringExpression(str) {
return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``;
}
// -----------------------------------------------------------------------------
// BlockDescription
// -----------------------------------------------------------------------------
@@ -3805,15 +3825,14 @@ class CodeGenerator {
mainCode.push(``);
for (let block of this.blocks) {
if (block.dom) {
let xmlString = block.asXmlString();
xmlString = xmlString.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
let xmlString = toStringExpression(block.asXmlString());
if (block.dynamicTagName) {
xmlString = xmlString.replace(/^<\w+/, `<\${tag || '${block.dom.nodeName}'}`);
xmlString = xmlString.replace(/\w+>$/, `\${tag || '${block.dom.nodeName}'}>`);
mainCode.push(`let ${block.blockName} = tag => createBlock(\`${xmlString}\`);`);
xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`);
xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``);
mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`);
}
else {
mainCode.push(`let ${block.blockName} = createBlock(\`${xmlString}\`);`);
mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`);
}
}
}
@@ -3991,7 +4010,7 @@ class CodeGenerator {
const isNewBlock = !block || forceNewBlock;
if (isNewBlock) {
block = this.createBlock(block, "comment", ctx);
this.insertBlock(`comment(\`${ast.value}\`)`, block, {
this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, {
...ctx,
forceNewBlock: forceNewBlock && !block,
});
@@ -4013,7 +4032,7 @@ class CodeGenerator {
}
if (!block || forceNewBlock) {
block = this.createBlock(block, "text", ctx);
this.insertBlock(`text(\`${value}\`)`, block, {
this.insertBlock(`text(${toStringExpression(value)})`, block, {
...ctx,
forceNewBlock: forceNewBlock && !block,
});
@@ -4234,7 +4253,8 @@ class CodeGenerator {
expr = compileExpr(ast.expr);
if (ast.defaultValue) {
this.helpers.add("withDefault");
expr = `withDefault(${expr}, \`${ast.defaultValue}\`)`;
// FIXME: defaultValue is not translated
expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`;
}
}
if (!block || forceNewBlock) {
@@ -4487,7 +4507,7 @@ class CodeGenerator {
this.addLine(`${ctxVar}[zero] = ${bl};`);
}
}
const key = `key + \`${this.generateComponentKey()}\``;
const key = this.generateComponentKey();
if (isDynamic) {
const templateVar = generateId("template");
if (!this.staticDefs.find((d) => d.id === "call")) {
@@ -4539,12 +4559,12 @@ class CodeGenerator {
else {
let value;
if (ast.defaultValue) {
const defaultValue = ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue;
const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue);
if (ast.value) {
value = `withDefault(${expr}, \`${defaultValue}\`)`;
value = `withDefault(${expr}, ${defaultValue})`;
}
else {
value = `\`${defaultValue}\``;
value = defaultValue;
}
}
else {
@@ -4555,12 +4575,12 @@ class CodeGenerator {
}
return null;
}
generateComponentKey() {
generateComponentKey(currentKey = "key") {
const parts = [generateId("__")];
for (let i = 0; i < this.target.loopLevel; i++) {
parts.push(`\${key${i + 1}}`);
}
return parts.join("__");
return `${currentKey} + \`${parts.join("__")}\``;
}
/**
* Formats a prop name and value into a string suitable to be inserted in the
@@ -4651,7 +4671,6 @@ class CodeGenerator {
this.addLine(`${propVar}.slots = markRaw(Object.assign(${slotDef}, ${propVar}.slots))`);
}
// cmap key
const key = this.generateComponentKey();
let expr;
if (ast.isDynamic) {
expr = generateId("Comp");
@@ -4667,7 +4686,7 @@ class CodeGenerator {
// todo: check the forcenewblock condition
this.insertAnchor(block);
}
let keyArg = `key + \`${key}\``;
let keyArg = this.generateComponentKey();
if (ctx.tKeyExpr) {
keyArg = `${ctx.tKeyExpr} + ${keyArg}`;
}
@@ -4740,7 +4759,7 @@ class CodeGenerator {
}
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
if (isMultiple) {
key = `${key} + \`${this.generateComponentKey()}\``;
key = this.generateComponentKey(key);
}
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
const scope = this.getPropString(props, dynProps);
@@ -4781,7 +4800,6 @@ class CodeGenerator {
}
let { block } = ctx;
const name = this.compileInNewTarget("slot", ast.content, ctx);
const key = this.generateComponentKey();
let ctxStr = "ctx";
if (this.target.loopLevel || !this.hasSafeContext) {
ctxStr = generateId("ctx");
@@ -4794,7 +4812,8 @@ class CodeGenerator {
expr: `app.createComponent(null, false, true, false, false)`,
});
const target = compileExpr(ast.target);
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`;
const key = this.generateComponentKey();
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`;
if (block) {
this.insertAnchor(block);
}
@@ -4953,9 +4972,9 @@ function parseDOMNode(node, ctx) {
const isSelect = tagName === "select";
const isCheckboxInput = isInput && typeAttr === "checkbox";
const isRadioInput = isInput && typeAttr === "radio";
const hasLazyMod = attr.includes(".lazy");
const hasNumberMod = attr.includes(".number");
const hasTrimMod = attr.includes(".trim");
const hasLazyMod = hasTrimMod || attr.includes(".lazy");
const hasNumberMod = attr.includes(".number");
const eventType = isRadioInput ? "click" : isSelect || hasLazyMod ? "change" : "input";
model = {
baseExpr,
@@ -5501,41 +5520,6 @@ function normalizeTEscTOut(el) {
function normalizeXML(el) {
normalizeTIf(el);
normalizeTEscTOut(el);
}
/**
* Parses an XML string into an XML document, throwing errors on parser errors
* instead of returning an XML document containing the parseerror.
*
* @param xml the string to parse
* @returns an XML document corresponding to the content of the string
*/
function parseXML(xml) {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
let msg = "Invalid XML in template.";
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
if (parsererrorText) {
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
const re = /\d+/g;
const firstMatch = re.exec(parsererrorText);
if (firstMatch) {
const lineNumber = Number(firstMatch[0]);
const line = xml.split("\n")[lineNumber - 1];
const secondMatch = re.exec(parsererrorText);
if (line && secondMatch) {
const columnIndex = Number(secondMatch[0]) - 1;
if (line[columnIndex]) {
msg +=
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
`${line}\n${"-".repeat(columnIndex - 1)}^`;
}
}
}
}
throw new OwlError(msg);
}
return doc;
}
function compile(template, options = {}) {
@@ -5562,7 +5546,7 @@ function compile(template, options = {}) {
}
// do not modify manually. This file is generated by the release script.
const version = "2.2.7";
const version = "2.2.11";
// -----------------------------------------------------------------------------
// Scheduler
@@ -5988,9 +5972,9 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
});
};
export { App, Component, EventBus, OwlError, __info__, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
__info__.date = '2023-12-06T13:56:01.636Z';
__info__.hash = 'e94428a';
__info__.date = '2024-06-17T13:31:12.099Z';
__info__.hash = 'e7f405c';
__info__.url = 'https://github.com/odoo/owl';
+2 -3
View File
@@ -41,9 +41,6 @@ const loadFile = (path) => {
* Make an iframe, with all the js, css and xml properly injected.
*/
function makeCodeIframe(js, css, xml) {
// escape backticks in the xml so they don't close the template string
const escapedXml = xml.replace(/`/g, '\\\`');
const iframe = document.createElement("iframe");
iframe.onload = () => {
const doc = iframe.contentDocument;
@@ -55,6 +52,8 @@ function makeCodeIframe(js, css, xml) {
const script = doc.createElement("script");
script.type = "module";
// escape characters with special meaning in template literals
const escapedXml = xml.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${");
script.textContent = `const TEMPLATES = \`${escapedXml}\`\n${js}`;
doc.body.appendChild(script);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.2.7",
"version": "2.2.11",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.2.7",
"version": "2.2.11",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
+38
View File
@@ -0,0 +1,38 @@
import { OwlError } from "./owl_error";
/**
* Parses an XML string into an XML document, throwing errors on parser errors
* instead of returning an XML document containing the parseerror.
*
* @param xml the string to parse
* @returns an XML document corresponding to the content of the string
*/
export function parseXML(xml: string): XMLDocument {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
let msg = "Invalid XML in template.";
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
if (parsererrorText) {
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
const re = /\d+/g;
const firstMatch = re.exec(parsererrorText);
if (firstMatch) {
const lineNumber = Number(firstMatch[0]);
const line = xml.split("\n")[lineNumber - 1];
const secondMatch = re.exec(parsererrorText);
if (line && secondMatch) {
const columnIndex = Number(secondMatch[0]) - 1;
if (line[columnIndex]) {
msg +=
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
`${line}\n${"-".repeat(columnIndex - 1)}^`;
}
}
}
}
throw new OwlError(msg);
}
return doc;
}
+29 -20
View File
@@ -82,6 +82,14 @@ function isProp(tag: string, key: string): boolean {
return false;
}
/**
* Returns a template literal that evaluates to str. You can add interpolation
* sigils into the string if required
*/
function toStringExpression(str: string) {
return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``;
}
// -----------------------------------------------------------------------------
// BlockDescription
// -----------------------------------------------------------------------------
@@ -311,14 +319,13 @@ export class CodeGenerator {
mainCode.push(``);
for (let block of this.blocks) {
if (block.dom) {
let xmlString = block.asXmlString();
xmlString = xmlString.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
let xmlString = toStringExpression(block.asXmlString());
if (block.dynamicTagName) {
xmlString = xmlString.replace(/^<\w+/, `<\${tag || '${block.dom.nodeName}'}`);
xmlString = xmlString.replace(/\w+>$/, `\${tag || '${block.dom.nodeName}'}>`);
mainCode.push(`let ${block.blockName} = tag => createBlock(\`${xmlString}\`);`);
xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`);
xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``);
mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`);
} else {
mainCode.push(`let ${block.blockName} = createBlock(\`${xmlString}\`);`);
mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`);
}
}
}
@@ -515,7 +522,7 @@ export class CodeGenerator {
const isNewBlock = !block || forceNewBlock;
if (isNewBlock) {
block = this.createBlock(block, "comment", ctx);
this.insertBlock(`comment(\`${ast.value}\`)`, block, {
this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, {
...ctx,
forceNewBlock: forceNewBlock && !block,
});
@@ -539,7 +546,7 @@ export class CodeGenerator {
if (!block || forceNewBlock) {
block = this.createBlock(block, "text", ctx);
this.insertBlock(`text(\`${value}\`)`, block, {
this.insertBlock(`text(${toStringExpression(value)})`, block, {
...ctx,
forceNewBlock: forceNewBlock && !block,
});
@@ -774,7 +781,8 @@ export class CodeGenerator {
expr = compileExpr(ast.expr);
if (ast.defaultValue) {
this.helpers.add("withDefault");
expr = `withDefault(${expr}, \`${ast.defaultValue}\`)`;
// FIXME: defaultValue is not translated
expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`;
}
}
if (!block || forceNewBlock) {
@@ -1039,7 +1047,7 @@ export class CodeGenerator {
}
}
const key = `key + \`${this.generateComponentKey()}\``;
const key = this.generateComponentKey();
if (isDynamic) {
const templateVar = generateId("template");
if (!this.staticDefs.find((d) => d.id === "call")) {
@@ -1091,11 +1099,13 @@ export class CodeGenerator {
} else {
let value: string;
if (ast.defaultValue) {
const defaultValue = ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue;
const defaultValue = toStringExpression(
ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue
);
if (ast.value) {
value = `withDefault(${expr}, \`${defaultValue}\`)`;
value = `withDefault(${expr}, ${defaultValue})`;
} else {
value = `\`${defaultValue}\``;
value = defaultValue;
}
} else {
value = expr;
@@ -1106,12 +1116,12 @@ export class CodeGenerator {
return null;
}
generateComponentKey() {
generateComponentKey(currentKey: string = "key") {
const parts = [generateId("__")];
for (let i = 0; i < this.target.loopLevel; i++) {
parts.push(`\${key${i + 1}}`);
}
return parts.join("__");
return `${currentKey} + \`${parts.join("__")}\``;
}
/**
@@ -1214,7 +1224,6 @@ export class CodeGenerator {
}
// cmap key
const key = this.generateComponentKey();
let expr: string;
if (ast.isDynamic) {
expr = generateId("Comp");
@@ -1232,7 +1241,7 @@ export class CodeGenerator {
this.insertAnchor(block);
}
let keyArg = `key + \`${key}\``;
let keyArg = this.generateComponentKey();
if (ctx.tKeyExpr) {
keyArg = `${ctx.tKeyExpr} + ${keyArg}`;
}
@@ -1311,7 +1320,7 @@ export class CodeGenerator {
}
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
if (isMultiple) {
key = `${key} + \`${this.generateComponentKey()}\``;
key = this.generateComponentKey(key);
}
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
@@ -1354,7 +1363,6 @@ export class CodeGenerator {
let { block } = ctx;
const name = this.compileInNewTarget("slot", ast.content, ctx);
const key = this.generateComponentKey();
let ctxStr = "ctx";
if (this.target.loopLevel || !this.hasSafeContext) {
ctxStr = generateId("ctx");
@@ -1368,7 +1376,8 @@ export class CodeGenerator {
});
const target = compileExpr(ast.target);
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`;
const key = this.generateComponentKey();
const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`;
if (block) {
this.insertAnchor(block);
}
+3 -1
View File
@@ -268,7 +268,7 @@ export function compileExprToArray(expr: string): Token[] {
const localVars = new Set<string>();
const tokens = tokenize(expr);
let i = 0;
let stack = []; // to track last opening [ or {
let stack = []; // to track last opening (, [ or {
while (i < tokens.length) {
let token = tokens[i];
@@ -279,10 +279,12 @@ export function compileExprToArray(expr: string): Token[] {
switch (token.type) {
case "LEFT_BRACE":
case "LEFT_BRACKET":
case "LEFT_PAREN":
stack.push(token.type);
break;
case "RIGHT_BRACE":
case "RIGHT_BRACKET":
case "RIGHT_PAREN":
stack.pop();
}
+3 -39
View File
@@ -1,4 +1,5 @@
import { OwlError } from "../common/owl_error";
import { parseXML } from "../common/utils";
// -----------------------------------------------------------------------------
// AST Type definition
@@ -366,9 +367,9 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
const isSelect = tagName === "select";
const isCheckboxInput = isInput && typeAttr === "checkbox";
const isRadioInput = isInput && typeAttr === "radio";
const hasLazyMod = attr.includes(".lazy");
const hasNumberMod = attr.includes(".number");
const hasTrimMod = attr.includes(".trim");
const hasLazyMod = hasTrimMod || attr.includes(".lazy");
const hasNumberMod = attr.includes(".number");
const eventType = isRadioInput ? "click" : isSelect || hasLazyMod ? "change" : "input";
model = {
@@ -972,40 +973,3 @@ function normalizeXML(el: Element) {
normalizeTIf(el);
normalizeTEscTOut(el);
}
/**
* Parses an XML string into an XML document, throwing errors on parser errors
* instead of returning an XML document containing the parseerror.
*
* @param xml the string to parse
* @returns an XML document corresponding to the content of the string
*/
function parseXML(xml: string): XMLDocument {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
let msg = "Invalid XML in template.";
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
if (parsererrorText) {
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
const re = /\d+/g;
const firstMatch = re.exec(parsererrorText);
if (firstMatch) {
const lineNumber = Number(firstMatch[0]);
const line = xml.split("\n")[lineNumber - 1];
const secondMatch = re.exec(parsererrorText);
if (line && secondMatch) {
const columnIndex = Number(secondMatch[0]) - 1;
if (line[columnIndex]) {
msg +=
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
`${line}\n${"-".repeat(columnIndex - 1)}^`;
}
}
}
}
throw new OwlError(msg);
}
return doc;
}
+1 -1
View File
@@ -41,7 +41,7 @@ export { useComponent, useState } from "./component_node";
export { status } from "./status";
export { reactive, markRaw, toRaw } from "./reactivity";
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
export { EventBus, whenReady, loadFile, markup } from "./utils";
export { batched, EventBus, whenReady, loadFile, markup } from "./utils";
export {
onWillStart,
onMounted,
+1 -1
View File
@@ -28,7 +28,7 @@ function wrapError(fn: (...args: any[]) => any, hookName: string) {
result.catch(() => {}),
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)),
]).then((res) => {
if (res === TIMEOUT && node.fiber === fiber) {
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
console.warn(timeoutError);
}
});
+5 -4
View File
@@ -20,8 +20,9 @@ type CollectionRawType = "Set" | "Map" | "WeakMap";
const objectToString = Object.prototype.toString;
const objectHasOwnProperty = Object.prototype.hasOwnProperty;
const SUPPORTED_RAW_TYPES = new Set(["Object", "Array", "Set", "Map", "WeakMap"]);
const COLLECTION_RAWTYPES = new Set(["Set", "Map", "WeakMap"]);
// Use arrays because Array.includes is faster than Set.has for small arrays
const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"];
const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"];
/**
* extract "RawType" from strings like "[object RawType]" => this lets us ignore
@@ -45,7 +46,7 @@ function canBeMadeReactive(value: any): boolean {
if (typeof value !== "object") {
return false;
}
return SUPPORTED_RAW_TYPES.has(rawType(value));
return SUPPORTED_RAW_TYPES.includes(rawType(value));
}
/**
* Creates a reactive from the given object/callback if possible and returns it,
@@ -220,7 +221,7 @@ export function reactive<T extends Target>(target: T, callback: Callback = NO_CA
const reactivesForTarget = reactiveCache.get(target)!;
if (!reactivesForTarget.has(callback)) {
const targetRawType = rawType(target);
const handler = COLLECTION_RAWTYPES.has(targetRawType)
const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
? collectionsProxyHandler(target as Collection, callback, targetRawType as CollectionRawType)
: basicProxyHandler<T>(callback);
const proxy = new Proxy(target, handler as ProxyHandler<T>) as Reactive<T>;
+5 -31
View File
@@ -4,44 +4,16 @@ import { getCurrent } from "./component_node";
import { Portal, portalTemplate } from "./portal";
import { helpers } from "./template_helpers";
import { OwlError } from "../common/owl_error";
import { parseXML } from "../common/utils";
const bdom = { text, createBlock, list, multi, html, toggler, comment };
function parseXML(xml: string): Document {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
let msg = "Invalid XML in template.";
const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
if (parsererrorText) {
msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
const re = /\d+/g;
const firstMatch = re.exec(parsererrorText);
if (firstMatch) {
const lineNumber = Number(firstMatch[0]);
const line = xml.split("\n")[lineNumber - 1];
const secondMatch = re.exec(parsererrorText);
if (line && secondMatch) {
const columnIndex = Number(secondMatch[0]) - 1;
if (line[columnIndex]) {
msg +=
`\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
`${line}\n${"-".repeat(columnIndex - 1)}^`;
}
}
}
}
throw new OwlError(msg);
}
return doc;
}
export interface TemplateSetConfig {
dev?: boolean;
translatableAttributes?: string[];
translateFn?: (s: string) => string;
templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void;
}
export class TemplateSet {
@@ -51,6 +23,7 @@ export class TemplateSet {
dev: boolean;
rawTemplates: typeof globalTemplates = Object.create(globalTemplates);
templates: { [name: string]: Template } = {};
getRawTemplate?: (s: string) => Element | Function | string | void;
translateFn?: (s: string) => string;
translatableAttributes?: string[];
Portal = Portal;
@@ -68,6 +41,7 @@ export class TemplateSet {
}
}
}
this.getRawTemplate = config.getTemplate;
}
addTemplate(name: string, template: string | Element) {
@@ -106,7 +80,7 @@ export class TemplateSet {
getTemplate(name: string): Template {
if (!(name in this.templates)) {
const rawTemplate = this.rawTemplates[name];
const rawTemplate = this.getRawTemplate?.(name) || this.rawTemplates[name];
if (rawTemplate === undefined) {
let extraInfo = "";
try {
+1 -10
View File
@@ -1,16 +1,7 @@
import { OwlError } from "../common/owl_error";
import { toRaw } from "./reactivity";
type BaseType =
| typeof String
| typeof Boolean
| typeof Number
| typeof Date
| typeof Object
| typeof Array
| typeof Function
| true
| "*";
type BaseType = { new (...args: any[]): any } | true | "*";
interface TypeInfo {
type?: TypeDescription;
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script.
export const version = "2.2.7";
export const version = "2.2.11";
@@ -1,5 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`comments comment node with backslash at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return comment(\` \\\\\\\\ \`);
}
}"
`;
exports[`comments comment node with backtick at top-level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return comment(\` \\\\\` \`);
}
}"
`;
exports[`comments comment node with interpolation sigil at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return comment(\` \\\\\${very cool} \`);
}
}"
`;
exports[`comments only a comment 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -341,6 +341,39 @@ exports[`simple templates, mostly static template with t tag with multiple conte
}"
`;
exports[`simple templates, mostly static text node with backslash at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`\\\\\\\\\`);
}
}"
`;
exports[`simple templates, mostly static text node with backtick at top-level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`\\\\\`\`);
}
}"
`;
exports[`simple templates, mostly static text node with interpolation sigil at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`\\\\\${very cool}\`);
}
}"
`;
exports[`simple templates, mostly static two t-escs next to each other 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -1,5 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-esc default with backslash at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { withDefault } = helpers;
return function template(ctx, node, key = \\"\\") {
return text(withDefault(undefined, \`\\\\\\\\\`));
}
}"
`;
exports[`t-esc default with backtick at top-level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { withDefault } = helpers;
return function template(ctx, node, key = \\"\\") {
return text(withDefault(undefined, \`\\\\\`\`));
}
}"
`;
exports[`t-esc default with interpolation sigil at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { withDefault } = helpers;
return function template(ctx, node, key = \\"\\") {
return text(withDefault(undefined, \`\\\\\${very cool}\`));
}
}"
`;
exports[`t-esc div with falsy values 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -1,5 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-set body with backslash at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"value\\", \`\\\\\\\\\`);
return text(ctx['value']);
}
}"
`;
exports[`t-set body with backtick at top-level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"value\\", \`\\\\\`\`);
return text(ctx['value']);
}
}"
`;
exports[`t-set body with interpolation sigil at top level 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"value\\", \`\\\\\${very cool}\`);
return text(ctx['value']);
}
}"
`;
exports[`t-set evaluate value expression 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -101,3 +101,55 @@ exports[`loading templates can load a few templates from an XMLDocument 2`] = `
}
}"
`;
exports[`loading templates getTemplate: element returned (2) 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>Hello World!</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`loading templates getTemplate: element returned 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>Hello World!</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`loading templates getTemplate: template string returned 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>Hello World!</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`loading templates getTemplate: undefined returned 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>Hello World!</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
+15
View File
@@ -26,4 +26,19 @@ describe("comments", () => {
</div>`;
expect(renderToString(template)).toBe("<div><span>true</span></div>");
});
test("comment node with backslash at top level", () => {
const template = "<!-- \\ -->";
expect(renderToString(template)).toBe("<!-- \\ -->");
});
test("comment node with backtick at top-level", () => {
const template = "<!-- ` -->";
expect(renderToString(template)).toBe("<!-- ` -->");
});
test("comment node with interpolation sigil at top level", () => {
const template = "<!-- ${very cool} -->";
expect(renderToString(template)).toBe("<!-- ${very cool} -->");
});
});
@@ -174,6 +174,9 @@ describe("expression evaluation", () => {
expect(compileExpr("list.data.map((data) => data)")).toBe(
"ctx['list'].data.map((_data)=>_data)"
);
expect(compileExpr("(ev) => { myFunc(v1, v2, ev.target.value); }")).toBe(
"(_ev)=>{ctx['myFunc'](ctx['v1'],ctx['v2'],_ev.target.value);}"
);
});
test.skip("arrow functions: not yet supported", () => {
// e is added to localvars in inline_expression but not removed after the arrow func body
+15
View File
@@ -154,4 +154,19 @@ describe("simple templates, mostly static", () => {
</div>`;
expect(renderToString(template, { a: "a", b: "b", c: "c" })).toBe("<div>abLoadingc</div>");
});
test("text node with backslash at top level", () => {
const template = "\\";
expect(renderToString(template)).toBe("\\");
});
test("text node with backtick at top-level", () => {
const template = "`";
expect(renderToString(template)).toBe("`");
});
test("text node with interpolation sigil at top level", () => {
const template = "${very cool}";
expect(renderToString(template)).toBe("${very cool}");
});
});
+15
View File
@@ -121,4 +121,19 @@ describe("t-esc", () => {
mount(bdom, fixture);
expect(fixture.querySelector("span")!.textContent).toBe("<p>escaped</p>");
});
test("default with backslash at top level", () => {
const template = '<t t-esc="undefined">\\</t>';
expect(renderToString(template)).toBe("\\");
});
test("default with backtick at top-level", () => {
const template = '<t t-esc="undefined">`</t>';
expect(renderToString(template)).toBe("`");
});
test("default with interpolation sigil at top level", () => {
const template = '<t t-esc="undefined">${very cool}</t>';
expect(renderToString(template)).toBe("${very cool}");
});
});
+15
View File
@@ -54,6 +54,21 @@ describe("t-set", () => {
expect(renderToString(template)).toBe("ok");
});
test("body with backslash at top level", () => {
const template = '<t t-set="value">\\</t><t t-esc="value"/>';
expect(renderToString(template)).toBe("\\");
});
test("body with backtick at top-level", () => {
const template = '<t t-set="value">`</t><t t-esc="value"/>';
expect(renderToString(template)).toBe("`");
});
test("body with interpolation sigil at top level", () => {
const template = '<t t-set="value">${very cool}</t><t t-esc="value"/>';
expect(renderToString(template)).toBe("${very cool}");
});
test("set from body literal (with t-if/t-else", () => {
const template = `
<t>
+58
View File
@@ -78,4 +78,62 @@ describe("loading templates", () => {
context.addTemplates(xml);
expect(Object.keys(context.rawTemplates)).toEqual([]);
});
test("getTemplate: element returned", () => {
const context = new TestContext({
getTemplate: (name) => {
if (name === "main") {
const data = `<div>Hello World!</div>`;
const xml = new DOMParser().parseFromString(data, "text/xml");
return xml.firstChild as Element;
}
return;
},
});
const result = context.renderToString("main");
expect(result).toBe("<div>Hello World!</div>");
});
test("getTemplate: element returned (2)", () => {
const context = new TestContext({
getTemplate: (name) => {
if (name === "main") {
const doc = new Document();
const div = doc.createElement("div");
div.append(doc.createTextNode("Hello World!"));
return div;
}
return;
},
});
const result = context.renderToString("main");
expect(result).toBe("<div>Hello World!</div>");
});
test("getTemplate: template string returned", () => {
const context = new TestContext({
getTemplate: (name) => {
if (name === "main") {
return `<div>Hello World!</div>`;
}
return;
},
});
const result = context.renderToString("main");
expect(result).toBe("<div>Hello World!</div>");
});
test("getTemplate: undefined returned", () => {
const context = new TestContext({
getTemplate: () => {},
});
const data = `<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<div t-name="main">Hello World!</div>
</templates>`;
const xml = new DOMParser().parseFromString(data, "text/xml");
context.addTemplates(xml);
const result = context.renderToString("main");
expect(result).toBe("<div>Hello World!</div>");
});
});
@@ -683,6 +683,19 @@ exports[`lifecycle hooks sub widget (inside sub node): hooks are correctly calle
}"
`;
exports[`lifecycle hooks timeout in onWillStart doesn't emit a warning if app is destroyed 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<span/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`lifecycle hooks timeout in onWillStart emits a warning 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -167,6 +167,45 @@ exports[`props validation can specify that additional props are allowed (object)
}"
`;
exports[`props validation can use custom class as type 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, [\\"customObj\\"]);
return function template(ctx, node, key = \\"\\") {
const props1 = {customObj: ctx['customObj']};
helpers.validateProps(\`Child\`, props1, this);
return comp1(props1, key + \`__1\`, node, this, null);
}
}"
`;
exports[`props validation can use custom class as type 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['props'].customObj.val);
}
}"
`;
exports[`props validation can use custom class as type: validation failure 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, [\\"customObj\\"]);
return function template(ctx, node, key = \\"\\") {
const props1 = {customObj: ctx['customObj']};
helpers.validateProps(\`Child\`, props1, this);
return comp1(props1, key + \`__1\`, node, this, null);
}
}"
`;
exports[`props validation can validate a prop with multiple types 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -44,7 +44,26 @@ exports[`t-model directive .trim modifier 1`] = `
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { toNumber } = helpers;
let block1 = createBlock(\`<div><input block-property-0=\\"value\\" block-handler-1=\\"input\\"/><span><block-text-2/></span></div>\`);
let block1 = createBlock(\`<div><input block-property-0=\\"value\\" block-handler-1=\\"change\\"/><span><block-text-2/></span></div>\`);
return function template(ctx, node, key = \\"\\") {
const bExpr1 = ctx['state'];
const expr1 = 'text';
let prop1 = bExpr1[expr1];
let hdlr1 = [(ev) => { bExpr1[expr1] = ev.target.value.trim(); }];
let txt1 = ctx['state'].text;
return block1([prop1, hdlr1, txt1]);
}
}"
`;
exports[`t-model directive .trim modifier implies .lazy modifier 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { toNumber } = helpers;
let block1 = createBlock(\`<div><input block-property-0=\\"value\\" block-handler-1=\\"change\\"/><span><block-text-2/></span></div>\`);
return function template(ctx, node, key = \\"\\") {
const bExpr1 = ctx['state'];
+80 -35
View File
@@ -1,5 +1,9 @@
import { App, Component, mount, onMounted, onWillStart, useState, xml } from "../../src";
import {
App,
Component,
mount,
useState,
xml,
onWillPatch,
onWillUnmount,
onPatched,
@@ -7,7 +11,9 @@ import {
onWillRender,
onWillDestroy,
onRendered,
} from "../../src/runtime/lifecycle_hooks";
onMounted,
onWillStart,
} from "../../src";
import { status } from "../../src/runtime/status";
import {
elem,
@@ -117,24 +123,60 @@ describe("lifecycle hooks", () => {
timeoutCbs[++timeoutId] = cb;
return timeoutId;
}) as any;
class Test extends Component {
static template = xml`<span/>`;
setup() {
onWillStart(() => new Promise(() => {}));
try {
class Test extends Component {
static template = xml`<span/>`;
setup() {
onWillStart(() => new Promise(() => {}));
}
}
mount(Test, fixture, { test: true });
nextTick();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
}
await nextMicroTick();
await nextMicroTick();
expect(console.warn).toHaveBeenCalledTimes(1);
expect(warnArgs![0]!.message).toBe("onWillStart's promise hasn't resolved after 3 seconds");
} finally {
console.warn = warn;
window.setTimeout = setTimeout;
}
mount(Test, fixture, { test: true });
nextTick();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
});
test("timeout in onWillStart doesn't emit a warning if app is destroyed", async () => {
const { warn } = console;
console.warn = jest.fn();
const { setTimeout } = window;
let timeoutCbs: any = {};
let timeoutId = 0;
window.setTimeout = ((cb: any) => {
timeoutCbs[++timeoutId] = cb;
return timeoutId;
}) as any;
try {
class Test extends Component {
static template = xml`<span/>`;
setup() {
onWillStart(() => new Promise(() => {}));
}
}
const app = new App(Test, { test: true });
app.mount(fixture);
app.destroy();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
}
await nextMicroTick();
await nextMicroTick();
expect(console.warn).toHaveBeenCalledTimes(0);
} finally {
console.warn = warn;
window.setTimeout = setTimeout;
}
await nextMicroTick();
await nextMicroTick();
expect(console.warn).toHaveBeenCalledTimes(1);
expect(warnArgs![0]!.message).toBe("onWillStart's promise hasn't resolved after 3 seconds");
console.warn = warn;
window.setTimeout = setTimeout;
});
test("timeout in onWillUpdateProps emits a warning", async () => {
@@ -162,25 +204,28 @@ describe("lifecycle hooks", () => {
return timeoutId;
}) as any;
parent.state.prop = 2;
let tick = nextTick();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
try {
parent.state.prop = 2;
let tick = nextTick();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
}
await tick;
tick = nextTick();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
}
await tick;
expect(console.warn).toHaveBeenCalledTimes(1);
expect(warnArgs![0]!.message).toBe(
"onWillUpdateProps's promise hasn't resolved after 3 seconds"
);
} finally {
console.warn = warn;
window.setTimeout = setTimeout;
}
await tick;
tick = nextTick();
for (const id in timeoutCbs) {
timeoutCbs[id]();
delete timeoutCbs[id];
}
await tick;
expect(console.warn).toHaveBeenCalledTimes(1);
expect(warnArgs![0]!.message).toBe(
"onWillUpdateProps's promise hasn't resolved after 3 seconds"
);
console.warn = warn;
window.setTimeout = setTimeout;
});
test("mounted hook is called if mounted in DOM", async () => {
+46
View File
@@ -829,6 +829,52 @@ describe("props validation", () => {
expect(error!).toBeDefined();
expect(error!.message).toBe("Invalid props for component 'Child': 'message' is missing");
});
test("can use custom class as type", async () => {
class CustomClass {
val = "hey";
}
class Child extends Component {
static props = { customObj: CustomClass };
static template = xml`<t t-esc="props.customObj.val"/>`;
}
class Parent extends Component {
static components = { Child };
static template = xml`<Child customObj="customObj" />`;
customObj = new CustomClass();
}
const app = new App(Parent, { test: true });
await app.mount(fixture);
expect(fixture.innerHTML).toBe("hey");
});
test("can use custom class as type: validation failure", async () => {
class CustomClass {}
class Child extends Component {
static props = { customObj: CustomClass };
static template = xml`<div>hey</div>`;
}
class Parent extends Component {
static components = { Child };
static template = xml`<Child customObj="customObj" />`;
customObj = {};
}
const app = new App(Parent, { test: true });
let error: OwlError | undefined;
const mountProm = app.mount(fixture).catch((e: Error) => (error = e));
await expect(nextAppError(app)).resolves.toThrow(
"Invalid props for component 'Child': 'customObj' is not a customclass"
);
await mountProm;
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid props for component 'Child': 'customObj' is not a customclass"
);
});
});
//------------------------------------------------------------------------------
+26
View File
@@ -331,6 +331,32 @@ describe("t-model directive", () => {
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
});
test(".trim modifier implies .lazy modifier", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<input t-model.trim="state.text"/>
<span><t t-esc="state.text"/></span>
</div>
`;
state = useState({ text: "" });
}
const comp = await mount(SomeComponent, fixture);
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
const input = fixture.querySelector("input")!;
input.value = "test ";
input.dispatchEvent(new Event("input"));
await nextTick();
expect(comp.state.text).toBe("");
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
input.dispatchEvent(new Event("change"));
await nextTick();
expect(comp.state.text).toBe("test");
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
});
test(".number modifier", async () => {
class SomeComponent extends Component {
static template = xml`
+22
View File
@@ -4,6 +4,28 @@ All notable changes to the "owl-vision" extension will be documented in this fil
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [0.1.0] - 2024-11-06
### Added
- Basic autocomplete in xml files. This includes autocompletion for elements, components,
props, attributes, and javascript expressions.
The current implementation, while relatively simple, has a couple of drawbacks:
- Javascript imports are not resolved by the xml autocomplete, this means that it does not
understand the types of imported functions or objects. That said, I've added custom support
for frequently used Owl imports, namely `useState` and `useRef`. You can add more in
the settings if needed.
- The autocomplete is limited to templates directly linked to components, sub-templates
used via t-call will not get autocompletion as no component/context can be bound to them.
- "Go To Definition" support for props and javascript expressions in xml
- Support for the following directives: t-att, t-model, t-tag, t-debug, t-log
### Fixed
- Changed t-else syntax highlight from dynamic to static attribute
## [0.0.2] - 2023-2-11
### Added
+5557 -5527
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -4,7 +4,7 @@
"description": "Owl framework extension that highlights templates and ease navigation between components and templates.",
"publisher": "Odoo",
"license": "LGPL-3.0-only",
"version": "0.0.2",
"version": "0.1.0",
"repository": {
"type": "git",
"url": "https://github.com/odoo/owl/tree/master/tools/owl-vision"
@@ -63,6 +63,13 @@
"type": "string",
"default": "**/node_modules/**,**/lib/**,**/tests/**",
"description": "Files to exclude in search"
},
"owl-vision.autocomplete-mocks": {
"order": 2,
"type": "string",
"editPresentation": "multilineText",
"default": "/**\n* @template T\n* @param {T} obj\n* @returns {T}\n*/\nfunction useState(obj) {}\n\n/**\n* @typedef {Object} Ref\n* @property {HTMLElement} el\n*/\n/**\n* @returns {Ref}\n*/\nfunction useRef(name) {}",
"description": "Mocks for functions or object that are imported but not resolved by the autcomplete. Add docstring comments for them to work properly."
}
}
},
@@ -128,6 +135,7 @@
"devDependencies": {
"@types/node": "20.2.5",
"@types/vscode": "^1.73.0",
"@types/xmldoc": "^1.1.9",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"@vscode/test-electron": "^2.3.2",
@@ -135,5 +143,8 @@
"esbuild": "^0.19.5",
"eslint": "^8.41.0",
"typescript": "^5.1.3"
},
"dependencies": {
"xmldoc": "^1.3.0"
}
}
}
@@ -62,7 +62,6 @@ export const propsAttributes = createAttributePatterns("props-attributes", {
export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dynamic", {
match: [
"t-if",
"t-else",
"t-elif",
"t-foreach",
"t-as",
@@ -75,6 +74,10 @@ export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dyna
"t-value",
"t-portal",
"t-slot-scope",
"t-att",
"t-tag",
"t-log",
"t-model",
"t-att-[a-z_:.-]+",
"t-on-[a-z_:.-]+"
].join("|"),
@@ -86,12 +89,13 @@ export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dyna
export const owlAttributesStatic = createAttributePatterns("owl-attributes-static", {
match: [
"t-name",
"t-else",
"t-ref",
"t-set-slot",
"t-model",
"t-inherit",
"t-inherit-mode",
"t-translation"
"t-translation",
"t-debug",
].join("|"),
attributeName: "owl.attribute owl.attribute.static",
});
+29 -4
View File
@@ -1,18 +1,43 @@
{
"Basic owl component": {
"Basic OWL Component": {
"prefix": "owlcomponent",
"scope": "javascript,typescript",
"body": [
"export class ${1:component-name} extends Component {",
" static template = \"${2:template-name}\";",
"import { Component } from \"@odoo/owl\";",
"",
"class ${1:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}} extends ${2:Component} {",
"",
" static template = \"${3:${RELATIVE_FILEPATH/(.*[\\|\\/])??([a-zA-Z_]+)([\\|\\/]static[\\|\\/].*)/${2}/g}}.${4:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}}\";",
" static components = {};",
" static props = {};",
"",
" setup() {",
"",
" ${5:super.setup();}",
" }",
"",
" ${6:// Do Something}",
"}",
""
],
"description": "The starting base for an owl component"
},
"Basic OWL Template": {
"prefix": "owltemplate",
"scope": "xml",
"body": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" ?>",
"",
"<templates xml:space=\"preserve\">",
"",
" <t t-name=\"${2:${RELATIVE_FILEPATH/(.*[\\|\\/])??([a-zA-Z_]+)([\\|\\/]static[\\|\\/].*)/${2}/g}}.${3:${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}}\">",
" ${3:<h1>Hello World</h1>}",
" </t>",
"",
"</templates>",
""
],
"description": "Generate a basic OWL template XML file"
}
}
@@ -1,29 +0,0 @@
import * as vscode from 'vscode';
import { getSelectedText, showStatusMessage, hideStatusMessage } from './utils';
import { Search } from './search';
export class ComponentDefinitionProvider implements vscode.DefinitionProvider {
search: Search;
constructor(search: Search) {
this.search = search;
}
/**
* Interface implementation to provide definition when ctrl+click on Component
* tag in template.
*/
async provideDefinition(document: vscode.TextDocument, position: vscode.Position) {
const currentWord = getSelectedText(/<\/?[A-Z][a-zA-Z]+/, document, position);
if (!currentWord) {
return;
}
const componentName = currentWord.replace(/[\/<]/g, "").trim();
showStatusMessage(`Searching for component "${componentName}"`);
const result = await this.search.findComponent(componentName);
hideStatusMessage();
return result;
}
}
+4 -5
View File
@@ -1,20 +1,19 @@
import * as vscode from 'vscode';
import { Search } from './search';
import { ComponentDefinitionProvider } from './definiton_providers';
import { OpenDirection } from './utils';
import { OwlLanguageFeaturesProvider } from './language_features/language_features_provider';
export async function activate(context: vscode.ExtensionContext) {
const search = new Search();
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch', () => search.switch()));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch-besides', () => search.switch(OpenDirection.Besides)));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch-below', () => search.switch(OpenDirection.Below)));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.find-component', () => search.findComponentCommand()));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.find-template', () => search.findTemplateCommand()));
const componentDefProvider = new ComponentDefinitionProvider(search);
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'xml' }, componentDefProvider));
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'javascript' }, componentDefProvider));
const languageFeaturesProvider = new OwlLanguageFeaturesProvider(search);
context.subscriptions.push(vscode.languages.registerCompletionItemProvider({ language: 'xml', scheme: 'file' }, languageFeaturesProvider, '.', '<'));
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'xml', scheme: 'file' }, languageFeaturesProvider));
}
export function deactivate() { }
@@ -0,0 +1,395 @@
import { CompletionItemKind } from "vscode"
export const owlComponentAttributes = [
"t-if",
"t-else",
"t-elif",
"t-foreach",
"t-as",
"t-key",
"t-esc",
"t-out",
"t-props",
"t-set",
"t-value",
"t-portal",
"t-slot-scope",
"t-log",
].map(label => ({
label: label,
insertText: label + '=""',
kind: CompletionItemKind.Property,
}));
export const owlElementAttributes = [
"t-component",
"t-att",
"t-tag",
"t-model",
].map(label => ({
label: label,
insertText: label + '=""',
kind: CompletionItemKind.Property,
}));
owlElementAttributes.push(...owlComponentAttributes);
/**
To generate this list, run the following snippet on https://developer.mozilla.org/fr/docs/Web/Events
(function () {
const events = [...document.querySelectorAll(".section-content li a")]
.map(e => e.childNodes[0])
.filter(n => n.nodeType === 3 && n.textContent.toLowerCase() == n.textContent)
.map(n => "t-on-" + n.textContent.trim());
return JSON.stringify([...new Set(events)], null, 2);
}())
*/
export const events = [
"t-on-abort",
"t-on-ended",
"t-on-addtrack",
"t-on-change",
"t-on-removetrack",
"t-on-messageerror",
"t-on-message",
"t-on-animationcancel",
"t-on-animationend",
"t-on-animationiteration",
"t-on-animationstart",
"t-on-copy",
"t-on-cut",
"t-on-dragend",
"t-on-dragenter",
"t-on-dragleave",
"t-on-dragover",
"t-on-dragstart",
"t-on-drag",
"t-on-drop",
"t-on-fullscreenchange",
"t-on-fullscreenerror",
"t-on-gotpointercapture",
"t-on-keydown",
"t-on-keypress",
"t-on-keyup",
"t-on-lostpointercapture",
"t-on-paste",
"t-on-pointercancel",
"t-on-pointerdown",
"t-on-pointerenter",
"t-on-pointerleave",
"t-on-pointerlockchange",
"t-on-pointerlockerror",
"t-on-pointermove",
"t-on-pointerout",
"t-on-pointerover",
"t-on-pointerup",
"t-on-readystatechange",
"t-on-scroll",
"t-on-selectionchange",
"t-on-selectstart",
"t-on-touchcancel",
"t-on-touchend",
"t-on-touchmove",
"t-on-touchstart",
"t-on-transitioncancel",
"t-on-transitionend",
"t-on-transitionrun",
"t-on-transitionstart",
"t-on-visibilitychange",
"t-on-wheel",
"t-on-afterscriptexecute",
"t-on-auxclick",
"t-on-beforescriptexecute",
"t-on-blur",
"t-on-click",
"t-on-compositionend",
"t-on-compositionstart",
"t-on-compositionupdate",
"t-on-contextmenu",
"t-on-dblclick",
"t-on-error",
"t-on-focusin",
"t-on-focusout",
"t-on-focus",
"t-on-gesturechange",
"t-on-gestureend",
"t-on-gesturestart",
"t-on-mousedown",
"t-on-mouseenter",
"t-on-mouseleave",
"t-on-mousemove",
"t-on-mouseout",
"t-on-mouseover",
"t-on-mouseup",
"t-on-mousewheel",
"t-on-overflow",
"t-on-select",
"t-on-show",
"t-on-underflow",
"t-on-webkitmouseforcechanged",
"t-on-webkitmouseforcedown",
"t-on-webkitmouseforceup",
"t-on-webkitmouseforcewillbegin",
"t-on-open",
"t-on-loadend",
"t-on-loadstart",
"t-on-load",
"t-on-progress",
"t-on-webglcontextcreationerror",
"t-on-webglcontextlost",
"t-on-webglcontextrestored",
"t-on-toggle",
"t-on-cancel",
"t-on-close",
"t-on-beforeinput",
"t-on-input",
"t-on-formdata",
"t-on-reset",
"t-on-submit",
"t-on-invalid",
"t-on-search",
"t-on-canplaythrough",
"t-on-canplay",
"t-on-durationchange",
"t-on-emptied",
"t-on-loadeddata",
"t-on-loadedmetadata",
"t-on-pause",
"t-on-playing",
"t-on-play",
"t-on-ratechange",
"t-on-seeked",
"t-on-seeking",
"t-on-stalled",
"t-on-suspend",
"t-on-timeupdate",
"t-on-volumechange",
"t-on-waiting",
"t-on-slotchange",
"t-on-cuechange",
"t-on-enterpictureinpicture",
"t-on-leavepictureinpicture",
"t-on-versionchange",
"t-on-blocked",
"t-on-upgradeneeded",
"t-on-success",
"t-on-complete",
"t-on-devicechange",
"t-on-mute",
"t-on-unmute",
"t-on-merchantvalidation",
"t-on-paymentmethodchange",
"t-on-shippingaddresschange",
"t-on-shippingoptionchange",
"t-on-payerdetailchange",
"t-on-resourcetimingbufferfull",
"t-on-resize",
"t-on-bufferedamountlow",
"t-on-closing",
"t-on-tonechange",
"t-on-gatheringstatechange",
"t-on-selectedcandidatepairchange",
"t-on-statechange",
"t-on-addstream",
"t-on-connectionstatechange",
"t-on-datachannel",
"t-on-icecandidateerror",
"t-on-icecandidate",
"t-on-iceconnectionstatechange",
"t-on-icegatheringstatechange",
"t-on-negotiationneeded",
"t-on-removestream",
"t-on-signalingstatechange",
"t-on-track",
"t-on-audioprocess",
"t-on-activate",
"t-on-contentdelete",
"t-on-install",
"t-on-notificationclick",
"t-on-pushsubscriptionchange",
"t-on-push",
"t-on-connect",
"t-on-audioend",
"t-on-audiostart",
"t-on-end",
"t-on-nomatch",
"t-on-result",
"t-on-soundend",
"t-on-soundstart",
"t-on-speechend",
"t-on-speechstart",
"t-on-start",
"t-on-voiceschanged",
"t-on-boundary",
"t-on-mark",
"t-on-resume",
"t-on-unload",
"t-on-afterprint",
"t-on-appinstalled",
"t-on-beforeprint",
"t-on-beforeunload",
"t-on-devicemotion",
"t-on-deviceorientation",
"t-on-gamepadconnected",
"t-on-gamepaddisconnected",
"t-on-hashchange",
"t-on-languagechange",
"t-on-offline",
"t-on-online",
"t-on-orientationchange",
"t-on-pagehide",
"t-on-pageshow",
"t-on-popstate",
"t-on-rejectionhandled",
"t-on-storage",
"t-on-unhandledrejection",
"t-on-vrdisplayactivate",
"t-on-vrdisplayblur",
"t-on-vrdisplayconnect",
"t-on-vrdisplaydeactivate",
"t-on-vrdisplaydisconnect",
"t-on-vrdisplayfocus",
"t-on-vrdisplaypointerrestricted",
"t-on-vrdisplaypointerunrestricted",
"t-on-vrdisplaypresentchange",
"t-on-timeout",
"t-on-inputsourceschange",
"t-on-selectend",
"t-on-squeezeend",
"t-on-squeezestart",
"t-on-squeeze"
].map(label => ({
label: label,
insertText: label + '=""',
kind: CompletionItemKind.Property,
}))
/**
To generate the elements list, run the following snippet on https://developer.mozilla.org/en-US/docs/Web/HTML/Element
(function () {
const names = [...document.querySelectorAll("section:not([aria-labelledby='obsolete_and_deprecated_elements']) td:nth-child(1)")]
.flatMap(n => n.innerText.split(","))
.map(n => n.trim().replace("<", "").replace(">", ""))
return JSON.stringify([...new Set(names)], null, 2);
})()
*/
export const elements = [
"t",
"link",
"meta",
"style",
"title",
"body",
"address",
"article",
"aside",
"footer",
"header",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hgroup",
"main",
"nav",
"section",
"search",
"blockquote",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"hr",
"li",
"menu",
"ol",
"p",
"pre",
"ul",
"a",
"abbr",
"b",
"bdi",
"bdo",
"br",
"cite",
"code",
"data",
"dfn",
"em",
"i",
"kbd",
"mark",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"small",
"span",
"strong",
"sub",
"sup",
"time",
"u",
"var",
"wbr",
"area",
"audio",
"img",
"map",
"track",
"video",
"embed",
"iframe",
"object",
"picture",
"portal",
"source",
"svg",
"math",
"canvas",
"noscript",
"script",
"del",
"ins",
"caption",
"col",
"colgroup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"button",
"datalist",
"fieldset",
"form",
"input",
"label",
"legend",
"meter",
"optgroup",
"option",
"output",
"progress",
"select",
"textarea",
"details",
"dialog",
"summary",
].map(label => ({
label: label,
insertText: label,
kind: CompletionItemKind.Property,
}));
@@ -0,0 +1,422 @@
import { CancellationToken, CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, DefinitionProvider, Location, Position, Range, TextDocument, TextDocumentContentProvider, Uri, commands, workspace } from "vscode";
import { Search } from "../search";
import { getSelectedText, hash, readFile } from "../utils";
import { elements, events, owlComponentAttributes, owlElementAttributes } from "./items";
import { ParseResultType, ParseResult, parse, getNodePath, parseXml } from "./parser";
/**
* Commands return basic js object which needs to be converted
* to actual CompletionItem instances, this methods streamlines
* this process.
*/
function mapCompletionItems(items: any): CompletionItem[] {
return items.map((i: any) => {
const item = new CompletionItem(i.label, i.kind);
item.sortText = i.sortText;
item.detail = i.detail;
item.filterText = i.filterText;
item.insertText = i.insertText?.startsWith?.(".") ? i.insertText.substring(1) : i.insertText;
return item;
});
}
function filterComponentItems(items: CompletionItem[], excludedLabels: string[] = []): CompletionItem[] {
return items.filter((item) => {
return !excludedLabels.includes(item.label.toString()) && [
CompletionItemKind.Field,
CompletionItemKind.Method,
CompletionItemKind.Variable,
CompletionItemKind.Property,
].includes(item.kind as number);
});
}
/**
* Adds "this." in front of the expression if needed and increments
* the expression offset accordingly.
*/
function contextualize(properties: string[], expression: string, expressionOffset = 0) {
const match = expression.match(/^([a-zA-Z_]+)\b/);
if (!expression.startsWith("this.") && ((match && properties.includes(match[1])) || expression.match(/^\s*$/))) {
expression = "this." + expression;
expressionOffset += 5;
}
return { expression, expressionOffset };
};
const Commands = {
Completion: "vscode.executeCompletionItemProvider",
Definition: "vscode.executeDefinitionProvider",
}
export class OwlLanguageFeaturesProvider implements CompletionItemProvider, TextDocumentContentProvider, DefinitionProvider {
virtualDocuments = new Map();
componentProperties = new Map();
search: Search;
constructor(search: Search) {
this.search = search;
workspace.registerTextDocumentContentProvider("owl", this);
}
/**
* TextDocumentContentProvider interface implementation to provide
* virtual documents to vscode commands.
*/
async provideTextDocumentContent(uri: Uri) {
const id = uri.toString(true);
return this.virtualDocuments.get(id);
}
/**
* DefinitionProvider interface implementation.
*
* - If the target is a js expression, will try to find the definition
* inside the current component.
* - If the target is a component element, will try to find the definition
* of the component.
*/
async provideDefinition(document: TextDocument, position: Position) {
let offset = document.offsetAt(position);
const documentText = document.getText();
const parseResult = await parse(documentText, offset);
if (parseResult.type === ParseResultType.Expression) {
const component = await this.search.getCurrentComponent();
if (!component) {
return;
}
const { xmlDocument, xmlNode } = parseXml(documentText, offset);
const componentText = await readFile(component.uri);
const virtualDocument = await this.getVirtualJsDocument(document.uri, component.componentName, componentText, xmlDocument, xmlNode, parseResult);
const definitions: any = await this.executeCommand(
Commands.Definition,
document.uri,
virtualDocument.content,
virtualDocument.offset
);
if (definitions.length > 0) {
const selectionRange = definitions[0].targetSelectionRange;
const range = new Range(
new Position(selectionRange.start.line, selectionRange.start.character),
new Position(selectionRange.end.line, selectionRange.end.character),
)
return new Location(component.uri, range);
}
} else if (parseResult.type === ParseResultType.Attribute) {
const { xmlNode } = parseXml(documentText, offset);
const childComponent = await this.search.findComponent(xmlNode.name);
if (!childComponent) {
return [];
}
const modifiersRegex = new RegExp([
"\\.bind",
"\\.stop",
"\\.prevent",
"\\.self",
"\\.capture",
"\\.sythetic",
].join("|"), "g");
let attributeName = getSelectedText(/\b[a-zA-Z0-9_\-.]+\b/, document, position)
attributeName = attributeName?.replace(modifiersRegex, "") ?? "";
const componentText = await readFile(childComponent.uri);
const content = `${componentText}\n${xmlNode.name}.props.${attributeName}`;
const definitions: any = await this.executeCommand(Commands.Definition, document.uri, content);
if (definitions.length > 0) {
const selectionRange = definitions[0].targetSelectionRange;
const range = new Range(
new Position(selectionRange.start.line, selectionRange.start.character),
new Position(selectionRange.end.line, selectionRange.end.character),
)
return new Location(childComponent.uri, range);
}
} else if (parseResult.type === ParseResultType.Element) {
const currentWord = getSelectedText(/<\/?[A-Z][a-zA-Z]+/, document, position);
if (!currentWord) {
return;
}
const componentName = currentWord.replace(/[\/<]/g, "").trim();
return await this.search.findComponent(componentName);
}
}
/**
* CompletionItemProvider interface implementation
*
* See {@link provideElementItems}, {@link provideAttributeItems} and {@link provideExpressionItems}
* for further details.
*/
async provideCompletionItems(
document: TextDocument,
position: Position,
token: CancellationToken,
context: CompletionContext
): Promise<CompletionItem[]> {
const component = await this.search.getCurrentComponent();
if (!component || token.isCancellationRequested) {
return [];
}
const offset = document.offsetAt(position);
const documentText = document.getText();
const parseResult = await parse(documentText, offset);
const { xmlDocument, xmlNode } = parseXml(documentText, offset);
if (parseResult.type === ParseResultType.Expression) {
return this.provideExpressionItems(document.uri, component.uri, component.componentName, xmlDocument, xmlNode, parseResult);
} else if (parseResult.type === ParseResultType.Attribute) {
return this.provideAttributeItems(document.uri, xmlNode);
} else if (parseResult.type === ParseResultType.Element) {
return this.provideElementItems(document.uri, component.uri, component.componentName, parseResult);
}
return [];
}
/**
* Returns the completion items for attributes.
* - Returns props if the element is a component
* - Returns the owl directives based on the element type
*/
private async provideAttributeItems(
documentUri: Uri,
xmlNode: any,
): Promise<CompletionItem[]> {
if (!xmlNode || xmlNode.name === xmlNode.name.toLowerCase()) {
return [...owlElementAttributes, ...events];
}
const childComponent = await this.search.findComponent(xmlNode.name);
if (!childComponent) {
return [];
}
const componentText = await readFile(childComponent.uri);
const content = `${componentText}\n${xmlNode.name}.props.`;
const list = await this.executeCommand(Commands.Completion, documentUri, content) as CompletionList;
const modifiersRegex = new RegExp([
"\\.bind",
"\\.stop",
"\\.prevent",
"\\.self",
"\\.capture",
"\\.sythetic",
].join("|"), "g");
const excludedAttrs = [
"slots",
...Object.keys(xmlNode.attr).map(attr => attr.replace(modifiersRegex, ""))
];
return mapCompletionItems(filterComponentItems([
...owlComponentAttributes,
...list.items
], excludedAttrs));
}
/**
* Returns the completion items for elements, this includes
* components, "t" and html elements.
*/
private async provideElementItems(
documentUri: Uri,
componentUri: Uri,
componentName: string,
parseResult: any,
): Promise<CompletionItem[]> {
const componentText = await readFile(componentUri);
const content = `${componentText}\n${componentName}.components.${parseResult.expression}`;
const list = await this.executeCommand(Commands.Completion, documentUri, content) as CompletionList;
return mapCompletionItems([
...elements,
...filterComponentItems(list.items),
]);
}
/**
* Returns the completion items for a js expression
*/
private async provideExpressionItems(
documentUri: Uri,
componentUri: Uri,
componentName: string,
xmlDocument: any,
xmlNode: any,
parseResult: ParseResult,
): Promise<CompletionItem[]> {
let { attributeName } = parseResult;
const dynamicAttributeRegex = new RegExp([
"t-if",
"t-elif",
"t-foreach",
"t-as",
"t-key",
"t-esc",
"t-out",
"t-props",
"t-component",
"t-set",
"t-value",
"t-portal",
"t-slot-scope",
"t-att",
"t-tag",
"t-log",
"t-model",
"t-att-[a-z_:.-]+",
"t-on-[a-z_:.-]+"
].join("|"));
if (xmlNode.name === xmlNode.name.toLowerCase() && !dynamicAttributeRegex.test(attributeName)) {
return [];
}
const componentText = await readFile(componentUri);
const virtualDocument = await this.getVirtualJsDocument(documentUri, componentName, componentText, xmlDocument, xmlNode, parseResult);
const completionList = await this.executeCommand(
Commands.Completion,
documentUri,
virtualDocument.content,
virtualDocument.offset
) as CompletionList;
let items = filterComponentItems(completionList.items, ["__VIRTUAL__", "setup"]);
if (!/\bthis\./.test(parseResult.expression)) {
items = items.map(item => {
let insertText = item.insertText as string;
if (/\bthis\./.test(insertText)) {
item.insertText = insertText.replace(/\bthis\./, "");
}
return item;
})
}
return mapCompletionItems([...items]);
}
/**
* Creates a virtual document to provide the appropriate
* completion items for a parsed js expression.
*
* This method:
* - Adds default owl variables such as env and props
* - Adds mocks for frequently used Owl imports (which cannot be resolved using commands)
* - Adds local variables generated from Owl xml directives such as t-for or t-set
* - Will try to add a "this." in front of the js expression if it was omitted so it can
* be understood by vscode typescript server.
*
* The expression offset is also modified accordingly.
*/
async getVirtualJsDocument(
documentUri: Uri,
componentName: string,
componentText: string,
xmlDocument: any,
xmlNode: any,
parseResult: ParseResult,
) {
const properties = await this.getComponentProperties(documentUri, componentName, componentText);
// As imports do not work, use mocks for frequently used owl functions.
let importReplacements = workspace.getConfiguration().get(`owl-vision.autocomplete-mocks`);
// As imports do not work, manually add "env" and "props"
// to the current component instance.
const localVariables = [
"let env = {};",
"this.env = env;",
`let props = ${componentName}.props;`,
`this.props = props;`,
];
// Adds local variables generated based on the template
const path = getNodePath(xmlDocument, xmlNode);
for (const node of path) {
if (node.attr["t-foreach"]) {
let array = contextualize(properties, node.attr["t-foreach"]).expression;
localVariables.push(`const ${node.attr["t-as"]} = ${array}[0];`);
localVariables.push(`const ${node.attr["t-as"]}_index = 0;`);
localVariables.push(`const ${node.attr["t-as"]}_first = ${array}[0];`);
localVariables.push(`const ${node.attr["t-as"]}_last = ${array}.at(-1);`);
localVariables.push(`const ${node.attr["t-as"]}_value = {};`);
} else if (node.attr["t-set"]) {
localVariables.push(`const ${node.attr["t-set"]} = ${node.attr["t-value"]};`);
}
}
const { expression, expressionOffset } = contextualize(properties, parseResult.expression, parseResult.expressionOffset);
return {
offset: expressionOffset,
content: `${componentText}
${importReplacements}
class __VIRTUAL__ extends ${componentName} { __VIRTUAL__() {
${localVariables.join("\n")}
${expression} }}`,
}
}
/**
* Returns the list of properties for a given component class.
* The result in cached in `componentProperties`.
*
* @param documentUri
* @param componentName
* @param componentText
* @returns
*/
async getComponentProperties(
documentUri: Uri,
componentName: string,
componentText: string,
): Promise<string[]> {
const check = hash(componentText);
let cached = this.componentProperties.get(componentName);
if (!cached || cached.check !== check) {
const contextExpression = `${componentText}\nclass __VIRTUAL__ extends ${componentName} { __VIRTUAL__() { \nthis. }}`;
const contextItemsCompletion = await this.executeCommand(Commands.Completion, documentUri, contextExpression, 5) as CompletionList;
const items = filterComponentItems(contextItemsCompletion.items, ["__VIRTUAL__", "setup"]).map(item => item.label);
cached = { check, items };
this.componentProperties.set(componentName, cached);
}
return cached.items;
}
private async executeCommand(commandId: string, uri: Uri, content: string, offset: any = undefined) {
const lines = content.split(/\r\n|\r|\n/);
const _offset = offset !== undefined ? offset : (lines.at(-1)?.length ?? 0);
const position = new Position(lines.length - 1, _offset);
const originalUri = uri.toString(true);
const hashValue = hash(content);
const id = `owl://js/${originalUri}_${hashValue}.js`;
this.virtualDocuments.set(id, content);
return await commands.executeCommand(
commandId,
Uri.parse(`owl://js/${encodeURIComponent(originalUri)}_${hashValue}.js`),
position
);
}
}
@@ -0,0 +1,153 @@
import { XmlDocument, XmlElement, XmlNode } from "xmldoc";
export enum ParseResultType {
Expression,
Attribute,
Element
}
export interface ParseResult {
type: ParseResultType;
expression: string;
expressionOffset: number;
attributeName: string
}
/**
* Will parse the document to find the selected expression based on an offset.
* The result can be on of three types:
*
* Element: The offset is on a element tag name, the expression is the current
* tagname or and empty string if it's just a opening tag.
*
* Attribute: The offset is inside the element but not in an attribute value,
* the expression is the current attribute name if any.
*
* Expression: The offset is inside an attribute value, the expression is the value.
*/
export async function parse(
documentText: string,
offset: number,
): Promise<ParseResult> {
// Check if the offset is preceded by "<xyz", if true returns a type Element
// with the current name.
const elementMatch = documentText.substring(0, offset).match(/<([a-zA-Z\-._]*)$/);
if (elementMatch) {
return {
type: ParseResultType.Element,
expression: elementMatch[1] || "",
expressionOffset: elementMatch[1].length || 0,
attributeName: "",
};
}
let {
value: expression,
offset: expressionOffset,
from,
} = getSection(documentText, offset, '="', '"');
// If the expression contains '"', it means we aren't inside an attribute
// value.
if (expression.includes('"')) {
const attributeMatch = documentText.substring(0, offset).match(/\s([a-zA-Z\-._]*)$/);
if (attributeMatch) {
return {
type: ParseResultType.Attribute,
expression: attributeMatch[1] || "",
expressionOffset: attributeMatch[1].length || 0,
attributeName: "",
};
}
}
let attributeName = "";
let i = from - 2;
while (/\S/.test(documentText[i])) {
attributeName = documentText[i] + attributeName;
i--;
}
return {
type: ParseResultType.Expression,
expression,
expressionOffset,
attributeName,
};
}
export function getSection(text: string, offset: number, prefix: string, postfix: string) {
const beforeText = text.substring(0, offset);
let from = beforeText.lastIndexOf(prefix);
const afterText = text.substring(offset);
const to = beforeText.length + afterText.indexOf(postfix);
from = from + (prefix.length);
return {
value: text.substring(from, to),
offset: offset - from,
from: from,
to,
};
}
/**
* Returns 2 xml nodes:
* xmlNode: Tries to create the current element based on a string offset,
* even if the node is invalid.
* xmlDocument: The document root element, only works if the document is
* valid xml.
*/
export function parseXml(text: string, offset: number): any {
let xmlDocument = undefined;
try {
xmlDocument = new XmlDocument(text);
} catch (error) { }
let i = 0;
while (text[offset + i] !== "<") {
i--;
}
let node = "";
while (text[offset + i] !== ">" || text[offset + i - 1] === "=") {
node += text[offset + i];
i++;
}
let xmlNode = undefined;
try {
xmlNode = new XmlDocument(`${node}${node.endsWith("/") ? '' : '/'}>`);
} catch (error) { }
return { xmlDocument, xmlNode };
}
/**
* Returns an array representing the elements order from the document's
* root to the specified element.
*/
export function getNodePath(xmlDocument: XmlDocument, xmlNode: any): Array<XmlElement> {
let path: Array<XmlElement> = [];
const traverse = (node: XmlElement, currentPath: Array<XmlElement>) => {
if (node.name === xmlNode.name && JSON.stringify(node.attr) === JSON.stringify(xmlNode.attr)) {
path = currentPath;
return;
}
if (node.children) {
for (const child of node.children) {
if (child instanceof XmlElement && child.name) {
traverse(child, currentPath.concat(child));
}
}
}
}
traverse(xmlDocument, []);
return path;
}
+46
View File
@@ -94,6 +94,31 @@ export class Search {
return await this.find(templateName, query, "xml");
}
public async getCurrentComponent(): Promise<any | undefined> {
if (!this.currentDocument) {
return;
}
const text = this.currentDocument.getText();
const templateName = this.getTemplateName(text, false);
if (templateName) {
const component = await this.findComponentFromTemplateName(templateName);
if (component) {
const componentFile = await workspace.fs.readFile(component.uri);
const componentText = Buffer.from(componentFile).toString('utf8');
const componentName = this.getComponentName(componentText, templateName);
return {
uri: component.uri,
templateName,
componentName,
};
}
}
}
private findComponentFromTemplateName(templateName: string): Promise<Location | undefined> {
const query = this.buildQuery(`template\\s*=\\s*["']`, templateName, `["']`);
return this.find(templateName, query, "js");
@@ -107,6 +132,27 @@ export class Search {
}
}
private getComponentName(str: string, templateName: string): string {
const templateNameRegex = new RegExp(`template\\s*=\\s*["'](${templateName})["']`, 'g');
const templateIndex = [...str.matchAll(templateNameRegex)][0]?.index ?? 0;
const matches = [...str.matchAll(new RegExp(`class\\s+([A-Za-z_]+)\\sextends\\s+[A-Za-z_]+`, 'g'))];
let result = "";
let currentIndex = -1;
for (const match of matches) {
if (match.index > templateIndex) {
continue;
}
if (match.index > currentIndex) {
result = match[1];
currentIndex = match.index;
}
}
return result;
}
public async find(
name: string,
searchQuery: string,
+17
View File
@@ -102,3 +102,20 @@ export async function showResult(result: vscode.Location, openDirection: OpenDir
editor.revealRange(result.range);
editor.selection = new vscode.Selection(result.range.start, result.range.end);
}
export async function readFile(uri: vscode.Uri): Promise<string> {
const data = await vscode.workspace.fs.readFile(uri);
return Buffer.from(data).toString('utf8');
}
export function hash(str: string) {
var hash = 0,
i, chr;
if (str.length === 0) return hash;
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash;
}
+4 -4
View File
@@ -160,7 +160,7 @@
"patterns": [
{
"contentName": "meta.embedded.block.javascript string.quoted.double.xml",
"begin": "(\\s*)(t-if|t-else|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(\")",
"begin": "(\\s*)(t-if|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att|t-tag|t-log|t-model|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(\")",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.dynamic"
@@ -183,7 +183,7 @@
},
{
"contentName": "meta.embedded.block.javascript string.quoted.single.xml",
"begin": "(\\s*)(t-if|t-else|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(')",
"begin": "(\\s*)(t-if|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att|t-tag|t-log|t-model|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(')",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.dynamic"
@@ -210,7 +210,7 @@
"patterns": [
{
"contentName": "string.quoted.double.xml",
"begin": "(\\s*)(t-name|t-ref|t-set-slot|t-model|t-inherit|t-inherit-mode|t-translation)(=)(\")",
"begin": "(\\s*)(t-name|t-else|t-ref|t-set-slot|t-inherit|t-inherit-mode|t-translation|t-debug)(=)(\")",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.static"
@@ -229,7 +229,7 @@
},
{
"contentName": "string.quoted.single.xml",
"begin": "(\\s*)(t-name|t-ref|t-set-slot|t-model|t-inherit|t-inherit-mode|t-translation)(=)(')",
"begin": "(\\s*)(t-name|t-else|t-ref|t-set-slot|t-inherit|t-inherit-mode|t-translation|t-debug)(=)(')",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.static"