Skip to content

Commit 6c98b63

Browse files
reusable xml id validation method
1 parent 42c2e5f commit 6c98b63

4 files changed

Lines changed: 346 additions & 6 deletions

File tree

packages/fe-fpm-writer/src/common/file.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { CopyOptions, Editor } from 'mem-fs-editor';
22
import type { TabInfo } from '../common/types';
33
import { sep, normalize } from 'node:path';
44
import { findFilesByExtension } from '@sap-ux/project-access/dist/file';
5-
import { DOMParser } from '@xmldom/xmldom';
5+
import { validateId } from '@sap-ux/project-access';
66

77
/**
88
* Options for creating an ID generator with cached file contents.
@@ -168,9 +168,16 @@ export const CONFIG = {
168168
function generateUniqueElementId(baseId: string, filteredFilesContent: string[], validatedIds: string[] = []): string {
169169
const maxAttempts = 1000;
170170

171+
/**
172+
* Checks if an element with the specified id is available (does not exist) in the XML content.
173+
*
174+
* @param id - id to check for availability
175+
* @param xmlContent - XML content as string
176+
* @returns true if the id is available (not found), false if it exists
177+
*/
171178
function checkElementIdAvailable(id: string, xmlContent: string): boolean {
172-
const xmlDocument = new DOMParser({ errorHandler: (): void => {} }).parseFromString(xmlContent);
173-
return xmlDocument.documentElement ? !xmlDocument.getElementById(id) : true;
179+
// Use validateId from @sap-ux/project-access (synchronous overload)
180+
return validateId(id, undefined, { files: [xmlContent] });
174181
}
175182

