diff --git a/extensions/odoo_theme/__init__.py b/extensions/odoo_theme/__init__.py
index da65ea8d5f..31cd457d7e 100644
--- a/extensions/odoo_theme/__init__.py
+++ b/extensions/odoo_theme/__init__.py
@@ -16,6 +16,8 @@ def setup(app):
app.add_js_file('js/menu.js')
app.add_js_file('js/page_toc.js')
app.add_js_file('js/switchers.js')
+ app.add_js_file('js/turndown.js')
+ app.add_js_file('js/copy_page_md.js')
roles.register_canonical_role('icon', icon_role)
diff --git a/extensions/odoo_theme/layout_templates/header.html b/extensions/odoo_theme/layout_templates/header.html
index f04d9664a1..72b5ca3bcf 100644
--- a/extensions/odoo_theme/layout_templates/header.html
+++ b/extensions/odoo_theme/layout_templates/header.html
@@ -19,5 +19,10 @@
{%- include "layout_templates/language_switcher.html" %}
{%- include "layout_templates/version_switcher.html" %}
+
+
+
diff --git a/extensions/odoo_theme/static/js/copy_page_md.js b/extensions/odoo_theme/static/js/copy_page_md.js
new file mode 100644
index 0000000000..8cb12a630a
--- /dev/null
+++ b/extensions/odoo_theme/static/js/copy_page_md.js
@@ -0,0 +1,46 @@
+(function () {
+ document.addEventListener('DOMContentLoaded', () => {
+ const btn = document.getElementById('o_copy_markdown');
+ const content = document.getElementById('o_content');
+ if (!btn || !content || typeof TurndownService === 'undefined') return;
+
+ const originalHtml = btn.innerHTML;
+ const baseUrl = window.location.href;
+ const ATTRS = ['src', 'href', 'poster', 'data', 'action', 'formaction'];
+
+ btn.addEventListener('click', () => {
+ content.querySelectorAll('.headerlink, .modal img').forEach(el => el.remove());
+
+ const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
+
+ td.addRule('code', {
+ filter: n => n.nodeName === 'DIV' && n.className.includes('highlight') && n.querySelector('pre'),
+ replacement: (_, n) => {
+ const lang = n.className.match(/highlight-(\w+)/)?.[1] || '';
+ const code = n.querySelector('pre').textContent.trim();
+ return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
+ }
+ });
+
+ let html = content.innerHTML;
+ try {
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ ATTRS.forEach(attr => {
+ doc.querySelectorAll(`[${attr}]`).forEach(el => {
+ const val = el.getAttribute(attr);
+ if (val && !/^https?:|^data:|^#/.test(val)) {
+ el.setAttribute(attr, new URL(val, baseUrl).href);
+ }
+ });
+ });
+ html = doc.body.innerHTML;
+ } catch (e) {}
+
+ const markdown = td.turndown(html);
+ navigator.clipboard.writeText(markdown).then(() => {
+ btn.innerHTML = 'Copied!';
+ setTimeout(() => (btn.innerHTML = originalHtml), 2000);
+ });
+ });
+ });
+})();
diff --git a/extensions/odoo_theme/static/js/turndown.js b/extensions/odoo_theme/static/js/turndown.js
new file mode 100644
index 0000000000..1f5c584ec3
--- /dev/null
+++ b/extensions/odoo_theme/static/js/turndown.js
@@ -0,0 +1,1000 @@
+/**
+ * This file includes code from the Turndown project:
+ * https://github.com/mixmark-io/turndown
+ *
+ * Copyright (c) 2017 Dom Christie
+ * Licensed under the MIT License.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+var TurndownService = (function () {
+ 'use strict';
+
+ function extend (destination) {
+ for (var i = 1; i < arguments.length; i++) {
+ var source = arguments[i];
+ for (var key in source) {
+ if (source.hasOwnProperty(key)) destination[key] = source[key];
+ }
+ }
+ return destination
+ }
+
+ function repeat (character, count) {
+ return Array(count + 1).join(character)
+ }
+
+ function trimLeadingNewlines (string) {
+ return string.replace(/^\n*/, '')
+ }
+
+ function trimTrailingNewlines (string) {
+ // avoid match-at-end regexp bottleneck, see #370
+ var indexEnd = string.length;
+ while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
+ return string.substring(0, indexEnd)
+ }
+
+ var blockElements = [
+ 'ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS',
+ 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE',
+ 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER',
+ 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES',
+ 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD',
+ 'TFOOT', 'TH', 'THEAD', 'TR', 'UL'
+ ];
+
+ function isBlock (node) {
+ return is(node, blockElements)
+ }
+
+ var voidElements = [
+ 'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT',
+ 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'
+ ];
+
+ function isVoid (node) {
+ return is(node, voidElements)
+ }
+
+ function hasVoid (node) {
+ return has(node, voidElements)
+ }
+
+ var meaningfulWhenBlankElements = [
+ 'A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT',
+ 'AUDIO', 'VIDEO'
+ ];
+
+ function isMeaningfulWhenBlank (node) {
+ return is(node, meaningfulWhenBlankElements)
+ }
+
+ function hasMeaningfulWhenBlank (node) {
+ return has(node, meaningfulWhenBlankElements)
+ }
+
+ function is (node, tagNames) {
+ return tagNames.indexOf(node.nodeName) >= 0
+ }
+
+ function has (node, tagNames) {
+ return (
+ node.getElementsByTagName &&
+ tagNames.some(function (tagName) {
+ return node.getElementsByTagName(tagName).length
+ })
+ )
+ }
+
+ var rules = {};
+
+ rules.paragraph = {
+ filter: 'p',
+
+ replacement: function (content) {
+ return '\n\n' + content + '\n\n'
+ }
+ };
+
+ rules.lineBreak = {
+ filter: 'br',
+
+ replacement: function (content, node, options) {
+ return options.br + '\n'
+ }
+ };
+
+ rules.heading = {
+ filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+
+ replacement: function (content, node, options) {
+ var hLevel = Number(node.nodeName.charAt(1));
+
+ if (options.headingStyle === 'setext' && hLevel < 3) {
+ var underline = repeat((hLevel === 1 ? '=' : '-'), content.length);
+ return (
+ '\n\n' + content + '\n' + underline + '\n\n'
+ )
+ } else {
+ return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n'
+ }
+ }
+ };
+
+ rules.blockquote = {
+ filter: 'blockquote',
+
+ replacement: function (content) {
+ content = content.replace(/^\n+|\n+$/g, '');
+ content = content.replace(/^/gm, '> ');
+ return '\n\n' + content + '\n\n'
+ }
+ };
+
+ rules.list = {
+ filter: ['ul', 'ol'],
+
+ replacement: function (content, node) {
+ var parent = node.parentNode;
+ if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
+ return '\n' + content
+ } else {
+ return '\n\n' + content + '\n\n'
+ }
+ }
+ };
+
+ rules.listItem = {
+ filter: 'li',
+
+ replacement: function (content, node, options) {
+ content = content
+ .replace(/^\n+/, '') // remove leading newlines
+ .replace(/\n+$/, '\n') // replace trailing newlines with just a single one
+ .replace(/\n/gm, '\n '); // indent
+ var prefix = options.bulletListMarker + ' ';
+ var parent = node.parentNode;
+ if (parent.nodeName === 'OL') {
+ var start = parent.getAttribute('start');
+ var index = Array.prototype.indexOf.call(parent.children, node);
+ prefix = (start ? Number(start) + index : index + 1) + '. ';
+ }
+ return (
+ prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
+ )
+ }
+ };
+
+ rules.indentedCodeBlock = {
+ filter: function (node, options) {
+ return (
+ options.codeBlockStyle === 'indented' &&
+ node.nodeName === 'PRE' &&
+ node.firstChild &&
+ node.firstChild.nodeName === 'CODE'
+ )
+ },
+
+ replacement: function (content, node, options) {
+ return (
+ '\n\n ' +
+ node.firstChild.textContent.replace(/\n/g, '\n ') +
+ '\n\n'
+ )
+ }
+ };
+
+ rules.fencedCodeBlock = {
+ filter: function (node, options) {
+ return (
+ options.codeBlockStyle === 'fenced' &&
+ node.nodeName === 'PRE' &&
+ node.firstChild &&
+ node.firstChild.nodeName === 'CODE'
+ )
+ },
+
+ replacement: function (content, node, options) {
+ var className = node.firstChild.getAttribute('class') || '';
+ var language = (className.match(/language-(\S+)/) || [null, ''])[1];
+ var code = node.firstChild.textContent;
+
+ var fenceChar = options.fence.charAt(0);
+ var fenceSize = 3;
+ var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
+
+ var match;
+ while ((match = fenceInCodeRegex.exec(code))) {
+ if (match[0].length >= fenceSize) {
+ fenceSize = match[0].length + 1;
+ }
+ }
+
+ var fence = repeat(fenceChar, fenceSize);
+
+ return (
+ '\n\n' + fence + language + '\n' +
+ code.replace(/\n$/, '') +
+ '\n' + fence + '\n\n'
+ )
+ }
+ };
+
+ rules.horizontalRule = {
+ filter: 'hr',
+
+ replacement: function (content, node, options) {
+ return '\n\n' + options.hr + '\n\n'
+ }
+ };
+
+ rules.inlineLink = {
+ filter: function (node, options) {
+ return (
+ options.linkStyle === 'inlined' &&
+ node.nodeName === 'A' &&
+ node.getAttribute('href')
+ )
+ },
+
+ replacement: function (content, node) {
+ var href = node.getAttribute('href');
+ if (href) href = href.replace(/([()])/g, '\\$1');
+ var title = cleanAttribute(node.getAttribute('title'));
+ if (title) title = ' "' + title.replace(/"/g, '\\"') + '"';
+ return '[' + content + '](' + href + title + ')'
+ }
+ };
+
+ rules.referenceLink = {
+ filter: function (node, options) {
+ return (
+ options.linkStyle === 'referenced' &&
+ node.nodeName === 'A' &&
+ node.getAttribute('href')
+ )
+ },
+
+ replacement: function (content, node, options) {
+ var href = node.getAttribute('href');
+ var title = cleanAttribute(node.getAttribute('title'));
+ if (title) title = ' "' + title + '"';
+ var replacement;
+ var reference;
+
+ switch (options.linkReferenceStyle) {
+ case 'collapsed':
+ replacement = '[' + content + '][]';
+ reference = '[' + content + ']: ' + href + title;
+ break
+ case 'shortcut':
+ replacement = '[' + content + ']';
+ reference = '[' + content + ']: ' + href + title;
+ break
+ default:
+ var id = this.references.length + 1;
+ replacement = '[' + content + '][' + id + ']';
+ reference = '[' + id + ']: ' + href + title;
+ }
+
+ this.references.push(reference);
+ return replacement
+ },
+
+ references: [],
+
+ append: function (options) {
+ var references = '';
+ if (this.references.length) {
+ references = '\n\n' + this.references.join('\n') + '\n\n';
+ this.references = []; // Reset references
+ }
+ return references
+ }
+ };
+
+ rules.emphasis = {
+ filter: ['em', 'i'],
+
+ replacement: function (content, node, options) {
+ if (!content.trim()) return ''
+ return options.emDelimiter + content + options.emDelimiter
+ }
+ };
+
+ rules.strong = {
+ filter: ['strong', 'b'],
+
+ replacement: function (content, node, options) {
+ if (!content.trim()) return ''
+ return options.strongDelimiter + content + options.strongDelimiter
+ }
+ };
+
+ rules.code = {
+ filter: function (node) {
+ var hasSiblings = node.previousSibling || node.nextSibling;
+ var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
+
+ return node.nodeName === 'CODE' && !isCodeBlock
+ },
+
+ replacement: function (content) {
+ if (!content) return ''
+ content = content.replace(/\r?\n|\r/g, ' ');
+
+ var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
+ var delimiter = '`';
+ var matches = content.match(/`+/gm) || [];
+ while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
+
+ return delimiter + extraSpace + content + extraSpace + delimiter
+ }
+ };
+
+ rules.image = {
+ filter: 'img',
+
+ replacement: function (content, node) {
+ var alt = cleanAttribute(node.getAttribute('alt'));
+ var src = node.getAttribute('src') || '';
+ var title = cleanAttribute(node.getAttribute('title'));
+ var titlePart = title ? ' "' + title + '"' : '';
+ return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : ''
+ }
+ };
+
+ function cleanAttribute (attribute) {
+ return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
+ }
+
+ /**
+ * Manages a collection of rules used to convert HTML to Markdown
+ */
+
+ function Rules (options) {
+ this.options = options;
+ this._keep = [];
+ this._remove = [];
+
+ this.blankRule = {
+ replacement: options.blankReplacement
+ };
+
+ this.keepReplacement = options.keepReplacement;
+
+ this.defaultRule = {
+ replacement: options.defaultReplacement
+ };
+
+ this.array = [];
+ for (var key in options.rules) this.array.push(options.rules[key]);
+ }
+
+ Rules.prototype = {
+ add: function (key, rule) {
+ this.array.unshift(rule);
+ },
+
+ keep: function (filter) {
+ this._keep.unshift({
+ filter: filter,
+ replacement: this.keepReplacement
+ });
+ },
+
+ remove: function (filter) {
+ this._remove.unshift({
+ filter: filter,
+ replacement: function () {
+ return ''
+ }
+ });
+ },
+
+ forNode: function (node) {
+ if (node.isBlank) return this.blankRule
+ var rule;
+
+ if ((rule = findRule(this.array, node, this.options))) return rule
+ if ((rule = findRule(this._keep, node, this.options))) return rule
+ if ((rule = findRule(this._remove, node, this.options))) return rule
+
+ return this.defaultRule
+ },
+
+ forEach: function (fn) {
+ for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
+ }
+ };
+
+ function findRule (rules, node, options) {
+ for (var i = 0; i < rules.length; i++) {
+ var rule = rules[i];
+ if (filterValue(rule, node, options)) return rule
+ }
+ return void 0
+ }
+
+ function filterValue (rule, node, options) {
+ var filter = rule.filter;
+ if (typeof filter === 'string') {
+ if (filter === node.nodeName.toLowerCase()) return true
+ } else if (Array.isArray(filter)) {
+ if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true
+ } else if (typeof filter === 'function') {
+ if (filter.call(rule, node, options)) return true
+ } else {
+ throw new TypeError('`filter` needs to be a string, array, or function')
+ }
+ }
+
+ /**
+ * The collapseWhitespace function is adapted from collapse-whitespace
+ * by Luc Thevenard.
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2014 Luc Thevenard