Spaces:
Sleeping
Sleeping
| import fs from 'fs'; | |
| import path from 'path'; | |
| import { fileURLToPath } from 'url'; | |
| import manualOverrides from '../doc/contributors/extensions/manual_overrides.json.js'; | |
| // Get the directory name in ES modules | |
| const __filename = fileURLToPath(import.meta.url); | |
| const __dirname = path.dirname(__filename); | |
| // Create a map of manual overrides for quick lookup | |
| const manualOverridesMap = new Map(); | |
| manualOverrides.forEach(override => { | |
| manualOverridesMap.set(override.id, override); | |
| }); | |
| // Array to collect all warnings | |
| const warnings = []; | |
| // Add a function to detect and collect duplicate events | |
| function checkForDuplicateEvent(eventId, filePath, seenEvents) { | |
| if (seenEvents.has(eventId)) { | |
| const existing = seenEvents.get(eventId); | |
| if (existing.fromManualOverride) { | |
| warnings.push(`Event ${eventId} found in ${filePath} but already defined in manual overrides. Using manual override.`); | |
| } else { | |
| warnings.push(`Duplicate event ${eventId} found in ${filePath}. First seen in ${existing.filename}.`); | |
| } | |
| return true; | |
| } | |
| return false; | |
| } | |
| function extractEventsFromFile(filePath, seenEvents, debugMode) { | |
| const content = fs.readFileSync(filePath, 'utf-8'); | |
| // Use a more general regex to capture all event emissions | |
| // This captures the event name and whatever is passed as the second argument | |
| const regex = /svc_event\.emit\(['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g; | |
| let match; | |
| while ((match = regex.exec(content)) !== null) { | |
| const eventName = match[1]; | |
| const eventId = eventName; | |
| const eventArg = match[2].trim(); | |
| // Check if this file contains code that might affect event.allow | |
| const hasAllowEffect = content.includes('event.allow') || | |
| content.includes('.allow =') || | |
| content.includes('.allow='); | |
| // Check for duplicate events and collect warnings | |
| if (checkForDuplicateEvent(eventId, filePath, seenEvents)) { | |
| continue; // Skip this event if it's a duplicate | |
| } | |
| // Check if this event has a manual override | |
| if (manualOverridesMap.has(eventId)) { | |
| // Use the manual override instead of generating a new definition | |
| const override = manualOverridesMap.get(eventId); | |
| // Mark this as coming from manual override for later reference | |
| override.fromManualOverride = true; | |
| seenEvents.set(eventId, override); | |
| continue; | |
| } | |
| // Generate description based on event name | |
| let description = generateDescription(eventName); | |
| let propertyDetails = {}; | |
| // Case 1: Inline object - extract properties directly | |
| if (eventArg.startsWith('{')) { | |
| // Extract properties from inline object | |
| const propertiesMatch = eventArg.match(/{([^}]*)}/); | |
| if (propertiesMatch) { | |
| const propertiesText = propertiesMatch[1]; | |
| extractProperties(propertiesText, propertyDetails, hasAllowEffect, eventName); | |
| } | |
| } | |
| // Case 2: Variable reference - find variable definition | |
| else { | |
| const varName = eventArg.trim(); | |
| // Look for variable definition patterns like: const event = { prop1: value1 }; | |
| const varDefRegex = new RegExp(`(?:const|let|var)\\s+${varName}\\s*=\\s*{([^}]*)}`, 'g'); | |
| let varMatch; | |
| if ((varMatch = varDefRegex.exec(content)) !== null) { | |
| const propertiesText = varMatch[1]; | |
| extractProperties(propertiesText, propertyDetails, hasAllowEffect, eventName); | |
| } | |
| } | |
| // Add the event to our collection | |
| seenEvents.set(eventId, { | |
| id: eventId, | |
| event: eventName, | |
| filename: path.basename(filePath), | |
| description: description, | |
| properties: propertyDetails, | |
| fromManualOverride: false | |
| }); | |
| } | |
| } | |
| // Helper function to extract properties from a properties text string | |
| function extractProperties(propertiesText, propertyDetails, hasAllowEffect, eventName) { | |
| // filter out all comments (lines starting with //) | |
| const lines = propertiesText.split('\n').map(line => line.trim()).filter(line => !line.startsWith('//')); | |
| // glue all lines together, then split by commas | |
| const gluedTest = lines.join('\n'); | |
| const properties = gluedTest | |
| .split(/\s*,\s*/) | |
| .map(prop => prop.split(/[^_A-Za-z0-9]/)[0].trim()) | |
| .filter(prop => prop); | |
| // // const event = { allow: true, email }; | |
| // // text: allow: true, email | |
| // // split to: [allow: true] [email] | |
| // const properties = propertiesText | |
| // .split(/\s*,\s*/) | |
| // .map(prop => prop.split(':')[0].trim()) | |
| // .filter(prop => prop); | |
| // Generate property details | |
| properties.forEach(prop => { | |
| propertyDetails[prop] = { | |
| type: guessType(prop), | |
| mutability: hasAllowEffect ? 'effect' : 'no-effect', | |
| summary: guessSummary(prop, eventName) | |
| }; | |
| }); | |
| } | |
| function generateDescription(eventName) { | |
| const parts = eventName.split('.'); | |
| if (parts.length >= 2) { | |
| const system = parts[0]; | |
| const action = parts.slice(1).join('.'); | |
| if (action.includes('create')) { | |
| return `This event is emitted when a ${parts[parts.length - 1]} is created.`; | |
| } else if (action.includes('update') || action.includes('write')) { | |
| return `This event is emitted when a ${parts[parts.length - 1]} is updated.`; | |
| } else if (action.includes('delete') || action.includes('remove')) { | |
| return `This event is emitted when a ${parts[parts.length - 1]} is deleted.`; | |
| } else if (action.includes('progress')) { | |
| return `This event reports progress of a ${parts[parts.length - 1]} operation.`; | |
| } else if (action.includes('validate')) { | |
| return `This event is emitted when a ${parts[parts.length - 1]} is being validated.\nThe event can be used to block certain ${parts[parts.length - 1]}s from being validated.`; | |
| } else { | |
| return `This event is emitted for ${system} ${action.replace(/[-\.]/g, ' ')} operations.`; | |
| } | |
| } | |
| return `This event is emitted for ${eventName} operations.`; | |
| } | |
| function guessType(propertyName) { | |
| // Guess the type based on property name | |
| if (propertyName === 'node') return 'FSNodeContext'; | |
| if (propertyName === 'context') return 'Context'; | |
| if (propertyName === 'user') return 'User'; | |
| if (propertyName.includes('path')) return 'string'; | |
| if (propertyName.includes('id')) return 'string'; | |
| if (propertyName.includes('name')) return 'string'; | |
| if (propertyName.includes('progress')) return 'number'; | |
| if (propertyName.includes('tracker')) return 'ProgressTracker'; | |
| if (propertyName.includes('meta')) return 'object'; | |
| if (propertyName.includes('policy')) return 'Policy'; | |
| if (propertyName.includes('allow')) return 'boolean'; | |
| return 'any'; | |
| } | |
| function guessSummary(propertyName, eventName) { | |
| // Generate summary based on property name and event context | |
| if (propertyName === 'node') { | |
| const entityType = eventName.split('.').pop(); | |
| return `the ${entityType} that was affected`; | |
| } | |
| if (propertyName === 'context') return 'current context'; | |
| if (propertyName === 'user') return 'user associated with the operation'; | |
| if (propertyName.includes('path')) return 'path to the affected resource'; | |
| if (propertyName.includes('tracker')) return 'tracks progress of the operation'; | |
| if (propertyName.includes('meta')) return 'additional metadata for the operation'; | |
| if (propertyName.includes('policy')) return 'policy information for the operation'; | |
| if (propertyName.includes('allow')) return 'whether the operation is allowed'; | |
| // Default summary based on property name | |
| return propertyName.replace(/_/g, ' '); | |
| } | |
| function scanDirectory(directory, seenEvents, debugMode) { | |
| const files = fs.readdirSync(directory); | |
| for (const file of files) { | |
| const filePath = path.join(directory, file); | |
| const stat = fs.statSync(filePath); | |
| if (stat.isDirectory()) { | |
| scanDirectory(filePath, seenEvents, debugMode); | |
| } else if (file.endsWith('.js')) { | |
| try { | |
| extractEventsFromFile(filePath, seenEvents, debugMode); | |
| } catch (error) { | |
| warnings.push(`Error processing file ${filePath}: ${error.message}`); | |
| } | |
| } | |
| } | |
| } | |
| function generateTestExtension(events) { | |
| let code = `// Test extension for event listeners\n\n`; | |
| events.forEach(event => { | |
| const eventId = event.id; | |
| const eventName = event.event ? event.event.toUpperCase() : eventId.split('.').slice(1).join('.').toUpperCase(); | |
| code += `extension.on('${eventId}', event => {\n`; | |
| code += ` console.log('GOT ${eventName} EVENT', event);\n`; | |
| code += `});\n\n`; | |
| }); | |
| return code; | |
| } | |
| function main() { | |
| const args = process.argv.slice(2); | |
| if (args.length < 1) { | |
| console.error('Usage: node doc_helper.js <directory> [output_file] [--generate-test] [--test-dir=<directory>] [--debug]'); | |
| // node tools/doc_helper.js . doc/contributors/extensions/events.json.js | |
| // [output_file] [--generate-test] [--test-dir=<directory>] [--debug]'); | |
| process.exit(1); | |
| } | |
| // Resolve directory path relative to project root | |
| const directory = path.resolve(path.join(path.dirname(__dirname), args[0])); | |
| let outputFile = null; | |
| let generateTest = false; | |
| let testOutputDir = "./extensions/"; | |
| let debugMode = false; | |
| // Parse arguments | |
| for (let i = 1; i < args.length; i++) { | |
| if (args[i] === '--generate-test') { | |
| generateTest = true; | |
| } else if (args[i].startsWith('--test-dir=')) { | |
| testOutputDir = args[i].substring('--test-dir='.length); | |
| } else if (args[i] === '--debug') { | |
| debugMode = true; | |
| } else if (!args[i].startsWith('--')) { | |
| // Only treat non-flag arguments as output file | |
| outputFile = path.resolve(path.join(path.dirname(__dirname), args[i])); | |
| } | |
| } | |
| // Resolve test output directory relative to project root if it's not an absolute path | |
| if (!path.isAbsolute(testOutputDir)) { | |
| testOutputDir = path.resolve(path.join(path.dirname(__dirname), testOutputDir)); | |
| } | |
| const seenEvents = new Map(); | |
| // First, add all manual overrides to the seenEvents map | |
| manualOverrides.forEach(override => { | |
| // Mark this as coming from manual override for later reference | |
| override.fromManualOverride = true; | |
| seenEvents.set(override.id, override); | |
| }); | |
| // Then scan the directory for additional events | |
| scanDirectory(directory, seenEvents, debugMode); | |
| // Check for any manual overrides that weren't used | |
| manualOverrides.forEach(override => { | |
| const event = seenEvents.get(override.id); | |
| if (!event || !event.fromManualOverride) { | |
| warnings.push(`Manual override for ${override.id} exists but no matching event was found in the codebase.`); | |
| } | |
| }); | |
| const result = Array.from(seenEvents.values()); | |
| // Sort events alphabetically by ID | |
| result.sort((a, b) => a.id.localeCompare(b.id)); | |
| // Format the output to match events.json.js | |
| const formattedOutput = formatEventsOutput(result); | |
| // Output the result | |
| if (outputFile) { | |
| fs.writeFileSync(outputFile, formattedOutput); | |
| console.log(`Event metadata written to ${outputFile}`); | |
| } else { | |
| console.log(formattedOutput); | |
| } | |
| // Generate test extension file if requested | |
| if (generateTest) { | |
| const testCode = generateTestExtension(result); | |
| // Ensure the output directory exists | |
| if (!fs.existsSync(testOutputDir)) { | |
| fs.mkdirSync(testOutputDir, { recursive: true }); | |
| } | |
| const testFilePath = path.join(testOutputDir, 'testex.js'); | |
| fs.writeFileSync(testFilePath, testCode); | |
| console.log(`Test extension file generated: ${testFilePath}`); | |
| } | |
| // Print warnings in the requested format | |
| if (warnings.length > 0) { | |
| // Collect duplicate events | |
| const duplicateEvents = new Set(); | |
| const overrideEvents = new Set(); | |
| const otherWarnings = []; | |
| warnings.forEach(warning => { | |
| if (warning.includes("Duplicate event")) { | |
| // Extract event ID from the warning message | |
| const match = warning.match(/Duplicate event (core\.[^ ]+)/); | |
| if (match && match[1]) { | |
| duplicateEvents.add(match[1]); | |
| } | |
| } else if (warning.includes("already defined in manual overrides")) { | |
| // Extract event ID from the warning message | |
| const match = warning.match(/Event (core\.[^ ]+) found/); | |
| if (match && match[1]) { | |
| overrideEvents.add(match[1]); | |
| } | |
| } else { | |
| otherWarnings.push(warning); | |
| } | |
| }); | |
| // Output in the requested format | |
| console.log(`\nduplicate events: ${Array.from(duplicateEvents).join(', ')}`); | |
| console.log(`Override events: ${Array.from(overrideEvents).join(', ')}`); | |
| // If there are any other warnings, print them too | |
| if (otherWarnings.length > 0) { | |
| console.log("\nOther warnings:"); | |
| otherWarnings.forEach(warning => { | |
| console.log(`- ${warning}`); | |
| }); | |
| } | |
| } | |
| } | |
| /** | |
| * Format the events data to match the events.json.js format | |
| */ | |
| function formatEventsOutput(events) { | |
| let output = 'export default [\n'; | |
| events.forEach((event, index) => { | |
| // Check if this is a manual override | |
| if (event.fromManualOverride) { | |
| // This is a manual override, output it exactly as defined | |
| output += ' {\n'; | |
| output += ` id: '${event.id}',\n`; | |
| output += ` description: \``; | |
| // Format the description with proper indentation, preserving original formatting | |
| // Don't add extra newlines before or after the description | |
| output += event.description; | |
| output += `\`,\n`; | |
| // Add properties if they exist, preserving exact format | |
| if (event.properties && Object.keys(event.properties).length > 0) { | |
| output += ' properties: {\n'; | |
| Object.entries(event.properties).forEach(([propName, propDetails], propIndex) => { | |
| output += ` ${propName}: {\n`; | |
| output += ` type: '${propDetails.type}',\n`; | |
| output += ` mutability: '${propDetails.mutability}',\n`; | |
| output += ` summary: '${propDetails.summary}'`; | |
| // Add notes array if it exists | |
| if (propDetails.notes && propDetails.notes.length > 0) { | |
| output += `,\n notes: [\n`; | |
| propDetails.notes.forEach((note, noteIndex) => { | |
| output += ` '${note}'`; | |
| if (noteIndex < propDetails.notes.length - 1) { | |
| output += ','; | |
| } | |
| output += '\n'; | |
| }); | |
| output += ` ]`; | |
| } | |
| output += '\n }'; | |
| // Add comma if not the last property | |
| if (propIndex < Object.keys(event.properties).length - 1) { | |
| output += ','; | |
| } | |
| output += '\n'; | |
| }); | |
| output += ' },\n'; | |
| } | |
| // Add example if it exists | |
| if (event.example) { | |
| output += ' example: {\n'; | |
| output += ` language: '${event.example.language}',\n`; | |
| output += ` code: /*${event.example.language}*/\``; | |
| // Preserve the exact formatting of the example code | |
| // Don't add extra newlines and preserve escape sequences exactly as they are | |
| output += event.example.code; | |
| output += `\`\n`; | |
| output += ' },\n'; | |
| } | |
| output += ' }'; | |
| } else { | |
| // This is an auto-generated event | |
| output += ' {\n'; | |
| output += ` id: '${event.id}',\n`; | |
| output += ` description: \`\n`; | |
| // Format the description with proper indentation | |
| const descriptionLines = event.description.split('\n'); | |
| descriptionLines.forEach(line => { | |
| output += ` ${line}\n`; | |
| }); | |
| output += ` \`,\n`; | |
| // Add properties if they exist | |
| if (Object.keys(event.properties).length > 0) { | |
| output += ' properties: {\n'; | |
| Object.entries(event.properties).forEach(([propName, propDetails], propIndex) => { | |
| output += ` ${propName}: {\n`; | |
| output += ` type: '${propDetails.type}',\n`; | |
| output += ` mutability: '${propDetails.mutability === 'effect' ? 'mutable' : 'no-effect'}',\n`; | |
| output += ` summary: '${propDetails.summary}',\n`; | |
| // Add notes array with appropriate content | |
| if (propName === 'allow' && event.event.includes('validate')) { | |
| output += ` notes: [\n`; | |
| output += ` 'If set to false, the ${event.event.split('.')[0]} will be considered invalid.',\n`; | |
| output += ` ],\n`; | |
| } else if (propName === 'email' && event.event.includes('validate')) { | |
| output += ` notes: [\n`; | |
| output += ` 'The email may have already been cleaned.',\n`; | |
| output += ` ],\n`; | |
| } else { | |
| output += ` notes: [],\n`; | |
| } | |
| output += ' }'; | |
| // Add comma if not the last property | |
| if (propIndex < Object.keys(event.properties).length - 1) { | |
| output += ','; | |
| } | |
| output += '\n'; | |
| }); | |
| output += ' },\n'; | |
| } | |
| output += ' }'; | |
| } | |
| // Add comma if not the last event | |
| if (index < events.length - 1) { | |
| output += ','; | |
| } | |
| output += '\n'; | |
| }); | |
| output += '];\n'; | |
| return output; | |
| } | |
| main(); | |
| // Updated Sun Mar 9 23:52:51 EDT 2025 | |