176183
if (

packages/project-access/src/library/helpers.ts

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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';
1212
import { FileName } from '../constants';
1313
import { existsSync, promises as fs } from 'node:fs';
1414
import { XMLParser } from 'fast-xml-parser';
1515
import { getI18nPropertiesPaths } from '../project/i18n';
1616
import { 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+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { getReuseLibs, checkDependencies } from './helpers';
1+
export { getReuseLibs, checkDependencies, validateId } from './helpers';

packages/project-access/test/library/helpers.test.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { join } from 'node:path';
2-
import { checkDependencies, getReuseLibs, getLibraryDesc, getManifestDesc } from '../../src/library/helpers';
2+
import {
3+
checkDependencies,
4+
getReuseLibs,
5+
getLibraryDesc,
6+
getManifestDesc,
7+
validateId
8+
} from '../../src/library/helpers';
39
import * as manifestJson from '../test-data/libs/sap.reuse.ex.test.lib.attachmentservice/src/sap/reuse/ex/test/lib/attachmentservice/manifest.json';
410
import type { LibraryXml, Manifest, ReuseLib } from '../../src';
511
import * as fileUtils from '../../src/file';
@@ -101,3 +107,158 @@ describe('library utils', () => {
101107
expect(description).toEqual('test description');
102108
});
103109
});
110+
111+
describe('validateId', () => {
112+
const sampleView = `<mvc:View
113+
xmlns:mvc="sap.ui.core.mvc"
114+
xmlns="sap.m"
115+
controllerName="my.app.controller.Main">
116+
<Page id="mainPage" title="Main View">
117+
<content>
118+
<Button id = "submitButton" text="Submit" />
119+
<Input id="nameInput" placeholder="Enter name" />
120+
<Table id ="dataTable">
121+
<columns>
122+
<Column>
123+
<Text text="Name" />
124+
</Column>
125+
</columns>
126+
</Table>
127+
</content>
128+
</Page>
129+
</mvc:View>`;
130+
131+
const sampleFragment = `<core:FragmentDefinition
132+
xmlns="sap.m"
133+
xmlns:core="sap.ui.core">
134+
<Dialog id="confirmDialog" title="Confirm Action">
135+
<content>
136+
<Text id= "dialogText" text="Are you sure?" />
137+
</content>
138+
<beginButton>
139+
<Button id="confirmButton" text="Confirm" press="onConfirm" />
140+
</beginButton>
141+
<endButton>
142+
<Button id="cancelButton" text="Cancel" press="onCancel" />
143+
</endButton>
144+
</Dialog>
145+
</core:FragmentDefinition>`;
146+
147+
const sampleViewWithNamespace = `<mvc:View
148+
xmlns:mvc="sap.ui.core.mvc"
149+
xmlns="sap.m"
150+
xmlns:f="sap.ui.layout.form">
151+
<f:SimpleForm id="detailForm">
152+
<f:content>
153+
<Label text="Title" />
154+
<Input id="titleInput" />
155+
</f:content>
156+
</f:SimpleForm>
157+
</mvc:View>`;
158+
159+
test('should return true when id does not exist in any files', async () => {
160+
const result = await validateId('newButton', undefined, {
161+
files: [sampleView, sampleFragment]
162+
});
163+
expect(result).toBe(true);
164+
});
165+
166+
test('should return false when id exists in view', async () => {
167+
const result = await validateId('submitButton', undefined, {
168+
files: [sampleView, sampleFragment]
169+
});
170+
expect(result).toBe(false);
171+
});
172+
173+
test('should return false when id exists in fragment', async () => {
174+
const result = await validateId('confirmDialog', undefined, {
175+
files: [sampleView, sampleFragment]
176+
});
177+
expect(result).toBe(false);
178+
});
179+
180+
test('should return false when id is in validatedIds array', async () => {
181+
const result = await validateId('newButton', ['newButton', 'anotherButton'], {
182+
files: [sampleView, sampleFragment]
183+
});
184+
expect(result).toBe(false);
185+
});
186+
187+
test('should return true when id is unique across multiple files', async () => {
188+
const result = await validateId('uniqueId', undefined, {
189+
files: [sampleView, sampleFragment, sampleViewWithNamespace]
190+
});
191+
expect(result).toBe(true);
192+
});
193+
194+
test('should return false when id exists in nested elements', async () => {
195+
const result = await validateId('dataTable', undefined, {
196+
files: [sampleView]
197+
});
198+
expect(result).toBe(false);
199+
});
200+
201+
test('should return false when id exists in fragment dialog content', async () => {
202+
const result = await validateId('dialogText', undefined, {
203+
files: [sampleFragment]
204+
});
205+
expect(result).toBe(false);
206+
});
207+
208+
test('should return true for empty files array', async () => {
209+
const result = await validateId('anyId', undefined, {
210+
files: []
211+
});
212+
expect(result).toBe(true);
213+
});
214+
215+
test('should return true when XML parsing fails', async () => {
216+
// fast-xml-parser is lenient, but completely invalid content should fail
217+
const invalidXml = '<<<>>><invalid';
218+
const result = await validateId('test', undefined, {
219+
files: [invalidXml]
220+
});
221+
expect(result).toBe(true);
222+
});
223+
224+
test('should return true when files contain empty strings', async () => {
225+
const result = await validateId('testId', undefined, {
226+
files: ['', '', sampleView]
227+
});
228+
expect(result).toBe(true);
229+
});
230+
231+
test('should handle ids with special characters', async () => {
232+
const xmlWithSpecialId = `<?xml version="1.0" encoding="UTF-8"?>
233+
<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m">
234+
<Button id="button-with-dash" text="Test" />
235+
<Button id="button_with_underscore" text="Test" />
236+
<Button id="button.with.dot" text="Test" />
237+
</mvc:View>`;
238+
239+
const result1 = await validateId('button-with-dash', undefined, {
240+
files: [xmlWithSpecialId]
241+
});
242+
expect(result1).toBe(false);
243+
244+
const result2 = await validateId('button_with_underscore', undefined, {
245+
files: [xmlWithSpecialId]
246+
});
247+
expect(result2).toBe(false);
248+
249+
const result3 = await validateId('button.with.dot', undefined, {
250+
files: [xmlWithSpecialId]
251+
});
252+
expect(result3).toBe(false);
253+
254+
const result4 = await validateId('button-not-exists', undefined, {
255+
files: [xmlWithSpecialId]
256+
});
257+
expect(result4).toBe(true);
258+
});
259+
260+
test('should return true when no options provided', async () => {
261+
const result = await validateId('anyId');
262+
expect(result).toBe(true);
263+
});
264+
});

0 commit comments

Comments
 (0)