spec-up-t 1.6.1 → 1.6.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,764 @@
1
+ /**
2
+ * @fileoverview Validates external references (xrefs and trefs) by fetching live data from external gh-pages.
3
+ *
4
+ * This script runs on page load and checks if external references are still valid:
5
+ * - Checks if the referenced term still exists in the external specification
6
+ * - Checks if the definition content has changed
7
+ * - Displays visual indicators next to references when issues are detected
8
+ *
9
+ * The validation works by:
10
+ * 1. Collecting all unique external specifications from the page
11
+ * 2. Fetching the live gh-page HTML for each external spec
12
+ * 3. Extracting term definitions from the fetched HTML
13
+ * 4. Comparing with the cached data in allXTrefs
14
+ * 5. Adding visual indicators for missing or changed terms
15
+ */
16
+
17
+ /**
18
+ * Configuration object for the validator
19
+ * @type {Object}
20
+ */
21
+ const VALIDATOR_CONFIG = {
22
+ // CSS classes for different validation states
23
+ classes: {
24
+ indicator: 'external-ref-validation-indicator',
25
+ missing: 'external-ref-missing',
26
+ changed: 'external-ref-changed',
27
+ valid: 'external-ref-valid',
28
+ error: 'external-ref-error'
29
+ },
30
+ // Text labels for indicators
31
+ labels: {
32
+ missing: '⚠️ Term not found',
33
+ changed: '🔄 Definition changed',
34
+ error: '❌ Could not verify',
35
+ valid: '✓ Verified'
36
+ },
37
+ // Whether to show valid indicators (can be noisy)
38
+ showValidIndicators: false,
39
+ // Cache timeout in milliseconds (5 minutes)
40
+ cacheTimeout: 5 * 60 * 1000,
41
+ // Similarity threshold (0-1) - only show changed indicator if similarity is below this
42
+ // 0.95 means content must be at least 95% similar to be considered unchanged
43
+ // This prevents false positives from minor formatting differences
44
+ similarityThreshold: 0.95,
45
+ // Enable debug logging to console (set to true to see comparison details)
46
+ debug: true
47
+ };
48
+
49
+ /**
50
+ * Cache for fetched external specifications
51
+ * Prevents redundant fetches for the same spec
52
+ * @type {Map<string, {timestamp: number, data: Object}>}
53
+ */
54
+ const fetchCache = new Map();
55
+
56
+ /**
57
+ * Normalizes HTML content for comparison by extracting text and normalizing whitespace
58
+ * This helps compare definitions that might have minor formatting differences
59
+ *
60
+ * @param {string} html - The HTML content to normalize
61
+ * @returns {string} - Normalized text content
62
+ */
63
+ function normalizeContent(html) {
64
+ if (!html) return '';
65
+
66
+ // Create a temporary element to parse HTML and extract text
67
+ const tempDiv = document.createElement('div');
68
+ tempDiv.innerHTML = html;
69
+
70
+ // Get text content and normalize whitespace
71
+ let text = tempDiv.textContent || tempDiv.innerText || '';
72
+
73
+ // Normalize the text
74
+ text = text
75
+ .toLowerCase()
76
+ .replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove zero-width spaces
77
+ .replace(/\s+/g, ' ') // Collapse all whitespace to single spaces
78
+ .replace(/\s*([.,;:!?])\s*/g, '$1 ') // Normalize punctuation spacing
79
+ .trim();
80
+
81
+ return text;
82
+ }
83
+
84
+ /**
85
+ * Calculates the similarity between two text strings
86
+ * Returns a value between 0 (completely different) and 1 (identical)
87
+ *
88
+ * @param {string} str1 - First string
89
+ * @param {string} str2 - Second string
90
+ * @returns {number} - Similarity score between 0 and 1
91
+ */
92
+ function calculateSimilarity(str1, str2) {
93
+ if (str1 === str2) return 1;
94
+ if (!str1 || !str2) return 0;
95
+
96
+ const longer = str1.length > str2.length ? str1 : str2;
97
+ const shorter = str1.length > str2.length ? str2 : str1;
98
+
99
+ if (longer.length === 0) return 1;
100
+
101
+ // Calculate Levenshtein distance
102
+ const editDistance = levenshteinDistance(shorter, longer);
103
+ return (longer.length - editDistance) / longer.length;
104
+ }
105
+
106
+ /**
107
+ * Calculates the Levenshtein distance between two strings
108
+ * This measures the minimum number of single-character edits needed
109
+ *
110
+ * @param {string} str1 - First string
111
+ * @param {string} str2 - Second string
112
+ * @returns {number} - Edit distance
113
+ */
114
+ function levenshteinDistance(str1, str2) {
115
+ const matrix = [];
116
+
117
+ for (let i = 0; i <= str2.length; i++) {
118
+ matrix[i] = [i];
119
+ }
120
+
121
+ for (let j = 0; j <= str1.length; j++) {
122
+ matrix[0][j] = j;
123
+ }
124
+
125
+ for (let i = 1; i <= str2.length; i++) {
126
+ for (let j = 1; j <= str1.length; j++) {
127
+ if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
128
+ matrix[i][j] = matrix[i - 1][j - 1];
129
+ } else {
130
+ matrix[i][j] = Math.min(
131
+ matrix[i - 1][j - 1] + 1,
132
+ matrix[i][j - 1] + 1,
133
+ matrix[i - 1][j] + 1
134
+ );
135
+ }
136
+ }
137
+ }
138
+
139
+ return matrix[str2.length][str1.length];
140
+ }
141
+
142
+ /**
143
+ * Extracts terms and their definitions from fetched HTML content
144
+ * Parses the HTML structure used by Spec-Up-T specifications
145
+ *
146
+ * @param {string} html - The full HTML of the fetched page
147
+ * @returns {Map<string, {content: string, rawContent: string}>} - Map of term IDs to their definitions
148
+ */
149
+ function extractTermsFromHtml(html) {
150
+ const terms = new Map();
151
+
152
+ // Create a DOM parser to work with the HTML
153
+ const parser = new DOMParser();
154
+ const doc = parser.parseFromString(html, 'text/html');
155
+
156
+ // Find all dt elements in terms-and-definitions-list
157
+ const termElements = doc.querySelectorAll('dl.terms-and-definitions-list dt');
158
+
159
+ termElements.forEach(dt => {
160
+ // Extract term ID from the span element
161
+ const termSpan = dt.querySelector('[id^="term:"]');
162
+ if (!termSpan) return;
163
+
164
+ const termId = termSpan.id;
165
+ // Extract just the term name (last part after the last colon)
166
+ const termName = termId.split(':').pop();
167
+
168
+ // Get the definition content from ALL following dd element(s)
169
+ // NOTE: We collect ALL dd elements to match what's cached in allXTrefs
170
+ // Terms can have multiple dd blocks for extended definitions
171
+ const ddElements = [];
172
+ let definitionContent = '';
173
+ let rawContent = '';
174
+ let sibling = dt.nextElementSibling;
175
+
176
+ // Collect all consecutive dd elements, skipping meta-info wrappers
177
+ while (sibling && sibling.tagName === 'DD') {
178
+ // Skip meta-info wrapper elements for trefs
179
+ if (!sibling.classList.contains('meta-info-content-wrapper')) {
180
+ ddElements.push(sibling);
181
+ }
182
+ sibling = sibling.nextElementSibling;
183
+ }
184
+
185
+ if (ddElements.length > 0) {
186
+ // Combine all dd elements' raw HTML
187
+ rawContent = ddElements.map(dd => dd.outerHTML).join('\n');
188
+ // Combine all dd elements' text content
189
+ definitionContent = ddElements.map(dd => dd.textContent).join('\n');
190
+ }
191
+
192
+ terms.set(termName.toLowerCase(), {
193
+ content: definitionContent.trim(),
194
+ rawContent: rawContent,
195
+ termId: termId
196
+ });
197
+ });
198
+
199
+ return terms;
200
+ }
201
+
202
+ /**
203
+ * Fetches the external specification page and extracts term definitions
204
+ * Uses caching to prevent redundant fetches
205
+ *
206
+ * @param {string} ghPageUrl - The URL of the external specification's gh-page
207
+ * @param {string} specName - The name of the external specification (for logging)
208
+ * @returns {Promise<Map<string, Object>|null>} - Map of terms or null if fetch fails
209
+ */
210
+ async function fetchExternalSpec(ghPageUrl, specName) {
211
+ // Check cache first
212
+ const cached = fetchCache.get(ghPageUrl);
213
+ if (cached && (Date.now() - cached.timestamp) < VALIDATOR_CONFIG.cacheTimeout) {
214
+ return cached.data;
215
+ }
216
+
217
+ try {
218
+ const response = await fetch(ghPageUrl, {
219
+ mode: 'cors',
220
+ headers: {
221
+ 'Accept': 'text/html'
222
+ }
223
+ });
224
+
225
+ if (!response.ok) {
226
+ console.warn(`[External Ref Validator] Failed to fetch ${specName}: ${response.status}`);
227
+ return null;
228
+ }
229
+
230
+ const html = await response.text();
231
+ const terms = extractTermsFromHtml(html);
232
+
233
+ // Cache the result
234
+ fetchCache.set(ghPageUrl, {
235
+ timestamp: Date.now(),
236
+ data: terms
237
+ });
238
+
239
+ return terms;
240
+ } catch (error) {
241
+ console.warn(`[External Ref Validator] Error fetching ${specName}:`, error.message);
242
+ return null;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Finds and extracts the differences between two texts
248
+ * Returns objects with the unique parts from each text
249
+ *
250
+ * @param {string} text1 - First text (cached)
251
+ * @param {string} text2 - Second text (live)
252
+ * @returns {Object} - Object with oldUnique and newUnique properties
253
+ */
254
+ function findTextDifferences(text1, text2) {
255
+ // Split texts into lines for comparison
256
+ const lines1 = text1.split(/\n+/).filter(line => line.trim());
257
+ const lines2 = text2.split(/\n+/).filter(line => line.trim());
258
+
259
+ // Find lines that are only in text1 (removed)
260
+ const oldUnique = lines1.filter(line => !lines2.includes(line));
261
+
262
+ // Find lines that are only in text2 (added)
263
+ const newUnique = lines2.filter(line => !lines1.includes(line));
264
+
265
+ return {
266
+ oldUnique: oldUnique.length > 0 ? oldUnique.join('\n') : '(no removed content)',
267
+ newUnique: newUnique.length > 0 ? newUnique.join('\n') : '(no added content)',
268
+ hasDifferences: oldUnique.length > 0 || newUnique.length > 0
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Creates a validation indicator element to display next to a reference
274
+ *
275
+ * @param {string} type - The type of indicator: 'missing', 'changed', 'valid', or 'error'
276
+ * @param {Object} details - Additional details to show in the indicator
277
+ * @param {string} [details.message] - Custom message to display
278
+ * @param {string} [details.oldContent] - The old/cached content
279
+ * @param {string} [details.newContent] - The new/live content
280
+ * @returns {HTMLElement} - The indicator element
281
+ */
282
+ function createIndicator(type, details = {}) {
283
+ const indicator = document.createElement('span');
284
+ indicator.classList.add(
285
+ VALIDATOR_CONFIG.classes.indicator,
286
+ VALIDATOR_CONFIG.classes[type]
287
+ );
288
+
289
+ // Set the main label text
290
+ const labelText = details.message || VALIDATOR_CONFIG.labels[type];
291
+ indicator.setAttribute('title', labelText);
292
+
293
+ // Create the visible indicator content
294
+ const iconSpan = document.createElement('span');
295
+ iconSpan.classList.add('indicator-icon');
296
+ iconSpan.textContent = VALIDATOR_CONFIG.labels[type].split(' ')[0]; // Just the emoji
297
+ indicator.appendChild(iconSpan);
298
+
299
+ // For changed content, add a details popup
300
+ if (type === 'changed' && details.oldContent && details.newContent) {
301
+ indicator.classList.add('has-details');
302
+
303
+ // Find the actual differences between old and new content
304
+ const diff = findTextDifferences(details.oldContent, details.newContent);
305
+
306
+ const detailsDiv = document.createElement('div');
307
+ detailsDiv.classList.add('validation-details');
308
+ const similarityText = details.similarity ? `<div class="validation-similarity">Similarity: ${details.similarity}</div>` : '';
309
+
310
+ // Show only the differences if they exist, otherwise show full content
311
+ if (diff.hasDifferences) {
312
+ detailsDiv.innerHTML = `
313
+ <div class="validation-details-header">Definition Changed - Showing Differences</div>
314
+ ${similarityText}
315
+ <div class="validation-details-section">
316
+ <strong>Removed from cached version:</strong>
317
+ <div class="validation-content-old">${escapeHtml(truncateText(diff.oldUnique, 300))}</div>
318
+ </div>
319
+ <div class="validation-details-section">
320
+ <strong>Added in live version:</strong>
321
+ <div class="validation-content-new">${escapeHtml(truncateText(diff.newUnique, 300))}</div>
322
+ </div>
323
+ <div class="validation-details-footer">
324
+ <em>Rebuild the spec to update the definition</em>
325
+ </div>
326
+ `;
327
+ } else {
328
+ // Fallback to showing full content if diff detection fails
329
+ detailsDiv.innerHTML = `
330
+ <div class="validation-details-header">Definition Changed</div>
331
+ ${similarityText}
332
+ <div class="validation-details-section">
333
+ <strong>Cached (at build time):</strong>
334
+ <div class="validation-content-old">${escapeHtml(truncateText(details.oldContent, 500))}</div>
335
+ </div>
336
+ <div class="validation-details-section">
337
+ <strong>Current (live):</strong>
338
+ <div class="validation-content-new">${escapeHtml(truncateText(details.newContent, 500))}</div>
339
+ </div>
340
+ <div class="validation-details-footer">
341
+ <em>Rebuild the spec to update the definition</em>
342
+ </div>
343
+ `;
344
+ }
345
+ indicator.appendChild(detailsDiv);
346
+ }
347
+
348
+ // For missing terms, add a simple tooltip
349
+ if (type === 'missing') {
350
+ indicator.classList.add('has-details');
351
+
352
+ const detailsDiv = document.createElement('div');
353
+ detailsDiv.classList.add('validation-details');
354
+ detailsDiv.innerHTML = `
355
+ <div class="validation-details-header">Term Not Found</div>
356
+ <div class="validation-details-section">
357
+ The term referenced here no longer exists in the external specification.
358
+ It may have been renamed, moved, or removed.
359
+ </div>
360
+ <div class="validation-details-footer">
361
+ <em>Check the external specification for updates</em>
362
+ </div>
363
+ `;
364
+ indicator.appendChild(detailsDiv);
365
+ }
366
+
367
+ return indicator;
368
+ }
369
+
370
+ /**
371
+ * Escapes HTML special characters to prevent XSS
372
+ *
373
+ * @param {string} text - Text to escape
374
+ * @returns {string} - Escaped text
375
+ */
376
+ function escapeHtml(text) {
377
+ const div = document.createElement('div');
378
+ div.textContent = text;
379
+ return div.innerHTML;
380
+ }
381
+
382
+ /**
383
+ * Truncates text to a maximum length with ellipsis
384
+ *
385
+ * @param {string} text - Text to truncate
386
+ * @param {number} maxLength - Maximum length
387
+ * @returns {string} - Truncated text
388
+ */
389
+ function truncateText(text, maxLength) {
390
+ if (!text || text.length <= maxLength) return text || '';
391
+ return text.substring(0, maxLength) + '...';
392
+ }
393
+
394
+ /**
395
+ * Validates a single xref element against live data
396
+ *
397
+ * @param {HTMLElement} element - The xref anchor element
398
+ * @param {Map<string, Map<string, Object>>} liveData - Map of spec URLs to their live term data
399
+ * @param {Object} cachedXtref - The cached xtref data for this reference
400
+ */
401
+ function validateXref(element, liveData, cachedXtref) {
402
+ if (!cachedXtref || !cachedXtref.ghPageUrl) {
403
+ return; // No data to validate against
404
+ }
405
+
406
+ const liveTerms = liveData.get(cachedXtref.ghPageUrl);
407
+
408
+ // Check if we could fetch the live data
409
+ if (liveTerms === null) {
410
+ const indicator = createIndicator('error');
411
+ insertIndicatorAfterElement(element, indicator);
412
+ return;
413
+ }
414
+
415
+ if (!liveTerms) {
416
+ return; // Fetch is still pending or failed silently
417
+ }
418
+
419
+ const termKey = cachedXtref.term.toLowerCase();
420
+ const liveTerm = liveTerms.get(termKey);
421
+
422
+ // Check if term exists in live spec
423
+ if (!liveTerm) {
424
+ const indicator = createIndicator('missing');
425
+ insertIndicatorAfterElement(element, indicator);
426
+ return;
427
+ }
428
+
429
+ // Compare content using similarity threshold to avoid false positives
430
+ // Both cached and live content are reprocessed through markdown-it for consistency
431
+ const cachedNormalized = normalizeContent(cachedXtref.content);
432
+ const liveNormalized = normalizeContent(liveTerm.rawContent);
433
+
434
+ // Debug logging if enabled
435
+ if (VALIDATOR_CONFIG.debug) {
436
+ console.log('[External Ref Validator] Comparing xref:', cachedXtref.term);
437
+ console.log(' Cached length:', cachedNormalized.length, 'chars');
438
+ console.log(' Live length: ', liveNormalized.length, 'chars');
439
+ console.log(' Cached:', cachedNormalized.substring(0, 100));
440
+ console.log(' Live: ', liveNormalized.substring(0, 100));
441
+ if (cachedNormalized.length !== liveNormalized.length) {
442
+ console.log(' Length diff:', Math.abs(cachedNormalized.length - liveNormalized.length), 'chars');
443
+ }
444
+ }
445
+
446
+ // Calculate similarity between cached and live content
447
+ const similarity = calculateSimilarity(cachedNormalized, liveNormalized);
448
+
449
+ // Debug logging for similarity
450
+ if (VALIDATOR_CONFIG.debug) {
451
+ console.log(' Similarity:', (similarity * 100).toFixed(2) + '%');
452
+ }
453
+
454
+ // Only show changed indicator if similarity is below threshold (significant change)
455
+ if (similarity < VALIDATOR_CONFIG.similarityThreshold) {
456
+ const indicator = createIndicator('changed', {
457
+ oldContent: cachedXtref.content ? extractTextFromHtml(cachedXtref.content) : 'No cached content',
458
+ newContent: liveTerm.content || 'No live content',
459
+ similarity: (similarity * 100).toFixed(1) + '%'
460
+ });
461
+ insertIndicatorAfterElement(element, indicator);
462
+ return;
463
+ }
464
+
465
+ // Content is valid and unchanged
466
+ if (VALIDATOR_CONFIG.showValidIndicators) {
467
+ const indicator = createIndicator('valid');
468
+ insertIndicatorAfterElement(element, indicator);
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Validates a single tref element against live data
474
+ *
475
+ * @param {HTMLElement} element - The tref dt element
476
+ * @param {Map<string, Map<string, Object>>} liveData - Map of spec URLs to their live term data
477
+ * @param {Object} cachedXtref - The cached xtref data for this reference
478
+ */
479
+ function validateTref(element, liveData, cachedXtref) {
480
+ if (!cachedXtref || !cachedXtref.ghPageUrl) {
481
+ return; // No data to validate against
482
+ }
483
+
484
+ const liveTerms = liveData.get(cachedXtref.ghPageUrl);
485
+
486
+ // Check if we could fetch the live data
487
+ if (liveTerms === null) {
488
+ const indicator = createIndicator('error');
489
+ insertIndicatorIntoTref(element, indicator);
490
+ return;
491
+ }
492
+
493
+ if (!liveTerms) {
494
+ return; // Fetch is still pending or failed silently
495
+ }
496
+
497
+ const termKey = cachedXtref.term.toLowerCase();
498
+ const liveTerm = liveTerms.get(termKey);
499
+
500
+ // Check if term exists in live spec
501
+ if (!liveTerm) {
502
+ const indicator = createIndicator('missing');
503
+ insertIndicatorIntoTref(element, indicator);
504
+ return;
505
+ }
506
+
507
+ // Compare content using similarity threshold to avoid false positives
508
+ // Both cached and live content are reprocessed through markdown-it for consistency
509
+ const cachedNormalized = normalizeContent(cachedXtref.content);
510
+ const liveNormalized = normalizeContent(liveTerm.rawContent);
511
+
512
+ // Debug logging if enabled
513
+ if (VALIDATOR_CONFIG.debug) {
514
+ console.log('[External Ref Validator] Comparing tref:', cachedXtref.term);
515
+ console.log(' Cached length:', cachedNormalized.length, 'chars');
516
+ console.log(' Live length: ', liveNormalized.length, 'chars');
517
+ console.log(' Cached:', cachedNormalized.substring(0, 100));
518
+ console.log(' Live: ', liveNormalized.substring(0, 100));
519
+ if (cachedNormalized.length !== liveNormalized.length) {
520
+ console.log(' Length diff:', Math.abs(cachedNormalized.length - liveNormalized.length), 'chars');
521
+ }
522
+ }
523
+
524
+ // Calculate similarity between cached and live content
525
+ const similarity = calculateSimilarity(cachedNormalized, liveNormalized);
526
+
527
+ // Debug logging for similarity
528
+ if (VALIDATOR_CONFIG.debug) {
529
+ console.log(' Similarity:', (similarity * 100).toFixed(2) + '%');
530
+ }
531
+
532
+ // Only show changed indicator if similarity is below threshold (significant change)
533
+ if (similarity < VALIDATOR_CONFIG.similarityThreshold) {
534
+ const indicator = createIndicator('changed', {
535
+ oldContent: cachedXtref.content ? extractTextFromHtml(cachedXtref.content) : 'No cached content',
536
+ newContent: liveTerm.content || 'No live content',
537
+ similarity: (similarity * 100).toFixed(1) + '%'
538
+ });
539
+ insertIndicatorIntoTref(element, indicator);
540
+ return;
541
+ }
542
+
543
+ // Content is valid and unchanged
544
+ if (VALIDATOR_CONFIG.showValidIndicators) {
545
+ const indicator = createIndicator('valid');
546
+ insertIndicatorIntoTref(element, indicator);
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Extracts plain text from HTML content
552
+ *
553
+ * @param {string} html - HTML content
554
+ * @returns {string} - Plain text
555
+ */
556
+ function extractTextFromHtml(html) {
557
+ const div = document.createElement('div');
558
+ div.innerHTML = html;
559
+ return div.textContent || '';
560
+ }
561
+
562
+ /**
563
+ * Inserts an indicator element after an xref anchor
564
+ *
565
+ * @param {HTMLElement} element - The anchor element
566
+ * @param {HTMLElement} indicator - The indicator to insert
567
+ */
568
+ function insertIndicatorAfterElement(element, indicator) {
569
+ // Check if indicator already exists
570
+ if (element.nextElementSibling?.classList.contains(VALIDATOR_CONFIG.classes.indicator)) {
571
+ return; // Already has an indicator
572
+ }
573
+ element.insertAdjacentElement('afterend', indicator);
574
+ }
575
+
576
+ /**
577
+ * Inserts an indicator element into a tref dt element
578
+ *
579
+ * @param {HTMLElement} dtElement - The dt element
580
+ * @param {HTMLElement} indicator - The indicator to insert
581
+ */
582
+ function insertIndicatorIntoTref(dtElement, indicator) {
583
+ // Find the span with the term text
584
+ const termSpan = dtElement.querySelector('span.term-external');
585
+ if (!termSpan) {
586
+ return;
587
+ }
588
+
589
+ // Check if indicator already exists
590
+ if (termSpan.querySelector('.' + VALIDATOR_CONFIG.classes.indicator)) {
591
+ return; // Already has an indicator
592
+ }
593
+
594
+ termSpan.appendChild(indicator);
595
+ }
596
+
597
+ /**
598
+ * Collects all unique external specifications from the page
599
+ * by examining xref and tref elements
600
+ *
601
+ * @returns {Map<string, {url: string, specName: string}>} - Map of ghPageUrls to spec info
602
+ */
603
+ function collectExternalSpecs() {
604
+ const specs = new Map();
605
+
606
+ // Check if allXTrefs is available
607
+ if (typeof allXTrefs === 'undefined' || !allXTrefs?.xtrefs) {
608
+ console.warn('[External Ref Validator] allXTrefs data not available');
609
+ return specs;
610
+ }
611
+
612
+ // Collect unique specs from allXTrefs
613
+ allXTrefs.xtrefs.forEach(xtref => {
614
+ if (xtref.ghPageUrl && !specs.has(xtref.ghPageUrl)) {
615
+ specs.set(xtref.ghPageUrl, {
616
+ url: xtref.ghPageUrl,
617
+ specName: xtref.externalSpec
618
+ });
619
+ }
620
+ });
621
+
622
+ return specs;
623
+ }
624
+
625
+ /**
626
+ * Finds the cached xtref data for an xref element
627
+ *
628
+ * @param {HTMLElement} element - The xref anchor element
629
+ * @returns {Object|null} - The cached xtref data or null
630
+ */
631
+ function findCachedXtrefForXref(element) {
632
+ if (typeof allXTrefs === 'undefined' || !allXTrefs?.xtrefs) {
633
+ return null;
634
+ }
635
+
636
+ // Extract spec and term from data-local-href (format: #term:specName:termName)
637
+ const localHref = element.getAttribute('data-local-href') || '';
638
+ const match = localHref.match(/#term:([^:]+):(.+)/);
639
+
640
+ if (!match) {
641
+ return null;
642
+ }
643
+
644
+ const [, specName, termName] = match;
645
+
646
+ // Find matching xtref
647
+ return allXTrefs.xtrefs.find(x =>
648
+ x.externalSpec === specName &&
649
+ x.term.toLowerCase() === termName.toLowerCase()
650
+ );
651
+ }
652
+
653
+ /**
654
+ * Finds the cached xtref data for a tref element
655
+ *
656
+ * @param {HTMLElement} element - The tref dt element or its inner span
657
+ * @returns {Object|null} - The cached xtref data or null
658
+ */
659
+ function findCachedXtrefForTref(element) {
660
+ if (typeof allXTrefs === 'undefined' || !allXTrefs?.xtrefs) {
661
+ return null;
662
+ }
663
+
664
+ // Get the original term from data attribute
665
+ const termSpan = element.querySelector('[data-original-term]') ||
666
+ element.closest('[data-original-term]');
667
+
668
+ if (!termSpan) {
669
+ return null;
670
+ }
671
+
672
+ const originalTerm = termSpan.dataset.originalTerm;
673
+
674
+ // Find matching xtref that has a tref source file
675
+ return allXTrefs.xtrefs.find(x =>
676
+ x.term.toLowerCase() === originalTerm.toLowerCase() &&
677
+ x.sourceFiles?.some(sf => sf.type === 'tref')
678
+ );
679
+ }
680
+
681
+ /**
682
+ * Main validation function that orchestrates the entire validation process
683
+ * Called after DOM is loaded and trefs are inserted
684
+ */
685
+ async function validateExternalRefs() {
686
+ console.log('[External Ref Validator] Starting validation...');
687
+
688
+ // Collect unique external specs
689
+ const specs = collectExternalSpecs();
690
+
691
+ if (specs.size === 0) {
692
+ console.log('[External Ref Validator] No external specifications to validate');
693
+ return;
694
+ }
695
+
696
+ console.log(`[External Ref Validator] Found ${specs.size} external specification(s) to validate`);
697
+
698
+ // Fetch all external specs in parallel
699
+ const fetchPromises = Array.from(specs.entries()).map(async ([url, spec]) => {
700
+ const terms = await fetchExternalSpec(url, spec.specName);
701
+ return [url, terms];
702
+ });
703
+
704
+ const fetchResults = await Promise.all(fetchPromises);
705
+ const liveData = new Map(fetchResults);
706
+
707
+ // Validate all xref elements
708
+ const xrefElements = document.querySelectorAll('a.x-term-reference');
709
+ xrefElements.forEach(element => {
710
+ const cachedXtref = findCachedXtrefForXref(element);
711
+ if (cachedXtref) {
712
+ validateXref(element, liveData, cachedXtref);
713
+ }
714
+ });
715
+
716
+ // Validate all tref elements
717
+ const trefElements = document.querySelectorAll('dt.term-external');
718
+ trefElements.forEach(element => {
719
+ const cachedXtref = findCachedXtrefForTref(element);
720
+ if (cachedXtref) {
721
+ validateTref(element, liveData, cachedXtref);
722
+ }
723
+ });
724
+
725
+ console.log('[External Ref Validator] Validation complete');
726
+
727
+ // Dispatch event to signal validation is complete
728
+ document.dispatchEvent(new CustomEvent('external-refs-validated', {
729
+ detail: {
730
+ specsValidated: specs.size,
731
+ xrefsValidated: xrefElements.length,
732
+ trefsValidated: trefElements.length
733
+ }
734
+ }));
735
+ }
736
+
737
+ /**
738
+ * Initialize the validator when DOM and trefs are ready
739
+ * We wait for the 'trefs-inserted' event to ensure all content is in place
740
+ */
741
+ function initializeValidator() {
742
+ // Wait for trefs to be inserted first
743
+ document.addEventListener('trefs-inserted', () => {
744
+ // Small delay to ensure DOM is fully updated
745
+ setTimeout(validateExternalRefs, 100);
746
+ });
747
+
748
+ // Fallback: if trefs-inserted doesn't fire within 3 seconds, run anyway
749
+ setTimeout(() => {
750
+ // Check if we've already validated
751
+ if (document.querySelector('.' + VALIDATOR_CONFIG.classes.indicator)) {
752
+ return; // Already ran
753
+ }
754
+ console.warn('[External Ref Validator] trefs-inserted event not received, validating anyway');
755
+ validateExternalRefs();
756
+ }, 3000);
757
+ }
758
+
759
+ // Initialize when DOM is ready
760
+ if (document.readyState === 'loading') {
761
+ document.addEventListener('DOMContentLoaded', initializeValidator);
762
+ } else {
763
+ initializeValidator();
764
+ }