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.
- package/README.md +1 -0
- package/assets/compiled/body.js +2 -1
- package/assets/compiled/head.css +1 -0
- package/assets/css/validate-external-refs.css +369 -0
- package/assets/js/validate-external-refs.js +764 -0
- package/assets/js/validate-external-refs.test.js +577 -0
- package/config/asset-map.json +2 -0
- package/package.json +1 -1
- package/src/install-from-boilerplate/boilerplate/spec/terms-and-definitions-intro.md +4 -0
- package/src/install-from-boilerplate/boilerplate/spec/terms-definitions/composability.md +4 -2
- package/src/install-from-boilerplate/boilerplate/spec/terms-definitions/dormancy.md +5 -0
- package/src/install-from-boilerplate/boilerplate/spec/terms-definitions/greenhouse.md +5 -0
- package/src/install-from-boilerplate/boilerplate/spec/terms-definitions/soil.md +4 -2
- package/src/install-from-boilerplate/boilerplate/specs.json +6 -18
- package/src/pipeline/references/external-references-service.js +12 -4
|
@@ -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
|
+
}
|