diff --git a/.changeset/funny-socks-rescue.md b/.changeset/funny-socks-rescue.md new file mode 100644 index 00000000000..aa1e98e8a58 --- /dev/null +++ b/.changeset/funny-socks-rescue.md @@ -0,0 +1,6 @@ +--- +'@sap-ux/project-access': minor +'@sap-ux/fe-fpm-writer': patch +--- + +Added validateId function to @sap-ux/project-access with sync/async overloads and reused it in @sap-ux/fe-fpm-writer. diff --git a/packages/fe-fpm-writer/src/common/file.ts b/packages/fe-fpm-writer/src/common/file.ts index 3165ead92d6..040a62eac97 100644 --- a/packages/fe-fpm-writer/src/common/file.ts +++ b/packages/fe-fpm-writer/src/common/file.ts @@ -2,7 +2,7 @@ import type { CopyOptions, Editor } from 'mem-fs-editor'; import type { TabInfo } from '../common/types'; import { sep, normalize } from 'node:path'; import { findFilesByExtension } from '@sap-ux/project-access/dist/file'; -import { DOMParser } from '@xmldom/xmldom'; +import { validateId } from '@sap-ux/project-access'; /** * Options for creating an ID generator with cached file contents. @@ -168,25 +168,14 @@ export const CONFIG = { function generateUniqueElementId(baseId: string, filteredFilesContent: string[], validatedIds: string[] = []): string { const maxAttempts = 1000; - function checkElementIdAvailable(id: string, xmlContent: string): boolean { - const xmlDocument = new DOMParser({ errorHandler: (): void => {} }).parseFromString(xmlContent); - return xmlDocument.documentElement ? !xmlDocument.getElementById(id) : true; - } - - if ( - filteredFilesContent.every((content) => content === '' || checkElementIdAvailable(baseId, content)) && - !validatedIds.includes(baseId) - ) { + if (validateId(baseId, validatedIds, { files: filteredFilesContent })) { return baseId; } for (let counter = 1; counter < maxAttempts; counter++) { const candidateId = `${baseId}${counter}`; - if ( - filteredFilesContent.every((content) => content === '' || checkElementIdAvailable(candidateId, content)) && - !validatedIds.includes(candidateId) - ) { + if (validateId(candidateId, validatedIds, { files: filteredFilesContent })) { return candidateId; } } diff --git a/packages/project-access/src/index.ts b/packages/project-access/src/index.ts index f94f4e6f052..cd45aaffa20 100644 --- a/packages/project-access/src/index.ts +++ b/packages/project-access/src/index.ts @@ -71,6 +71,6 @@ export { } from './project'; export { execNpmCommand } from './command/npm-command'; export * from './types'; -export * from './library'; +export { checkDependencies, getReuseLibs, validateId } from './library'; export { findRecursiveHierarchyKey, getTableCapabilitiesByEntitySet } from './odata'; export { hasDependency } from './project'; diff --git a/packages/project-access/src/library/helpers.ts b/packages/project-access/src/library/helpers.ts index b6b8c03aef3..655a50d03ff 100644 --- a/packages/project-access/src/library/helpers.ts +++ b/packages/project-access/src/library/helpers.ts @@ -8,12 +8,15 @@ import { type ReuseLib, type LibraryXml } from '../types'; -import { findFiles, readJSON } from '../file'; +import { findFiles, findFilesByExtension, readJSON } from '../file'; import { FileName } from '../constants'; import { existsSync, promises as fs } from 'node:fs'; import { XMLParser } from 'fast-xml-parser'; import { getI18nPropertiesPaths } from '../project/i18n'; import { getPropertiesI18nBundle } from '@sap-ux/i18n'; +import type { Editor } from 'mem-fs-editor'; +import { create as createStorage } from 'mem-fs'; +import { create } from 'mem-fs-editor'; /** * Reads the manifest file and returns the reuse library. @@ -307,3 +310,169 @@ export function getManifestDependencies(manifest: Manifest): string[] { return result; } + +/** + * Recursively searches for an element with the specified id attribute in a parsed XML object. + * + * @param obj - parsed XML object to search in + * @param id - id attribute value to search for + * @param attrPrefix - attribute prefix used by the parser (default: '@_') + * @returns true if an element with the specified id is found + */ +function hasElementWithId(obj: unknown, id: string, attrPrefix: string = '@_'): boolean { + if (!obj || typeof obj !== 'object') { + return false; + } + + const objRecord = obj as Record; + + // Check if current object has the id attribute + if (objRecord[`${attrPrefix}id`] === id) { + return true; + } + + // Recursively search in all properties + for (const key in objRecord) { + if (key.startsWith(attrPrefix)) { + continue; // Skip attributes + } + + if (checkIdInValue(objRecord[key], id, attrPrefix)) { + return true; + } + } + + return false; +} + +/** + * Checks if a value (object or array) contains an element with the specified id. + * + * @param value - value to check (can be array or object) + * @param id - id to search for + * @param attrPrefix - attribute prefix used by the parser + * @returns true if id is found in the value + */ +function checkIdInValue(value: unknown, id: string, attrPrefix: string): boolean { + if (Array.isArray(value)) { + return value.some((item) => hasElementWithId(item, id, attrPrefix)); + } + if (typeof value === 'object' && value !== null) { + return hasElementWithId(value, id, attrPrefix); + } + return false; +} + +/** + * Checks if an element with the specified id is available (does not exist) in the XML content. + * + * @param id - id to check for availability + * @param xmlContent - XML content as string + * @returns true if the id is available (not found), false if it exists + */ +function checkElementIdAvailable(id: string, xmlContent: string): boolean { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + parseAttributeValue: false + }); + + try { + const xmlDocument: unknown = parser.parse(xmlContent); + return xmlDocument ? !hasElementWithId(xmlDocument, id) : true; + } catch { + // Parse error = no valid document = no element with id + return true; + } +} + +/** + * Validates if an id is unique across XML files (fragments and views) in the project. + * Synchronous overload - when files are provided directly. + * + * @param baseId - id to validate + * @param validatedIds - array of ids that are already validated/used + * @param options - validation options with files array + * @param options.files - array of XML file contents to check + * @param options.appPath - must be undefined for synchronous overload + * @param options.memFs - must be undefined for synchronous overload + * @returns true if the id is unique (available), false if it already exists + */ +export function validateId( + baseId: string, + validatedIds: string[] | undefined, + options: { files: string[]; appPath?: never; memFs?: never } +): boolean; + +/** + * Validates if an id is unique across XML files (fragments and views) in the project. + * Asynchronous overload - when appPath is provided (requires file system access). + * + * @param baseId - id to validate + * @param validatedIds - array of ids that are already validated/used + * @param options - validation options with appPath + * @param options.files - must be undefined for asynchronous overload + * @param options.appPath - path to search for XML files + * @param options.memFs - optional mem-fs-editor instance for reading files + * @returns Promise that resolves to true if the id is unique (available), false if it already exists + */ +export function validateId( + baseId: string, + validatedIds: string[] | undefined, + options: { files?: never; appPath: string; memFs?: Editor } +): Promise; + +// Implementation +export function validateId( + baseId: string, + validatedIds: string[] | undefined, + options: { files?: string[]; appPath?: string; memFs?: Editor } +): boolean | Promise { + const { memFs, appPath, files: fileContents } = options; + + /** + * Validates the ID against the provided files. + * + * @param files - array of XML file contents to validate against + * @returns true if the id is unique (available), false if it already exists + */ + function validateAgainstFiles(files: string[]): boolean { + // Check validatedIds first - fast O(n) check avoids expensive XML parsing + if (validatedIds?.includes(baseId)) { + return false; + } + + // Only parse XML files if validatedIds check passed + return files.every((content) => content === '' || checkElementIdAvailable(baseId, content)); + } + + // Synchronous path: when files are provided directly + if (fileContents !== undefined) { + return validateAgainstFiles(fileContents); + } + + // Asynchronous path: when appPath is provided + if (!appPath) { + throw new Error('validateId requires either files or appPath to be provided in options'); + } + + return (async (): Promise => { + // Ensure we have a memFs instance + const fsEditor = memFs ?? create(createStorage()); + + const xmlFilePaths = await findFilesByExtension( + '.xml', + appPath, + ['.git', 'node_modules', 'dist', 'annotations', 'localService'], + fsEditor + ); + const lookupFiles = ['.fragment.xml', '.view.xml']; + const filteredPaths = xmlFilePaths.filter((fileName: string) => + lookupFiles.some((lookupFile) => fileName.endsWith(lookupFile)) + ); + + // Read file contents from paths using memFs + const files = filteredPaths.map((path: string) => fsEditor.read(path)); + return validateAgainstFiles(files); + })(); +} diff --git a/packages/project-access/src/library/index.ts b/packages/project-access/src/library/index.ts index df1d68bb312..26c5876d516 100644 --- a/packages/project-access/src/library/index.ts +++ b/packages/project-access/src/library/index.ts @@ -1 +1 @@ -export { getReuseLibs, checkDependencies } from './helpers'; +export { getReuseLibs, checkDependencies, validateId } from './helpers'; diff --git a/packages/project-access/test/library/helpers.test.ts b/packages/project-access/test/library/helpers.test.ts index e4c79d15bcb..1153727f366 100644 --- a/packages/project-access/test/library/helpers.test.ts +++ b/packages/project-access/test/library/helpers.test.ts @@ -1,8 +1,16 @@ import { join } from 'node:path'; -import { checkDependencies, getReuseLibs, getLibraryDesc, getManifestDesc } from '../../src/library/helpers'; +import { + checkDependencies, + getReuseLibs, + getLibraryDesc, + getManifestDesc, + validateId +} from '../../src/library/helpers'; import * as manifestJson from '../test-data/libs/sap.reuse.ex.test.lib.attachmentservice/src/sap/reuse/ex/test/lib/attachmentservice/manifest.json'; import type { LibraryXml, Manifest, ReuseLib } from '../../src'; import * as fileUtils from '../../src/file'; +import { create as createStorage } from 'mem-fs'; +import { create as createEditor } from 'mem-fs-editor'; describe('library utils', () => { test('should return library choices', async () => { @@ -101,3 +109,331 @@ describe('library utils', () => { expect(description).toEqual('test description'); }); }); + +describe('validateId', () => { + const sampleView = ` + + +