Skip to content

Instantly share code, notes, and snippets.

@ged-odoo
Created December 15, 2025 11:30
Show Gist options
  • Select an option

  • Save ged-odoo/3576c4e218c4e052381e219fa4ed7afa to your computer and use it in GitHub Desktop.

Select an option

Save ged-odoo/3576c4e218c4e052381e219fa4ed7afa to your computer and use it in GitHub Desktop.
assets bundle analyzer
{
/**
* Odoo Assets Analyser Script
*
* Last Update: august 29th, 2025
* Supported Odoo versions: > 16.0 in theory
*
* Instructions:
* - copy paste it in the console, then press enter
* - or, add it to a snippet (in source tab), then Ctrl+Enter
*/
if (!odoo) {
throw new Error("This script should only be run in a odoo active browser tab");
}
if (odoo.debug === "assets") {
throw new Error("This script does not work in debug=assets");
}
try {
for (let app of owl.App.apps) {
app.destroy();
}
} catch (e) {
// silently ignoring errors. they suck.
// console.error(e);
}
document.body.innerHTML = "Loading data";
document.body.className = "overflow-auto h-100";
console.time("Elapsed time: ");
const bundles = await fetchBundles();
document.body.innerHTML = "Processing data";
await new Promise(r => { setTimeout(r, 1) });
const data = extractData(bundles);
const aggregates = aggregateData(data);
const listData = createListData(data);
await mountUI(aggregates, listData);
console.timeEnd("Elapsed time: ");
/**
* Fetch all assets links/scripts found in DOM and sort them by bundle bundleName
*/
async function fetchBundles() {
// find all script/link urls
const bundles = {};
const scriptUrls = [...document.head.querySelectorAll("script[src]")]
.map(s => s.getAttribute("src"))
.filter(s => s.startsWith("/web/assets"));
const linkUrls = [...document.head.querySelectorAll("link[href]")]
.map(s => s.getAttribute("href"))
.filter(s => s.startsWith("/web/assets"));
for (let url of scriptUrls) {
const bundle = url.match(/web\/assets\/[a-zA-Z0-9_\-]*\/(.*)\.min.js/)[1];
if (!(bundle in bundles)) {
bundles[bundle] = { js: null, css: null };
}
bundles[bundle].js = url;
}
for (let url of linkUrls) {
if (url.endsWith(".css")) {
const bundle = url.match(/web\/assets\/[a-zA-Z0-9_\-]*\/(.*)\.min.css/)[1];
if (!(bundle in bundles)) {
bundles[bundle] = { js: null, css: null };
}
bundles[bundle].css = url;
}
}
// fetch and decode all data
const proms = [];
for (let bundleName in bundles) {
const bundle = bundles[bundleName];
if (bundle.css) {
proms.push(fetch(bundle.css).then(data => data.text()).then(t => { bundle.css = t }));
}
if (bundle.js) {
proms.push(fetch(bundle.js).then(data => data.text()).then(t => {
const [js, xml] = t.split("/*****")
bundle.js = js;
bundle.xml = xml;
}));
}
}
await Promise.all(proms);
return bundles
}
/**
* extract all data from bundle texts
*/
function extractData(bundles) {
const data = {}
for (let bundleName in bundles) {
data[bundleName] = { css: {}, xml: {}, js: {} };
const bundle = bundles[bundleName];
if (bundle.css) {
processFile(bundleName, bundle.css, "css");
}
if (bundle.js) {
processFile(bundleName, bundle.js, "js");
}
if (bundle.xml) {
processTemplates(bundleName, bundle.xml);
}
}
function processFile(bundleName, str, type) {
const filenames = type === "css" ? [...str.matchAll(/\/\* \/.*\.scss.*\*\//mg)] : [...str.matchAll(/\/\* \/.*\.js.*\*\//mg)];
const files = {};
for (let i = 0; i < filenames.length - 1; i++) {
const filename = filenames[i][0].replace("/* ", "").replace(" */", "")
str = str.split(filenames[i][0])[1];
files[filename] = str.split(filenames[i + 1][0])[0];
}
str = str.split(filenames[filenames.length - 1][0])[1];
files[filenames[filenames.length - 1][0].replace("/* ", "").replace(" */", "")] = str.split("/*****")[0];
for (let f in files) {
const l = files[f].length * 1.024;
data[bundleName][type][f] = l;
}
}
function processTemplates(bundleName, str) {
if (str.includes("registerTemplate")) {
const parts = str.split(/(?:registerTemplate|registerTemplateExtension)\("([\.A-Za-z_0-9]+)"/g).slice(1);
for (let i = 0; i < parts.length; i += 2) {
const name = parts[i];
const content = parts[i+1];
data[bundleName].xml[name] = content.length*1.024;
}
} else {
const templateNames = [...str.matchAll(/\<t t-name="([^"]+)"/gm)].map((m) => m[1]);
for (let i = 0; i < templateNames.length - 1; i++) {
str = str.split(`t-name="${templateNames[i]}"`)[1];
if (str) {
const l = str.split(`t-name="${templateNames[i + 1]}"`)[0].length * 1.024;
data[bundleName].xml[templateNames[i]] = l;
}
}
}
}
return data;
}
function aggregateData(data) {
const aggregates = [];
for (let bundleName in data) {
const bundle = data[bundleName];
const js = processData('js', bundle.js);
const css = processData('css', bundle.css);
const xml = processTemplates(bundle.xml);
aggregates.push([bundleName, js[1] + css[1] + xml[1], [js, css, xml]])
}
return aggregates;
}
function processData(name, files) {
function getAggregates(files, prefix = "") {
const aggregates = {};
for (let [path, code] of files) {
const mod = path.trim().split("/")[prefix.split("/").length];
if (mod) {
if (!aggregates[mod]) {
const children = files.filter(([path]) => path.trim().startsWith(`${prefix}/${mod}/`));
if (!mod.endsWith(".js")) {
const result = Object.entries(getAggregates(children, `${prefix}/${mod}`)).sort((a, b) => b[1][0] - a[1][0]);
aggregates[mod] = [0, result.map((r) => [r[0], r[1][0], r[1][1]])];
} else {
aggregates[mod] = [0];
}
}
aggregates[mod][0] += code;
}
}
return aggregates;
}
const aggregates = getAggregates(Object.entries(files));
const result = Object.entries(aggregates).sort((a, b) => b[1][0] - a[1][0]);
let total = 0;
for (let val of result) {
total += val[1][0];
}
return [name, total, result.map((r) => [r[0], r[1][0], r[1][1]])];
}
function processTemplates(templates) {
let sum = 0;
const entries = Object.entries(templates);
const addons = {};
for (let i = 0; i < entries.length; i++) {
sum += entries[i][1];
const addon = entries[i][0].split(".")[0];
addons[addon] = addons[addon] || [addon, 0, []];
addons[addon][1] += entries[i][1];
addons[addon][2].push(entries[i]);
}
for (const addon in addons) {
addons[addon][2] = addons[addon][2].sort((a, b) => b[1] - a[1]);
}
return ['xml', sum, Object.values(addons).sort((a, b) => b[1] - a[1])];
}
function createListData(data) {
const result = ['all', 0, []];
for (let bundleName in data) {
const bundleData = [bundleName, 0, []];
for (let type of ['js', 'css', 'xml']) {
const datapoint = makeDataPoint(type, data[bundleName][type]);
bundleData[1] += datapoint[1];
bundleData[2].push(datapoint);
}
result[1] += bundleData[1];
result[2].push(bundleData);
}
function makeDataPoint(name, obj) {
let total = 0;
let children = Object.entries(obj).sort((a, b) => b[1] - a[1]);
for (let child of children) {
total += child[1];
}
return [name, total, children]
}
return [result];
}
// ------------------------------------------------------------------------------------------
// UI
// ------------------------------------------------------------------------------------------
function formatNumber(number) {
const formatter = Intl.NumberFormat("en", { notation: "compact", minimumFractionDigits: 2, maximumFractionDigits: 2 });
return formatter.format(number);
}
function mountUI(data, listData) {
const xml = owl.xml || owl.tags.xml;
class DataPoint extends owl.Component {
static template = xml`<div>
<div>
<span t-on-click.stop="toggle" class="o-datapoint p-1 cursor-pointer">
<i t-attf-class="pe-1 fa fa-caret-{{state.isOpen ? 'down' : 'right'}} {{(props.data[2]?.length) ? '' : 'invisible'}}"></i>
<span class="p-1 pe-3">
<t t-if="props.data[2] or !props.path.startsWith('/JS')">
<t t-esc="props.data[0]"/>
</t>
<t t-else="">
<a t-att-href="props.path.slice(3) + props.data[0]"><t t-esc="props.data[0]"/></a>
</t>
</span>
<span class="p-1"><t t-esc="humanNumber(props.data[1])"/></span>
</span>
</div>
<div t-if="state.isOpen and props.data[2]" class="ps-4">
<t t-foreach="props.data[2]" t-as="datapoint" t-key="datapoint_index">
<DataPoint data="datapoint" openByDefault="props.data[2].length === 1" path="path"/>
</t>
</div>
</div>`;
static components = {};
state = owl.useState({ isOpen: this.props.openByDefault });
path = this.props.path + this.props.data[0] + "/";
humanNumber(n) {
return formatNumber(n);
}
toggle() {
if (this.props.data[2]?.length) {
this.state.isOpen = !this.state.isOpen;
}
}
}
DataPoint.components.DataPoint = DataPoint; // lol...
class ReportingUI extends owl.Component {
static components = { DataPoint };
static template = xml`
<div>
<div class="d-flex p-2 align-items-center">
<style>
.o-datapoint:hover {
background-color: var(--200);
}
</style>
View:
<select t-model="state.view" class="m-1" style="width:300px;">
<option value="tree">Aggregate</option>
<option value="list">Flat List</option>
</select>
</div>
<div class="p-2">
<t t-foreach="currentData" t-as="datapoint" t-key="datapoint_index + state.view">
<DataPoint data="datapoint" openByDefault="true" path="'/'" />
</t>
</div></div>`;
state = owl.useState({ view: "tree" });
get currentData() {
return this.state.view === "tree" ? data : listData;
}
}
document.body.innerHTML = "";
if (owl.tags) {
return owl.mount(ReportingUI, { target: document.body });
}
return owl.mount(ReportingUI, document.body);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment