diff --git a/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts index a931c05..a5af6f4 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts @@ -2,6 +2,7 @@ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router'; import { ErrorReason } from '@lib/models'; import { NavigationService, ParticipantTokenService, RoomService, SessionStorageService } from '@lib/services'; +import { WebComponentProperty } from '@lib/typings/ce/webcomponent/properties.model'; export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { const navigationService = inject(NavigationService); @@ -52,12 +53,12 @@ export const extractRecordingQueryParamsGuard: CanActivateFn = (route: Activated return true; }; -const extractParams = (route: ActivatedRouteSnapshot) => ({ - roomId: route.params['room-id'], - participantName: route.queryParams['participant-name'], - secret: route.queryParams['secret'], - leaveRedirectUrl: route.queryParams['leave-redirect-url'], - showOnlyRecordings: route.queryParams['show-only-recordings'] +const extractParams = ({ params, queryParams }: ActivatedRouteSnapshot) => ({ + roomId: params['room-id'], + participantName: queryParams[WebComponentProperty.PARTICIPANT_NAME], + secret: queryParams['secret'], + leaveRedirectUrl: queryParams[WebComponentProperty.LEAVE_REDIRECT_URL], + showOnlyRecordings: queryParams[WebComponentProperty.SHOW_ONLY_RECORDINGS] || 'false' }); const isValidUrl = (url: string) => { diff --git a/frontend/webcomponent/src/components/OpenViduMeet.ts b/frontend/webcomponent/src/components/OpenViduMeet.ts index ed026c6..57726b5 100644 --- a/frontend/webcomponent/src/components/OpenViduMeet.ts +++ b/frontend/webcomponent/src/components/OpenViduMeet.ts @@ -2,6 +2,7 @@ import { CommandsManager } from './CommandsManager'; import { EventsManager } from './EventsManager'; import { WebComponentEvent } from '../typings/ce/event.model'; import styles from '../assets/css/styles.css'; +import { WebComponentProperty } from '../typings/ce/properties.model'; /** * The `OpenViduMeet` web component provides an interface for embedding an OpenVidu Meet room within a web page. @@ -134,9 +135,12 @@ export class OpenViduMeet extends HTMLElement { } private updateIframeSrc() { - const baseUrl = this.getAttribute('room-url') || this.getAttribute('recording-url'); + const baseUrl = + this.getAttribute(WebComponentProperty.ROOM_URL) || this.getAttribute(WebComponentProperty.RECORDING_URL); if (!baseUrl) { - console.error('The "room-url" or "recording-url" attribute is required.'); + console.error( + `The "${WebComponentProperty.ROOM_URL}" or "${WebComponentProperty.RECORDING_URL}" attribute is required.` + ); return; } @@ -147,7 +151,7 @@ export class OpenViduMeet extends HTMLElement { // Update query params Array.from(this.attributes).forEach((attr) => { - if (attr.name !== 'room-url' && attr.name !== 'recording-url') { + if (attr.name !== WebComponentProperty.ROOM_URL && attr.name !== WebComponentProperty.RECORDING_URL) { url.searchParams.set(attr.name, attr.value); } }); diff --git a/frontend/webcomponent/src/typings/ce/properties.model.ts b/frontend/webcomponent/src/typings/ce/properties.model.ts new file mode 100644 index 0000000..fb75e3e --- /dev/null +++ b/frontend/webcomponent/src/typings/ce/properties.model.ts @@ -0,0 +1,33 @@ + +/** +* THIS HEADER IS AUTOGENERATED. DO NOT MODIFY MANUALLY. +* ! For any changes, please update the '/openvidu-meet/typings' directory. +**/ + +export enum WebComponentProperty { + + /** + * The base URL of the OpenVidu Meet room. + * @required This attribute is required unless `recording-url` is provided. + */ + ROOM_URL = 'room-url', + /** + * The URL of a recording to view. + * @required This attribute is required if `room-url` is not provided. + */ + RECORDING_URL = 'recording-url', + /** + * Display name for the local participant. + */ + PARTICIPANT_NAME = 'participant-name', + + /** + * URL to redirect to when leaving the meeting. + * Redirection occurs after the `CLOSED` event fires. + */ + LEAVE_REDIRECT_URL = 'leave-redirect-url', + /** + * Whether to show only recordings instead of live meetings. + */ + SHOW_ONLY_RECORDINGS = 'show-only-recordings' +} \ No newline at end of file diff --git a/prepare.sh b/prepare.sh index 23ff084..821323a 100755 --- a/prepare.sh +++ b/prepare.sh @@ -14,6 +14,7 @@ BUILD_FRONTEND=false BUILD_BACKEND=false BUILD_WEBCOMPONENT=false BUILD_TESTAPP=false +BUILD_WC_DOC=false # Function to display help show_help() { @@ -25,12 +26,14 @@ show_help() { echo " --backend Build backend" echo " --webcomponent Build webcomponent" echo " --testapp Build testapp" + echo " --wc-doc Generate webcomponent documentation" echo " --all Build all artifacts (default)" echo " --help Show this help" echo echo "If no arguments are provided, all artifacts will be built." echo echo -e "${YELLOW}Example:${NC} ./prepare.sh --frontend --backend" + echo -e "${YELLOW}Example:${NC} ./prepare.sh --wc-doc" } # If no arguments, build everything @@ -60,6 +63,9 @@ else --testapp) BUILD_TESTAPP=true ;; + --wc-doc) + BUILD_WC_DOC=true + ;; --all) BUILD_TYPINGS=true BUILD_FRONTEND=true @@ -125,4 +131,19 @@ if [ "$BUILD_TESTAPP" = true ]; then cd .. fi +# Generate webcomponent documentation if selected +if [ "$BUILD_WC_DOC" = true ]; then + echo -e "${GREEN}Generating webcomponent documentation...${NC}" + node scripts/generate-webcomponent-docs.js docs + + # Copy the generated documentation to the openvidu.io directory + echo -e "${GREEN}Copying webcomponent documentation to openvidu.io...${NC}" + + cp docs/webcomponent-events.md ../openvidu.io/shared/meet/webcomponent-events.md + cp docs/webcomponent-commands.md ../openvidu.io/shared/meet/webcomponent-commands.md + cp docs/webcomponent-attributes.md ../openvidu.io/shared/meet/webcomponent-attributes.md + + echo -e "${GREEN}Webcomponent documentation generated successfully!${NC}" +fi + echo -e "${BLUE}Preparation completed!${NC}" \ No newline at end of file diff --git a/scripts/generate-webcomponent-docs.js b/scripts/generate-webcomponent-docs.js new file mode 100644 index 0000000..8deb5d8 --- /dev/null +++ b/scripts/generate-webcomponent-docs.js @@ -0,0 +1,488 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Generates documentation for the OpenVidu Meet WebComponent + */ +class WebComponentDocGenerator { + constructor() { + this.typingsPath = path.join(__dirname, '../typings/src/webcomponent'); + this.webComponentPath = path.join(__dirname, '../frontend/webcomponent/src/components/OpenViduMeet.ts'); + } + + /** + * Reads and parses a TypeScript file to extract enum documentation + */ + parseEnumFile(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split('\n'); + + const enums = []; + let currentEnum = null; + let currentItem = null; + let inEnum = false; + let inComment = false; + let commentLines = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Detect start of enum + if (line.startsWith('export enum')) { + inEnum = true; + currentEnum = { + name: line.match(/export enum (\w+)/)[1], + items: [] + }; + continue; + } + + // Detect end of enum + if (inEnum && line === '}') { + if (currentItem) { + currentEnum.items.push(currentItem); + } + enums.push(currentEnum); + inEnum = false; + currentEnum = null; + currentItem = null; + continue; + } + + if (!inEnum) continue; + + // Handle multi-line comments + if (line.startsWith('/**')) { + inComment = true; + commentLines = []; + continue; + } + + if (inComment) { + if (line.endsWith('*/')) { + inComment = false; + continue; + } + + // Extract comment content + const commentContent = line.replace(/^\*\s?/, '').trim(); + if (commentContent) { + commentLines.push(commentContent); + } + continue; + } + + // Parse enum item + if (line.includes('=') && !line.startsWith('//')) { + // Save previous item if exists + if (currentItem) { + currentEnum.items.push(currentItem); + } + + const match = line.match(/(\w+)\s*=\s*'([^']+)'/); + if (match) { + // Extract @required text if present + const requiredComment = commentLines.find(c => c.includes('@required')); + let requiredText = ''; + if (requiredComment) { + const requiredMatch = requiredComment.match(/@required\s*(.*)/); + requiredText = requiredMatch ? requiredMatch[1].trim() : ''; + } + + currentItem = { + name: match[1], + value: match[2], + description: commentLines.filter(line => !line.includes('@')).join(' '), + isPrivate: commentLines.some(c => c.includes('@private')), + isModerator: commentLines.some(c => c.includes('@moderator')), + isRequired: commentLines.some(c => c.includes('@required')), + requiredText: requiredText + }; + commentLines = []; + } + } + } + + return enums; + } + + /** + * Extracts payload information from interface definitions + */ + extractPayloads(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const payloads = {}; + + // Find the payload interface + const interfaceMatch = content.match(/export interface \w+Payloads\s*{([\s\S]*?)^}/m); + if (!interfaceMatch) return payloads; + + const interfaceContent = interfaceMatch[1]; + const lines = interfaceContent.split('\n'); + + let currentKey = null; + let inComment = false; + let commentLines = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('/**')) { + inComment = true; + commentLines = []; + continue; + } + + if (inComment) { + if (trimmed.endsWith('*/')) { + inComment = false; + continue; + } + + const commentContent = trimmed.replace(/^\*\s?/, '').trim(); + if (commentContent && !commentContent.includes('@')) { + commentLines.push(commentContent); + } + continue; + } + + // Parse payload property - looking for patterns like [WebComponentEvent.JOIN]: { + const propMatch = trimmed.match(/\[\w+\.(\w+)\]:\s*({[\s\S]*?}|[^,;]+)[,;]?/); + if (propMatch) { + const enumValue = propMatch[1]; + let type = propMatch[2].trim(); + + // If it's a multi-line object, we need to collect the full definition + if (type.startsWith('{') && !type.endsWith('}')) { + // Find the closing brace + let braceCount = 1; + let i = lines.indexOf(line) + 1; + while (i < lines.length && braceCount > 0) { + const nextLine = lines[i].trim(); + type += '\n' + nextLine; + for (const char of nextLine) { + if (char === '{') braceCount++; + if (char === '}') braceCount--; + } + i++; + } + } + + payloads[enumValue] = { + type: type.replace(/[,;]$/, ''), // Remove trailing comma or semicolon + description: commentLines.join(' ') + }; + commentLines = []; + } + } + + return payloads; + } + + /** + * Extracts WebComponent attributes from the OpenViduMeet.ts file + */ + extractWebComponentAttributes() { + const content = fs.readFileSync(this.webComponentPath, 'utf8'); + const attributes = []; + + // Look for @attribute JSDoc comments + const attributeMatches = content.match(/@attribute\s+([^\s]+)\s*-\s*([^\n]+)/g); + if (attributeMatches) { + attributeMatches.forEach(match => { + const parts = match.match(/@attribute\s+([^\s]+)\s*-\s*(.+)/); + if (parts) { + attributes.push({ + name: parts[1], + description: parts[2].trim() + }); + } + }); + } + + return attributes; + } + + /** + * Generates markdown table for events (only public events) + */ + generateEventsTable() { + const enums = this.parseEnumFile(path.join(this.typingsPath, 'event.model.ts')); + const payloads = this.extractPayloads(path.join(this.typingsPath, 'event.model.ts')); + + const eventEnum = enums.find(e => e.name === 'WebComponentEvent'); + if (!eventEnum) return ''; + + let markdown = '| Event | Description | Payload |\n'; + markdown += '|-------|-------------|------------|\n'; + + for (const item of eventEnum.items) { + // Skip private events + if (item.isPrivate) continue; + + const payload = payloads[item.name]; + const payloadInfo = payload ? this.formatPayload(payload.type) : '-'; + + markdown += `| \`${item.value}\` | ${item.description || 'No description available'} | ${payloadInfo} |\n`; + } + + return markdown; + } + + /** + * Generates markdown table for commands/methods (only public methods) + */ + generateCommandsTable() { + const enums = this.parseEnumFile(path.join(this.typingsPath, 'command.model.ts')); + const payloads = this.extractPayloads(path.join(this.typingsPath, 'command.model.ts')); + + const commandEnum = enums.find(e => e.name === 'WebComponentCommand'); + if (!commandEnum) return ''; + + let markdown = '| Method | Description | Parameters | Access Level |\n'; + markdown += '|--------|-------------|------------|-------------|\n'; + + for (const item of commandEnum.items) { + // Skip private commands + if (item.isPrivate) continue; + + const payload = payloads[item.name]; + + // Generate method name from command name and payload + const methodName = this.generateMethodName(item.name, item.value, payload); + + const params = payload ? this.formatMethodParameters(payload.type) : '-'; + + // Determine access level based on @moderator annotation + const accessLevel = this.getAccessLevel(item); + + markdown += `| \`${methodName}\` | ${item.description || 'No description available'} | ${params} | ${accessLevel} |\n`; + } + + return markdown; + } + + /** + * Generates method name and signature from command enum + */ + generateMethodName(commandName, commandValue, payload) { + // Convert COMMAND_NAME to camelCase method name + const methodName = commandName + .toLowerCase() + .split('_') + .map((word, index) => index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + + // If there's no payload or payload is void, no parameters needed + if (!payload || payload.type === 'void') { + return `${methodName}()`; + } + + // Extract parameter names from payload type + if (payload.type.includes('{') && payload.type.includes('}')) { + // Remove comments (both single-line // and multi-line /* */) + const cleanedType = payload.type + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments + .replace(/\/\/.*$/gm, ''); // Remove // comments + + const properties = cleanedType + .replace(/[{}]/g, '') + .split(';') + .map(prop => prop.trim()) + .filter(prop => prop && !prop.startsWith('//') && !prop.startsWith('/*')) + .map(prop => { + const [key] = prop.split(':').map(s => s.trim()); + return key; + }) + .filter(key => key); // Remove empty keys + + if (properties.length > 0) { + return `${methodName}(${properties.join(', ')})`; + } + } + + // Fallback: no parameters + return `${methodName}()`; + } + + /** + * Determines the access level of a command based on its @moderator annotation + */ + getAccessLevel(item) { + return item.isModerator ? 'Moderator' : 'All'; + } + + /** + * Generates markdown table for attributes/properties + */ + generateAttributesTable() { + const propertyEnums = this.parseEnumFile(path.join(this.typingsPath, 'properties.model.ts')); + const propertyEnum = propertyEnums.find(e => e.name === 'WebComponentProperty'); + + let markdown = '| Attribute | Description | Required |\n'; + markdown += '|-----------|-------------|----------|\n'; + + // Add attributes from the properties enum only + if (propertyEnum) { + for (const item of propertyEnum.items) { + // Format required column with additional text if present + let requiredColumn = 'No'; + if (item.isRequired) { + requiredColumn = item.requiredText ? `Yes (${item.requiredText})` : 'Yes'; + } + + // Use description from JSDoc comments, fallback to hardcoded if not available + const description = item.description || this.getDescriptionForAttribute(item.value); + + markdown += `| \`${item.value}\` | ${description} | ${requiredColumn} |\n`; + } + } + + return markdown; + } + + /** + * Formats payload type information for display in events table + */ + formatPayload(type) { + if (type === 'void' || type === '{}') { + return 'None'; + } + + // Handle object types + if (type.includes('{') && type.includes('}')) { + const properties = type + .replace(/[{}]/g, '') + .split(';') + .map(prop => prop.trim()) + .filter(prop => prop) + .map(prop => { + const [key, value] = prop.split(':').map(s => s.trim()); + return `"${key}": "${value}"`; + }); + + if (properties.length > 0) { + const tab = '    '; + const jsonContent = '{
' + tab + properties.join(',
' + tab) + '
}'; + return `
${jsonContent}
`; + } else { + return '
{}
'; + } + } + + return `\`${type}\``; + } + + /** + * Formats method parameters for display + */ + formatMethodParameters(type) { + if (type === 'void') { + return '-'; + } + + // Handle object types + if (type.includes('{') && type.includes('}')) { + // Remove comments (both single-line // and multi-line /* */) + const cleanedType = type + .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments + .replace(/\/\/.*$/gm, ''); // Remove // comments + + const properties = cleanedType + .replace(/[{}]/g, '') + .split(';') + .map(prop => prop.trim()) + .filter(prop => prop && !prop.startsWith('//') && !prop.startsWith('/*')) + .map(prop => { + const [key, value] = prop.split(':').map(s => s.trim()); + return `• \`${key}\`: ${value}`; + }) + .filter(param => param && !param.includes('undefined')); // Remove malformed parameters + + return properties.length > 0 ? properties.join('
') : 'object'; + } + + return type; + } + + /** + * Gets description for an attribute + */ + getDescriptionForAttribute(attributeName) { + const descriptions = { + 'room-id': 'Unique identifier for the meeting room', + 'participant-name': 'Display name for the local participant', + 'leave-redirect-url': 'URL to redirect to when leaving the meeting' + }; + return descriptions[attributeName] || 'No description available'; + } + + /** + * Generates separate documentation files + */ + generateSeparateDocuments(outputDir = './docs') { + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Header comment for all generated files + const headerComment = `\n\n\n`; + + const eventsTable = this.generateEventsTable(); + const commandsTable = this.generateCommandsTable(); + const attributesTable = this.generateAttributesTable(); + + // Write separate files with header comments + const eventsPath = path.join(outputDir, 'webcomponent-events.md'); + const commandsPath = path.join(outputDir, 'webcomponent-commands.md'); + const attributesPath = path.join(outputDir, 'webcomponent-attributes.md'); + + fs.writeFileSync(eventsPath, headerComment + eventsTable, 'utf8'); + fs.writeFileSync(commandsPath, headerComment + commandsTable, 'utf8'); + fs.writeFileSync(attributesPath, headerComment + attributesTable, 'utf8'); + + return { + events: eventsPath, + commands: commandsPath, + attributes: attributesPath + }; + } + + /** + * Saves the generated documentation to separate files + */ + saveDocumentation(outputDir = './docs') { + const files = this.generateSeparateDocuments(outputDir); + + console.log('āœ… Documentation generated successfully:'); + console.log(`šŸ“„ Events: ${files.events}`); + console.log(`šŸ”§ Commands: ${files.commands}`); + console.log(`āš™ļø Attributes: ${files.attributes}`); + + // Display summary + console.log('\nšŸ“Š Documentation Summary:'); + console.log('- Only public/non-private elements included'); + console.log('- Three separate markdown files generated'); + console.log('- Tables only, no additional content'); + } +} + +// Main execution +if (require.main === module) { + const generator = new WebComponentDocGenerator(); + + // Parse command line arguments + const args = process.argv.slice(2); + const outputDir = args[0] || './docs'; + + try { + generator.saveDocumentation(outputDir); + } catch (error) { + console.error('āŒ Error generating documentation:', error.message); + console.error('Stack trace:', error.stack); + process.exit(1); + } +} + +module.exports = WebComponentDocGenerator; diff --git a/typings/src/webcomponent/command.model.ts b/typings/src/webcomponent/command.model.ts index 3f22e06..22e4881 100644 --- a/typings/src/webcomponent/command.model.ts +++ b/typings/src/webcomponent/command.model.ts @@ -10,7 +10,7 @@ export enum WebComponentCommand { INITIALIZE = 'INITIALIZE', /** * Ends the current meeting for all participants. - * This command is only available for the moderator. + * @moderator */ END_MEETING = 'END_MEETING', /** @@ -19,7 +19,7 @@ export enum WebComponentCommand { LEAVE_ROOM = 'LEAVE_ROOM', /** * Kicks a participant from the meeting. - * This command is only available for the moderator. + * @moderator */ KICK_PARTICIPANT = 'KICK_PARTICIPANT' } diff --git a/typings/src/webcomponent/properties.model.ts b/typings/src/webcomponent/properties.model.ts new file mode 100644 index 0000000..bf7afec --- /dev/null +++ b/typings/src/webcomponent/properties.model.ts @@ -0,0 +1,27 @@ +export enum WebComponentProperty { + + /** + * The OpenVidu Meet room URL to connect to (moderator or publisher url) + * @required This attribute is required unless `recording-url` is provided. + */ + ROOM_URL = 'room-url', + /** + * The URL of a recording to view. + * @required This attribute is required unless `room-url` is provided. + */ + RECORDING_URL = 'recording-url', + /** + * Display name for the local participant. + */ + PARTICIPANT_NAME = 'participant-name', + + /** + * URL to redirect to when leaving the meeting. + * Redirection occurs after the **`CLOSED` event** fires. + */ + LEAVE_REDIRECT_URL = 'leave-redirect-url', + /** + * Whether to show only recordings instead of live meetings. + */ + SHOW_ONLY_RECORDINGS = 'show-only-recordings' +}