Skip to content

Commit b92bd46

Browse files
JSON doc performance improvements (CycloneDX#865)
major changes to json template doc generation - reducing filesize from over 30MB to 8.6MB, while preserving SEO.
2 parents 1886816 + 526a2db commit b92bd46

File tree

4 files changed

+364
-12
lines changed

4 files changed

+364
-12
lines changed

docgen/json/gen.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ generate () {
6161
mkdir -p "$OUT_DIR"
6262

6363
generate-schema-doc \
64-
--config no_link_to_reused_ref \
64+
--config link_to_reused_ref \
6565
--config no_show_breadcrumbs \
6666
--config no_collapse_long_descriptions \
6767
--deprecated-from-description \

docgen/json/templates/cyclonedx/schema_doc.css

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,19 @@ ul .dropdown-menu li {
277277
.highlight .vg { color: #bb60d5 } /* Name.Variable.Global */
278278
.highlight .vi { color: #bb60d5 } /* Name.Variable.Instance */
279279
.highlight .vm { color: #bb60d5 } /* Name.Variable.Magic */
280-
.highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */
280+
.highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */
281+
282+
/* ═══════════════════════════════════════════════════════════
283+
Inline expansion for reused definitions (ref-links)
284+
═══════════════════════════════════════════════════════════ */
285+
286+
/* Hide the "Same definition as..." link text; content is
287+
cloned inline automatically when the parent row expands. */
288+
.ref-link[data-ref-expanded="true"] {
289+
display: none;
290+
}
291+
292+
/* Container for the cloned definition content */
293+
.ref-expand-content {
294+
margin-top: 0.25rem;
295+
}

docgen/json/templates/cyclonedx/schema_doc.js

Lines changed: 346 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
document.addEventListener('click', function(event) {
22
var anchor = event.target.closest('a[href^="#"]');
33
if (anchor) {
4+
// Skip ref-links; they are replaced by inline expansions
5+
if (anchor.classList.contains('ref-link')) {
6+
event.preventDefault();
7+
return;
8+
}
9+
// Don't interfere with Bootstrap tabs or collapse toggles
10+
if (anchor.getAttribute('data-bs-toggle')) return;
411
event.preventDefault();
512
history.pushState({}, '', anchor.href);
613
}
@@ -71,4 +78,342 @@ function anchorLink(linkTarget) {
7178
}, 500);
7279
}
7380
}, 1000);
74-
}
81+
}
82+
83+
84+
// ═══════════════════════════════════════════════════════════
85+
// Fix duplicate IDs produced by link_to_reused_ref
86+
// ═══════════════════════════════════════════════════════════
87+
//
88+
// The schema doc generator reuses the same IDs when inlining
89+
// a $ref definition at multiple schema paths. Duplicate IDs
90+
// break Bootstrap tabs/collapses because getElementById
91+
// always returns the first match. This pass finds duplicates
92+
// and rewrites subsequent occurrences so every ID is unique.
93+
// ═══════════════════════════════════════════════════════════
94+
95+
(function() {
96+
function fixDuplicateIds() {
97+
var seen = {}; // id -> true for first occurrence
98+
var dupCount = 0;
99+
100+
// Pass 1: rename duplicate IDs. First occurrence keeps
101+
// its id; subsequent occurrences get a unique suffix.
102+
var allWithId = document.querySelectorAll('[id]');
103+
allWithId.forEach(function(el) {
104+
var id = el.id;
105+
if (!id) return;
106+
if (seen[id]) {
107+
dupCount++;
108+
el.setAttribute('data-orig-id', id);
109+
el.id = id + '__d' + dupCount;
110+
} else {
111+
seen[id] = true;
112+
}
113+
});
114+
115+
if (dupCount === 0) return;
116+
117+
// Build lookup: origId -> [el, el, ...] for fast scoping
118+
var renamed = {};
119+
document.querySelectorAll('[data-orig-id]').forEach(function(el) {
120+
var origId = el.getAttribute('data-orig-id');
121+
if (!renamed[origId]) renamed[origId] = [];
122+
renamed[origId].push(el);
123+
});
124+
125+
// Build full candidate list: origId -> [el, ...] including
126+
// both the original (first-occurrence) element and all renamed
127+
// duplicates so that scoping works for every occurrence.
128+
var allTargets = {};
129+
Object.keys(renamed).forEach(function(origId) {
130+
var orig = document.getElementById(origId);
131+
allTargets[origId] = orig ? [orig].concat(renamed[origId]) : renamed[origId];
132+
});
133+
134+
// Find the target element (original or renamed) that shares
135+
// the closest common ancestor with the referrer.
136+
function findLocalTarget(referrer, origId) {
137+
var candidates = allTargets[origId];
138+
if (!candidates) return origId;
139+
var scope = referrer.parentElement;
140+
while (scope) {
141+
for (var i = 0; i < candidates.length; i++) {
142+
if (scope.contains(candidates[i])) return candidates[i].id;
143+
}
144+
scope = scope.parentElement;
145+
}
146+
return origId;
147+
}
148+
149+
// Pass 2: fix references that point to renamed IDs.
150+
function fixHashAttr(el, attr) {
151+
var val = el.getAttribute(attr);
152+
if (!val || val.charAt(0) !== '#') return;
153+
var refId = val.substring(1);
154+
if (!renamed[refId]) return;
155+
var localId = findLocalTarget(el, refId);
156+
if (localId !== refId) el.setAttribute(attr, '#' + localId);
157+
}
158+
159+
function fixPlainAttr(el, attr) {
160+
var val = el.getAttribute(attr);
161+
if (!val || !renamed[val]) return;
162+
var localId = findLocalTarget(el, val);
163+
if (localId !== val) el.setAttribute(attr, localId);
164+
}
165+
166+
document.querySelectorAll('a[href^="#"]').forEach(function(el) {
167+
fixHashAttr(el, 'href');
168+
});
169+
document.querySelectorAll('[data-bs-target^="#"]').forEach(function(el) {
170+
fixHashAttr(el, 'data-bs-target');
171+
});
172+
document.querySelectorAll('[data-bs-parent^="#"]').forEach(function(el) {
173+
fixHashAttr(el, 'data-bs-parent');
174+
});
175+
document.querySelectorAll('[aria-controls]').forEach(function(el) {
176+
fixPlainAttr(el, 'aria-controls');
177+
});
178+
document.querySelectorAll('[aria-labelledby]').forEach(function(el) {
179+
fixPlainAttr(el, 'aria-labelledby');
180+
});
181+
document.querySelectorAll('[onclick]').forEach(function(el) {
182+
var onclick = el.getAttribute('onclick');
183+
if (!onclick) return;
184+
var changed = false;
185+
var updated = onclick.replace(
186+
/anchorLink\('([^']+)'\)/g,
187+
function(match, id) {
188+
if (!renamed[id]) return match;
189+
var localId = findLocalTarget(el, id);
190+
if (localId !== id) { changed = true; return "anchorLink('" + localId + "')"; }
191+
return match;
192+
}
193+
).replace(
194+
/setAnchor\('#([^']+)'\)/g,
195+
function(match, id) {
196+
if (!renamed[id]) return match;
197+
var localId = findLocalTarget(el, id);
198+
if (localId !== id) { changed = true; return "setAnchor('#" + localId + "')"; }
199+
return match;
200+
}
201+
);
202+
if (changed) el.setAttribute('onclick', updated);
203+
});
204+
}
205+
206+
if (document.readyState === 'loading') {
207+
document.addEventListener('DOMContentLoaded', fixDuplicateIds);
208+
} else {
209+
fixDuplicateIds();
210+
}
211+
})();
212+
213+
214+
// ═══════════════════════════════════════════════════════════
215+
// Automatic inline expansion for reused definitions
216+
// ═══════════════════════════════════════════════════════════
217+
//
218+
// When link_to_reused_ref is enabled, repeated definitions
219+
// render as "Same definition as X" links pointing to the
220+
// original. This enhancement hides those links and clones
221+
// the original definition inline automatically when the
222+
// parent property row is expanded. No user click required.
223+
// The full HTML stays in the DOM for SEO crawlability.
224+
// ═══════════════════════════════════════════════════════════
225+
226+
(function() {
227+
var expandCounter = 0;
228+
229+
/**
230+
* Rewrite IDs inside a cloned subtree so they don't collide
231+
* with the originals. Also updates internal href="#...",
232+
* data-bs-target, data-bs-parent, and aria attributes.
233+
*/
234+
function deduplicateIds(container, suffix) {
235+
var elements = container.querySelectorAll('[id]');
236+
var idMap = {};
237+
elements.forEach(function(el) {
238+
var oldId = el.id;
239+
var newId = oldId + suffix;
240+
idMap[oldId] = newId;
241+
el.id = newId;
242+
});
243+
244+
container.querySelectorAll('[href]').forEach(function(el) {
245+
var href = el.getAttribute('href');
246+
if (href && href.charAt(0) === '#') {
247+
var refId = href.substring(1);
248+
if (idMap[refId]) {
249+
el.setAttribute('href', '#' + idMap[refId]);
250+
}
251+
}
252+
});
253+
container.querySelectorAll('[data-bs-target]').forEach(function(el) {
254+
var val = el.getAttribute('data-bs-target');
255+
if (val && val.charAt(0) === '#') {
256+
var refId = val.substring(1);
257+
if (idMap[refId]) {
258+
el.setAttribute('data-bs-target', '#' + idMap[refId]);
259+
}
260+
}
261+
});
262+
container.querySelectorAll('[data-bs-parent]').forEach(function(el) {
263+
var val = el.getAttribute('data-bs-parent');
264+
if (val && val.charAt(0) === '#') {
265+
var refId = val.substring(1);
266+
if (idMap[refId]) {
267+
el.setAttribute('data-bs-parent', '#' + idMap[refId]);
268+
}
269+
}
270+
});
271+
container.querySelectorAll('[aria-controls]').forEach(function(el) {
272+
var val = el.getAttribute('aria-controls');
273+
if (val && idMap[val]) {
274+
el.setAttribute('aria-controls', idMap[val]);
275+
}
276+
});
277+
container.querySelectorAll('[aria-labelledby]').forEach(function(el) {
278+
var val = el.getAttribute('aria-labelledby');
279+
if (val && idMap[val]) {
280+
el.setAttribute('aria-labelledby', idMap[val]);
281+
}
282+
});
283+
284+
container.querySelectorAll('[onclick]').forEach(function(el) {
285+
var onclick = el.getAttribute('onclick');
286+
if (onclick) {
287+
var updated = onclick.replace(
288+
/anchorLink\('([^']+)'\)/g,
289+
function(match, id) {
290+
return idMap[id] ? "anchorLink('" + idMap[id] + "')" : match;
291+
}
292+
).replace(
293+
/setAnchor\('#([^']+)'\)/g,
294+
function(match, id) {
295+
return idMap[id] ? "setAnchor('#" + idMap[id] + "')" : match;
296+
}
297+
);
298+
el.setAttribute('onclick', updated);
299+
}
300+
});
301+
}
302+
303+
/**
304+
* Check whether a node is "leading metadata" that already
305+
* appears in the ref-link's container: the type badge
306+
* (span.badge.value-type), a <br>, a description span,
307+
* or whitespace text nodes between them.
308+
*/
309+
function isLeadingMeta(node) {
310+
if (node.nodeType === 3) {
311+
// Text node: skip if whitespace-only
312+
return node.textContent.trim() === '';
313+
}
314+
if (node.nodeType !== 1) return false;
315+
var el = node;
316+
// Type badge, e.g. <span class="badge ... value-type">
317+
if (el.tagName === 'SPAN' && el.classList.contains('value-type')) return true;
318+
// <br> element right after the type badge
319+
if (el.tagName === 'BR') return true;
320+
// Description span
321+
if (el.tagName === 'SPAN' && el.classList.contains('description')) return true;
322+
return false;
323+
}
324+
325+
/**
326+
* Clone a source definition into the container that holds
327+
* the ref-link. The ref-link itself is hidden via CSS.
328+
* Leading type badge, <br>, and description are skipped
329+
* because the container already shows them.
330+
*/
331+
function expandRefLink(link) {
332+
// Skip if already expanded
333+
if (link.getAttribute('data-ref-expanded') === 'true') return;
334+
link.setAttribute('data-ref-expanded', 'true');
335+
336+
var targetId = link.getAttribute('href').substring(1);
337+
var source = document.getElementById(targetId);
338+
if (!source) return;
339+
340+
expandCounter++;
341+
var suffix = '__exp' + expandCounter;
342+
343+
var content = document.createElement('div');
344+
content.className = 'ref-expand-content';
345+
346+
// Clone child nodes, skipping leading metadata that
347+
// duplicates what the container already displays.
348+
var nodes = source.childNodes;
349+
var pastLeading = false;
350+
for (var i = 0; i < nodes.length; i++) {
351+
if (!pastLeading && isLeadingMeta(nodes[i])) continue;
352+
pastLeading = true;
353+
content.appendChild(nodes[i].cloneNode(true));
354+
}
355+
356+
deduplicateIds(content, suffix);
357+
358+
// Insert the cloned content after the ref-link
359+
link.parentNode.insertBefore(content, link.nextSibling);
360+
}
361+
362+
/**
363+
* Check whether a ref-link is directly visible within the
364+
* panel that was just shown. Returns false if the link sits
365+
* inside a nested collapse that is still hidden.
366+
*/
367+
function isVisibleInPanel(link, panel) {
368+
var el = link.parentElement;
369+
while (el && el !== panel) {
370+
if (el.classList.contains('collapse') && !el.classList.contains('show')) {
371+
return false;
372+
}
373+
el = el.parentElement;
374+
}
375+
return true;
376+
}
377+
378+
/**
379+
* When a collapse panel is shown, expand only the ref-links
380+
* that are directly visible (not buried in nested collapses).
381+
*/
382+
function onCollapseShown(e) {
383+
var panel = e.target;
384+
var refLinks = panel.querySelectorAll('.ref-link');
385+
refLinks.forEach(function(link) {
386+
if (isVisibleInPanel(link, panel)) {
387+
expandRefLink(link);
388+
}
389+
});
390+
}
391+
392+
/**
393+
* Initialize: hide ref-link text, listen for collapse events.
394+
*/
395+
function initRefLinks() {
396+
var refLinks = document.querySelectorAll('.ref-link');
397+
refLinks.forEach(function(link) {
398+
// Remove the original onclick
399+
link.removeAttribute('onclick');
400+
401+
// Expand ref-links that are already visible on load
402+
// (not inside any collapsed panel)
403+
var parentCollapse = link.closest('.collapse');
404+
if (!parentCollapse || parentCollapse.classList.contains('show')) {
405+
expandRefLink(link);
406+
}
407+
});
408+
409+
// Listen for Bootstrap collapse show events
410+
document.addEventListener('shown.bs.collapse', onCollapseShown);
411+
}
412+
413+
// Run on DOM ready
414+
if (document.readyState === 'loading') {
415+
document.addEventListener('DOMContentLoaded', initRefLinks);
416+
} else {
417+
initRefLinks();
418+
}
419+
})();

0 commit comments

Comments
 (0)