diff --git a/package-lock.json b/package-lock.json index 637204d423..f85a8f6ab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10270,7 +10270,7 @@ }, "packages/superdoc": { "name": "@harbour-enterprises/superdoc", - "version": "0.4.37-RC1", + "version": "0.4.40", "license": "AGPL-3.0", "dependencies": { "@harbour-enterprises/super-editor": "0.0.1-alpha.0", diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index 0db05e84de..b29602baa5 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -567,6 +567,8 @@ export class Editor extends EventEmitter { const dom = this.view.dom; dom.editor = this; + + this.options.telemetry?.sendReport(); } /** @@ -912,7 +914,7 @@ export class Editor extends EventEmitter { isHeadless: this.options.isHeadless, }); - this.telemetry?.trackUsage('document_export', { + this.options.telemetry?.trackUsage('document_export', { documentType: 'docx', timestamp: new Date().toISOString() }); diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index d09788a1e3..09834d68ad 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -55,6 +55,7 @@ class SuperConverter { 'w:rPr': 'runProperties', 'w:sectPr': 'sectionProperties', 'w:numPr': 'numberingProperties', + 'w:tcPr': 'tableCellProperties', }); static elements = new Set(['w:document', 'w:body', 'w:p', 'w:r', 'w:t', 'w:delText']); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index ab6eb5827a..e8fb9d6ee0 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -38,17 +38,46 @@ export const createDocumentJson = (docx, converter, editor) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; - const nodeListHandler = defaultNodeListHandler(); + // Track initial document structure + if (converter?.telemetry) { + const files = Object.keys(docx).map((filePath) => { + const parts = filePath.split('/'); + return { + filePath, + fileDepth: parts.length, + fileType: filePath.split('.').pop(), + }; + }); + + converter.telemetry.trackFileStructure( + { + totalFiles: files.length, + maxDepth: Math.max(...files.map((f) => f.fileDepth)), + totalNodes: 0, + files, + }, + converter.fileSource, + converter.documentId, + converter.documentInternalId, + ); + } + const nodeListHandler = defaultNodeListHandler(); const bodyNode = json.elements[0].elements.find((el) => el.name === 'w:body'); + if (bodyNode) { const node = bodyNode; const ignoreNodes = ['w:sectPr']; const content = node.elements?.filter((n) => !ignoreNodes.includes(n.name)) ?? []; const parsedContent = nodeListHandler.handler({ - nodes: content, nodeListHandler, docx, converter, editor + nodes: content, + nodeListHandler, + docx, + converter, + editor, }); + const result = { type: 'doc', content: parsedContent, @@ -56,18 +85,16 @@ export const createDocumentJson = (docx, converter, editor) => { attributes: json.elements[0].attributes, }, }; - + // Not empty document if (result.content.length > 1) { converter?.telemetry?.trackUsage( - converter?.fileSource, - converter?.documentId, - 'document_import', + 'document_import', { - documentType: 'docx', - internalId: converter?.documentInternalId, - timestamp: new Date().toISOString() - }); + documentType: 'docx', + timestamp: new Date().toISOString() + } + ); } return { @@ -112,9 +139,11 @@ const createNodeListHandler = (nodeHandlers) => { * Gets safe element context even if index is out of bounds * @param {Array} elements Array of elements * @param {number} index Index to check + * @param {Object} processedNode result node + * @param {String} path Occurrence filename * @returns {Object} Safe context object */ - const getSafeElementContext = (elements, index) => { + const getSafeElementContext = (elements, index, processedNode, path) => { if (!elements || index < 0 || index >= elements.length) { return { elementIndex: index, @@ -125,11 +154,12 @@ const createNodeListHandler = (nodeHandlers) => { const element = elements[index]; return { - elementIndex: index, elementName: element?.name, - elementAttributes: element?.attributes, - hasElements: !!element?.elements, - elementCount: element?.elements?.length + attributes: processedNode?.attrs, + marks: processedNode?.marks, + elementPath: path, + type: processedNode?.type, + content: processedNode?.content, }; }; @@ -137,24 +167,12 @@ const createNodeListHandler = (nodeHandlers) => { if (!elements || !elements.length) return []; const processedElements = []; - const unhandledNodes = []; try { for (let index = 0; index < elements.length; index++) { try { const nodesToHandle = elements.slice(index); if (!nodesToHandle || nodesToHandle.length === 0) { - converter?.telemetry?.trackParsing( - converter?.fileSource, - converter?.documentId, - 'node', - 'empty_slice', - `/word/${filename || 'document.xml'}`, - { - index, - internalId: converter?.documentInternalId, - totalElements: elements.length, - }); continue; } @@ -175,140 +193,74 @@ const createNodeListHandler = (nodeHandlers) => { { nodes: [], consumed: 0 } ); - // Track unhandled nodes with safe context + // Only track unhandled nodes that should have been handled + const context = getSafeElementContext(elements, index, nodes[0], `/word/${filename || 'document.xml'}`); if (unhandled) { - const context = getSafeElementContext(elements, index); if (!context.elementName) continue; - unhandledNodes.push({ - name: context.elementName, - attributes: context.elementAttributes - }); - - converter?.telemetry?.trackParsing( - converter?.fileSource, - converter?.documentId, - 'node', - 'unhandled', - `/word/${filename || 'document.xml'}`, - { - context, - internalId: converter?.documentInternalId, - }); - + converter?.telemetry?.trackStatistic('unknown', context); continue; - } - - // Bounds check before incrementing index - if (consumed > 0) { - index += consumed - 1; - if (index < 0) { - converter?.telemetry?.trackParsing( - converter?.fileSource, - converter?.documentId, - 'node', - 'invalid_index', - `/word/${filename || 'document.xml'}`, - { - originalIndex: index - (consumed - 1), - consumed, - resultingIndex: index, - internalId: converter?.documentInternalId, - } - ); - index = 0; // Reset to safe value + } else { + converter?.telemetry?.trackStatistic('node', context); + + // Use Telemetry to track list item attributes + if (context.type === 'orderedList' || context.type === 'bulletList') { + context.content.forEach((item) => { + const innerItemContext = getSafeElementContext([item], 0, item, `/word/${filename || 'document.xml'}`); + converter?.telemetry?.trackStatistic('attributes', innerItemContext); + }) } } - - // Process valid nodes - for (let node of (nodes || [])) { - if (node?.type) { - const ignore = ['runProperties']; - - if (node.type === 'text' && Array.isArray(node.content) && !node.content.length) { - - converter?.telemetry?.trackParsing( - converter?.fileSource, - converter?.documentId, - 'node', - 'empty_text', - `/word/${filename || 'document.xml'}`, - { - context: node.attrs, - internalId: converter?.documentInternalId, - }); - continue; - } - if (!ignore.includes(node.type)) { + // Process and store nodes (no tracking needed for success) + if (nodes) { + nodes.forEach((node) => { + if (node?.type && !['runProperties'].includes(node.type)) { + if (node.type === 'text' && Array.isArray(node.content) && !node.content.length) { + return; + } processedElements.push(node); } - } + }); } } catch (error) { editor?.emit('exception', { error }); - - const context = getSafeElementContext(elements, index); - if (error.details) { - context.elementAttributes = error.details; - } - - // Track individual element processing errors with safe context - converter?.telemetry?.trackParsing( - converter?.fileSource, - converter?.documentId, - 'element', - 'processing_error', - `/word/${filename || 'document.xml'}`, - { - error: { - message: error.message, - name: error.name, - stack: error.stack, - }, - internalId: converter?.documentInternalId, - context - } - ); + + converter?.telemetry?.trackStatistic('error', { + type: 'processing_error', + message: error.message, + name: error.name, + stack: error.stack, + fileName: `/word/${filename || 'document.xml'}`, + }); } } return processedElements; } catch (error) { editor?.emit('exception', { error }); + + // Track only catastrophic handler failures + converter?.telemetry?.trackStatistic('error', { + type: 'fatal_error', + message: error.message, + name: error.name, + stack: error.stack, + fileName: `/word/${filename || 'document.xml'}`, + }); - // Track catastrophic errors in the node list handler - converter?.telemetry?.trackParsing( - converter?.fileSource, - converter?.documentId, - 'handler', - 'nodeListHandler', - `/word/${filename || 'document.xml'}`, - { - status: 'error', - error: { - message: error.message, - name: error.name, - stack: error.stack - }, - internalId: converter?.documentInternalId, - context: { - totalElements: elements?.length || 0, - processedCount: processedElements.length, - unhandledCount: unhandledNodes.length, - } - } - ); throw error; } }; - return nodeListHandlerFn; }; /** * * @param {XmlNode} node + * @param {ParsedDocx} docx + * @param {SuperConverter} converter instance. + * @param {Editor} editor instance. * @returns {Object} The document styles object */ function getDocumentStyles(node, docx, converter, editor) { @@ -397,4 +349,4 @@ function getHeaderFooter(el, elementType, docx, converter, editor) { storage[rId] = { type: 'doc', content: [...schema] }; storageIds[sectionType] = rId; -}; +} diff --git a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js index 0f3d6bebc0..a3ffc7f955 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -91,6 +91,10 @@ export function getElementName(element) { return SuperConverter.allowedElements[element.name || element.type]; } +export const isPropertiesElement = (element) => { + return !!SuperConverter.propertyTypes[element.name || element.type]; +} + /** * * @param {XmlNode[]} elements diff --git a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js index 1075e03234..0867eed9e0 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -1,4 +1,4 @@ -import { getElementName, parseProperties } from './importerHelpers.js'; +import { getElementName, parseProperties, isPropertiesElement } from './importerHelpers.js'; /** * @type {import("docxImporter").NodeHandler} @@ -14,6 +14,19 @@ export const handleStandardNode = (params) => { const { name } = node; const { attributes, elements, marks = [] } = parseProperties(node, docx); + // Formatting only nodes + if (isPropertiesElement(node)) { + return { + nodes: [{ + type: getElementName(node), + attrs: { ...attributes }, + marks: [], + }], + consumed: 0 + }; + } + + // Unhandled nodes if (!getElementName(node)) { return { nodes: [{ @@ -30,7 +43,7 @@ export const handleStandardNode = (params) => { // Iterate through the children and build the schemaNode content // Skip run properties since they are formatting only elements const content = []; - if (elements && elements.length && name !== 'w:rPr') { + if (elements && elements.length) { const updatedElements = elements.map((el) => { if (!el.marks) el.marks = []; el.marks.push(...marks); @@ -41,14 +54,14 @@ export const handleStandardNode = (params) => { const childContent = nodeListHandler.handler(childParams); content.push(...childContent); } - + const resultNode = { type: getElementName(node), content, attrs: { ...attributes }, marks: [], }; - + return { nodes: [resultNode], consumed: 1 }; }; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 763338bfda..40191d680d 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -220,9 +220,10 @@ export class SuperDoc extends EventEmitter { #initTelemetry() { this.telemetry = new Telemetry({ enabled: this.config.telemetry?.enabled ?? true, - licenceKey: this.config.telemetry?.licenceKey, + licenseKey: this.config.telemetry?.licenseKey, endpoint: this.config.telemetry?.endpoint, superdocId: this.superdocId, + superdocVersion: this.version, }); } diff --git a/shared/common/Telemetry.js b/shared/common/Telemetry.js index 1e1e803bc1..41e3efc5de 100644 --- a/shared/common/Telemetry.js +++ b/shared/common/Telemetry.js @@ -1,43 +1,10 @@ -/** - * @typedef {Object} BaseEvent - * @property {string} id - Unique event identifier - * @property {string} timestamp - ISO timestamp of the event - * @property {string} sessionId - Current session identifier - * @property {string} superdocId - SuperDoc ID - * @property {Object} document - Document information - * @property {string} [document.id] - Reference ID - * @property {string} [document.type] - Document type - * @property {string} [document.internalId] - Internal document ID - * @property {string} [document.hash] - Document CRC32 hash - * @property {string} [document.lastModified] - Last modified timestamp - */ - -/** - * @typedef {Object} UsageEvent - * @param {File} fileSource - File object - * @param {string} documentId - document id - * @property {string} name - Name of the usage event - * @property {Object.} properties - Event properties - */ - -/** - * @typedef {Object} ParsingEvent - * @param {File} fileSource - File object - * @param {string} documentId - document id - * @property {'mark'|'element'} category - Category of the parsed item - * @property {string} name - Name of the parsed item - * @property {string} path - Document path where item was found - * @property {Object.} [metadata] - Additional context - */ - -/** @typedef {(UsageEvent & BaseEvent) | (ParsingEvent & BaseEvent)} TelemetryEvent */ - /** * @typedef {Object} TelemetryConfig - * @property {string} [licenceKey] - Licence key for telemetry service + * @property {string} [licenseKey] - License key for telemetry service * @property {boolean} [enabled=true] - Whether telemetry is enabled * @property {string} endpoint - service endpoint * @property {string} superdocId - SuperDoc id + * @property {string} superdocVersion - SuperDoc version */ import crc32 from 'buffer-crc32'; @@ -50,6 +17,9 @@ class Telemetry { /** @type {string} */ superdocId; + /** @type {string} */ + superdocVersion; + /** @type {string} */ licenseKey; @@ -59,17 +29,30 @@ class Telemetry { /** @type {string} */ sessionId; - /** @type {TelemetryEvent[]} */ - events = []; + /** @type {Object} */ + statistics = { + nodeTypes: {}, + markTypes: {}, + attributes: {}, + errorCount: 0, + }; + + /** @type {Array} */ + unknownElements = []; - /** @type {number|undefined} */ - flushInterval; + /** @type {Array} */ + errors = []; - /** @type {number} */ - static BATCH_SIZE = 50; + /** @type {Object} */ + fileStructure = { + totalFiles: 0, + maxDepth: 0, + totalNodes: 0, + files: [], + }; - /** @type {number} */ - static FLUSH_INTERVAL = 10000; // 30 seconds + /** @type {Object} */ + documentInfo = null; /** @type {string} */ static COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3'; @@ -84,27 +67,25 @@ class Telemetry { constructor(config = {}) { this.enabled = config.enabled ?? true; - this.licenseKey = config.licenceKey ?? Telemetry.COMMUNITY_LICENSE_KEY; + this.licenseKey = config.licenseKey ?? Telemetry.COMMUNITY_LICENSE_KEY; this.endpoint = config.endpoint ?? Telemetry.DEFAULT_ENDPOINT; this.superdocId = config.superdocId; + this.superdocVersion = config.superdocVersion; this.sessionId = this.generateId(); - - if (this.enabled) { - this.startPeriodicFlush(); - } } /** - * Create source payload for request + * Get browser environment information + * @returns {Object} Browser information */ - getSourceData() { + getBrowserInfo() { return { userAgent: window.navigator.userAgent, - url: window.location.href, - host: window.location.host, - referrer: document.referrer, - screen: { + currentUrl: window.location.href, + hostname: window.location.hostname, + referrerUrl: document.referrer, + screenSize: { width: window.screen.width, height: window.screen.height, }, @@ -112,101 +93,129 @@ class Telemetry { } /** - * Track feature usage - * @param {File} fileSource - File object - * @param {string} documentId - document id - * @param {string} name - Name of the feature/event - * @param {Object.} [properties] - Additional properties + * Track document usage event + * @param {string} name - Event name + * @param {Object} properties - Additional properties */ - async trackUsage(fileSource, documentId, name, properties = {}) { + async trackUsage(name, properties = {}) { if (!this.enabled) return; - if (!fileSource) { - console.warn('Telemetry: missing file source'); - return; - } - - const processedDoc = await this.processDocument(fileSource, { - id: documentId, - internalId: properties.internalId, - }); - - /** @type {UsageEvent & BaseEvent} */ const event = { id: this.generateId(), type: 'usage', timestamp: new Date().toISOString(), sessionId: this.sessionId, superdocId: this.superdocId, - source: this.getSourceData(), - document: processedDoc, + superdocVersion: this.superdocVersion, + file: this.documentInfo, + browser: this.getBrowserInfo(), name, properties, }; - this.queueEvent(event); + await this.sendDataToTelemetry(event); } /** - * Track parsing events - * @param {File} fileSource - File object - * @param {string} documentId - document id - * @param {'mark'|'element'} category - Category of parsed item - * @param {string} name - Name of the item - * @param {string} path - Document path where item was found - * @param {Object.} [metadata] - Additional context + * Track parsing statistics + * @param {string} category - Statistic category + * @param {string|Object} data - Statistic data */ - async trackParsing(fileSource, documentId, category, name, path, metadata) { - if (!this.enabled) return; - - if (!fileSource) { - console.warn('Telemetry: missing file source'); - return; + trackStatistic(category, data) { + if (category === 'node') { + this.statistics.nodeTypes[data.elementName] = (this.statistics.nodeTypes[data.elementName] || 0) + 1; + this.fileStructure.totalNodes++; + } else if (category === 'unknown') { + const addedElement = this.unknownElements.find(e => e.elementName === data.elementName); + if (addedElement) { + addedElement.count += 1; + addedElement.attributes = { + ...addedElement.attributes, + ...data.attributes + }; + } else { + this.unknownElements.push({ + ...data, + count: 1 + }); + } + } else if (category === 'error') { + this.errors.push(data); + this.statistics.errorCount++; } - const processedDoc = await this.processDocument(fileSource, { - id: documentId, - internalId: metadata.internalId, - }); + if (data.marks?.length) { + data.marks.forEach((mark) => { + this.statistics.markTypes[mark.type] = (this.statistics.markTypes[mark.type] || 0) + 1; + }); + } - /** @type {ParsingEvent & BaseEvent} */ - const event = { - id: this.generateId(), - type: 'parsing', - timestamp: new Date().toISOString(), - sessionId: this.sessionId, - superdocId: this.superdocId, - source: this.getSourceData(), - category, - document: processedDoc, - name, - path, - ...(metadata && { metadata }), - }; + // Style attributes + if (data.attributes && Object.keys(data.attributes).length) { + const styleAttributes = [ + 'textIndent', + 'textAlign', + 'spacing', + 'lineHeight', + 'indent', + 'list-style-type', + 'listLevel', + 'textStyle', + 'order', + 'lvlText', + 'lvlJc', + 'listNumberingType', + 'numId' + ]; + Object.keys(data.attributes).forEach((attribute) => { + if (!styleAttributes.includes(attribute)) return; + this.statistics.attributes[attribute] = (this.statistics.attributes[attribute] || 0) + 1; + }); + } + } - this.queueEvent(event); + /** + * Track file structure + * @param {Object} structure - File structure information + * @param {File} fileSource - original file + * @param {String} documentId - document ID + * @param {string} internalId - document ID form settings.xml + */ + async trackFileStructure(structure, fileSource, documentId, internalId) { + this.fileStructure = structure; + this.documentInfo = await this.processDocument(fileSource, { + id: documentId, + internalId: internalId, + }); } /** * Process document metadata * @param {File} file - Document file - * @param {Object} options - Additional metadata options + * @param {Object} options - Additional options * @returns {Promise} Document metadata */ async processDocument(file, options = {}) { + if (!file) { + console.warn('Telemetry: missing file source'); + return {}; + } + let hash = ''; try { hash = await this.generateCrc32Hash(file); } catch (error) { - console.error('Failed to retrieve file hash:', error); + console.error('Failed to generate file hash:', error); } - + return { id: options.id, - type: options.type || file.type, - internalId: options.internalId, - hash, + name: file.name, + size: file.size, + crc32: hash, lastModified: file.lastModified ? new Date(file.lastModified).toISOString() : null, + type: file.type || 'docx', + internalId: options.internalId, }; } @@ -220,33 +229,60 @@ class Telemetry { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const hashBuffer = crc32(buffer); - const hashArray = Array.from(hashBuffer); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashBuffer.toString('hex'); } - /** - * Queue event for sending - * @param {TelemetryEvent} event - * @private - */ - queueEvent(event) { - this.events.push(event); - - if (this.events.length >= Telemetry.BATCH_SIZE) { - this.flush(); + isTelemetryDataChanged() { + const initialStatistics = { + nodeTypes: {}, + markTypes: {}, + styleTypes: {}, + unknownElements: [], + errorCount: 0, + }; + const initialFileStructure = { + totalFiles: 0, + maxDepth: 0, + totalNodes: 0, + files: [], } + // Empty document case + if (Object.keys(this.statistics.nodeTypes).length <= 1) return; + + return this.statistics !== initialStatistics || initialFileStructure !== this.fileStructure || this.errors.length > 0 || this.unknownElements.length > 0; } /** - * Flush queued events to server + * Sends current report * @returns {Promise} */ - async flush() { - if (!this.enabled || !this.events.length) return; + async sendReport() { + if (!this.enabled || !this.isTelemetryDataChanged()) return; - const eventsToSend = [...this.events]; - this.events = []; + const report = [{ + id: this.generateId(), + type: 'parsing', + timestamp: new Date().toISOString(), + sessionId: this.sessionId, + superdocId: this.superdocId, + superdocVersion: this.superdocVersion, + file: this.documentInfo, + browser: this.getBrowserInfo(), + statistics: this.statistics, + fileStructure: this.fileStructure, + unknownElements: this.unknownElements, + errors: this.errors, + }]; + await this.sendDataToTelemetry(report); + } + + /** + * Sends data to the service + * @returns {Object} payload + * @returns {Promise} + */ + async sendDataToTelemetry(data) { try { const response = await fetch(this.endpoint, { method: 'POST', @@ -254,50 +290,52 @@ class Telemetry { 'Content-Type': 'application/json', 'X-License-Key': this.licenseKey, }, - body: JSON.stringify(eventsToSend), + body: JSON.stringify(data), }); if (!response.ok) { throw new Error(`Upload failed: ${response.statusText}`); + } else { + this.resetStatistics(); } } catch (error) { console.error('Failed to upload telemetry:', error); - // Add events back to queue - this.events = [...eventsToSend, ...this.events]; } } - /** - * Start periodic flush interval - * @private - */ - startPeriodicFlush() { - this.flushInterval = setInterval(() => { - if (this.events.length > 0) { - this.flush(); - } - }, Telemetry.FLUSH_INTERVAL); - } - /** * Generate unique identifier - * @returns {string} + * @returns {string} Unique ID * @private */ generateId() { - const randomValue = randomBytes(4).toString('hex'); - return `${Date.now()}-${randomValue}`; + const timestamp = Date.now(); + const random = randomBytes(4).toString('hex'); + return `${timestamp}-${random}`; } /** - * Clean up telemetry service - * @returns {Promise} + * Reset statistics */ - destroy() { - if (this.flushInterval) { - clearInterval(this.flushInterval); - } - return this.flush(); + resetStatistics() { + this.statistics = { + nodeTypes: {}, + markTypes: {}, + styleTypes: {}, + unknownElements: [], + errorCount: 0, + }; + + this.fileStructure = { + totalFiles: 0, + maxDepth: 0, + totalNodes: 0, + files: [], + }; + + this.unknownElements = []; + + this.errors = []; } }