frontend: add webcomponent documentation generation and update command model annotations

This commit is contained in:
Carlos Santos 2025-07-22 12:43:39 +02:00
parent b92aec9d30
commit 3530e557c4
7 changed files with 585 additions and 11 deletions

View File

@ -2,6 +2,7 @@ import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router'; import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { ErrorReason } from '@lib/models'; import { ErrorReason } from '@lib/models';
import { NavigationService, ParticipantTokenService, RoomService, SessionStorageService } from '@lib/services'; import { NavigationService, ParticipantTokenService, RoomService, SessionStorageService } from '@lib/services';
import { WebComponentProperty } from '@lib/typings/ce/webcomponent/properties.model';
export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService); const navigationService = inject(NavigationService);
@ -52,12 +53,12 @@ export const extractRecordingQueryParamsGuard: CanActivateFn = (route: Activated
return true; return true;
}; };
const extractParams = (route: ActivatedRouteSnapshot) => ({ const extractParams = ({ params, queryParams }: ActivatedRouteSnapshot) => ({
roomId: route.params['room-id'], roomId: params['room-id'],
participantName: route.queryParams['participant-name'], participantName: queryParams[WebComponentProperty.PARTICIPANT_NAME],
secret: route.queryParams['secret'], secret: queryParams['secret'],
leaveRedirectUrl: route.queryParams['leave-redirect-url'], leaveRedirectUrl: queryParams[WebComponentProperty.LEAVE_REDIRECT_URL],
showOnlyRecordings: route.queryParams['show-only-recordings'] showOnlyRecordings: queryParams[WebComponentProperty.SHOW_ONLY_RECORDINGS] || 'false'
}); });
const isValidUrl = (url: string) => { const isValidUrl = (url: string) => {

View File

@ -2,6 +2,7 @@ import { CommandsManager } from './CommandsManager';
import { EventsManager } from './EventsManager'; import { EventsManager } from './EventsManager';
import { WebComponentEvent } from '../typings/ce/event.model'; import { WebComponentEvent } from '../typings/ce/event.model';
import styles from '../assets/css/styles.css'; 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. * 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() { 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) { 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; return;
} }
@ -147,7 +151,7 @@ export class OpenViduMeet extends HTMLElement {
// Update query params // Update query params
Array.from(this.attributes).forEach((attr) => { 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); url.searchParams.set(attr.name, attr.value);
} }
}); });

View File

@ -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'
}

View File

@ -14,6 +14,7 @@ BUILD_FRONTEND=false
BUILD_BACKEND=false BUILD_BACKEND=false
BUILD_WEBCOMPONENT=false BUILD_WEBCOMPONENT=false
BUILD_TESTAPP=false BUILD_TESTAPP=false
BUILD_WC_DOC=false
# Function to display help # Function to display help
show_help() { show_help() {
@ -25,12 +26,14 @@ show_help() {
echo " --backend Build backend" echo " --backend Build backend"
echo " --webcomponent Build webcomponent" echo " --webcomponent Build webcomponent"
echo " --testapp Build testapp" echo " --testapp Build testapp"
echo " --wc-doc Generate webcomponent documentation"
echo " --all Build all artifacts (default)" echo " --all Build all artifacts (default)"
echo " --help Show this help" echo " --help Show this help"
echo echo
echo "If no arguments are provided, all artifacts will be built." echo "If no arguments are provided, all artifacts will be built."
echo echo
echo -e "${YELLOW}Example:${NC} ./prepare.sh --frontend --backend" echo -e "${YELLOW}Example:${NC} ./prepare.sh --frontend --backend"
echo -e "${YELLOW}Example:${NC} ./prepare.sh --wc-doc"
} }
# If no arguments, build everything # If no arguments, build everything
@ -60,6 +63,9 @@ else
--testapp) --testapp)
BUILD_TESTAPP=true BUILD_TESTAPP=true
;; ;;
--wc-doc)
BUILD_WC_DOC=true
;;
--all) --all)
BUILD_TYPINGS=true BUILD_TYPINGS=true
BUILD_FRONTEND=true BUILD_FRONTEND=true
@ -125,4 +131,19 @@ if [ "$BUILD_TESTAPP" = true ]; then
cd .. cd ..
fi 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}" echo -e "${BLUE}Preparation completed!${NC}"

View File

@ -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 = '&nbsp;&nbsp;&nbsp;&nbsp;';
const jsonContent = '{ <br>' + tab + properties.join(',<br>' + tab) + '<br>}';
return `<pre><code>${jsonContent}</code></pre>`;
} else {
return '<pre><code>{}</code></pre>';
}
}
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('<br>') : '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 = `<!-- This file is auto-generated. Do not edit manually. -->\n<!-- Generated by openvidu-meet/scripts/generate-webcomponent-docs.js -->\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;

View File

@ -10,7 +10,7 @@ export enum WebComponentCommand {
INITIALIZE = 'INITIALIZE', INITIALIZE = 'INITIALIZE',
/** /**
* Ends the current meeting for all participants. * Ends the current meeting for all participants.
* This command is only available for the moderator. * @moderator
*/ */
END_MEETING = 'END_MEETING', END_MEETING = 'END_MEETING',
/** /**
@ -19,7 +19,7 @@ export enum WebComponentCommand {
LEAVE_ROOM = 'LEAVE_ROOM', LEAVE_ROOM = 'LEAVE_ROOM',
/** /**
* Kicks a participant from the meeting. * Kicks a participant from the meeting.
* This command is only available for the moderator. * @moderator
*/ */
KICK_PARTICIPANT = 'KICK_PARTICIPANT' KICK_PARTICIPANT = 'KICK_PARTICIPANT'
} }

View File

@ -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'
}