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('