Created
December 15, 2025 11:30
-
-
Save ged-odoo/3576c4e218c4e052381e219fa4ed7afa to your computer and use it in GitHub Desktop.
assets bundle analyzer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| /** | |
| * 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