@@ -8,12 +8,13 @@ import {
88 type ReuseLib ,
99 type LibraryXml
1010} from '../types' ;
11- import { findFiles , readJSON } from '../file' ;
11+ import { findFiles , findFilesByExtension , readJSON } from '../file' ;
1212import { FileName } from '../constants' ;
1313import { existsSync , promises as fs } from 'node:fs' ;
1414import { XMLParser } from 'fast-xml-parser' ;
1515import { getI18nPropertiesPaths } from '../project/i18n' ;
1616import { getPropertiesI18nBundle } from '@sap-ux/i18n' ;
17+ import type { Editor } from 'mem-fs-editor' ;
1718
1819/**
1920 * Reads the manifest file and returns the reuse library.
@@ -307,3 +308,174 @@ export function getManifestDependencies(manifest: Manifest): string[] {
307308
308309 return result ;
309310}
311+
312+ /**
313+ * Validates if an id is unique across XML files (fragments and views) in the project.
314+ * Synchronous overload - when files are provided directly.
315+ *
316+ * @param baseId - id to validate
317+ * @param validatedIds - array of ids that are already validated/used
318+ * @param options - validation options with files array
319+ * @param options.files - array of XML file contents to check
320+ * @param options.appPath - must be undefined for synchronous overload
321+ * @param options.memFs - must be undefined for synchronous overload
322+ * @returns true if the id is unique (available), false if it already exists
323+ */
324+ export function validateId (
325+ baseId : string ,
326+ validatedIds : string [ ] | undefined ,
327+ options : { files : string [ ] ; appPath ?: never ; memFs ?: never }
328+ ) : boolean ;
329+
330+ /**
331+ * Validates if an id is unique across XML files (fragments and views) in the project.
332+ * Asynchronous overload - when appPath is provided (requires file system access).
333+ *
334+ * @param baseId - id to validate
335+ * @param validatedIds - array of ids that are already validated/used
336+ * @param options - validation options with appPath
337+ * @param options.files - must be undefined for asynchronous overload
338+ * @param options.appPath - path to search for XML files
339+ * @param options.memFs - optional mem-fs-editor instance for reading files
340+ * @returns Promise that resolves to true if the id is unique (available), false if it already exists
341+ */
342+ export function validateId (
343+ baseId : string ,
344+ validatedIds : string [ ] | undefined ,
345+ options : { files ?: never ; appPath : string ; memFs ?: Editor }
346+ ) : Promise < boolean > ;
347+
348+ /**
349+ * Validates if an id is unique across XML files (fragments and views) in the project.
350+ * Asynchronous overload - when no options are provided.
351+ *
352+ * @param baseId - id to validate
353+ * @param validatedIds - array of ids that are already validated/used
354+ * @param options - undefined (no validation options)
355+ * @returns Promise that resolves to true (always valid when no files to check)
356+ */
357+ export function validateId ( baseId : string , validatedIds ?: string [ ] , options ?: undefined ) : Promise < boolean > ;
358+
359+ // Implementation
360+ export function validateId (
361+ baseId : string ,
362+ validatedIds ?: string [ ] ,
363+ options ?: { files ?: string [ ] ; appPath ?: string ; memFs ?: Editor }
364+ ) : boolean | Promise < boolean > {
365+ const { memFs, appPath, files : fileContents } = options ?? { } ;
366+
367+ /**
368+ * Checks if an element with the specified id is available (does not exist) in the XML content.
369+ *
370+ * @param id - id to check for availability
371+ * @param xmlContent - XML content as string
372+ * @returns true if the id is available (not found), false if it exists
373+ */
374+ function checkElementIdAvailable ( id : string , xmlContent : string ) : boolean {
375+ const parser = new XMLParser ( {
376+ ignoreAttributes : false ,
377+ attributeNamePrefix : '@_' ,
378+ parseAttributeValue : false
379+ } ) ;
380+
381+ try {
382+ const xmlDocument : unknown = parser . parse ( xmlContent ) ;
383+ return xmlDocument ? ! hasElementWithId ( xmlDocument , id ) : true ;
384+ } catch {
385+ // Parse error = no valid document = no element with id
386+ return true ;
387+ }
388+ }
389+
390+ /**
391+ * Checks if a value (object or array) contains an element with the specified id.
392+ *
393+ * @param value - value to check (can be array or object)
394+ * @param id - id to search for
395+ * @param attrPrefix - attribute prefix used by the parser
396+ * @returns true if id is found in the value
397+ */
398+ function checkIdInValue ( value : unknown , id : string , attrPrefix : string ) : boolean {
399+ if ( Array . isArray ( value ) ) {
400+ return value . some ( ( item ) => hasElementWithId ( item , id , attrPrefix ) ) ;
401+ }
402+ if ( typeof value === 'object' && value !== null ) {
403+ return hasElementWithId ( value , id , attrPrefix ) ;
404+ }
405+ return false ;
406+ }
407+
408+ /**
409+ * Recursively searches for an element with the specified id attribute in a parsed XML object.
410+ *
411+ * @param obj - parsed XML object to search in
412+ * @param id - id attribute value to search for
413+ * @param attrPrefix - attribute prefix used by the parser (default: '@_')
414+ * @returns true if an element with the specified id is found
415+ */
416+ function hasElementWithId ( obj : unknown , id : string , attrPrefix : string = '@_' ) : boolean {
417+ if ( ! obj || typeof obj !== 'object' ) {
418+ return false ;
419+ }
420+
421+ const objRecord = obj as Record < string , unknown > ;
422+
423+ // Check if current object has the id attribute
424+ if ( objRecord [ `${ attrPrefix } id` ] === id ) {
425+ return true ;
426+ }
427+
428+ // Recursively search in all properties
429+ for ( const key in objRecord ) {
430+ if ( key . startsWith ( attrPrefix ) ) {
431+ continue ; // Skip attributes
432+ }
433+
434+ if ( checkIdInValue ( objRecord [ key ] , id , attrPrefix ) ) {
435+ return true ;
436+ }
437+ }
438+
439+ return false ;
440+ }
441+
442+ /**
443+ * Validates the ID against the provided files.
444+ *
445+ * @param files - array of XML file contents to validate against
446+ * @returns true if the id is unique (available), false if it already exists
447+ */
448+ function validateAgainstFiles ( files : string [ ] ) : boolean {
449+ return (
450+ files . every ( ( content ) => content === '' || checkElementIdAvailable ( baseId , content ) ) &&
451+ ! validatedIds ?. includes ( baseId )
452+ ) ;
453+ }
454+
455+ // Synchronous path: when files are provided directly
456+ if ( fileContents !== undefined ) {
457+ return validateAgainstFiles ( fileContents ) ;
458+ }
459+
460+ // Asynchronous path: when appPath is provided or no options
461+ return ( async ( ) : Promise < boolean > => {
462+ let files : string [ ] | undefined = fileContents ;
463+ if ( ! files && appPath ) {
464+ const xmlFiles = await findFilesByExtension (
465+ '.xml' ,
466+ appPath ,
467+ [ '.git' , 'node_modules' , 'dist' , 'annotations' , 'localService' ] ,
468+ memFs
469+ ) ;
470+ const lookupFiles = [ '.fragment.xml' , '.view.xml' ] ;
471+ files = xmlFiles . filter ( ( fileName : string ) =>
472+ lookupFiles . some ( ( lookupFile ) => fileName . endsWith ( lookupFile ) )
473+ ) ;
474+ }
475+
476+ if ( files ) {
477+ return validateAgainstFiles ( files ) ;
478+ }
479+ return true ;
480+ } ) ( ) ;
481+ }
0 commit comments