Compare commits

...

15 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
41 changed files with 7158 additions and 5679 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
+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
```
+34 -26
View File
@@ -2622,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);
}
});
@@ -3512,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];
@@ -3521,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);
@@ -3636,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
// -----------------------------------------------------------------------------
@@ -3816,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});`);
}
}
}
@@ -4002,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,
});
@@ -4024,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,
});
@@ -4245,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) {
@@ -4498,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")) {
@@ -4550,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 {
@@ -4566,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
@@ -4662,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");
@@ -4678,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}`;
}
@@ -4751,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);
@@ -4792,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");
@@ -4805,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);
}
@@ -5538,7 +5546,7 @@ function compile(template, options = {}) {
}
// do not modify manually. This file is generated by the release script.
const version = "2.2.9";
const version = "2.2.11";
// -----------------------------------------------------------------------------
// Scheduler
@@ -5964,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 = '2024-01-12T14:43:56.804Z';
__info__.hash = '7b3e39b';
__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.9",
"version": "2.2.11",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.2.9",
"version": "2.2.11",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
+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();
}
+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);
}
});
+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.9";
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
) {
+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>
@@ -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
) {
+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"
);
});
});
//------------------------------------------------------------------------------
+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"