diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..fe4b72d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,93 @@ +name: Custom Metrics Documentation +run-name: Documentation Check & Sync (${{ github.ref_name }}) + +on: + push: + branches: + - main + paths: + - 'dist/**' + - 'bin/**' + - '.github/workflows/docs.yml' + pull_request: + branches: + - main + paths: + - 'dist/**' + - 'bin/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +jobs: + validate: + name: Validate JSDoc Parity + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Validate JSDoc vs Code + run: npm run validate:docs + + sync: + name: Sync Documentation to har.fyi + needs: validate + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + steps: + - name: Checkout custom-metrics + uses: actions/checkout@v4 + with: + path: custom-metrics + + - name: Checkout har.fyi + uses: actions/checkout@v4 + with: + repository: HTTPArchive/har.fyi + token: ${{ secrets.DOCS_SYNC_PAT || secrets.GITHUB_TOKEN }} + path: har.fyi + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: custom-metrics/package-lock.json + + - name: Install Dependencies + run: | + cd custom-metrics + npm ci + + - name: Generate MDX Documentation + run: | + cd custom-metrics + node bin/generate-docs.js --out ../har.fyi/src/content/docs/reference/custom-metrics + + - name: Create Pull Request in har.fyi + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.DOCS_SYNC_PAT || secrets.GITHUB_TOKEN }} + path: har.fyi + branch: sync-custom-metrics + base: main + delete-branch: true + title: "docs: sync custom metrics reference from custom-metrics" + body: | + Automated documentation sync from [custom-metrics commit ${{ github.sha }}][commit]. + + Generated by `HTTPArchive/custom-metrics` workflow. + + [commit]: https://github.com/HTTPArchive/custom-metrics/commit/${{ github.sha }} + commit-message: "docs: sync custom metrics from custom-metrics@${{ github.sha }}" + + diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 61883c7..a055604 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -68,3 +68,23 @@ jobs: VALIDATE_EDITORCONFIG: true VALIDATE_MARKDOWN: true VALIDATE_YAML: true + + validate-docs: + name: Validate JSDoc Parity + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install Dependencies + run: npm ci + + - name: Validate JSDoc vs Code + run: npm run validate:docs + diff --git a/bin/generate-docs.js b/bin/generate-docs.js new file mode 100644 index 0000000..d656645 --- /dev/null +++ b/bin/generate-docs.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + TOP_LEVEL_METRICS, + normalizeType, + extractCustomTypeName, + cleanDescription +} = require('./lib/types.js'); +const { + getPrimaryTypedef +} = require('./lib/jsdoc-parser.js'); +const { + getAnnotatedMetricFiles +} = require('./lib/file-utils.js'); +const { validateMetric } = require('./validate-docs.js'); + +/** + * Recursively renders schema properties down to basic types in markdown format. + */ +function renderProperties(properties, prefix = '', headingLevel = 3, typedefs = new Map(), visited = new Set()) { + let mdx = ''; + const hashes = '#'.repeat(headingLevel); + + for (const prop of properties) { + const rawType = prop.type || 'unknown'; + const customTypeName = extractCustomTypeName(rawType, typedefs); + const isArray = rawType.includes('[]') || /Array' : 'object') + : normalizedType; + + const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name; + + mdx += `${hashes} \`${fullPath}\`\n\n`; + mdx += `Type: \`${displayType}\`\n\n`; + mdx += `${cleanDescription(prop.description)}\n\n`; + + if (customTypeName && typedefs.has(customTypeName) && !visited.has(customTypeName)) { + const nestedTypedef = typedefs.get(customTypeName); + const nextPrefix = isArray ? `${fullPath}[i]` : fullPath; + const nextVisited = new Set(visited).add(customTypeName); + mdx += renderProperties(nestedTypedef.properties, nextPrefix, headingLevel + 1, typedefs, nextVisited); + } + } + + return mdx; +} + +/** + * Generates Starlight-compliant MDX content from parsed JSDoc typedefs. + */ +function generateMDX(metricName, typedefs) { + const primaryTypedef = getPrimaryTypedef(metricName, typedefs); + const capitalizedName = metricName.charAt(0).toUpperCase() + metricName.slice(1); + const isTopLevel = TOP_LEVEL_METRICS.has(metricName); + + const parentLink = isTopLevel + ? `_Appears in: [\`custom_metrics\`](/reference/structs/custom-metrics/) struct_\\\n_As: [\`${metricName}\`](/reference/structs/custom-metrics/#${metricName})_` + : `_Appears in: [\`custom_metrics.other\`](/reference/custom-metrics/other/) struct_\\\n_As: [\`${metricName}\`](/reference/custom-metrics/other/#${metricName})_`; + + let mdx = `--- +title: ${capitalizedName} custom metric +description: Reference docs for the ${metricName} custom metric +--- + +${parentLink} + +## Schema + +`; + + mdx += renderProperties(primaryTypedef.properties, '', 3, typedefs); + + return mdx; +} + +// CLI execution +if (require.main === module) { + const args = process.argv.slice(2); + let outDir = path.join(__dirname, '../../har.fyi/src/content/docs/reference/custom-metrics'); + let explicitFiles = []; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--out' && args[i + 1]) { + outDir = path.resolve(args[i + 1]); + i++; + } else if (!args[i].startsWith('--')) { + explicitFiles.push(path.resolve(args[i])); + } + } + + const targetFiles = explicitFiles.length > 0 ? explicitFiles : getAnnotatedMetricFiles(); + + fs.mkdirSync(outDir, { recursive: true }); + + for (const file of targetFiles) { + const metricName = path.basename(file, '.js'); + console.log(`Generating docs for ${metricName}...`); + + const validation = validateMetric(file); + if (!validation.valid) { + console.error(`❌ Validation failed for ${file}. Docs generation aborted:`); + for (const err of validation.errors) { + console.error(` - ${err}`); + } + process.exit(1); + } + + const isTopLevel = TOP_LEVEL_METRICS.has(metricName); + const targetDir = isTopLevel ? outDir : path.join(outDir, 'other'); + fs.mkdirSync(targetDir, { recursive: true }); + + const mdxContent = generateMDX(metricName, validation.typedefs); + const outFile = path.join(targetDir, `${metricName}.mdx`); + fs.writeFileSync(outFile, mdxContent, 'utf8'); + console.log(`✅ Generated ${outFile}\n`); + } +} + +module.exports = { + generateMDX, + renderProperties +}; diff --git a/bin/lib/ast-extractor.js b/bin/lib/ast-extractor.js new file mode 100644 index 0000000..87d8e13 --- /dev/null +++ b/bin/lib/ast-extractor.js @@ -0,0 +1,266 @@ +'use strict'; + +const parser = require('@babel/parser'); +const traverse = require('@babel/traverse').default; + +/** + * Infers primitive data type from an AST node expression. + */ +function inferTypeFromNode(node) { + if (!node) return null; + + if (node.type === 'BooleanLiteral') return 'boolean'; + if (node.type === 'NumericLiteral') { + return Number.isInteger(node.value) ? 'integer' : 'number'; + } + if (node.type === 'StringLiteral' || node.type === 'TemplateLiteral') return 'string'; + if (node.type === 'NullLiteral') return 'null'; + if (node.type === 'ArrayExpression') return 'array'; + if (node.type === 'ObjectExpression') return 'object'; + + if (node.type === 'UnaryExpression') { + if (node.operator === '!') return 'boolean'; + if (node.operator === '+' || node.operator === '-') return 'number'; + if (node.operator === 'typeof') return 'string'; + } + + if (node.type === 'BinaryExpression') { + if (['===', '!==', '==', '!=', '<', '<=', '>', '>=', 'instanceof', 'in'].includes(node.operator)) { + return 'boolean'; + } + if (['+', '-', '*', '/', '%'].includes(node.operator)) { + return 'number'; + } + } + + if (node.type === 'MemberExpression' && node.property.type === 'Identifier') { + const prop = node.property.name; + if (['redirected', 'ok', 'bodyUsed'].includes(prop)) return 'boolean'; + if (['status', 'length', 'size', 'count'].includes(prop)) return 'integer'; + if (['url', 'statusText', 'name', 'message'].includes(prop)) return 'string'; + } + + if (node.type === 'CallExpression') { + if (node.callee.type === 'Identifier') { + const fn = node.callee.name; + if (['isPresent', 'Boolean'].includes(fn)) return 'boolean'; + if (['parseInt', 'Math.floor', 'Math.round', 'Math.ceil'].includes(fn)) return 'integer'; + if (['Number', 'parseFloat'].includes(fn)) return 'number'; + if (['String'].includes(fn)) return 'string'; + } else if (node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier') { + const method = node.callee.property.name; + if (['includes', 'some', 'every', 'startsWith', 'endsWith', 'has'].includes(method)) return 'boolean'; + if (['toLowerCase', 'toUpperCase', 'trim', 'trimStart', 'trimEnd', 'substring', 'substr'].includes(method)) return 'string'; + if (['split', 'slice', 'concat', 'filter', 'map'].includes(method)) return 'array'; + if (node.callee.object && node.callee.object.name === 'Array' && method === 'from') return 'array'; + if (node.callee.object && node.callee.object.name === 'JSON' && method === 'stringify') return 'string'; + if (node.callee.object && node.callee.object.name === 'JSON' && method === 'parse') return 'object'; + } + } + + if (node.type === 'ConditionalExpression') { + const consType = inferTypeFromNode(node.consequent); + const altType = inferTypeFromNode(node.alternate); + if (consType && altType && consType === altType) return consType; + if (consType && altType) return `${consType}|${altType}`; + return consType || altType || null; + } + + return null; +} + +/** + * Extracts object property keys from an AST ObjectExpression node. + * Handles spread elements if the referenced identifier is found in variable declarations. + */ +function extractObjectKeys(objNode, scopeBindings = {}, propertyTypes = {}) { + const keys = new Set(); + + for (const prop of objNode.properties) { + if (prop.type === 'ObjectProperty') { + let keyName = null; + if (prop.key.type === 'Identifier') { + keyName = prop.key.name; + } else if (prop.key.type === 'StringLiteral') { + keyName = prop.key.value; + } + + if (keyName) { + keys.add(keyName); + const inferred = inferTypeFromNode(prop.value); + if (inferred && !propertyTypes[keyName]) { + propertyTypes[keyName] = inferred; + } + } + } else if (prop.type === 'SpreadElement') { + if (prop.argument.type === 'Identifier') { + const idName = prop.argument.name; + if (scopeBindings[idName]) { + const spreadKeys = extractObjectKeys(scopeBindings[idName], scopeBindings, propertyTypes); + for (const k of spreadKeys) keys.add(k); + } + } else if (prop.argument.type === 'ObjectExpression') { + const spreadKeys = extractObjectKeys(prop.argument, scopeBindings, propertyTypes); + for (const k of spreadKeys) keys.add(k); + } + } + } + + return keys; +} + +/** + * Parses JavaScript code and extracts returned top-level keys, nested IIFE object properties, + * and statically inferred property types. + */ +function extractReturnKeysAndNested(code) { + const ast = parser.parse(code, { + sourceType: 'script', + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + plugins: [] + }); + + const scopeBindings = {}; + const returnedKeys = new Set(); + const nestedReturnKeys = {}; + const propertyTypes = {}; + + function extractKeysFromNode(node) { + const keys = new Set(); + if (!node) return keys; + + if (node.type === 'ObjectExpression') { + const objKeys = extractObjectKeys(node, scopeBindings, propertyTypes); + for (const k of objKeys) keys.add(k); + } else if (node.type === 'CallExpression') { + const callee = node.callee; + if (callee.type === 'ArrowFunctionExpression' || callee.type === 'FunctionExpression') { + traverse(callee, { + noScope: true, + ObjectExpression(path) { + const objKeys = extractObjectKeys(path.node, scopeBindings, propertyTypes); + for (const k of objKeys) keys.add(k); + }, + AssignmentExpression(path) { + const left = path.node.left; + if (left.type === 'MemberExpression' && left.property.type === 'Identifier') { + const keyName = left.property.name; + keys.add(keyName); + const inferred = inferTypeFromNode(path.node.right); + if (inferred && !propertyTypes[keyName]) { + propertyTypes[keyName] = inferred; + } + } + } + }); + } + } + return keys; + } + + traverse(ast, { + VariableDeclarator(p) { + if (p.node.id.type === 'Identifier' && p.node.init && p.node.init.type === 'ObjectExpression') { + scopeBindings[p.node.id.name] = p.node.init; + extractObjectKeys(p.node.init, scopeBindings, propertyTypes); + + // Extract nested keys for each object property + for (const prop of p.node.init.properties) { + if (prop.type === 'ObjectProperty' && prop.key.name) { + const innerKeys = extractKeysFromNode(prop.value); + if (innerKeys.size > 0) { + nestedReturnKeys[prop.key.name] = Array.from(innerKeys); + } + } + } + } + }, + AssignmentExpression(p) { + if (p.node.left.type === 'MemberExpression' && p.node.left.property.type === 'Identifier') { + const keyName = p.node.left.property.name; + const inferred = inferTypeFromNode(p.node.right); + if (inferred && !propertyTypes[keyName]) { + propertyTypes[keyName] = inferred; + } + } + }, + ReturnStatement(p) { + // Only process top-level return statements in the WPT script + if (p.parent.type !== 'Program') return; + + const arg = p.node.argument; + if (!arg) return; + + function extractFromExpression(expr) { + if (!expr) return; + + // Pattern: JSON.stringify(OBJ) + if ( + expr.type === 'CallExpression' && + expr.callee.type === 'MemberExpression' && + expr.callee.object.name === 'JSON' && + expr.callee.property.name === 'stringify' && + expr.arguments.length > 0 + ) { + const jsonArg = expr.arguments[0]; + if (jsonArg.type === 'ObjectExpression') { + const keys = extractObjectKeys(jsonArg, scopeBindings, propertyTypes); + for (const k of keys) returnedKeys.add(k); + } else if (jsonArg.type === 'Identifier' && scopeBindings[jsonArg.name]) { + const keys = extractObjectKeys(scopeBindings[jsonArg.name], scopeBindings, propertyTypes); + for (const k of keys) returnedKeys.add(k); + } + } + // Pattern: Promise chains (.then, .catch, .finally) + else if ( + expr.type === 'CallExpression' && + expr.callee.type === 'MemberExpression' && + ['then', 'catch', 'finally'].includes(expr.callee.property.name) + ) { + const methodName = expr.callee.property.name; + + // If .then(), extract return from primary success callback + if (methodName === 'then' && expr.arguments.length > 0) { + const thenCallback = expr.arguments[0]; + if (thenCallback && (thenCallback.type === 'ArrowFunctionExpression' || thenCallback.type === 'FunctionExpression')) { + if (thenCallback.body.type === 'BlockStatement') { + for (const stmt of thenCallback.body.body) { + if (stmt.type === 'ReturnStatement') { + extractFromExpression(stmt.argument); + } + } + } else { + extractFromExpression(thenCallback.body); + } + } + } + + // Recursively traverse up the promise chain + if (expr.callee.object) { + extractFromExpression(expr.callee.object); + } + } + // Direct ObjectExpression + else if (expr.type === 'ObjectExpression') { + const keys = extractObjectKeys(expr, scopeBindings, propertyTypes); + for (const k of keys) returnedKeys.add(k); + } + } + + extractFromExpression(arg); + } + }); + + return { + returnedKeys: Array.from(returnedKeys), + nestedReturnKeys, + propertyTypes + }; +} + +module.exports = { + inferTypeFromNode, + extractObjectKeys, + extractReturnKeysAndNested +}; diff --git a/bin/lib/file-utils.js b/bin/lib/file-utils.js new file mode 100644 index 0000000..8f8d2b4 --- /dev/null +++ b/bin/lib/file-utils.js @@ -0,0 +1,66 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// Whitelist of metric files required to have JSDoc documentation. +// Set to null or ['*'] to require JSDoc for all files in dist/. +const REQUIRED_METRICS = [ + 'privacy.js', + 'ads.js' +]; + +/** + * Returns all metric JS files in dist/ that contain JSDoc *Metrics typedefs. + */ +function getAnnotatedMetricFiles(distDir = path.join(__dirname, '../../dist')) { + const files = fs.readdirSync(distDir).filter(f => f.endsWith('.js')); + const annotated = []; + const metricsTypedefRegex = /@typedef\s+\{[^}]+\}\s+\w+Metrics/i; + + for (const f of files) { + const fullPath = path.join(distDir, f); + const content = fs.readFileSync(fullPath, 'utf8'); + if (metricsTypedefRegex.test(content)) { + annotated.push(fullPath); + } + } + return annotated; +} + +/** + * Validates that all required metrics in dist/ have JSDoc metric documentation. + */ +function validateMetricCoverage(distDir = path.join(__dirname, '../../dist'), requiredList = REQUIRED_METRICS) { + const allFiles = fs.readdirSync(distDir).filter(f => f.endsWith('.js') && f !== '00_reset.js'); + const requiredFiles = (requiredList === null || requiredList.includes('*')) + ? allFiles + : requiredList; + + const errors = []; + const metricsTypedefRegex = /@typedef\s+\{[^}]+\}\s+\w+Metrics/i; + + for (const fileName of requiredFiles) { + const filePath = path.join(distDir, fileName); + if (!fs.existsSync(filePath)) { + errors.push(`Required metric file does not exist: dist/${fileName}`); + continue; + } + const content = fs.readFileSync(filePath, 'utf8'); + if (!metricsTypedefRegex.test(content)) { + errors.push(`dist/${fileName} is missing top-level JSDoc @typedef {Object} Metrics documentation.`); + } + } + + return { + valid: errors.length === 0, + errors, + targetFiles: requiredFiles.map(f => path.join(distDir, f)) + }; +} + +module.exports = { + REQUIRED_METRICS, + getAnnotatedMetricFiles, + validateMetricCoverage +}; diff --git a/bin/lib/jsdoc-parser.js b/bin/lib/jsdoc-parser.js new file mode 100644 index 0000000..c933fe1 --- /dev/null +++ b/bin/lib/jsdoc-parser.js @@ -0,0 +1,93 @@ +'use strict'; + +const { parse: parseJSDoc } = require('comment-parser'); + +/** + * Parses JSDoc blocks in code and extracts all @typedef structures and @property tags. + */ +function parseJSDocTypedefs(code) { + const parsedComments = parseJSDoc(code, { spacing: 'preserve' }); + const typedefs = new Map(); + + for (const block of parsedComments) { + const typedefTag = block.tags.find(t => t.tag === 'typedef'); + if (!typedefTag) continue; + + const typeName = typedefTag.name; + const typeDesc = block.description || typedefTag.description || ''; + const properties = []; + + for (const tag of block.tags) { + if (tag.tag === 'property' || tag.tag === 'prop') { + properties.push({ + name: tag.name, + type: tag.type, + description: tag.description.trim(), + optional: tag.optional + }); + } + } + + typedefs.set(typeName, { + name: typeName, + description: typeDesc.trim(), + properties + }); + } + + return typedefs; +} + +/** + * Resolves the primary top-level typedef representing the metric entrypoint. + */ +function getPrimaryTypedef(metricName, typedefs) { + const metricBase = metricName.toLowerCase().replace(/[^a-z0-9]/g, ''); + + for (const [name, td] of typedefs) { + const cleanName = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (cleanName.includes(metricBase) && cleanName.includes('metric')) { + return td; + } + } + + for (const td of typedefs.values()) { + if (td.name.toLowerCase().endsWith('metrics')) { + return td; + } + } + + return Array.from(typedefs.values())[0] || null; +} + +/** + * Recursively collects all documented property names for a typedef and its referenced subtypes. + */ +function getAllDocumentedKeysForTypedef(typeName, typedefs, visited = new Set()) { + const keys = new Set(); + if (!typedefs.has(typeName) || visited.has(typeName)) return keys; + visited.add(typeName); + + const td = typedefs.get(typeName); + for (const prop of td.properties) { + keys.add(prop.name); + if (prop.type) { + const clean = prop.type.replace(/^{|}$/g, ''); + const parts = clean.split('|').map(p => p.trim()); + for (const part of parts) { + const childTypeName = part.replace(/^Array<(.+)>$/i, '$1').replace(/\[\]$/, '').trim(); + if (typedefs.has(childTypeName)) { + const childKeys = getAllDocumentedKeysForTypedef(childTypeName, typedefs, visited); + for (const ck of childKeys) keys.add(ck); + } + } + } + } + return keys; +} + +module.exports = { + parseJSDocTypedefs, + getPrimaryTypedef, + getAllDocumentedKeysForTypedef +}; diff --git a/bin/lib/types.js b/bin/lib/types.js new file mode 100644 index 0000000..975c0e6 --- /dev/null +++ b/bin/lib/types.js @@ -0,0 +1,127 @@ +'use strict'; + +const BASIC_TYPES = new Set([ + 'string', + 'number', + 'integer', + 'boolean', + 'object', + 'any', + 'unknown', + 'null', + 'undefined' +]); + +const TOP_LEVEL_METRICS = new Set([ + 'a11y', + 'cms', + 'cookies', + 'css_variables', + 'ecommerce', + 'element_count', + 'javascript', + 'markup', + 'media', + 'origin_trials', + 'performance', + 'privacy', + 'responsive_images', + 'robots_txt', + 'security', + 'structured_data', + 'third_parties', + 'well_known', + 'wpt_bodies', + 'other' +]); + +/** + * Normalizes JSDoc type strings to Starlight / har.fyi standard types. + */ +function normalizeType(typeStr) { + if (!typeStr) return 'unknown'; + + let clean = typeStr.trim(); + if (clean.startsWith('{') && clean.endsWith('}')) { + clean = clean.slice(1, -1).trim(); + } + + // Unwrap union types with null / undefined first + const parts = clean.split('|').map(p => p.trim()).filter(p => p !== 'null' && p !== 'undefined'); + if (parts.length === 1) { + clean = parts[0]; + } + + // Handle Object. / Record + if (/^Object\.<[^>]+>$/i.test(clean) || /^Record<[^>]+>$/i.test(clean)) { + return 'object'; + } + + // Handle Array or T[] + if (clean.endsWith('[]')) { + const inner = clean.slice(0, -2); + return `array<${normalizeType(inner)}>`; + } + if (/^Array<(.+)>$/i.test(clean)) { + const match = clean.match(/^Array<(.+)>$/i); + return `array<${normalizeType(match[1])}>`; + } + + const lower = clean.toLowerCase(); + if (['string', 'boolean', 'number', 'integer', 'object'].includes(lower)) { + return lower; + } + + return clean; +} + +/** + * Extracts the custom typedef name from a raw type string if present in typedefs map. + */ +function extractCustomTypeName(rawType, typedefs) { + if (!rawType) return null; + const parts = rawType.replace(/^{|}$/g, '').split('|').map(p => p.trim()).filter(p => p !== 'null' && p !== 'undefined'); + for (const part of parts) { + const unwrapped = part.replace(/^Array<(.+)>$/i, '$1').replace(/\[\]$/, '').trim(); + if (typedefs && typedefs.has(unwrapped)) { + return unwrapped; + } + } + return null; +} + +/** + * Checks whether an inferred AST type is compatible with the documented JSDoc type. + */ +function isTypeCompatible(inferredType, docType) { + if (!inferredType || !docType) return true; + + const cleanDoc = docType.replace(/^{|}$/g, '').trim(); + const docParts = cleanDoc.split('|').map(p => p.trim().toLowerCase()); + const inferredParts = inferredType.split('|').map(p => p.trim().toLowerCase()); + + return inferredParts.every(inf => { + if (inf === 'null') return docParts.includes('null') || docParts.includes('undefined'); + if (inf === 'boolean') return docParts.includes('boolean'); + if (inf === 'integer') return docParts.includes('integer') || docParts.includes('number'); + if (inf === 'number') return docParts.includes('number') || docParts.includes('integer') || docParts.includes('float'); + if (inf === 'string') return docParts.includes('string'); + if (inf === 'array') return docParts.some(p => p.startsWith('array') || p.endsWith('[]')); + if (inf === 'object') return docParts.some(p => p === 'object' || !BASIC_TYPES.has(p)); + return true; + }); +} + +function cleanDescription(desc) { + if (!desc) return ''; + return desc.replace(/^-\s*/, '').trim(); +} + +module.exports = { + BASIC_TYPES, + TOP_LEVEL_METRICS, + normalizeType, + extractCustomTypeName, + isTypeCompatible, + cleanDescription +}; diff --git a/bin/validate-docs.js b/bin/validate-docs.js new file mode 100644 index 0000000..fafdc55 --- /dev/null +++ b/bin/validate-docs.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { BASIC_TYPES, isTypeCompatible } = require('./lib/types.js'); +const { + parseJSDocTypedefs, + getPrimaryTypedef, + getAllDocumentedKeysForTypedef +} = require('./lib/jsdoc-parser.js'); +const { extractReturnKeysAndNested } = require('./lib/ast-extractor.js'); +const { + REQUIRED_METRICS, + getAnnotatedMetricFiles, + validateMetricCoverage +} = require('./lib/file-utils.js'); + +/** + * Validates bidirectional parity between JavaScript code and JSDoc annotations. + */ +function validateMetric(filePath) { + const fileName = path.basename(filePath); + const metricName = path.basename(filePath, '.js'); + const code = fs.readFileSync(filePath, 'utf8'); + + const typedefs = parseJSDocTypedefs(code); + const { returnedKeys, nestedReturnKeys, propertyTypes } = extractReturnKeysAndNested(code); + const errors = []; + + if (typedefs.size === 0) { + errors.push(`No JSDoc @typedef found in ${fileName}. Please document the metric with @typedef and @property.`); + return { valid: false, errors, typedefs, returnedKeys, nestedReturnKeys }; + } + + const primaryTypedef = getPrimaryTypedef(metricName, typedefs); + if (!primaryTypedef) { + errors.push(`Could not determine primary @typedef for ${metricName} in ${fileName}.`); + return { valid: false, errors, typedefs, returnedKeys, nestedReturnKeys }; + } + + const documentedPropertyMap = new Map(primaryTypedef.properties.map(p => [p.name, p])); + + // Check 1: All returned keys from code must be documented in primary typedef + for (const key of returnedKeys) { + if (!documentedPropertyMap.has(key)) { + errors.push(`Missing JSDoc documentation for returned property: "${key}"`); + } + } + + // Check 2: No stale properties documented in primary typedef that aren't returned + if (returnedKeys.length > 0) { + for (const [propName] of documentedPropertyMap) { + if (!returnedKeys.includes(propName)) { + errors.push(`Stale JSDoc property: "${propName}" is documented in @typedef ${primaryTypedef.name} but not returned in code.`); + } + } + } + + // Check 3: Property completeness, recursive type validity, and AST static type compatibility + for (const [typeName, typedef] of typedefs) { + for (const prop of typedef.properties) { + if (!prop.type) { + errors.push(`Property "${prop.name}" in @typedef ${typeName} is missing a type definition.`); + continue; + } + if (!prop.description || prop.description.trim() === '') { + errors.push(`Property "${prop.name}" in @typedef ${typeName} is missing a description.`); + } + + // Check referenced types in unions / arrays + const cleanType = prop.type.replace(/^{|}$/g, ''); + const typeParts = cleanType.split('|').map(p => p.trim()); + + for (const part of typeParts) { + if (/^Object\.<[^>]+>$/i.test(part) || /^Record<[^>]+>$/i.test(part)) continue; + const unwrapArray = part.replace(/^Array<(.+)>$/i, '$1').replace(/\[\]$/, '').trim(); + const lower = unwrapArray.toLowerCase(); + + if (!BASIC_TYPES.has(lower) && !typedefs.has(unwrapArray)) { + errors.push( + `Property "${prop.name}" in @typedef ${typeName} references custom type "${unwrapArray}", but no @typedef for "${unwrapArray}" is defined in the file.` + ); + } + } + + // Check AST static type compatibility if inferred from code + const inferred = propertyTypes[prop.name]; + if (inferred && !isTypeCompatible(inferred, prop.type)) { + errors.push( + `Type mismatch for property "${prop.name}" in @typedef ${typeName}: code assigns ${inferred}, but JSDoc documents ${prop.type}.` + ); + } + } + } + + // Check 4: Nested object parity (validate IIFE/object returned keys against referenced sub-typedefs) + for (const [propName, subKeys] of Object.entries(nestedReturnKeys)) { + const parentProp = documentedPropertyMap.get(propName); + if (!parentProp || !parentProp.type) continue; + + const cleanType = parentProp.type.replace(/^{|}$/g, ''); + const parts = cleanType.split('|').map(p => p.trim()).filter(p => p !== 'null' && p !== 'undefined'); + + for (const part of parts) { + const customTypeName = part.replace(/^Array<(.+)>$/i, '$1').replace(/\[\]$/, '').trim(); + if (typedefs.has(customTypeName)) { + const allDocKeys = getAllDocumentedKeysForTypedef(customTypeName, typedefs); + + // Check each returned key in code is in sub-typedef tree + for (const subKey of subKeys) { + if (!allDocKeys.has(subKey)) { + errors.push(`Missing JSDoc documentation in @typedef ${customTypeName} (or child typedefs) for nested property "${propName}.${subKey}".`); + } + } + + // Check each documented key in sub-typedef tree is returned in code + for (const docSubKey of allDocKeys) { + if (!subKeys.includes(docSubKey)) { + errors.push(`Stale JSDoc property "${docSubKey}" in @typedef ${customTypeName} is not returned by "${propName}" in code.`); + } + } + } + } + } + + return { + valid: errors.length === 0, + errors, + typedefs, + returnedKeys, + nestedReturnKeys + }; +} + +// CLI execution +if (require.main === module) { + const args = process.argv.slice(2); + let totalErrors = 0; + + // Step 1: Check JSDoc Coverage if running general validation + if (args.length === 0) { + console.log('Checking JSDoc coverage for required metrics...'); + const coverage = validateMetricCoverage(); + if (!coverage.valid) { + console.error('❌ JSDoc coverage check failed:'); + for (const err of coverage.errors) { + console.error(` - ${err}`); + } + console.error(''); + totalErrors += coverage.errors.length; + } else { + console.log(`✅ All required metrics covered by JSDoc (${coverage.targetFiles.length} file(s)).\n`); + } + } + + // Step 2: Validate JSDoc Parity vs Code AST + const targetFiles = args.length > 0 + ? args.map(f => path.resolve(f)) + : getAnnotatedMetricFiles(); + + for (const file of targetFiles) { + const relPath = path.relative(process.cwd(), file); + console.log(`Validating JSDoc parity for ${relPath}...`); + const result = validateMetric(file); + + if (result.valid) { + console.log(`✅ ${relPath} passed parity validation (${result.returnedKeys.length} keys documented).\n`); + } else { + console.error(`❌ ${relPath} failed parity validation:`); + for (const err of result.errors) { + console.error(` - ${err}`); + } + console.error(''); + totalErrors += result.errors.length; + } + } + + process.exit(totalErrors === 0 ? 0 : 1); +} + +module.exports = { + REQUIRED_METRICS, + validateMetric, + validateMetricCoverage +}; diff --git a/dist/ads.js b/dist/ads.js index d647d32..7d285b3 100644 --- a/dist/ads.js +++ b/dist/ads.js @@ -2,15 +2,20 @@ const SELLER_TYPES = ['publisher', 'intermediary', 'both']; +/** + * @param {Response} response + * @param {string[]} endings + * @returns {boolean} + */ const isPresent = (response, endings) => response.ok && endings.some(ending => response.url.endsWith(ending)); +/** + * Google's sellers.json size is 120Mb as of May 2024 - too big for custom metrics. + * It's available at realtimebidding.google.com/sellers.json, so not part of crawled pages list. + * More details: https://support.google.com/authorizedbuyers/answer/9895942 + */ const fetchAndParse = async (url, parser) => { const timeout = 5000; - /* - Google's sellers.json size is 120Mb as of May 2024 - too big for custom metrics. - It's available at realtimebidding.google.com/sellers.json, so not part of crawled pages list. - More details: https://support.google.com/authorizedbuyers/answer/9895942 - */ const controller = new AbortController(); const { signal } = controller; setTimeout(() => controller.abort(), timeout); @@ -27,7 +32,35 @@ const fetchAndParse = async (url, parser) => { } }; -// https://iabtechlab.com/wp-content/uploads/2022/04/Ads.txt-1.1.pdf +/** + * @typedef {Object} AdsAccountTypeInfo + * @property {string[]} domains - List of domains with advertising accounts of this type. + * @property {integer} account_count - Number of advertising accounts of this type. + * @property {integer} domain_count - Number of unique domains with advertising accounts of this type. + */ + +/** + * @typedef {Object} AdsAccountTypes + * @property {AdsAccountTypeInfo} direct - Information about direct advertising accounts. + * @property {AdsAccountTypeInfo} reseller - Information about reseller advertising accounts. + */ + +/** + * Ads.txt / App-ads.txt response data + * + * @typedef {Object} AdsTxtData + * @property {boolean} present - Indicates if the ads.txt or app-ads.txt file is present. + * @property {integer} status - HTTP status code of the ads.txt file response. + * @property {boolean} redirected - Indicates if the ads.txt file request was redirected. + * @property {string|null} [redirected_to] - URL to which the ads.txt resource was redirected. + * @property {integer} [account_count] - Number of advertising accounts listed in the ads.txt file. + * @property {AdsAccountTypes} [account_types] - Types of accounts (direct or reseller) listed in the ads.txt file. + * @property {integer} [line_count] - Total number of lines in the ads.txt file. + * @property {string[]} [variables] - List of variables found in the ads.txt file. + * @property {integer} [variable_count] - Number of variables found in the ads.txt file. + * @property {string} [error] - Error message if fetch or parse failed. + */ + const parseAdsTxt = async (response) => { let content = await response.text(); @@ -100,7 +133,35 @@ const parseAdsTxt = async (response) => { } -// https://iabtechlab.com/wp-content/uploads/2019/07/Sellers.json_Final.pdf +/** + * @typedef {Object} SellerTypeInfo + * @property {string[]} domains - List of domains associated with this seller type. + * @property {integer} seller_count - Number of sellers of this type. + * @property {integer} domain_count - Number of unique domains associated with this seller type. + */ + +/** + * @typedef {Object} SellerTypes + * @property {SellerTypeInfo} publisher - Information about publisher sellers. + * @property {SellerTypeInfo} intermediary - Information about intermediary sellers. + * @property {SellerTypeInfo} both - Information about sellers who are both publishers and intermediaries. + */ + +/** + * Sellers.json response data + * + * @typedef {Object} SellersJsonData + * @property {boolean} present - Indicates if the sellers.json file is present. + * @property {integer} status - HTTP status code of the sellers.json file response. + * @property {boolean} redirected - Indicates if the sellers.json file request was redirected. + * @property {string|null} [redirected_to] - URL to which the sellers.json resource was redirected. + * @property {integer} [seller_count] - Number of sellers listed in the sellers.json file. + * @property {SellerTypes} [seller_types] - Types of sellers (publisher, intermediary, both) listed in the sellers.json file. + * @property {integer} [passthrough_count] - Number of passthrough sellers listed in the sellers.json file. + * @property {integer} [confidential_count] - Number of confidential sellers listed in the sellers.json file. + * @property {string} [error] - Error message if fetch or parse failed. + */ + const parseSellersJSON = async (response) => { let content; try { @@ -177,6 +238,15 @@ const parseSellersJSON = async (response) => { return result; } +/** + * Ads Metrics + * + * @typedef {Object} AdsMetrics + * @property {AdsTxtData} ads - Contains information about the ads.txt file. See [IAB Ads.txt Specification](https://github.com/InteractiveAdvertisingBureau/Supply-Chain-Validation/blob/main/ads.txt%20v1.1.md). + * @property {AdsTxtData} app_ads - Contains information about the app-ads.txt file. See [IAB App-Ads.txt Specification](https://github.com/InteractiveAdvertisingBureau/Supply-Chain-Validation/blob/main/app-ads.txt.md). + * @property {SellersJsonData} sellers - Contains information about the sellers.json file. See [IAB Sellers.json Specification](https://github.com/InteractiveAdvertisingBureau/Supply-Chain-Validation/blob/main/sellers-json.md). + */ + return Promise.all([ fetchAndParse("/ads.txt", parseAdsTxt).catch(e => e), fetchAndParse("/app-ads.txt", parseAdsTxt).catch(e => e), diff --git a/dist/privacy.js b/dist/privacy.js index 14842d8..a83fde6 100644 --- a/dist/privacy.js +++ b/dist/privacy.js @@ -1,10 +1,23 @@ //[privacy] // Uncomment the previous line for testing on webpagetest.org -// README! Instructions for adding a new custom metric for the Web Almanac. -// 2. If the value requires more than one line of code, evaluate it in an IIFE, eg `(() => { ... })()`. See `link-nodes`. -// 3. Test your change by following the instructions at https://github.com/HTTPArchive/almanac.httparchive.org/issues/33#issuecomment-502288773. -// 4. Submit a PR to update this file. +/** + * Privacy custom metrics evaluated on page crawl. + * + * @typedef {Object} PrivacyMetrics + * @property {IABTCFv1} iab_tcf_v1 - IAB Transparency and Consent Framework v1 settings and vendor consents. See [IAB TCF v1.1](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/CMP%20JS%20API%20v1.1%20Final.md). + * @property {IABTCFv2} iab_tcf_v2 - IAB Transparency and Consent Framework v2 settings and vendor consents. See [IAB TCF v2](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md). + * @property {IABGPP} iab_gpp - Global Privacy Platform (GPP) ping response data. See [Global-Privacy-Platform](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform). + * @property {IABUSP} iab_usp - IAB US Privacy User Signal Mechanism (USP API) data. See [USPrivacy](https://github.com/InteractiveAdvertisingBureau/USPrivacy). + * @property {boolean} navigator_doNotTrack - Whether the browser's "Do Not Track" setting was accessed or detected in response bodies. See [EFF Do Not Track](https://www.eff.org/issues/do-not-track). + * @property {boolean} navigator_globalPrivacyControl - Whether the Global Privacy Control (GPC) property was accessed or detected in response bodies. See [Global Privacy Control](https://globalprivacycontrol.org/). + * @property {boolean} document_permissionsPolicy - Whether document Permissions Policy is referenced in response bodies. See [W3C Permissions Policy](https://www.w3.org/TR/permissions-policy-1/#introspection). + * @property {boolean} document_featurePolicy - Whether document Feature Policy (legacy Permissions Policy) is referenced in response bodies. + * @property {ReferrerPolicyData} referrerPolicy - Referrer policy declared for the entire document, subresource requests, or link relations. See [MDN Referrer-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy). + * @property {Object.} request_hostnames_with_cname - Mapping of request hostnames to their canonical CNAME chains. + * @property {CCPAData} ccpa_link - California Consumer Privacy Act (CCPA) compliance links detection. See [CPPA FAQ](https://cppa.ca.gov/faq.html). + * @property {IABDataDeletionRequest} iab_ddr - IAB Data Deletion Request Framework response data. See [Data Deletion Request Framework](https://github.com/InteractiveAdvertisingBureau/Data-Subject-Rights/blob/main/Data%20Deletion%20Request%20Framework.md). + */ const response_bodies = $WPT_BODIES.filter(body => (body.response_body && (body.type === 'Document' || body.type === 'Script'))) @@ -26,10 +39,10 @@ function testPropertyStringInResponseBodies(pattern) { } /** - * @param {string} url - The URL to fetch. - * @param {function} parser - The function to parse the response. - * @returns {Promise} The parsed response or an error object. - */ + * @param {string} url - The URL to fetch. + * @param {function} parser - The function to parse the response. + * @returns {Promise} The parsed response or an error object. + */ const fetchAndParse = async (url, parser) => { const timeout = 5000; const controller = new AbortController(); @@ -48,10 +61,25 @@ const fetchAndParse = async (url, parser) => { } }; +/** + * IAB Data Deletion Request Framework response + * + * @typedef {Object} IABDataDeletionRequest + * @property {boolean} present - Whether the `/dsrdelete.json` endpoint exists and returns valid JSON. + * @property {integer} status - HTTP status code of the `/dsrdelete.json` request. + * @property {boolean} [redirected] - Whether the request was redirected. + * @property {Object[]} [identifiers] - Sanitized identifiers supported for data deletion requests. + * @property {string} [endpointOrigin] - Target origin if redirected. + * @property {boolean} [vendorScriptPresent] - Whether vendor script is declared. + * @property {boolean} [vendorScriptRequirement] - Whether vendor script requirement is declared. + * @property {string} [error] - Error message if request or parsing failed. + */ + /** * Parses the response from a DSR delete request. + * @param {string} url - The URL requested. * @param {Response} response - The response object from the fetch request. - * @returns {Promise} A promise that resolves to an object containing the parsed response data. + * @returns {IABDataDeletionRequest} A promise that resolves to an object containing the parsed response data. */ const parseDSRdelete = (url, response) => { let result = { @@ -83,26 +111,24 @@ let sync_metrics = { /** * IAB Transparency and Consent Framework v1 - * https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/CMP%20JS%20API%20v1.1%20Final.md + * + * @typedef {Object} IABTCFv1 + * @property {boolean} present - Whether the `__cmp` API function is present on the window object. + * @property {Object} [data] - TCF v1 vendor consents data returned by `getVendorConsents`. See [VendorConsents](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/CMP%20JS%20API%20v1.1%20Final.md#vendorconsents-). + * @property {boolean} [compliant_setup] - Verifies whether the TCF v1 CMP setup is compliant with IAB standards. */ iab_tcf_v1: (() => { let consentData = { present: typeof window.__cmp == 'function', }; - // description of `__cmp`: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/CMP%20JS%20API%20v1.1%20Final.md#what-api-will-need-to-be-provided-by-the-cmp- try { if (consentData.present) { - // Standard command: 'getVendorConsents' - // cf. https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/CMP%20JS%20API%20v1.1%20Final.md#what-api-will-need-to-be-provided-by-the-cmp- window.__cmp('getVendorConsents', null, (result, success) => { if (success) { consentData.data = result; consentData.compliant_setup = true; } else { // special case for consentmanager ('CMP settings are used that are not compliant with the IAB TCF') - // see warning at the top of https://help.consentmanager.net/books/cmp/page/changes-to-the-iab-cmp-framework-js-api - // cf. https://help.consentmanager.net/books/cmp/page/javascript-api - // Test site: https://www.pokellector.com/ window.__cmp('noncompliant_getVendorConsents', null, (result, success) => { if (success) { consentData.data = result; @@ -121,25 +147,24 @@ let sync_metrics = { /** * IAB Transparency and Consent Framework v2 - * https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2 + * + * @typedef {Object} IABTCFv2 + * @property {boolean} present - Whether the `__tcfapi` API function is present on the window object. + * @property {Object} [data] - TCF v2 vendor consents data returned by `getTCData`. See [TCData](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#tcdata). + * @property {boolean} [compliant_setup] - Verifies whether the TCF v2 CMP setup is compliant with IAB standards. */ iab_tcf_v2: (() => { let tcData = { present: typeof window.__tcfapi == 'function', }; - // description of `__tcfapi`: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#how-does-the-cmp-provide-the-api try { if (tcData.present) { - // based on https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#gettcdata window.__tcfapi('getTCData', 2, (result, success) => { if (success) { tcData.data = result; tcData.compliant_setup = true; } else { // special case for consentmanager ('CMP settings are used that are not compliant with the IAB TCF') - // see warning at the top of https://help.consentmanager.net/books/cmp/page/changes-to-the-iab-cmp-framework-js-api - // cf. https://help.consentmanager.net/books/cmp/page/javascript-api - // Test site: https://www.pokellector.com/ window.__tcfapi('noncompliant_getTCData', 2, (result, success) => { if (success) { tcData.data = result; @@ -154,12 +179,14 @@ let sync_metrics = { } return tcData; - })(), /** * Global Privacy Protocol (GPP) - * https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform + * + * @typedef {Object} IABGPP + * @property {boolean} present - Whether the `__gpp` API function is present on the window object. + * @property {Object} [data] - Ping response data returned by the `__gpp` API. */ iab_gpp: (() => { let gppData = { @@ -182,7 +209,10 @@ let sync_metrics = { /** * IAB US Privacy User Signal Mechanism “USP API” - * https://github.com/InteractiveAdvertisingBureau/USPrivacy + * + * @typedef {Object} IABUSP + * @property {boolean} present - Whether the `__uspapi` API function is present on the window object. + * @property {string} [privacy_string] - US Privacy string returned by `getUSPData`. */ iab_usp: (() => { let uspData = { @@ -205,13 +235,11 @@ let sync_metrics = { /** * Do Not Track (DNT) - * https://www.eff.org/issues/do-not-track */ navigator_doNotTrack: testPropertyStringInResponseBodies('doNotTrack'), /** * Global Privacy Control - * https://globalprivacycontrol.org/ */ navigator_globalPrivacyControl: testPropertyStringInResponseBodies( 'globalPrivacyControl' @@ -221,16 +249,24 @@ let sync_metrics = { /** * Permissions policy - * https://www.w3.org/TR/permissions-policy-1/#introspection - * Previously known as Feature policy - * iframes properties in `almanac` and `security` custom metrics. */ document_permissionsPolicy: testPropertyStringInResponseBodies('document.+permissionsPolicy'), document_featurePolicy: testPropertyStringInResponseBodies('document.+featurePolicy'), + /** + * @typedef {Object} ReferrerPolicyRequestEntry + * @property {string} tagName - HTML tag name of the element (e.g., IMG, SCRIPT). + * @property {string} referrerpolicy - Value of the referrerpolicy attribute. + * @property {integer} count - Number of occurrences on the page. + */ + /** * Referrer Policy - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + * + * @typedef {Object} ReferrerPolicyData + * @property {string|null} entire_document_policy - Referrer policy set for the entire document using meta tag. + * @property {ReferrerPolicyRequestEntry[]|null} individual_requests - Referrer policies specified on individual elements via referrerpolicy attribute. + * @property {Object.|null} link_relations - Count of elements specifying rel="noreferrer" grouped by HTML tag name. */ referrerPolicy: (() => { let rp = { @@ -314,6 +350,12 @@ let sync_metrics = { return results; })(), + /** + * California Consumer Privacy Act (CCPA) compliance + * + * @typedef {Object} CCPAData + * @property {boolean} hasCCPALink - Whether links matching CCPA opt-out criteria were found on the page. + */ ccpa_link: (() => { const allowedCCPALinkPhrases = [ //https://petsymposium.org/popets/2022/popets-2022-0030.pdf page 612 @@ -384,10 +426,6 @@ let sync_metrics = { }; -/** - * IAB: Data Deletion Request Framework - * https://github.com/InteractiveAdvertisingBureau/Data-Subject-Rights/blob/main/Data%20Deletion%20Request%20Framework.md - */ let iab_ddr = fetchAndParse("/dsrdelete.json", parseDSRdelete); return Promise.all([iab_ddr]).then(([iab_ddr]) => { diff --git a/package-lock.json b/package-lock.json index e267eb4..4afa9c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.0.0", "license": "Apache-2.0", "devDependencies": { + "@babel/parser": "^8.0.4", + "@babel/traverse": "^8.0.4", + "comment-parser": "^1.4.8", "fugu-api-data": "^1.25.1", "jest": "^29.7.0", "webpagetest": "github:HTTPArchive/WebPageTest.api-nodejs" @@ -28,18 +31,30 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.7", - "picocolors": "^1.0.0" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/compat-data": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", @@ -80,21 +95,84 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", @@ -123,39 +201,69 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-function-name": { + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-imports": { "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", "dev": true, "dependencies": { - "@babel/template": "^7.24.7", + "@babel/traverse": "^7.24.7", "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", - "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "node_modules/@babel/helper-module-imports/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" @@ -202,6 +310,51 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-simple-access/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-simple-access/node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", @@ -215,10 +368,11 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -254,102 +408,54 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "@babel/types": "^8.0.4" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" + "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/@babel/parser/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.8.0" + "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/@babel/parser/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@babel/parser/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" }, "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/plugin-syntax-async-generators": { @@ -530,54 +636,167 @@ } }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -888,17 +1107,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -910,26 +1126,19 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -972,6 +1181,22 @@ "@types/babel__traverse": "*" } }, + "node_modules/@types/babel__core/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@types/babel__generator": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", @@ -991,6 +1216,22 @@ "@babel/types": "^7.0.0" } }, + "node_modules/@types/babel__template/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@types/babel__traverse": { "version": "7.20.6", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", @@ -1033,6 +1274,13 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", @@ -1170,6 +1418,22 @@ "node": ">=8" } }, + "node_modules/babel-plugin-istanbul/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", @@ -1499,6 +1763,16 @@ "node": ">= 6" } }, + "node_modules/comment-parser": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.8.tgz", + "integrity": "sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1939,15 +2213,6 @@ "node": ">= 6" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2189,6 +2454,22 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/istanbul-lib-instrument/node_modules/semver": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", @@ -2813,7 +3094,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "3.14.1", @@ -2829,15 +3111,16 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-parse-even-better-errors": { @@ -3242,6 +3525,20 @@ "node": ">=8" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3369,10 +3666,11 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -3787,15 +4085,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index edf82b8..7660520 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "Custom metrics to use with WebPageTest agents", "scripts": { "prepare": "node bin/create-fugu-apis.js", - "test": "jest" + "test": "jest", + "validate:docs": "node bin/validate-docs.js", + "generate:docs": "node bin/generate-docs.js" }, "repository": { "type": "git", @@ -20,6 +22,9 @@ }, "homepage": "https://github.com/HTTPArchive/custom-metrics#readme", "devDependencies": { + "@babel/parser": "^8.0.4", + "@babel/traverse": "^8.0.4", + "comment-parser": "^1.4.8", "fugu-api-data": "^1.25.1", "jest": "^29.7.0", "webpagetest": "github:HTTPArchive/WebPageTest.api-nodejs"