From 526a2db615ffb512af5f884ea5465b3f728522a9 Mon Sep 17 00:00:00 2001 From: Steve Springett Date: Tue, 10 Mar 2026 12:57:08 -0500 Subject: [PATCH] major changes to json template doc generation - reducing filesize from over 30MB to 8.6MB, while preserving SEO. Signed-off-by: Steve Springett --- docgen/json/gen.sh | 2 +- .../json/templates/cyclonedx/schema_doc.css | 17 +- docgen/json/templates/cyclonedx/schema_doc.js | 347 +++++++++++++++++- .../templates/cyclonedx/schema_doc.min.js | 10 +- 4 files changed, 364 insertions(+), 12 deletions(-) diff --git a/docgen/json/gen.sh b/docgen/json/gen.sh index 88169379..7bd526a6 100755 --- a/docgen/json/gen.sh +++ b/docgen/json/gen.sh @@ -61,7 +61,7 @@ generate () { mkdir -p "$OUT_DIR" generate-schema-doc \ - --config no_link_to_reused_ref \ + --config link_to_reused_ref \ --config no_show_breadcrumbs \ --config no_collapse_long_descriptions \ --deprecated-from-description \ diff --git a/docgen/json/templates/cyclonedx/schema_doc.css b/docgen/json/templates/cyclonedx/schema_doc.css index cb73114f..1367c320 100644 --- a/docgen/json/templates/cyclonedx/schema_doc.css +++ b/docgen/json/templates/cyclonedx/schema_doc.css @@ -277,4 +277,19 @@ ul .dropdown-menu li { .highlight .vg { color: #bb60d5 } /* Name.Variable.Global */ .highlight .vi { color: #bb60d5 } /* Name.Variable.Instance */ .highlight .vm { color: #bb60d5 } /* Name.Variable.Magic */ -.highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */ \ No newline at end of file +.highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */ + +/* ═══════════════════════════════════════════════════════════ + Inline expansion for reused definitions (ref-links) + ═══════════════════════════════════════════════════════════ */ + +/* Hide the "Same definition as..." link text; content is + cloned inline automatically when the parent row expands. */ +.ref-link[data-ref-expanded="true"] { + display: none; +} + +/* Container for the cloned definition content */ +.ref-expand-content { + margin-top: 0.25rem; +} \ No newline at end of file diff --git a/docgen/json/templates/cyclonedx/schema_doc.js b/docgen/json/templates/cyclonedx/schema_doc.js index 93f1669e..fa9faf5b 100644 --- a/docgen/json/templates/cyclonedx/schema_doc.js +++ b/docgen/json/templates/cyclonedx/schema_doc.js @@ -1,6 +1,13 @@ document.addEventListener('click', function(event) { var anchor = event.target.closest('a[href^="#"]'); if (anchor) { + // Skip ref-links; they are replaced by inline expansions + if (anchor.classList.contains('ref-link')) { + event.preventDefault(); + return; + } + // Don't interfere with Bootstrap tabs or collapse toggles + if (anchor.getAttribute('data-bs-toggle')) return; event.preventDefault(); history.pushState({}, '', anchor.href); } @@ -71,4 +78,342 @@ function anchorLink(linkTarget) { }, 500); } }, 1000); -} \ No newline at end of file +} + + +// ═══════════════════════════════════════════════════════════ +// Fix duplicate IDs produced by link_to_reused_ref +// ═══════════════════════════════════════════════════════════ +// +// The schema doc generator reuses the same IDs when inlining +// a $ref definition at multiple schema paths. Duplicate IDs +// break Bootstrap tabs/collapses because getElementById +// always returns the first match. This pass finds duplicates +// and rewrites subsequent occurrences so every ID is unique. +// ═══════════════════════════════════════════════════════════ + +(function() { + function fixDuplicateIds() { + var seen = {}; // id -> true for first occurrence + var dupCount = 0; + + // Pass 1: rename duplicate IDs. First occurrence keeps + // its id; subsequent occurrences get a unique suffix. + var allWithId = document.querySelectorAll('[id]'); + allWithId.forEach(function(el) { + var id = el.id; + if (!id) return; + if (seen[id]) { + dupCount++; + el.setAttribute('data-orig-id', id); + el.id = id + '__d' + dupCount; + } else { + seen[id] = true; + } + }); + + if (dupCount === 0) return; + + // Build lookup: origId -> [el, el, ...] for fast scoping + var renamed = {}; + document.querySelectorAll('[data-orig-id]').forEach(function(el) { + var origId = el.getAttribute('data-orig-id'); + if (!renamed[origId]) renamed[origId] = []; + renamed[origId].push(el); + }); + + // Build full candidate list: origId -> [el, ...] including + // both the original (first-occurrence) element and all renamed + // duplicates so that scoping works for every occurrence. + var allTargets = {}; + Object.keys(renamed).forEach(function(origId) { + var orig = document.getElementById(origId); + allTargets[origId] = orig ? [orig].concat(renamed[origId]) : renamed[origId]; + }); + + // Find the target element (original or renamed) that shares + // the closest common ancestor with the referrer. + function findLocalTarget(referrer, origId) { + var candidates = allTargets[origId]; + if (!candidates) return origId; + var scope = referrer.parentElement; + while (scope) { + for (var i = 0; i < candidates.length; i++) { + if (scope.contains(candidates[i])) return candidates[i].id; + } + scope = scope.parentElement; + } + return origId; + } + + // Pass 2: fix references that point to renamed IDs. + function fixHashAttr(el, attr) { + var val = el.getAttribute(attr); + if (!val || val.charAt(0) !== '#') return; + var refId = val.substring(1); + if (!renamed[refId]) return; + var localId = findLocalTarget(el, refId); + if (localId !== refId) el.setAttribute(attr, '#' + localId); + } + + function fixPlainAttr(el, attr) { + var val = el.getAttribute(attr); + if (!val || !renamed[val]) return; + var localId = findLocalTarget(el, val); + if (localId !== val) el.setAttribute(attr, localId); + } + + document.querySelectorAll('a[href^="#"]').forEach(function(el) { + fixHashAttr(el, 'href'); + }); + document.querySelectorAll('[data-bs-target^="#"]').forEach(function(el) { + fixHashAttr(el, 'data-bs-target'); + }); + document.querySelectorAll('[data-bs-parent^="#"]').forEach(function(el) { + fixHashAttr(el, 'data-bs-parent'); + }); + document.querySelectorAll('[aria-controls]').forEach(function(el) { + fixPlainAttr(el, 'aria-controls'); + }); + document.querySelectorAll('[aria-labelledby]').forEach(function(el) { + fixPlainAttr(el, 'aria-labelledby'); + }); + document.querySelectorAll('[onclick]').forEach(function(el) { + var onclick = el.getAttribute('onclick'); + if (!onclick) return; + var changed = false; + var updated = onclick.replace( + /anchorLink\('([^']+)'\)/g, + function(match, id) { + if (!renamed[id]) return match; + var localId = findLocalTarget(el, id); + if (localId !== id) { changed = true; return "anchorLink('" + localId + "')"; } + return match; + } + ).replace( + /setAnchor\('#([^']+)'\)/g, + function(match, id) { + if (!renamed[id]) return match; + var localId = findLocalTarget(el, id); + if (localId !== id) { changed = true; return "setAnchor('#" + localId + "')"; } + return match; + } + ); + if (changed) el.setAttribute('onclick', updated); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fixDuplicateIds); + } else { + fixDuplicateIds(); + } +})(); + + +// ═══════════════════════════════════════════════════════════ +// Automatic inline expansion for reused definitions +// ═══════════════════════════════════════════════════════════ +// +// When link_to_reused_ref is enabled, repeated definitions +// render as "Same definition as X" links pointing to the +// original. This enhancement hides those links and clones +// the original definition inline automatically when the +// parent property row is expanded. No user click required. +// The full HTML stays in the DOM for SEO crawlability. +// ═══════════════════════════════════════════════════════════ + +(function() { + var expandCounter = 0; + + /** + * Rewrite IDs inside a cloned subtree so they don't collide + * with the originals. Also updates internal href="#...", + * data-bs-target, data-bs-parent, and aria attributes. + */ + function deduplicateIds(container, suffix) { + var elements = container.querySelectorAll('[id]'); + var idMap = {}; + elements.forEach(function(el) { + var oldId = el.id; + var newId = oldId + suffix; + idMap[oldId] = newId; + el.id = newId; + }); + + container.querySelectorAll('[href]').forEach(function(el) { + var href = el.getAttribute('href'); + if (href && href.charAt(0) === '#') { + var refId = href.substring(1); + if (idMap[refId]) { + el.setAttribute('href', '#' + idMap[refId]); + } + } + }); + container.querySelectorAll('[data-bs-target]').forEach(function(el) { + var val = el.getAttribute('data-bs-target'); + if (val && val.charAt(0) === '#') { + var refId = val.substring(1); + if (idMap[refId]) { + el.setAttribute('data-bs-target', '#' + idMap[refId]); + } + } + }); + container.querySelectorAll('[data-bs-parent]').forEach(function(el) { + var val = el.getAttribute('data-bs-parent'); + if (val && val.charAt(0) === '#') { + var refId = val.substring(1); + if (idMap[refId]) { + el.setAttribute('data-bs-parent', '#' + idMap[refId]); + } + } + }); + container.querySelectorAll('[aria-controls]').forEach(function(el) { + var val = el.getAttribute('aria-controls'); + if (val && idMap[val]) { + el.setAttribute('aria-controls', idMap[val]); + } + }); + container.querySelectorAll('[aria-labelledby]').forEach(function(el) { + var val = el.getAttribute('aria-labelledby'); + if (val && idMap[val]) { + el.setAttribute('aria-labelledby', idMap[val]); + } + }); + + container.querySelectorAll('[onclick]').forEach(function(el) { + var onclick = el.getAttribute('onclick'); + if (onclick) { + var updated = onclick.replace( + /anchorLink\('([^']+)'\)/g, + function(match, id) { + return idMap[id] ? "anchorLink('" + idMap[id] + "')" : match; + } + ).replace( + /setAnchor\('#([^']+)'\)/g, + function(match, id) { + return idMap[id] ? "setAnchor('#" + idMap[id] + "')" : match; + } + ); + el.setAttribute('onclick', updated); + } + }); + } + + /** + * Check whether a node is "leading metadata" that already + * appears in the ref-link's container: the type badge + * (span.badge.value-type), a
, a description span, + * or whitespace text nodes between them. + */ + function isLeadingMeta(node) { + if (node.nodeType === 3) { + // Text node: skip if whitespace-only + return node.textContent.trim() === ''; + } + if (node.nodeType !== 1) return false; + var el = node; + // Type badge, e.g. + if (el.tagName === 'SPAN' && el.classList.contains('value-type')) return true; + //
element right after the type badge + if (el.tagName === 'BR') return true; + // Description span + if (el.tagName === 'SPAN' && el.classList.contains('description')) return true; + return false; + } + + /** + * Clone a source definition into the container that holds + * the ref-link. The ref-link itself is hidden via CSS. + * Leading type badge,
, and description are skipped + * because the container already shows them. + */ + function expandRefLink(link) { + // Skip if already expanded + if (link.getAttribute('data-ref-expanded') === 'true') return; + link.setAttribute('data-ref-expanded', 'true'); + + var targetId = link.getAttribute('href').substring(1); + var source = document.getElementById(targetId); + if (!source) return; + + expandCounter++; + var suffix = '__exp' + expandCounter; + + var content = document.createElement('div'); + content.className = 'ref-expand-content'; + + // Clone child nodes, skipping leading metadata that + // duplicates what the container already displays. + var nodes = source.childNodes; + var pastLeading = false; + for (var i = 0; i < nodes.length; i++) { + if (!pastLeading && isLeadingMeta(nodes[i])) continue; + pastLeading = true; + content.appendChild(nodes[i].cloneNode(true)); + } + + deduplicateIds(content, suffix); + + // Insert the cloned content after the ref-link + link.parentNode.insertBefore(content, link.nextSibling); + } + + /** + * Check whether a ref-link is directly visible within the + * panel that was just shown. Returns false if the link sits + * inside a nested collapse that is still hidden. + */ + function isVisibleInPanel(link, panel) { + var el = link.parentElement; + while (el && el !== panel) { + if (el.classList.contains('collapse') && !el.classList.contains('show')) { + return false; + } + el = el.parentElement; + } + return true; + } + + /** + * When a collapse panel is shown, expand only the ref-links + * that are directly visible (not buried in nested collapses). + */ + function onCollapseShown(e) { + var panel = e.target; + var refLinks = panel.querySelectorAll('.ref-link'); + refLinks.forEach(function(link) { + if (isVisibleInPanel(link, panel)) { + expandRefLink(link); + } + }); + } + + /** + * Initialize: hide ref-link text, listen for collapse events. + */ + function initRefLinks() { + var refLinks = document.querySelectorAll('.ref-link'); + refLinks.forEach(function(link) { + // Remove the original onclick + link.removeAttribute('onclick'); + + // Expand ref-links that are already visible on load + // (not inside any collapsed panel) + var parentCollapse = link.closest('.collapse'); + if (!parentCollapse || parentCollapse.classList.contains('show')) { + expandRefLink(link); + } + }); + + // Listen for Bootstrap collapse show events + document.addEventListener('shown.bs.collapse', onCollapseShown); + } + + // Run on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initRefLinks); + } else { + initRefLinks(); + } +})(); diff --git a/docgen/json/templates/cyclonedx/schema_doc.min.js b/docgen/json/templates/cyclonedx/schema_doc.min.js index a8756dcb..a3934ac7 100644 --- a/docgen/json/templates/cyclonedx/schema_doc.min.js +++ b/docgen/json/templates/cyclonedx/schema_doc.min.js @@ -1,9 +1 @@ -document.addEventListener('click',function(event){var anchor=event.target.closest('a[href^="#"]');if(anchor){event.preventDefault();history.pushState({},'',anchor.href);}});function flashElement(elementId){var myElement=document.getElementById(elementId);if(myElement){myElement.classList.add("jsfh-animated-property");setTimeout(function(){myElement.classList.remove("jsfh-animated-property");},1000);}} -function setAnchor(anchorLinkDestination){history.pushState({},'',anchorLinkDestination);} -function anchorOnLoad(){var linkTarget=decodeURIComponent(window.location.hash.split("?")[0].split("&")[0]);if(linkTarget[0]==="#"){linkTarget=linkTarget.substr(1);} -if(linkTarget.length>0){anchorLink(linkTarget);}} -function anchorLink(linkTarget){var target=document.getElementById(linkTarget);if(!target)return;var element=target;while(element){if(element.classList.contains("collapse")&&!element.classList.contains("show")){var bsCollapse=new bootstrap.Collapse(element,{toggle:true});} -if(element.classList.contains("tab-pane")){var tabTrigger=document.querySelector('a[href="#'+element.id+'"]');if(tabTrigger){var bsTab=new bootstrap.Tab(tabTrigger);bsTab.show();}} -if(element.getAttribute("role")==="tab"){var bsTab=new bootstrap.Tab(element);bsTab.show();} -element=element.parentElement;} -setTimeout(function(){var targetElement=document.getElementById(linkTarget);if(targetElement){targetElement.scrollIntoView({block:"center",behavior:"smooth"});setTimeout(function(){flashElement(linkTarget);},500);}},1000);} \ No newline at end of file +function flashElement(t){var e=document.getElementById(t);e&&(e.classList.add("jsfh-animated-property"),setTimeout(function(){e.classList.remove("jsfh-animated-property")},1e3))}function setAnchor(t){history.pushState({},"",t)}function anchorOnLoad(){var t=decodeURIComponent(window.location.hash.split("?")[0].split("&")[0]);"#"===t[0]&&(t=t.substr(1)),t.length>0&&anchorLink(t)}function anchorLink(t){var e=document.getElementById(t);if(e){for(var r=e;r;){if(r.classList.contains("collapse")&&!r.classList.contains("show"))new bootstrap.Collapse(r,{toggle:!0});if(r.classList.contains("tab-pane")){var n=document.querySelector('a[href="#'+r.id+'"]');if(n)new bootstrap.Tab(n).show()}if("tab"===r.getAttribute("role"))new bootstrap.Tab(r).show();r=r.parentElement}setTimeout(function(){var e=document.getElementById(t);e&&(e.scrollIntoView({block:"center",behavior:"smooth"}),setTimeout(function(){flashElement(t)},500))},1e3)}}document.addEventListener("click",function(t){var e=t.target.closest('a[href^="#"]');if(e){if(e.classList.contains("ref-link"))return void t.preventDefault();if(e.getAttribute("data-bs-toggle"))return;t.preventDefault(),history.pushState({},"",e.href)}}),function(){function t(){var t={},e=0;if(document.querySelectorAll("[id]").forEach(function(r){var n=r.id;n&&(t[n]?(e++,r.setAttribute("data-orig-id",n),r.id=n+"__d"+e):t[n]=!0)}),0!==e){var r={};document.querySelectorAll("[data-orig-id]").forEach(function(t){var e=t.getAttribute("data-orig-id");r[e]||(r[e]=[]),r[e].push(t)});var n={};Object.keys(r).forEach(function(t){var e=document.getElementById(t);n[t]=e?[e].concat(r[t]):r[t]}),document.querySelectorAll('a[href^="#"]').forEach(function(t){o(t,"href")}),document.querySelectorAll('[data-bs-target^="#"]').forEach(function(t){o(t,"data-bs-target")}),document.querySelectorAll('[data-bs-parent^="#"]').forEach(function(t){o(t,"data-bs-parent")}),document.querySelectorAll("[aria-controls]").forEach(function(t){i(t,"aria-controls")}),document.querySelectorAll("[aria-labelledby]").forEach(function(t){i(t,"aria-labelledby")}),document.querySelectorAll("[onclick]").forEach(function(t){var e=t.getAttribute("onclick");if(e){var n=!1,o=e.replace(/anchorLink\('([^']+)'\)/g,function(e,o){if(!r[o])return e;var i=a(t,o);return i!==o?(n=!0,"anchorLink('"+i+"')"):e}).replace(/setAnchor\('#([^']+)'\)/g,function(e,o){if(!r[o])return e;var i=a(t,o);return i!==o?(n=!0,"setAnchor('#"+i+"')"):e});n&&t.setAttribute("onclick",o)}})}function a(t,e){var r=n[e];if(!r)return e;for(var a=t.parentElement;a;){for(var o=0;o