diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py
index 584a4170..465231bc 100644
--- a/runbot/__manifest__.py
+++ b/runbot/__manifest__.py
@@ -67,6 +67,16 @@
'runbot/static/src/diff_match_patch_module.js',
'runbot/static/src/fields/*',
],
+ 'runbot.assets_frontend': [
+ ('include', 'web.assets_frontend_minimal'), # Pray the gods this stays named correctly
+
+ 'runbot/static/libs/owl.js',
+ 'runbot/static/src/owl_module.js',
+
+ 'runbot/static/src/vendored/**/*', # Vendored files coming from odoo modules
+
+ 'runbot/static/src/frontend/root.js',
+ ]
},
'post_load': 'runbot_post_load',
}
diff --git a/runbot/static/src/frontend/root.js b/runbot/static/src/frontend/root.js
new file mode 100644
index 00000000..f4c24be1
--- /dev/null
+++ b/runbot/static/src/frontend/root.js
@@ -0,0 +1,64 @@
+import { whenReady, Component, xml, App, onError } from '@runbot/owl';
+
+import { getTemplate } from '@web/core/templates';
+import { registry } from '@web/core/registry';
+import { useRegistry } from '@web/core/registry_hook';
+
+
+const mainComponents = registry.category('main.components');
+
+class ErrorHandler extends Component {
+ static template = xml``;
+ static props = ["onError", "slots"];
+ setup() {
+ onError((error) => {
+ this.props.onError(error);
+ });
+ }
+}
+
+class ComponentContainer extends Component {
+ static components = { ErrorHandler };
+ static props = {};
+ static template = xml`
+
+
+
+
+
+
+
+ `;
+
+ setup() {
+ this.Components = useRegistry(mainComponents);
+ }
+
+ handleComponentError(error, C) {
+ console.error('Error while rendering', C[0], 'removing from app.');
+ // remove the faulty component and rerender without it
+ this.Components.entries.splice(this.Components.entries.indexOf(C), 1);
+ this.render();
+ /**
+ * we rethrow the error to notify the user something bad happened.
+ * We do it after a tick to make sure owl can properly finish its
+ * rendering
+ */
+ Promise.resolve().then(() => {
+ throw error;
+ });
+ }
+}
+
+/**
+ * Bootstrap the frontend.
+ */
+(async function startApp() {
+ await whenReady();
+
+ const app = new App(ComponentContainer, {
+ getTemplate,
+ env: {},
+ });
+ await app.mount(document.body);
+})();
diff --git a/runbot/static/src/vendored/registry.js b/runbot/static/src/vendored/registry.js
new file mode 100644
index 00000000..9eef3aba
--- /dev/null
+++ b/runbot/static/src/vendored/registry.js
@@ -0,0 +1,212 @@
+/** @odoo-module alias=@web/core/registry default=false **/
+
+
+import { EventBus, validate } from "@runbot/owl";
+
+// -----------------------------------------------------------------------------
+// Errors
+// -----------------------------------------------------------------------------
+export class KeyNotFoundError extends Error {}
+
+export class DuplicatedKeyError extends Error {}
+
+// -----------------------------------------------------------------------------
+// Validation
+// -----------------------------------------------------------------------------
+
+const validateSchema = (name, key, value, schema) => {
+ if (!odoo.debug) {
+ return;
+ }
+ try {
+ validate(value, schema);
+ } catch (error) {
+ throw new Error(`Validation error for key "${key}" in registry "${name}": ${error}`);
+ }
+};
+
+// -----------------------------------------------------------------------------
+// Types
+// -----------------------------------------------------------------------------
+
+/**
+ * @template S
+ * @template C
+ * @typedef {import("registries").RegistryData} RegistryData
+ */
+
+/**
+ * @template T
+ * @typedef {T extends RegistryData ? T : RegistryData} ToRegistryData
+ */
+
+/**
+ * @template T
+ * @typedef {ToRegistryData["__itemShape"]} GetRegistryItemShape
+ */
+
+/**
+ * @template T
+ * @typedef {ToRegistryData["__categories"]} GetRegistryCategories
+ */
+
+/**
+ * Registry
+ *
+ * The Registry class is basically just a mapping from a string key to an object.
+ * It is really not much more than an object. It is however useful for the
+ * following reasons:
+ *
+ * 1. it let us react and execute code when someone add something to the registry
+ * (for example, the FunctionRegistry subclass this for this purpose)
+ * 2. it throws an error when the get operation fails
+ * 3. it provides a chained API to add items to the registry.
+ *
+ * @template T
+ */
+export class Registry extends EventBus {
+ /**
+ * @param {string} [name]
+ */
+ constructor(name) {
+ super();
+ /** @type {Record]>}*/
+ this.content = {};
+ /** @type {{ [P in keyof GetRegistryCategories]?: Registry[P]> }} */
+ this.subRegistries = {};
+ /** @type {GetRegistryItemShape[]}*/
+ this.elements = null;
+ /** @type {[string, GetRegistryItemShape][]}*/
+ this.entries = null;
+ this.name = name;
+ this.validationSchema = null;
+
+ this.addEventListener("UPDATE", () => {
+ this.elements = null;
+ this.entries = null;
+ });
+ }
+
+ /**
+ * Add an entry (key, value) to the registry if key is not already used. If
+ * the parameter force is set to true, an entry with same key (if any) is replaced.
+ *
+ * Note that this also returns the registry, so another add method call can
+ * be chained
+ *
+ * @param {string} key
+ * @param {GetRegistryItemShape} value
+ * @param {{force?: boolean, sequence?: number}} [options]
+ * @returns {Registry}
+ */
+ add(key, value, { force, sequence } = {}) {
+ if (this.validationSchema) {
+ validateSchema(this.name, key, value, this.validationSchema);
+ }
+ if (!force && key in this.content) {
+ throw new DuplicatedKeyError(
+ `Cannot add key "${key}" in the "${this.name}" registry: it already exists`
+ );
+ }
+ let previousSequence;
+ if (force) {
+ const elem = this.content[key];
+ previousSequence = elem && elem[0];
+ }
+ sequence = sequence === undefined ? previousSequence || 50 : sequence;
+ this.content[key] = [sequence, value];
+ const payload = { operation: "add", key, value };
+ this.trigger("UPDATE", payload);
+ return this;
+ }
+
+ /**
+ * Get an item from the registry
+ *
+ * @param {string} key
+ * @returns {GetRegistryItemShape}
+ */
+ get(key, defaultValue) {
+ if (arguments.length < 2 && !(key in this.content)) {
+ throw new KeyNotFoundError(`Cannot find key "${key}" in the "${this.name}" registry`);
+ }
+ const info = this.content[key];
+ return info ? info[1] : defaultValue;
+ }
+
+ /**
+ * Check the presence of a key in the registry
+ *
+ * @param {string} key
+ * @returns {boolean}
+ */
+ contains(key) {
+ return key in this.content;
+ }
+
+ /**
+ * Get a list of all elements in the registry. Note that it is ordered
+ * according to the sequence numbers.
+ *
+ * @returns {GetRegistryItemShape[]}
+ */
+ getAll() {
+ if (!this.elements) {
+ const content = Object.values(this.content).sort((el1, el2) => el1[0] - el2[0]);
+ this.elements = content.map((elem) => elem[1]);
+ }
+ return this.elements.slice();
+ }
+
+ /**
+ * Return a list of all entries, ordered by sequence numbers.
+ *
+ * @returns {[string, GetRegistryItemShape][]}
+ */
+ getEntries() {
+ if (!this.entries) {
+ const entries = Object.entries(this.content).sort((el1, el2) => el1[1][0] - el2[1][0]);
+ this.entries = entries.map(([str, elem]) => [str, elem[1]]);
+ }
+ return this.entries.slice();
+ }
+
+ /**
+ * Remove an item from the registry
+ *
+ * @param {string} key
+ */
+ remove(key) {
+ const value = this.content[key];
+ delete this.content[key];
+ const payload = { operation: "delete", key, value };
+ this.trigger("UPDATE", payload);
+ }
+
+ /**
+ * Open a sub registry (and create it if necessary)
+ *
+ * @template {keyof GetRegistryCategories & string} K
+ * @param {K} subcategory
+ * @returns {Registry[K]>}
+ */
+ category(subcategory) {
+ if (!(subcategory in this.subRegistries)) {
+ this.subRegistries[subcategory] = new Registry(subcategory);
+ }
+ return this.subRegistries[subcategory];
+ }
+
+ addValidation(schema) {
+ if (this.validationSchema) {
+ throw new Error("Validation schema already set on this registry");
+ }
+ this.validationSchema = schema;
+ for (const [key, value] of this.getEntries()) {
+ validateSchema(this.name, key, value, schema);
+ }
+ }
+}
+
+/** @type {Registry} */
+export const registry = new Registry();
diff --git a/runbot/static/src/vendored/registry_hook.js b/runbot/static/src/vendored/registry_hook.js
new file mode 100644
index 00000000..43b83b4f
--- /dev/null
+++ b/runbot/static/src/vendored/registry_hook.js
@@ -0,0 +1,28 @@
+/** @odoo-module alias=@web/core/registry_hook default=false **/
+
+
+import { useState, onWillStart, onWillDestroy } from "@runbot/owl";
+
+export function useRegistry(registry) {
+ const state = useState({ entries: registry.getEntries() });
+ const listener = ({ detail }) => {
+ const index = state.entries.findIndex(([k]) => k === detail.key);
+ if (detail.operation === "add" && index === -1) {
+ // push the new entry at the right place
+ const newEntries = registry.getEntries();
+ const newEntry = newEntries.find(([k]) => k === detail.key);
+ const newIndex = newEntries.indexOf(newEntry);
+ if (newIndex === newEntries.length - 1) {
+ state.entries.push(newEntry);
+ } else {
+ state.entries.splice(newIndex, 0, newEntry);
+ }
+ } else if (detail.operation === "delete" && index >= 0) {
+ state.entries.splice(index, 1);
+ }
+ };
+
+ onWillStart(() => registry.addEventListener("UPDATE", listener));
+ onWillDestroy(() => registry.removeEventListener("UPDATE", listener));
+ return state;
+}
diff --git a/runbot/static/src/vendored/template_inheritance.js b/runbot/static/src/vendored/template_inheritance.js
new file mode 100644
index 00000000..de498b2c
--- /dev/null
+++ b/runbot/static/src/vendored/template_inheritance.js
@@ -0,0 +1,320 @@
+/** @odoo-module alias=@web/core/template_inheritance default=false **/
+
+const RSTRIP_REGEXP = /(?=\n[ \t]*$)/;
+/**
+ * The child nodes of operation represent new content to create before target or
+ * or other elements to move before target from the target tree (tree from which target is part of).
+ * Some processing of text nodes has to be done in order to normalize the situation.
+ * Note: we assume that target has a parent element.
+ * @param {Element} target
+ * @param {Element} operation
+ */
+function addBefore(target, operation) {
+ const nodes = getNodes(target, operation);
+ if (nodes.length === 0) {
+ return;
+ }
+ const { previousSibling } = target;
+ target.before(...nodes);
+ if (previousSibling?.nodeType === Node.TEXT_NODE) {
+ const [text1, text2] = previousSibling.data.split(RSTRIP_REGEXP);
+ previousSibling.data = text1.trimEnd();
+ if (nodes[0].nodeType === Node.TEXT_NODE) {
+ mergeTextNodes(previousSibling, nodes[0]);
+ }
+ if (text2 && nodes.some((n) => n.nodeType !== Node.TEXT_NODE)) {
+ const textNode = document.createTextNode(text2);
+ target.before(textNode);
+ if (textNode.previousSibling.nodeType === Node.TEXT_NODE) {
+ mergeTextNodes(textNode.previousSibling, textNode);
+ }
+ }
+ }
+}
+
+/**
+ * element is part of a tree. Here we return the root element of that tree.
+ * Note: this root element is not necessarily the documentElement of the ownerDocument
+ * of element (hence the following code).
+ * @param {Element} element
+ * @returns {Element}
+ */
+function getRoot(element) {
+ while (element.parentElement) {
+ element = element.parentElement;
+ }
+ return element;
+}
+
+const HASCLASS_REGEXP = /hasclass\(([^)]*)\)/g;
+const CLASS_CONTAINS_REGEX = /contains\(@class.*\)/g;
+/**
+ * @param {Element} operation
+ * @returns {string}
+ */
+function getXpath(operation) {
+ const xpath = operation.getAttribute("expr");
+ if (odoo.debug) {
+ if (CLASS_CONTAINS_REGEX.test(xpath)) {
+ const parent = operation.closest("t[t-inherit]");
+ const templateName = parent.getAttribute("t-name") || parent.getAttribute("t-inherit");
+ console.warn(
+ `Error-prone use of @class in template "${templateName}" (or one of its inheritors).` +
+ " Use the hasclass(*classes) function to filter elements by their classes"
+ );
+ }
+ }
+ // hasclass does not exist in XPath 1.0 but is a custom function defined server side (see _hasclass) usable in lxml.
+ // Here we have to replace it by a complex condition (which is not nice).
+ // Note: we assume that classes do not contain the 2 chars , and )
+ return xpath.replaceAll(HASCLASS_REGEXP, (_, capturedGroup) => {
+ return capturedGroup
+ .split(",")
+ .map((c) => `contains(concat(' ', @class, ' '), ' ${c.trim().slice(1, -1)} ')`)
+ .join(" and ");
+ });
+}
+
+/**
+ * @param {Element} element
+ * @param {Element} operation
+ * @returns {Node|null}
+ */
+function getNode(element, operation) {
+ const root = getRoot(element);
+ const doc = new Document();
+ doc.appendChild(root); // => root is the documentElement of its ownerDocument (we do that in case root is a clone)
+ if (operation.tagName === "xpath") {
+ const xpath = getXpath(operation);
+ const result = doc.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE);
+ return result.singleNodeValue;
+ }
+ for (const elem of root.querySelectorAll(operation.tagName)) {
+ if (
+ [...operation.attributes].every(
+ ({ name, value }) => name === "position" || elem.getAttribute(name) === value
+ )
+ ) {
+ return elem;
+ }
+ }
+ return null;
+}
+
+/**
+ * @param {Element} element
+ * @param {Element} operation
+ * @returns {Element}
+ */
+function getElement(element, operation) {
+ const node = getNode(element, operation);
+ if (!node) {
+ throw new Error(`Element '${operation.outerHTML}' cannot be located in element tree`);
+ }
+ if (!(node instanceof Element)) {
+ throw new Error(`Found node ${node} instead of an element`);
+ }
+ return node;
+}
+
+/**
+ * @param {Element} element
+ * @param {Element} operation
+ * @returns {Node[]}
+ */
+function getNodes(element, operation) {
+ const nodes = [];
+ for (const childNode of operation.childNodes) {
+ if (childNode.tagName === "xpath" && childNode.getAttribute?.("position") === "move") {
+ const node = getElement(element, childNode);
+ removeNode(node);
+ nodes.push(node);
+ } else {
+ nodes.push(childNode);
+ }
+ }
+ return nodes;
+}
+
+/**
+ * @param {Text} first
+ * @param {Text} second
+ * @param {boolean} [trimEnd=true]
+ */
+function mergeTextNodes(first, second, trimEnd = true) {
+ first.data = (trimEnd ? first.data.trimEnd() : first.data) + second.data;
+ second.remove();
+}
+
+function splitAndTrim(str, separator) {
+ return str.split(separator).map((s) => s.trim());
+}
+
+/**
+ * @param {Element} target
+ * @param {Element} operation
+ */
+function modifyAttributes(target, operation) {
+ for (const child of operation.children) {
+ if (child.tagName !== "attribute") {
+ continue;
+ }
+ const attributeName = child.getAttribute("name");
+ const firstNode = child.childNodes[0];
+ let value = firstNode?.nodeType === Node.TEXT_NODE ? firstNode.data : "";
+
+ const add = child.getAttribute("add") || "";
+ const remove = child.getAttribute("remove") || "";
+ if (add || remove) {
+ if (firstNode?.nodeType === Node.TEXT_NODE) {
+ throw new Error(`Useless element content ${firstNode.outerHTML}`);
+ }
+ const separator = child.getAttribute("separator") || ",";
+ const toRemove = new Set(splitAndTrim(remove, separator));
+ const values = splitAndTrim(target.getAttribute(attributeName) || "", separator).filter(
+ (s) => !toRemove.has(s)
+ );
+ values.push(...splitAndTrim(add, separator).filter((s) => s));
+ value = values.join(separator);
+ }
+
+ if (value) {
+ target.setAttribute(attributeName, value);
+ } else {
+ target.removeAttribute(attributeName);
+ }
+ }
+}
+
+/**
+ * Remove node and normalize surrounind text nodes (if any)
+ * Note: we assume that node has a parent element
+ * @param {Node} node
+ */
+function removeNode(node) {
+ const { nextSibling, previousSibling } = node;
+ node.remove();
+ if (nextSibling?.nodeType === Node.TEXT_NODE && previousSibling?.nodeType === Node.TEXT_NODE) {
+ mergeTextNodes(
+ previousSibling,
+ nextSibling,
+ previousSibling.parentElement.firstChild === previousSibling
+ );
+ }
+}
+
+/**
+ * @param {Element} root
+ * @param {Element} target
+ * @param {Element} operation
+ */
+function replace(root, target, operation) {
+ const mode = operation.getAttribute("mode") || "outer";
+ switch (mode) {
+ case "outer": {
+ const result = operation.ownerDocument.evaluate(
+ ".//*[text()='$0']",
+ operation,
+ null,
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
+ );
+ for (let i = 0; i < result.snapshotLength; i++) {
+ const loc = result.snapshotItem(i);
+ loc.firstChild.replaceWith(target.cloneNode(true));
+ }
+ if (target.parentElement) {
+ const nodes = getNodes(target, operation);
+ target.replaceWith(...nodes);
+ } else {
+ let operationContent = null;
+ let comment = null;
+ for (const child of operation.childNodes) {
+ if (child.nodeType === Node.ELEMENT_NODE) {
+ operationContent = child;
+ break;
+ }
+ if (child.nodeType === Node.COMMENT_NODE) {
+ comment = child;
+ }
+ }
+ root = operationContent.cloneNode(true);
+ if (target.hasAttribute("t-name")) {
+ root.setAttribute("t-name", target.getAttribute("t-name"));
+ }
+ if (comment) {
+ root.prepend(comment);
+ }
+ }
+ break;
+ }
+ case "inner":
+ while (target.firstChild) {
+ target.removeChild(target.lastChild);
+ }
+ target.append(...operation.childNodes);
+ break;
+ default:
+ throw new Error(`Invalid mode attribute: '${mode}'`);
+ }
+ return root;
+}
+
+/**
+ * @param {Element} root
+ * @param {Element} operations is a single element whose children represent operations to perform on root
+ * @param {string} [url=""]
+ * @returns {Element} root modified (in place) by the operations
+ */
+export function applyInheritance(root, operations, url = "") {
+ for (const operation of operations.children) {
+ const target = getElement(root, operation);
+ const position = operation.getAttribute("position") || "inside";
+
+ if (odoo.debug && url) {
+ const attributes = [...operation.attributes].map(
+ ({ name, value }) =>
+ `${name}=${JSON.stringify(name === "position" ? position : value)}`
+ );
+ const comment = document.createComment(
+ ` From file: ${url} ; ${attributes.join(" ; ")} `
+ );
+ if (position === "attributes") {
+ target.before(comment); // comment won't be visible if target is root
+ } else {
+ operation.prepend(comment);
+ }
+ }
+
+ switch (position) {
+ case "replace": {
+ root = replace(root, target, operation); // root can be replaced (see outer mode)
+ break;
+ }
+ case "attributes": {
+ modifyAttributes(target, operation);
+ break;
+ }
+ case "inside": {
+ const sentinel = document.createElement("sentinel");
+ target.append(sentinel);
+ addBefore(sentinel, operation);
+ removeNode(sentinel);
+ break;
+ }
+ case "after": {
+ const sentinel = document.createElement("sentinel");
+ target.after(sentinel);
+ addBefore(sentinel, operation);
+ removeNode(sentinel);
+ break;
+ }
+ case "before": {
+ addBefore(target, operation);
+ break;
+ }
+ default:
+ throw new Error(`Invalid position attribute: '${position}'`);
+ }
+ }
+ return root;
+}
diff --git a/runbot/static/src/vendored/templates.js b/runbot/static/src/vendored/templates.js
new file mode 100644
index 00000000..ccef0169
--- /dev/null
+++ b/runbot/static/src/vendored/templates.js
@@ -0,0 +1,176 @@
+/** @odoo-module alias=@web/core/templates default=false */
+
+import { applyInheritance } from "@web/core/template_inheritance";
+
+const parser = new DOMParser();
+/** @type {((document: Document) => void)[]} */
+const templateProcessors = [];
+/** @type {((url: string) => boolean)[]} */
+let urlFilters = [];
+function getParsedTemplate(templateString) {
+ const doc = parser.parseFromString(templateString, "text/xml");
+ for (const processor of templateProcessors) {
+ processor(doc);
+ }
+ return doc.firstChild;
+}
+
+function getClone(template) {
+ const c = template.cloneNode(true);
+ new Document().append(c); // => c is the documentElement of its ownerDocument
+ return c;
+}
+
+const registered = new Set();
+function isRegistered(...args) {
+ const key = JSON.stringify([...args]);
+ if (registered.has(key)) {
+ return true;
+ }
+ registered.add(key);
+ return false;
+}
+
+let blockType = null;
+let blockId = 0;
+
+const templates = {};
+const parsedTemplates = {};
+const info = {};
+export function registerTemplate(name, url, templateString) {
+ if (isRegistered(...arguments)) {
+ return;
+ }
+ if (blockType !== "templates") {
+ blockType = "templates";
+ blockId++;
+ }
+ if (name in templates && (info[name].url !== url || templates[name] !== templateString)) {
+ throw new Error(`Template ${name} already exists`);
+ }
+ templates[name] = templateString;
+ info[name] = { blockId, url };
+}
+
+const templateExtensions = {};
+const parsedTemplateExtensions = {};
+export function registerTemplateExtension(inheritFrom, url, templateString) {
+ if (isRegistered(...arguments)) {
+ return;
+ }
+ if (blockType !== "extensions") {
+ blockType = "extensions";
+ blockId++;
+ }
+ if (!templateExtensions[inheritFrom]) {
+ templateExtensions[inheritFrom] = [];
+ }
+ if (!templateExtensions[inheritFrom][blockId]) {
+ templateExtensions[inheritFrom][blockId] = [];
+ }
+ templateExtensions[inheritFrom][blockId].push({
+ templateString,
+ url,
+ });
+}
+
+/**
+ * @param {(document: Document) => void} processor
+ */
+export function registerTemplateProcessor(processor) {
+ templateProcessors.push(processor);
+}
+
+/**
+ * @param {typeof urlFilters} filters
+ */
+export function setUrlFilters(filters) {
+ urlFilters = filters;
+}
+
+function _getTemplate(name, blockId = null) {
+ if (!(name in parsedTemplates)) {
+ if (!(name in templates)) {
+ return null;
+ }
+ const templateString = templates[name];
+ parsedTemplates[name] = getParsedTemplate(templateString);
+ }
+ let processedTemplate = parsedTemplates[name];
+
+ const inheritFrom = processedTemplate.getAttribute("t-inherit");
+ if (inheritFrom) {
+ const parentTemplate = _getTemplate(inheritFrom, blockId || info[name].blockId);
+ if (!parentTemplate) {
+ throw new Error(
+ `Constructing template ${name}: template parent ${inheritFrom} not found`
+ );
+ }
+ const element = getClone(processedTemplate);
+ processedTemplate = applyInheritance(getClone(parentTemplate), element, info[name].url);
+ if (processedTemplate.tagName !== element.tagName) {
+ const temp = processedTemplate;
+ processedTemplate = new Document().createElement(element.tagName);
+ processedTemplate.append(...temp.childNodes);
+ }
+ for (const { name, value } of element.attributes) {
+ if (!["t-inherit", "t-inherit-mode"].includes(name)) {
+ processedTemplate.setAttribute(name, value);
+ }
+ }
+ }
+
+ for (const otherBlockId in templateExtensions[name] || {}) {
+ if (blockId && otherBlockId > blockId) {
+ break;
+ }
+ if (!(name in parsedTemplateExtensions)) {
+ parsedTemplateExtensions[name] = {};
+ }
+ if (!(otherBlockId in parsedTemplateExtensions[name])) {
+ parsedTemplateExtensions[name][otherBlockId] = [];
+ for (const { templateString, url } of templateExtensions[name][otherBlockId]) {
+ parsedTemplateExtensions[name][otherBlockId].push({
+ template: getParsedTemplate(templateString),
+ url,
+ });
+ }
+ }
+ for (const { template, url } of parsedTemplateExtensions[name][otherBlockId]) {
+ if (!urlFilters.every((filter) => filter(url))) {
+ continue;
+ }
+ processedTemplate = applyInheritance(
+ inheritFrom ? processedTemplate : getClone(processedTemplate),
+ getClone(template),
+ url
+ );
+ }
+ }
+
+ return processedTemplate;
+}
+
+/** @type {Record} */
+let processedTemplates = {};
+
+/**
+ * @param {string} name
+ */
+export function getTemplate(name) {
+ if (!processedTemplates[name]) {
+ processedTemplates[name] = _getTemplate(name);
+ }
+ return processedTemplates[name];
+}
+
+export function clearProcessedTemplates() {
+ processedTemplates = {};
+}
+
+export function checkPrimaryTemplateParents(namesToCheck) {
+ const missing = new Set(namesToCheck.filter((name) => !(name in templates)));
+ if (missing.size) {
+ console.error(`Missing (primary) parent templates: ${[...missing].join(", ")}`);
+ }
+}
diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml
index 10089f7e..db3bddba 100644
--- a/runbot/templates/utils.xml
+++ b/runbot/templates/utils.xml
@@ -15,6 +15,8 @@
+
+