Skip to content
Open
6 changes: 6 additions & 0 deletions .changeset/funny-socks-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sap-ux/project-access': patch
Comment thread
Jimmy-Joseph19 marked this conversation as resolved.
Outdated
'@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.
17 changes: 3 additions & 14 deletions packages/fe-fpm-writer/src/common/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
}
Expand Down
182 changes: 181 additions & 1 deletion packages/project-access/src/library/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -307,3 +310,180 @@ export function getManifestDependencies(manifest: Manifest): string[] {

return result;
}

/**
* 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<boolean>;

/**
* Validates if an id is unique across XML files (fragments and views) in the project.
* Asynchronous overload - when no options are provided.
*
* @param baseId - id to validate
* @param validatedIds - array of ids that are already validated/used
* @param options - undefined (no validation options)
* @returns Promise that resolves to true (always valid when no files to check)
*/
export function validateId(baseId: string, validatedIds?: string[], options?: undefined): Promise<boolean>;

// Implementation
export function validateId(
baseId: string,
validatedIds?: string[],
options?: { files?: string[]; appPath?: string; memFs?: Editor }
): boolean | Promise<boolean> {
const { memFs, appPath, files: fileContents } = options ?? {};

/**
* 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;
}
}

/**
* 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;
}

/**
* 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<string, unknown>;

// 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;
}

/**
* 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 {
return (
files.every((content) => content === '' || checkElementIdAvailable(baseId, content)) &&
!validatedIds?.includes(baseId)
);
}

// Synchronous path: when files are provided directly
if (fileContents !== undefined) {
return validateAgainstFiles(fileContents);
}

// Asynchronous path: when appPath is provided or no options
return (async (): Promise<boolean> => {
let files: string[] | undefined;
if (appPath) {
// 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
files = filteredPaths.map((path: string) => fsEditor.read(path));
}

if (files) {
return validateAgainstFiles(files);
}
return true;
})();
}
2 changes: 1 addition & 1 deletion packages/project-access/src/library/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { getReuseLibs, checkDependencies } from './helpers';
export { getReuseLibs, checkDependencies, validateId } from './helpers';
Comment thread
Jimmy-Joseph19 marked this conversation as resolved.
Loading
Loading