From 3752eebd1b1c869c52d7292a6c1c4c04beaddca2 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Wed, 6 May 2026 20:52:52 -0300 Subject: [PATCH 01/10] Add UI5-to-BSP build action and converter script - Workflow `.github/workflows/build-bsp.yml` runs `ui5 build` then converts the output into an abapGit-compatible BSP layout, uploaded as artifact - `scripts/build-bsp.mjs` implements the conversion: filename encoding (`/` -> `_-` in pagenames, `/` -> `#` in BSP/package names), subfolder layout with `FOLDER_LOGIC=FULL`, MIMETYPE/IS_START_PAGE for index.html, alphabetical PAGES sorting, manifest.json odata patching - Encoding rules verified against the abapGit WAPA serializer source --- .github/workflows/build-bsp.yml | 59 ++++++++ scripts/build-bsp.mjs | 247 ++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 .github/workflows/build-bsp.yml create mode 100644 scripts/build-bsp.mjs diff --git a/.github/workflows/build-bsp.yml b/.github/workflows/build-bsp.yml new file mode 100644 index 0000000..170150b --- /dev/null +++ b/.github/workflows/build-bsp.yml @@ -0,0 +1,59 @@ +name: Build BSP for abapGit + +on: + workflow_dispatch: + inputs: + bsp_name: + description: 'BSP application name (Z*** or /NAMESPACE/NAME)' + required: true + default: 'Z2UI5_V2' + package: + description: 'ABAP package / devclass (e.g. $TMP, /Z2UI5/UI5_APPS)' + required: true + default: '$TMP' + odata_path: + description: 'Backend URI for manifest.json dataSource' + required: true + default: '/sap/bc/z2ui5' + app_title: + description: 'BSP application title (TEXT field, optional)' + required: false + default: '' + source_dir: + description: 'Path to UI5 app (relative to repo root)' + required: true + default: 'app_v2_new' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install UI5 app dependencies + working-directory: ${{ inputs.source_dir }} + run: npm ci + + - name: Build UI5 app (ui5 build) + working-directory: ${{ inputs.source_dir }} + run: npm run build + + - name: Convert build output to abapGit BSP layout + run: | + node scripts/build-bsp.mjs \ + --src "${{ inputs.source_dir }}/dist" \ + --out result \ + --bsp "${{ inputs.bsp_name }}" \ + --pkg "${{ inputs.package }}" \ + --odata "${{ inputs.odata_path }}" \ + --title "${{ inputs.app_title }}" + + - name: Upload result as artifact + uses: actions/upload-artifact@v4 + with: + name: bsp-${{ inputs.bsp_name }} + path: result/ diff --git a/scripts/build-bsp.mjs b/scripts/build-bsp.mjs new file mode 100644 index 0000000..41774d3 --- /dev/null +++ b/scripts/build-bsp.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +// Convert a UI5 build output directory into an abapGit-compatible BSP +// repository layout (subfolder layout, FOLDER_LOGIC=FULL). +// +// Encoding rules verified against abapGit source +// (zcl_abapgit_object_wapa.clas.abap, zcl_abapgit_folder_logic.clas.abap): +// +// - Page filename: pagename is split at the FIRST '.' into (extra, ext). +// '/' in either part is replaced with '_-'. +// Result is lowercased. +// Final filename: .wapa.. +// - Namespace: '/NS/NAME' -> '#ns#name' in filenames (lowercase). +// APPLNAME inside XML keeps original uppercase form. +// - PAGEKEY: UPPERCASE form of pagename, with slashes preserved. +// - index.html: gets text/html + X +// instead of X. +// - PAGES order: alphabetical by PAGEKEY (matches abapGit serializer). +// +// Usage: +// node build-bsp.mjs --src --bsp [options] +// +// Options: +// --src Source directory (output of `ui5 build`) +// --out Output directory (default: result) +// --bsp BSP application name (Z*** or /NAMESPACE/NAME) +// --pkg ABAP package / devclass (default: $TMP) +// --odata OData/REST URI to inject into manifest.json +// --title BSP description (TEXT/CTEXT field) + +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +const { values: args } = parseArgs({ + options: { + src: { type: 'string' }, + out: { type: 'string', default: 'result' }, + bsp: { type: 'string' }, + pkg: { type: 'string', default: '$TMP' }, + odata: { type: 'string', default: '/sap/bc/z2ui5' }, + title: { type: 'string', default: '' }, + }, +}); + +if (!args.src || !args.bsp) { + console.error( + 'Usage: node build-bsp.mjs --src --bsp ' + + '[--pkg ] [--odata ] [--title ] [--out ]' + ); + process.exit(1); +} + +// --- naming helpers --- + +const isNamespaced = (n) => { + if (!n.startsWith('/')) return false; + const second = n.indexOf('/', 1); + return second > 0 && second < n.length - 1; +}; + +// SAP object name -> filesystem-safe encoded name (lowercase, '/' -> '#'). +const fsEncode = (n) => n.toLowerCase().replace(/\//g, '#'); + +// APPLEXT field: uppercased, slashes stripped, max 30 chars. +const computeApplext = (applname) => + applname.replace(/^\//, '').replace(/\//g, '').toUpperCase().substring(0, 30); + +// WAPA page filename per abapGit serializer: +// SPLIT pagename AT '.' INTO extra ext (only first '.' splits) +// REPLACE '/' WITH '_-' in both +// filename = .wapa.[.] +function pageFilename(applnameFs, pagename) { + const dot = pagename.indexOf('.'); + let extra, ext; + if (dot < 0) { + extra = pagename; + ext = ''; + } else { + extra = pagename.substring(0, dot); + ext = pagename.substring(dot + 1); + } + extra = extra.replace(/\//g, '_-').toLowerCase(); + ext = ext.replace(/\//g, '_-').toLowerCase(); + return ext + ? `${applnameFs}.wapa.${extra}.${ext}` + : `${applnameFs}.wapa.${extra}`; +} + +const escXml = (s) => + String(s).replace(/[&<>"]/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] + ); + +const BOM = ''; + +async function* walk(dir, base = dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full, base); + else if (entry.isFile()) yield relative(base, full).replaceAll('\\', '/'); + } +} + +// --- main --- + +const srcDir = resolve(args.src); +const outDir = resolve(args.out); + +const applname = args.bsp.toUpperCase(); +const applnameFs = fsEncode(args.bsp); +const applext = computeApplext(applname); +const pkgName = args.pkg.toUpperCase(); +const pkgFs = fsEncode(args.pkg); +const nsFlag = isNamespaced(args.bsp) || isNamespaced(args.pkg); + +const subfolder = `src/${pkgFs}`; +const targetDir = join(outDir, subfolder); +await mkdir(targetDir, { recursive: true }); + +const pages = []; +let patchedManifest = false; + +for await (const rel of walk(srcDir)) { + const filename = pageFilename(applnameFs, rel); + const dst = join(targetDir, filename); + let content = await readFile(join(srcDir, rel)); + + if (rel === 'manifest.json') { + try { + const json = JSON.parse(content.toString('utf8')); + const ds = json?.['sap.app']?.dataSources; + if (ds && typeof ds === 'object') { + for (const k of Object.keys(ds)) { + if (ds[k] && typeof ds[k].uri === 'string') { + ds[k].uri = args.odata; + } + } + } + content = Buffer.from(JSON.stringify(json, null, '\t'), 'utf8'); + patchedManifest = true; + } catch (err) { + console.warn(`warn: could not patch manifest.json (${err.message})`); + } + } + + await writeFile(dst, content); + + const lower = rel.toLowerCase(); + const extDot = lower.lastIndexOf('.'); + const extLc = extDot >= 0 ? lower.substring(extDot) : ''; + const isHtml = extLc === '.html' || extLc === '.htm'; + + pages.push({ + pagekey: rel.toUpperCase(), + pagename: rel, + isStartPage: lower === 'index.html', + mimetype: isHtml ? 'text/html' : null, + }); +} + +pages.sort((a, b) => + a.pagekey < b.pagekey ? -1 : a.pagekey > b.pagekey ? 1 : 0 +); + +const pagesXml = pages + .map((p) => { + const lines = [ + ` ${escXml(applname)}`, + ` ${escXml(p.pagekey)}`, + ` ${escXml(p.pagename)}`, + ]; + if (p.mimetype) { + lines.push(` ${escXml(p.mimetype)}`); + } else { + lines.push(` X`); + } + if (p.isStartPage) { + lines.push(` X`); + } + lines.push( + ` E`, + ` A`, + ` E` + ); + return ` \n \n${lines.join('\n')}\n \n `; + }) + .join('\n'); + +const description = args.title || applname; + +const wapaXml = `${BOM} + + + + + ${escXml(applname)} + /UI5/CL_UI5_BSP_APPLICATION + ${escXml(applext)} + X + E + E + ${escXml(description)} + + +${pagesXml} + + + + +`; + +await writeFile(join(targetDir, `${applnameFs}.wapa.xml`), wapaXml); + +const devcXml = `${BOM} + + + + + ${escXml(description)} + + + + +`; + +await writeFile(join(targetDir, 'package.devc.xml'), devcXml); + +const abapgitXml = `${BOM} + + + + E + /src/ + FULL + + + +`; + +await writeFile(join(outDir, '.abapgit.xml'), abapgitXml); + +console.log(`BSP layout written to ${outDir}`); +console.log(` package folder: ${subfolder}`); +console.log(` application: ${applname} (fs: ${applnameFs})`); +console.log(` pages: ${pages.length}`); +console.log(` manifest patch: ${patchedManifest ? `odata -> ${args.odata}` : 'no manifest.json found'}`); +if (nsFlag) console.log(` namespace mode: yes`); From 315b1dcb904edbb02a262480a0996bc41402e9ef Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 01:45:47 -0300 Subject: [PATCH 02/10] Add BSP validator and wire it into the workflow scripts/validate-bsp.mjs checks the generated layout against the abapGit WAPA invariants: - .abapgit.xml present with required fields - package folder + package.devc.xml present - WAPA header ATTRIBUTES complete (APPLNAME, APPLCLAS, APPLEXT, ...) - per-page: PAGEKEY = upper(PAGENAME), file exists at encoded name, exactly one of PAGETYPE/MIMETYPE, APPLNAME matches header - PAGES sorted alphabetically (matches abapGit serializer) - at most one IS_START_PAGE; index.html must be the start page - no duplicate PAGEKEYs, no orphan files in package folder - manifest.json valid JSON with patched odata URI The workflow now runs the validator after the converter (hard fail) and abaplint on the result for an additional XML/structural check (non-blocking, warnings only). --- .github/workflows/build-bsp.yml | 12 ++ scripts/validate-bsp.mjs | 235 ++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 scripts/validate-bsp.mjs diff --git a/.github/workflows/build-bsp.yml b/.github/workflows/build-bsp.yml index 170150b..aef91c4 100644 --- a/.github/workflows/build-bsp.yml +++ b/.github/workflows/build-bsp.yml @@ -52,6 +52,18 @@ jobs: --odata "${{ inputs.odata_path }}" \ --title "${{ inputs.app_title }}" + - name: Validate generated BSP + run: | + node scripts/validate-bsp.mjs \ + --root result \ + --bsp "${{ inputs.bsp_name }}" \ + --pkg "${{ inputs.package }}" \ + --odata "${{ inputs.odata_path }}" + + - name: Run abaplint on generated repo + continue-on-error: true + run: npx -y @abaplint/cli --format=summary result/ + - name: Upload result as artifact uses: actions/upload-artifact@v4 with: diff --git a/scripts/validate-bsp.mjs b/scripts/validate-bsp.mjs new file mode 100644 index 0000000..a12a28e --- /dev/null +++ b/scripts/validate-bsp.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// Validates a generated BSP layout against abapGit WAPA invariants. +// +// Checks: +// 1. Repo root has well-formed .abapgit.xml with required fields +// 2. Package folder src// exists with package.devc.xml +// 3. WAPA index .wapa.xml exists with header ATTRIBUTES + PAGES +// 4. Per page: PAGEKEY = upper(PAGENAME), exactly one of PAGETYPE/MIMETYPE, +// APPLNAME matches header, file exists at the encoded filename +// 5. PAGES sorted alphabetically by PAGEKEY (matches abapGit serializer) +// 6. At most one IS_START_PAGE; if index.html present it must be it +// 7. No duplicate PAGEKEYs, no orphan files in package folder +// 8. manifest.json is valid JSON; if --odata given, dataSource URIs match +// +// Exits non-zero on any error. Warnings are reported but non-fatal. + +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +const { values: args } = parseArgs({ + options: { + root: { type: 'string', default: 'result' }, + bsp: { type: 'string' }, + pkg: { type: 'string', default: '$TMP' }, + odata: { type: 'string', default: '' }, + }, +}); + +if (!args.bsp) { + console.error('Usage: validate-bsp.mjs --root --bsp [--pkg ] [--odata ]'); + process.exit(2); +} + +const errors = []; +const warnings = []; +const err = (m) => errors.push(m); +const warn = (m) => warnings.push(m); + +function report() { + for (const w of warnings) console.warn(`warn: ${w}`); + for (const e of errors) console.error(`error: ${e}`); + if (errors.length) { + console.error(`\nFAILED with ${errors.length} error(s), ${warnings.length} warning(s)`); + process.exit(1); + } + console.log(`OK (${warnings.length} warning(s))`); + process.exit(0); +} + +const fsEncode = (n) => n.toLowerCase().replace(/\//g, '#'); + +function pageFilename(applnameFs, pagename) { + const dot = pagename.indexOf('.'); + let extra, ext; + if (dot < 0) { extra = pagename; ext = ''; } + else { extra = pagename.substring(0, dot); ext = pagename.substring(dot + 1); } + extra = extra.replace(/\//g, '_-').toLowerCase(); + ext = ext.replace(/\//g, '_-').toLowerCase(); + return ext ? `${applnameFs}.wapa.${extra}.${ext}` : `${applnameFs}.wapa.${extra}`; +} + +const root = resolve(args.root); +const applname = args.bsp.toUpperCase(); +const applnameFs = fsEncode(args.bsp); +const pkgFs = fsEncode(args.pkg); +const pkgFolder = join(root, 'src', pkgFs); + +// --- 1. .abapgit.xml --- +const dotAbapgit = join(root, '.abapgit.xml'); +if (!existsSync(dotAbapgit)) { + err(`.abapgit.xml missing at ${dotAbapgit}`); +} else { + const c = await readFile(dotAbapgit, 'utf8'); + for (const tag of ['MASTER_LANGUAGE', 'STARTING_FOLDER', 'FOLDER_LOGIC']) { + if (!new RegExp(`<${tag}>[^<]+`).test(c)) { + err(`.abapgit.xml missing <${tag}>`); + } + } +} + +// --- 2. package folder --- +if (!existsSync(pkgFolder)) { + err(`package folder missing: ${pkgFolder}`); + report(); +} + +const devc = join(pkgFolder, 'package.devc.xml'); +if (!existsSync(devc)) { + err(`package.devc.xml missing in ${pkgFolder}`); +} else { + const c = await readFile(devc, 'utf8'); + if (!//.test(c)) err('package.devc.xml: missing '); + if (!//.test(c)) warn('package.devc.xml: missing '); +} + +// --- 3. WAPA index --- +const wapaPath = join(pkgFolder, `${applnameFs}.wapa.xml`); +if (!existsSync(wapaPath)) { + err(`WAPA index missing: ${wapaPath}`); + report(); +} + +const wapaXml = await readFile(wapaPath, 'utf8'); +const headerMatch = wapaXml.match(/([\s\S]*?)<\/ATTRIBUTES>\s*/); +if (!headerMatch) { + err('WAPA xml: cannot locate header ATTRIBUTES block'); + report(); +} +const headerBlock = headerMatch[1]; +const headerApplname = (headerBlock.match(/([^<]+)<\/APPLNAME>/) || [])[1]; +if (headerApplname !== applname) { + err(`WAPA header APPLNAME=${headerApplname || ''}, expected ${applname}`); +} +for (const tag of ['APPLCLAS', 'APPLEXT', 'SECURITY', 'ORIGLANG', 'MODIFLANG', 'TEXT']) { + if (!new RegExp(`<${tag}>[^<]*`).test(headerBlock)) { + err(`WAPA header missing <${tag}>`); + } +} + +const pagesMatch = wapaXml.match(/([\s\S]*?)<\/PAGES>/); +if (!pagesMatch) { err('WAPA xml: missing'); report(); } + +const itemRe = /\s*([\s\S]*?)<\/ATTRIBUTES>\s*<\/item>/g; +const pages = []; +let m; +while ((m = itemRe.exec(pagesMatch[1])) !== null) { + const block = m[1]; + const get = (tag) => (block.match(new RegExp(`<${tag}>([^<]*)<\/${tag}>`)) || [])[1]; + pages.push({ + applname: get('APPLNAME'), + pagekey: get('PAGEKEY'), + pagename: get('PAGENAME'), + pagetype: get('PAGETYPE'), + mimetype: get('MIMETYPE'), + isStart: !!get('IS_START_PAGE'), + }); +} + +if (pages.length === 0) err('WAPA xml: no entries inside '); + +// --- 4 & 6. per-page checks --- +const seenKeys = new Set(); +let startCount = 0; +for (const p of pages) { + const label = p.pagename || ''; + if (p.applname !== applname) { + err(`page ${label}: APPLNAME=${p.applname}, expected ${applname}`); + } + if (!p.pagename) err('page : PAGENAME empty'); + if (!p.pagekey) err(`page ${label}: PAGEKEY missing`); + if (p.pagename && p.pagekey !== p.pagename.toUpperCase()) { + err(`page ${label}: PAGEKEY=${p.pagekey}, expected ${p.pagename.toUpperCase()}`); + } + if (seenKeys.has(p.pagekey)) err(`duplicate PAGEKEY ${p.pagekey}`); + seenKeys.add(p.pagekey); + if (p.pagetype && p.mimetype) { + err(`page ${label}: both PAGETYPE and MIMETYPE set (mutually exclusive)`); + } + if (!p.pagetype && !p.mimetype) { + err(`page ${label}: neither PAGETYPE nor MIMETYPE set`); + } + if (p.isStart) startCount++; + if (p.pagename) { + const expected = pageFilename(applnameFs, p.pagename); + if (!existsSync(join(pkgFolder, expected))) { + err(`page ${label}: expected file ${expected} not found`); + } + } +} + +if (startCount > 1) err(`multiple IS_START_PAGE entries (${startCount})`); + +// --- 5. PAGES sorting --- +const sorted = [...pages].sort((a, b) => + a.pagekey < b.pagekey ? -1 : a.pagekey > b.pagekey ? 1 : 0 +); +const orderMismatch = pages.findIndex((p, i) => p.pagekey !== sorted[i].pagekey); +if (orderMismatch >= 0) { + err( + `PAGES not sorted alphabetically by PAGEKEY ` + + `(first mismatch at index ${orderMismatch}: "${pages[orderMismatch].pagekey}" ` + + `should come after "${sorted[orderMismatch].pagekey}")` + ); +} + +// --- 7. orphan files --- +const filesInPkg = await readdir(pkgFolder); +const expectedFiles = new Set([ + 'package.devc.xml', + `${applnameFs}.wapa.xml`, + ...pages.filter(p => p.pagename).map(p => pageFilename(applnameFs, p.pagename)), +]); +for (const f of filesInPkg) { + if (!expectedFiles.has(f)) warn(`orphan file in package folder: ${f}`); +} + +// --- 8. manifest.json content --- +const manifestPage = pages.find(p => p.pagename === 'manifest.json'); +if (manifestPage) { + const mfile = join(pkgFolder, pageFilename(applnameFs, 'manifest.json')); + if (existsSync(mfile)) { + try { + const mjson = JSON.parse(await readFile(mfile, 'utf8')); + if (args.odata) { + const ds = mjson?.['sap.app']?.dataSources; + if (ds && typeof ds === 'object') { + for (const k of Object.keys(ds)) { + const u = ds[k]?.uri; + if (typeof u === 'string' && u !== args.odata) { + warn(`manifest.json: dataSource '${k}' uri='${u}' (expected '${args.odata}')`); + } + } + } + } + } catch (e) { + err(`manifest.json: invalid JSON (${e.message})`); + } + } +} + +// --- index.html consistency --- +const idx = pages.find(p => (p.pagename || '').toLowerCase() === 'index.html'); +if (idx) { + if (idx.mimetype !== 'text/html') { + err(`index.html: MIMETYPE='${idx.mimetype || ''}', expected 'text/html'`); + } + if (!idx.isStart) err('index.html: IS_START_PAGE not set'); +} + +console.log(`Validated BSP at ${root}`); +console.log(` pages: ${pages.length}`); +console.log(` applname: ${applname} (fs: ${applnameFs})`); +report(); From 97ed43acc01a0d3db746e01998683447fd49ca86 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 01:55:21 -0300 Subject: [PATCH 03/10] Add reverse converter: BSP (abapGit) -> UI5 webapp scripts/bsp-to-ui5.mjs reads a generated/cloned BSP repo, locates the *.wapa.xml index, and reconstructs the original webapp/ folder by walking the PAGES list (PAGENAME holds the original path). Auto-discovers STARTING_FOLDER from .abapgit.xml and finds the WAPA file under it, so the same script works for any BSP repo regardless of package layout (flat or subfolder, with or without namespace). Optional --odata flag rewrites manifest.json dataSource URIs (useful when round-tripping back from a SAP backend to a CAP/local one). --- scripts/bsp-to-ui5.mjs | 173 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 scripts/bsp-to-ui5.mjs diff --git a/scripts/bsp-to-ui5.mjs b/scripts/bsp-to-ui5.mjs new file mode 100644 index 0000000..54577d7 --- /dev/null +++ b/scripts/bsp-to-ui5.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Reverse converter: abapGit BSP layout -> UI5 webapp directory. +// +// Reads a generated/cloned BSP repo, locates the *.wapa.xml index, and +// reconstructs the original webapp/ folder structure by following the +// PAGES entries (PAGENAME holds the original case-preserved path). +// +// Usage: +// node bsp-to-ui5.mjs --src --out [--odata ] +// +// Options: +// --src BSP repo root (contains .abapgit.xml). Default: "." +// --out UI5 webapp output directory. Default: "webapp" +// --odata Optional: overwrite manifest.json dataSource uris +// (e.g. "/rest/root/z2ui5" to revert to CAP backend) + +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { parseArgs } from 'node:util'; + +const { values: args } = parseArgs({ + options: { + src: { type: 'string', default: '.' }, + out: { type: 'string', default: 'webapp' }, + odata: { type: 'string', default: '' }, + }, +}); + +const root = resolve(args.src); +const outDir = resolve(args.out); + +const fsEncode = (n) => n.toLowerCase().replace(/\//g, '#'); + +function pageFilename(applnameFs, pagename) { + const dot = pagename.indexOf('.'); + let extra, ext; + if (dot < 0) { extra = pagename; ext = ''; } + else { extra = pagename.substring(0, dot); ext = pagename.substring(dot + 1); } + extra = extra.replace(/\//g, '_-').toLowerCase(); + ext = ext.replace(/\//g, '_-').toLowerCase(); + return ext ? `${applnameFs}.wapa.${extra}.${ext}` : `${applnameFs}.wapa.${extra}`; +} + +async function findWapaXml(dir) { + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } + catch { return null; } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + const r = await findWapaXml(full); + if (r) return r; + } else if (e.isFile() && e.name.endsWith('.wapa.xml')) { + return full; + } + } + return null; +} + +function stripBom(buf) { + if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) { + return buf.subarray(3); + } + return buf; +} + +// --- 1. resolve STARTING_FOLDER from .abapgit.xml (default /src/) --- +let startingFolder = '/src/'; +const dotAbapgit = join(root, '.abapgit.xml'); +if (existsSync(dotAbapgit)) { + const c = await readFile(dotAbapgit, 'utf8'); + const m = c.match(/([^<]+)<\/STARTING_FOLDER>/); + if (m) startingFolder = m[1]; +} else { + console.warn(`warn: .abapgit.xml not found at ${dotAbapgit}, using default STARTING_FOLDER=/src/`); +} + +const srcRoot = join(root, startingFolder.replace(/^\//, '')); +if (!existsSync(srcRoot)) { + console.error(`error: STARTING_FOLDER does not exist: ${srcRoot}`); + process.exit(1); +} + +// --- 2. locate the WAPA index --- +const wapaPath = await findWapaXml(srcRoot); +if (!wapaPath) { + console.error(`error: no *.wapa.xml found under ${srcRoot}`); + process.exit(1); +} +const pkgFolder = dirname(wapaPath); +const wapaXml = await readFile(wapaPath, 'utf8'); + +// --- 3. extract APPLNAME from header --- +const headerMatch = wapaXml.match(/([\s\S]*?)<\/ATTRIBUTES>\s*/); +if (!headerMatch) { + console.error('error: cannot parse WAPA header ATTRIBUTES block'); + process.exit(1); +} +const applname = (headerMatch[1].match(/([^<]+)<\/APPLNAME>/) || [])[1]; +if (!applname) { + console.error('error: APPLNAME missing in WAPA xml'); + process.exit(1); +} +const applnameFs = fsEncode(applname); + +// --- 4. parse PAGES --- +const pagesMatch = wapaXml.match(/([\s\S]*?)<\/PAGES>/); +if (!pagesMatch) { + console.error('error: missing in WAPA xml'); + process.exit(1); +} + +const itemRe = /\s*([\s\S]*?)<\/ATTRIBUTES>\s*<\/item>/g; +const pages = []; +let m; +while ((m = itemRe.exec(pagesMatch[1])) !== null) { + const block = m[1]; + const get = (tag) => (block.match(new RegExp(`<${tag}>([^<]*)<\/${tag}>`)) || [])[1]; + pages.push({ pagename: get('PAGENAME') }); +} + +if (pages.length === 0) { + console.error('error: no entries inside '); + process.exit(1); +} + +// --- 5. restore each page into the webapp directory --- +await mkdir(outDir, { recursive: true }); + +let copied = 0; +let skipped = 0; +let patchedManifest = false; + +for (const p of pages) { + if (!p.pagename) { skipped++; continue; } + const srcFile = join(pkgFolder, pageFilename(applnameFs, p.pagename)); + if (!existsSync(srcFile)) { + console.warn(`warn: source file missing for page ${p.pagename}: ${srcFile}`); + skipped++; + continue; + } + + const destFile = join(outDir, p.pagename); + await mkdir(dirname(destFile), { recursive: true }); + + let content = await readFile(srcFile); + + if (p.pagename === 'manifest.json' && args.odata) { + try { + const json = JSON.parse(stripBom(content).toString('utf8')); + const ds = json?.['sap.app']?.dataSources; + if (ds && typeof ds === 'object') { + for (const k of Object.keys(ds)) { + if (ds[k] && typeof ds[k].uri === 'string') ds[k].uri = args.odata; + } + } + content = Buffer.from(JSON.stringify(json, null, '\t'), 'utf8'); + patchedManifest = true; + } catch (err) { + console.warn(`warn: could not patch manifest.json (${err.message})`); + } + } + + await writeFile(destFile, content); + copied++; +} + +console.log(`Restored UI5 webapp at ${outDir}`); +console.log(` source: ${srcRoot}`); +console.log(` applname: ${applname} (fs: ${applnameFs})`); +console.log(` pages: ${copied} restored, ${skipped} skipped`); +if (args.odata) console.log(` odata: ${patchedManifest ? `patched -> ${args.odata}` : 'no manifest.json found'}`); From 5c49a58bd14e2aa3aa69dc34044186db1d99badd Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 13:35:54 -0300 Subject: [PATCH 04/10] Add restore workflow and round-trip verification - New `.github/workflows/restore-ui5.yml`: workflow_dispatch that runs scripts/bsp-to-ui5.mjs against any BSP directory in the checked-out repo and uploads the reconstructed webapp as artifact. - Update `.github/workflows/build-bsp.yml` to round-trip the freshly built BSP back to UI5 form and diff against the original ui5-build output. Catches lossy encoding / decoding regressions immediately. manifest.json is excluded from the byte-diff because it is re-serialized by JSON.stringify on both directions; the converter's semantic check on manifest.json is covered by validate-bsp. --- .github/workflows/build-bsp.yml | 31 ++++++++++++++++++++++- .github/workflows/restore-ui5.yml | 41 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/restore-ui5.yml diff --git a/.github/workflows/build-bsp.yml b/.github/workflows/build-bsp.yml index aef91c4..2f01909 100644 --- a/.github/workflows/build-bsp.yml +++ b/.github/workflows/build-bsp.yml @@ -60,12 +60,41 @@ jobs: --pkg "${{ inputs.package }}" \ --odata "${{ inputs.odata_path }}" + - name: Round-trip BSP back to UI5 webapp + run: | + # Capture the original odata URI from the UI5 build so that + # manifest.json round-trips byte-identical (the converter + # patched it to inputs.odata_path on the way in). + ORIG_URI=$(node -e " + const m = JSON.parse(require('fs').readFileSync('${{ inputs.source_dir }}/dist/manifest.json','utf8')); + const ds = m['sap.app'] && m['sap.app'].dataSources; + const first = ds && Object.values(ds)[0]; + process.stdout.write((first && first.uri) || ''); + ") + node scripts/bsp-to-ui5.mjs \ + --src result \ + --out restored \ + --odata "$ORIG_URI" + + - name: Diff round-trip vs UI5 build (manifest.json excluded) + run: | + # manifest.json is re-serialized by JSON.stringify on both sides + # (formatting may differ from a hand-edited source). All other + # files must match byte-for-byte to prove the encoding is lossless. + diff -r --brief --exclude=manifest.json "${{ inputs.source_dir }}/dist" restored + - name: Run abaplint on generated repo continue-on-error: true run: npx -y @abaplint/cli --format=summary result/ - - name: Upload result as artifact + - name: Upload BSP result uses: actions/upload-artifact@v4 with: name: bsp-${{ inputs.bsp_name }} path: result/ + + - name: Upload round-trip restored webapp + uses: actions/upload-artifact@v4 + with: + name: webapp-restored-${{ inputs.bsp_name }} + path: restored/ diff --git a/.github/workflows/restore-ui5.yml b/.github/workflows/restore-ui5.yml new file mode 100644 index 0000000..122dc96 --- /dev/null +++ b/.github/workflows/restore-ui5.yml @@ -0,0 +1,41 @@ +name: Restore UI5 webapp from BSP + +on: + workflow_dispatch: + inputs: + bsp_dir: + description: 'Path to BSP repo root (contains .abapgit.xml)' + required: true + default: 'result' + out_dir: + description: 'Output directory for restored UI5 webapp' + required: true + default: 'webapp_restored' + odata_path: + description: 'Optional: rewrite manifest.json dataSource URI (e.g. /rest/root/z2ui5)' + required: false + default: '' + +jobs: + restore: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Restore UI5 webapp from BSP layout + run: | + ARGS="--src ${{ inputs.bsp_dir }} --out ${{ inputs.out_dir }}" + if [ -n "${{ inputs.odata_path }}" ]; then + ARGS="$ARGS --odata ${{ inputs.odata_path }}" + fi + node scripts/bsp-to-ui5.mjs $ARGS + + - name: Upload restored webapp as artifact + uses: actions/upload-artifact@v4 + with: + name: ui5-webapp-restored + path: ${{ inputs.out_dir }}/ From 8232362c7dce340176a46abb6aa8afe100a29e44 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 13:42:59 -0300 Subject: [PATCH 05/10] Rename build-bsp.mjs to ui5-to-bsp.mjs, add validate-ui5.mjs - scripts/ui5-to-bsp.mjs (renamed from build-bsp.mjs, content unchanged) - scripts/validate-ui5.mjs (new): checks manifest.json, Component.js, rootView resolution, view->controller wiring, css resources - workflows updated to use the new script name and run validate-ui5 on both the source webapp (build) and the restored webapp (build + restore round-trip) --- .github/workflows/build-bsp.yml | 16 +- .github/workflows/restore-ui5.yml | 3 + scripts/ui5-to-bsp.mjs | 247 ++++++++++++++++++++++++++++++ scripts/validate-ui5.mjs | 160 +++++++++++++++++++ 4 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 scripts/ui5-to-bsp.mjs create mode 100644 scripts/validate-ui5.mjs diff --git a/.github/workflows/build-bsp.yml b/.github/workflows/build-bsp.yml index 2f01909..f9490be 100644 --- a/.github/workflows/build-bsp.yml +++ b/.github/workflows/build-bsp.yml @@ -38,13 +38,16 @@ jobs: working-directory: ${{ inputs.source_dir }} run: npm ci + - name: Validate source UI5 webapp + run: node scripts/validate-ui5.mjs --root ${{ inputs.source_dir }}/webapp + - name: Build UI5 app (ui5 build) working-directory: ${{ inputs.source_dir }} run: npm run build - - name: Convert build output to abapGit BSP layout + - name: Convert UI5 build to abapGit BSP layout run: | - node scripts/build-bsp.mjs \ + node scripts/ui5-to-bsp.mjs \ --src "${{ inputs.source_dir }}/dist" \ --out result \ --bsp "${{ inputs.bsp_name }}" \ @@ -62,9 +65,6 @@ jobs: - name: Round-trip BSP back to UI5 webapp run: | - # Capture the original odata URI from the UI5 build so that - # manifest.json round-trips byte-identical (the converter - # patched it to inputs.odata_path on the way in). ORIG_URI=$(node -e " const m = JSON.parse(require('fs').readFileSync('${{ inputs.source_dir }}/dist/manifest.json','utf8')); const ds = m['sap.app'] && m['sap.app'].dataSources; @@ -76,11 +76,11 @@ jobs: --out restored \ --odata "$ORIG_URI" + - name: Validate restored UI5 webapp + run: node scripts/validate-ui5.mjs --root restored + - name: Diff round-trip vs UI5 build (manifest.json excluded) run: | - # manifest.json is re-serialized by JSON.stringify on both sides - # (formatting may differ from a hand-edited source). All other - # files must match byte-for-byte to prove the encoding is lossless. diff -r --brief --exclude=manifest.json "${{ inputs.source_dir }}/dist" restored - name: Run abaplint on generated repo diff --git a/.github/workflows/restore-ui5.yml b/.github/workflows/restore-ui5.yml index 122dc96..f1ac563 100644 --- a/.github/workflows/restore-ui5.yml +++ b/.github/workflows/restore-ui5.yml @@ -34,6 +34,9 @@ jobs: fi node scripts/bsp-to-ui5.mjs $ARGS + - name: Validate restored UI5 webapp + run: node scripts/validate-ui5.mjs --root ${{ inputs.out_dir }} + - name: Upload restored webapp as artifact uses: actions/upload-artifact@v4 with: diff --git a/scripts/ui5-to-bsp.mjs b/scripts/ui5-to-bsp.mjs new file mode 100644 index 0000000..41076de --- /dev/null +++ b/scripts/ui5-to-bsp.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node +// Convert a UI5 build output directory into an abapGit-compatible BSP +// repository layout (subfolder layout, FOLDER_LOGIC=FULL). +// +// Encoding rules verified against abapGit source +// (zcl_abapgit_object_wapa.clas.abap, zcl_abapgit_folder_logic.clas.abap): +// +// - Page filename: pagename is split at the FIRST '.' into (extra, ext). +// '/' in either part is replaced with '_-'. +// Result is lowercased. +// Final filename: .wapa.. +// - Namespace: '/NS/NAME' -> '#ns#name' in filenames (lowercase). +// APPLNAME inside XML keeps original uppercase form. +// - PAGEKEY: UPPERCASE form of pagename, with slashes preserved. +// - index.html: gets text/html + X +// instead of X. +// - PAGES order: alphabetical by PAGEKEY (matches abapGit serializer). +// +// Usage: +// node ui5-to-bsp.mjs --src --bsp [options] +// +// Options: +// --src Source directory (output of `ui5 build`) +// --out Output directory (default: result) +// --bsp BSP application name (Z*** or /NAMESPACE/NAME) +// --pkg ABAP package / devclass (default: $TMP) +// --odata OData/REST URI to inject into manifest.json +// --title BSP description (TEXT/CTEXT field) + +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +const { values: args } = parseArgs({ + options: { + src: { type: 'string' }, + out: { type: 'string', default: 'result' }, + bsp: { type: 'string' }, + pkg: { type: 'string', default: '$TMP' }, + odata: { type: 'string', default: '/sap/bc/z2ui5' }, + title: { type: 'string', default: '' }, + }, +}); + +if (!args.src || !args.bsp) { + console.error( + 'Usage: node ui5-to-bsp.mjs --src --bsp ' + + '[--pkg ] [--odata ] [--title ] [--out ]' + ); + process.exit(1); +} + +// --- naming helpers --- + +const isNamespaced = (n) => { + if (!n.startsWith('/')) return false; + const second = n.indexOf('/', 1); + return second > 0 && second < n.length - 1; +}; + +// SAP object name -> filesystem-safe encoded name (lowercase, '/' -> '#'). +const fsEncode = (n) => n.toLowerCase().replace(/\//g, '#'); + +// APPLEXT field: uppercased, slashes stripped, max 30 chars. +const computeApplext = (applname) => + applname.replace(/^\//, '').replace(/\//g, '').toUpperCase().substring(0, 30); + +// WAPA page filename per abapGit serializer: +// SPLIT pagename AT '.' INTO extra ext (only first '.' splits) +// REPLACE '/' WITH '_-' in both +// filename = .wapa.[.] +function pageFilename(applnameFs, pagename) { + const dot = pagename.indexOf('.'); + let extra, ext; + if (dot < 0) { + extra = pagename; + ext = ''; + } else { + extra = pagename.substring(0, dot); + ext = pagename.substring(dot + 1); + } + extra = extra.replace(/\//g, '_-').toLowerCase(); + ext = ext.replace(/\//g, '_-').toLowerCase(); + return ext + ? `${applnameFs}.wapa.${extra}.${ext}` + : `${applnameFs}.wapa.${extra}`; +} + +const escXml = (s) => + String(s).replace(/[&<>"]/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] + ); + +const BOM = ''; + +async function* walk(dir, base = dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full, base); + else if (entry.isFile()) yield relative(base, full).replaceAll('\\', '/'); + } +} + +// --- main --- + +const srcDir = resolve(args.src); +const outDir = resolve(args.out); + +const applname = args.bsp.toUpperCase(); +const applnameFs = fsEncode(args.bsp); +const applext = computeApplext(applname); +const pkgName = args.pkg.toUpperCase(); +const pkgFs = fsEncode(args.pkg); +const nsFlag = isNamespaced(args.bsp) || isNamespaced(args.pkg); + +const subfolder = `src/${pkgFs}`; +const targetDir = join(outDir, subfolder); +await mkdir(targetDir, { recursive: true }); + +const pages = []; +let patchedManifest = false; + +for await (const rel of walk(srcDir)) { + const filename = pageFilename(applnameFs, rel); + const dst = join(targetDir, filename); + let content = await readFile(join(srcDir, rel)); + + if (rel === 'manifest.json') { + try { + const json = JSON.parse(content.toString('utf8')); + const ds = json?.['sap.app']?.dataSources; + if (ds && typeof ds === 'object') { + for (const k of Object.keys(ds)) { + if (ds[k] && typeof ds[k].uri === 'string') { + ds[k].uri = args.odata; + } + } + } + content = Buffer.from(JSON.stringify(json, null, '\t'), 'utf8'); + patchedManifest = true; + } catch (err) { + console.warn(`warn: could not patch manifest.json (${err.message})`); + } + } + + await writeFile(dst, content); + + const lower = rel.toLowerCase(); + const extDot = lower.lastIndexOf('.'); + const extLc = extDot >= 0 ? lower.substring(extDot) : ''; + const isHtml = extLc === '.html' || extLc === '.htm'; + + pages.push({ + pagekey: rel.toUpperCase(), + pagename: rel, + isStartPage: lower === 'index.html', + mimetype: isHtml ? 'text/html' : null, + }); +} + +pages.sort((a, b) => + a.pagekey < b.pagekey ? -1 : a.pagekey > b.pagekey ? 1 : 0 +); + +const pagesXml = pages + .map((p) => { + const lines = [ + ` ${escXml(applname)}`, + ` ${escXml(p.pagekey)}`, + ` ${escXml(p.pagename)}`, + ]; + if (p.mimetype) { + lines.push(` ${escXml(p.mimetype)}`); + } else { + lines.push(` X`); + } + if (p.isStartPage) { + lines.push(` X`); + } + lines.push( + ` E`, + ` A`, + ` E` + ); + return ` \n \n${lines.join('\n')}\n \n `; + }) + .join('\n'); + +const description = args.title || applname; + +const wapaXml = `${BOM} + + + + + ${escXml(applname)} + /UI5/CL_UI5_BSP_APPLICATION + ${escXml(applext)} + X + E + E + ${escXml(description)} + + +${pagesXml} + + + + +`; + +await writeFile(join(targetDir, `${applnameFs}.wapa.xml`), wapaXml); + +const devcXml = `${BOM} + + + + + ${escXml(description)} + + + + +`; + +await writeFile(join(targetDir, 'package.devc.xml'), devcXml); + +const abapgitXml = `${BOM} + + + + E + /src/ + FULL + + + +`; + +await writeFile(join(outDir, '.abapgit.xml'), abapgitXml); + +console.log(`BSP layout written to ${outDir}`); +console.log(` package folder: ${subfolder}`); +console.log(` application: ${applname} (fs: ${applnameFs})`); +console.log(` pages: ${pages.length}`); +console.log(` manifest patch: ${patchedManifest ? `odata -> ${args.odata}` : 'no manifest.json found'}`); +if (nsFlag) console.log(` namespace mode: yes`); diff --git a/scripts/validate-ui5.mjs b/scripts/validate-ui5.mjs new file mode 100644 index 0000000..5426c32 --- /dev/null +++ b/scripts/validate-ui5.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Validates a UI5 webapp directory for structural coherence. +// +// Checks: +// 1. manifest.json exists and is valid JSON (BOM-tolerant) +// 2. sap.app.id is set; sap.app.type recorded for context +// 3. For sap.app.type=application: Component.js exists at root +// For sap.app.type=application: index.html exists (warning only) +// 4. dataSources entries have non-empty uri strings +// 5. sap.ui5.rootView.viewName resolves to an existing view file +// 6. Each *.view.xml file's controllerName attribute resolves to +// an existing *.controller.js file +// 7. css resources listed in sap.ui5.resources.css exist on disk +// +// Hard fails on missing files / structural breaks. +// +// Usage: +// node validate-ui5.mjs --root + +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, resolve, relative } from 'node:path'; +import { parseArgs } from 'node:util'; + +const { values: args } = parseArgs({ + options: { + root: { type: 'string', default: 'webapp' }, + }, +}); + +const root = resolve(args.root); +const errors = []; +const warnings = []; +const err = (m) => errors.push(m); +const warn = (m) => warnings.push(m); + +function report() { + for (const w of warnings) console.warn(`warn: ${w}`); + for (const e of errors) console.error(`error: ${e}`); + if (errors.length) { + console.error(`\nFAILED with ${errors.length} error(s), ${warnings.length} warning(s)`); + process.exit(1); + } + console.log(`OK (${warnings.length} warning(s))`); + process.exit(0); +} + +if (!existsSync(root)) { + err(`webapp root does not exist: ${root}`); + report(); +} + +const stripBom = (s) => s.replace(/^/, ''); + +// --- 1. manifest.json --- +const manifestPath = join(root, 'manifest.json'); +if (!existsSync(manifestPath)) { + err('manifest.json missing at webapp root'); + report(); +} + +let manifest; +try { + manifest = JSON.parse(stripBom(await readFile(manifestPath, 'utf8'))); +} catch (e) { + err(`manifest.json: invalid JSON (${e.message})`); + report(); +} + +// --- 2. sap.app.id --- +const appId = manifest?.['sap.app']?.id; +if (!appId) err('manifest.json: sap.app.id missing or empty'); + +const appType = manifest?.['sap.app']?.type; + +// --- 3. application essentials --- +if (appType === 'application') { + if (!existsSync(join(root, 'Component.js'))) { + err('Component.js missing (sap.app.type=application)'); + } + if (!existsSync(join(root, 'index.html'))) { + warn('index.html missing (acceptable for launchpad-only apps)'); + } +} + +// --- 4. dataSources --- +const ds = manifest?.['sap.app']?.dataSources; +if (ds && typeof ds === 'object') { + for (const k of Object.keys(ds)) { + const u = ds[k]?.uri; + if (typeof u !== 'string' || !u) { + warn(`dataSource '${k}': uri missing or empty`); + } + } +} + +// --- helpers for resolving UI5 dotted names to file paths --- +function nameToRelPath(name, ext) { + // e.g. 'app_v2.view.App' with appId 'app_v2' -> 'view/App' + let suffix = name; + if (appId && (name === appId || name.startsWith(appId + '.'))) { + suffix = name.substring(appId.length).replace(/^\./, ''); + } + return suffix.replace(/\./g, '/') + ext; +} + +// --- 5. rootView --- +const rootView = manifest?.['sap.ui5']?.rootView; +if (rootView?.viewName) { + const ext = rootView.type === 'JS' ? '.view.js' + : rootView.type === 'JSON' ? '.view.json' + : rootView.type === 'HTML' ? '.view.html' + : '.view.xml'; + const rel = nameToRelPath(rootView.viewName, ext); + if (!existsSync(join(root, rel))) { + err(`rootView '${rootView.viewName}' -> file '${rel}' not found`); + } +} + +// --- 7. css resources --- +const cssRes = manifest?.['sap.ui5']?.resources?.css; +if (Array.isArray(cssRes)) { + for (const r of cssRes) { + if (r?.uri && !existsSync(join(root, r.uri))) { + err(`css resource '${r.uri}' not found`); + } + } +} + +// --- 6. view -> controller wiring (walk all *.view.xml) --- +async function* walk(dir) { + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) yield* walk(full); + else if (e.isFile()) yield full; + } +} + +let viewCount = 0; +for await (const f of walk(root)) { + if (!f.endsWith('.view.xml')) continue; + viewCount++; + const xml = await readFile(f, 'utf8'); + const m = xml.match(/controllerName\s*=\s*"([^"]+)"/); + if (!m) continue; + const ctrlName = m[1]; + const rel = nameToRelPath(ctrlName, '.controller.js'); + if (!existsSync(join(root, rel))) { + err(`view '${relative(root, f)}': controllerName '${ctrlName}' -> '${rel}' not found`); + } +} + +console.log(`Validated UI5 webapp at ${root}`); +console.log(` app id: ${appId || ''}`); +console.log(` app type: ${appType || ''}`); +console.log(` views: ${viewCount}`); +report(); From 504ba75150cc931ad7a54ecb287967fbccb8d237 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 13:43:06 -0300 Subject: [PATCH 06/10] Remove obsolete scripts/build-bsp.mjs (renamed to ui5-to-bsp.mjs) --- scripts/build-bsp.mjs | 247 ------------------------------------------ 1 file changed, 247 deletions(-) delete mode 100644 scripts/build-bsp.mjs diff --git a/scripts/build-bsp.mjs b/scripts/build-bsp.mjs deleted file mode 100644 index 41774d3..0000000 --- a/scripts/build-bsp.mjs +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env node -// Convert a UI5 build output directory into an abapGit-compatible BSP -// repository layout (subfolder layout, FOLDER_LOGIC=FULL). -// -// Encoding rules verified against abapGit source -// (zcl_abapgit_object_wapa.clas.abap, zcl_abapgit_folder_logic.clas.abap): -// -// - Page filename: pagename is split at the FIRST '.' into (extra, ext). -// '/' in either part is replaced with '_-'. -// Result is lowercased. -// Final filename: .wapa.. -// - Namespace: '/NS/NAME' -> '#ns#name' in filenames (lowercase). -// APPLNAME inside XML keeps original uppercase form. -// - PAGEKEY: UPPERCASE form of pagename, with slashes preserved. -// - index.html: gets text/html + X -// instead of X. -// - PAGES order: alphabetical by PAGEKEY (matches abapGit serializer). -// -// Usage: -// node build-bsp.mjs --src --bsp [options] -// -// Options: -// --src Source directory (output of `ui5 build`) -// --out Output directory (default: result) -// --bsp BSP application name (Z*** or /NAMESPACE/NAME) -// --pkg ABAP package / devclass (default: $TMP) -// --odata OData/REST URI to inject into manifest.json -// --title BSP description (TEXT/CTEXT field) - -import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; -import { parseArgs } from 'node:util'; - -const { values: args } = parseArgs({ - options: { - src: { type: 'string' }, - out: { type: 'string', default: 'result' }, - bsp: { type: 'string' }, - pkg: { type: 'string', default: '$TMP' }, - odata: { type: 'string', default: '/sap/bc/z2ui5' }, - title: { type: 'string', default: '' }, - }, -}); - -if (!args.src || !args.bsp) { - console.error( - 'Usage: node build-bsp.mjs --src --bsp ' + - '[--pkg ] [--odata ] [--title ] [--out ]' - ); - process.exit(1); -} - -// --- naming helpers --- - -const isNamespaced = (n) => { - if (!n.startsWith('/')) return false; - const second = n.indexOf('/', 1); - return second > 0 && second < n.length - 1; -}; - -// SAP object name -> filesystem-safe encoded name (lowercase, '/' -> '#'). -const fsEncode = (n) => n.toLowerCase().replace(/\//g, '#'); - -// APPLEXT field: uppercased, slashes stripped, max 30 chars. -const computeApplext = (applname) => - applname.replace(/^\//, '').replace(/\//g, '').toUpperCase().substring(0, 30); - -// WAPA page filename per abapGit serializer: -// SPLIT pagename AT '.' INTO extra ext (only first '.' splits) -// REPLACE '/' WITH '_-' in both -// filename = .wapa.[.] -function pageFilename(applnameFs, pagename) { - const dot = pagename.indexOf('.'); - let extra, ext; - if (dot < 0) { - extra = pagename; - ext = ''; - } else { - extra = pagename.substring(0, dot); - ext = pagename.substring(dot + 1); - } - extra = extra.replace(/\//g, '_-').toLowerCase(); - ext = ext.replace(/\//g, '_-').toLowerCase(); - return ext - ? `${applnameFs}.wapa.${extra}.${ext}` - : `${applnameFs}.wapa.${extra}`; -} - -const escXml = (s) => - String(s).replace(/[&<>"]/g, (c) => - ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] - ); - -const BOM = ''; - -async function* walk(dir, base = dir) { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) yield* walk(full, base); - else if (entry.isFile()) yield relative(base, full).replaceAll('\\', '/'); - } -} - -// --- main --- - -const srcDir = resolve(args.src); -const outDir = resolve(args.out); - -const applname = args.bsp.toUpperCase(); -const applnameFs = fsEncode(args.bsp); -const applext = computeApplext(applname); -const pkgName = args.pkg.toUpperCase(); -const pkgFs = fsEncode(args.pkg); -const nsFlag = isNamespaced(args.bsp) || isNamespaced(args.pkg); - -const subfolder = `src/${pkgFs}`; -const targetDir = join(outDir, subfolder); -await mkdir(targetDir, { recursive: true }); - -const pages = []; -let patchedManifest = false; - -for await (const rel of walk(srcDir)) { - const filename = pageFilename(applnameFs, rel); - const dst = join(targetDir, filename); - let content = await readFile(join(srcDir, rel)); - - if (rel === 'manifest.json') { - try { - const json = JSON.parse(content.toString('utf8')); - const ds = json?.['sap.app']?.dataSources; - if (ds && typeof ds === 'object') { - for (const k of Object.keys(ds)) { - if (ds[k] && typeof ds[k].uri === 'string') { - ds[k].uri = args.odata; - } - } - } - content = Buffer.from(JSON.stringify(json, null, '\t'), 'utf8'); - patchedManifest = true; - } catch (err) { - console.warn(`warn: could not patch manifest.json (${err.message})`); - } - } - - await writeFile(dst, content); - - const lower = rel.toLowerCase(); - const extDot = lower.lastIndexOf('.'); - const extLc = extDot >= 0 ? lower.substring(extDot) : ''; - const isHtml = extLc === '.html' || extLc === '.htm'; - - pages.push({ - pagekey: rel.toUpperCase(), - pagename: rel, - isStartPage: lower === 'index.html', - mimetype: isHtml ? 'text/html' : null, - }); -} - -pages.sort((a, b) => - a.pagekey < b.pagekey ? -1 : a.pagekey > b.pagekey ? 1 : 0 -); - -const pagesXml = pages - .map((p) => { - const lines = [ - ` ${escXml(applname)}`, - ` ${escXml(p.pagekey)}`, - ` ${escXml(p.pagename)}`, - ]; - if (p.mimetype) { - lines.push(` ${escXml(p.mimetype)}`); - } else { - lines.push(` X`); - } - if (p.isStartPage) { - lines.push(` X`); - } - lines.push( - ` E`, - ` A`, - ` E` - ); - return ` \n \n${lines.join('\n')}\n \n `; - }) - .join('\n'); - -const description = args.title || applname; - -const wapaXml = `${BOM} - - - - - ${escXml(applname)} - /UI5/CL_UI5_BSP_APPLICATION - ${escXml(applext)} - X - E - E - ${escXml(description)} - - -${pagesXml} - - - - -`; - -await writeFile(join(targetDir, `${applnameFs}.wapa.xml`), wapaXml); - -const devcXml = `${BOM} - - - - - ${escXml(description)} - - - - -`; - -await writeFile(join(targetDir, 'package.devc.xml'), devcXml); - -const abapgitXml = `${BOM} - - - - E - /src/ - FULL - - - -`; - -await writeFile(join(outDir, '.abapgit.xml'), abapgitXml); - -console.log(`BSP layout written to ${outDir}`); -console.log(` package folder: ${subfolder}`); -console.log(` application: ${applname} (fs: ${applnameFs})`); -console.log(` pages: ${pages.length}`); -console.log(` manifest patch: ${patchedManifest ? `odata -> ${args.odata}` : 'no manifest.json found'}`); -if (nsFlag) console.log(` namespace mode: yes`); From a73bf3086bf787374bfd294a4ac6f494ad12abb9 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 13:46:15 -0300 Subject: [PATCH 07/10] Unify workflow inputs to source_dir/result_dir Both workflows now expose: - source_dir (default: input) - result_dir (default: output) build-bsp.yml: source_dir is the UI5 app directory (was 'app_v2_new'), result_dir is the BSP output directory (was hardcoded 'result'). restore-ui5.yml: source_dir replaces bsp_dir (was 'result'), result_dir replaces out_dir (was 'webapp_restored'). Generic defaults make the workflows reusable across repositories. --- .github/workflows/build-bsp.yml | 22 +++++++++++++--------- .github/workflows/restore-ui5.yml | 14 +++++++------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build-bsp.yml b/.github/workflows/build-bsp.yml index f9490be..945b095 100644 --- a/.github/workflows/build-bsp.yml +++ b/.github/workflows/build-bsp.yml @@ -3,6 +3,14 @@ name: Build BSP for abapGit on: workflow_dispatch: inputs: + source_dir: + description: 'Path to UI5 app source (relative to repo root)' + required: true + default: 'input' + result_dir: + description: 'Output directory for the generated BSP layout' + required: true + default: 'output' bsp_name: description: 'BSP application name (Z*** or /NAMESPACE/NAME)' required: true @@ -19,10 +27,6 @@ on: description: 'BSP application title (TEXT field, optional)' required: false default: '' - source_dir: - description: 'Path to UI5 app (relative to repo root)' - required: true - default: 'app_v2_new' jobs: build: @@ -49,7 +53,7 @@ jobs: run: | node scripts/ui5-to-bsp.mjs \ --src "${{ inputs.source_dir }}/dist" \ - --out result \ + --out "${{ inputs.result_dir }}" \ --bsp "${{ inputs.bsp_name }}" \ --pkg "${{ inputs.package }}" \ --odata "${{ inputs.odata_path }}" \ @@ -58,7 +62,7 @@ jobs: - name: Validate generated BSP run: | node scripts/validate-bsp.mjs \ - --root result \ + --root "${{ inputs.result_dir }}" \ --bsp "${{ inputs.bsp_name }}" \ --pkg "${{ inputs.package }}" \ --odata "${{ inputs.odata_path }}" @@ -72,7 +76,7 @@ jobs: process.stdout.write((first && first.uri) || ''); ") node scripts/bsp-to-ui5.mjs \ - --src result \ + --src "${{ inputs.result_dir }}" \ --out restored \ --odata "$ORIG_URI" @@ -85,13 +89,13 @@ jobs: - name: Run abaplint on generated repo continue-on-error: true - run: npx -y @abaplint/cli --format=summary result/ + run: npx -y @abaplint/cli --format=summary "${{ inputs.result_dir }}/" - name: Upload BSP result uses: actions/upload-artifact@v4 with: name: bsp-${{ inputs.bsp_name }} - path: result/ + path: ${{ inputs.result_dir }}/ - name: Upload round-trip restored webapp uses: actions/upload-artifact@v4 diff --git a/.github/workflows/restore-ui5.yml b/.github/workflows/restore-ui5.yml index f1ac563..8f8ff27 100644 --- a/.github/workflows/restore-ui5.yml +++ b/.github/workflows/restore-ui5.yml @@ -3,14 +3,14 @@ name: Restore UI5 webapp from BSP on: workflow_dispatch: inputs: - bsp_dir: + source_dir: description: 'Path to BSP repo root (contains .abapgit.xml)' required: true - default: 'result' - out_dir: + default: 'input' + result_dir: description: 'Output directory for restored UI5 webapp' required: true - default: 'webapp_restored' + default: 'output' odata_path: description: 'Optional: rewrite manifest.json dataSource URI (e.g. /rest/root/z2ui5)' required: false @@ -28,17 +28,17 @@ jobs: - name: Restore UI5 webapp from BSP layout run: | - ARGS="--src ${{ inputs.bsp_dir }} --out ${{ inputs.out_dir }}" + ARGS="--src ${{ inputs.source_dir }} --out ${{ inputs.result_dir }}" if [ -n "${{ inputs.odata_path }}" ]; then ARGS="$ARGS --odata ${{ inputs.odata_path }}" fi node scripts/bsp-to-ui5.mjs $ARGS - name: Validate restored UI5 webapp - run: node scripts/validate-ui5.mjs --root ${{ inputs.out_dir }} + run: node scripts/validate-ui5.mjs --root ${{ inputs.result_dir }} - name: Upload restored webapp as artifact uses: actions/upload-artifact@v4 with: name: ui5-webapp-restored - path: ${{ inputs.out_dir }}/ + path: ${{ inputs.result_dir }}/ From 2d1d2684ccbe93aa05daf5057082412e95fe9bcd Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 18:57:44 -0300 Subject: [PATCH 08/10] Document BSP conversion scripts in README - Add layout entry for scripts/ - Add "BSP conversion scripts" section with per-script usage, flags table, customer-namespace example, and a round-trip verification recipe - All four scripts are documented for direct manual use with `node` --- README.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/README.md b/README.md index 9ff90d6..bda76eb 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ app_v2_new/ # the runtime app (UI5 2.x) ui5.yaml package.json eslint.config.mjs +scripts/ # UI5 <-> abapGit BSP conversion tooling + ui5-to-bsp.mjs + bsp-to-ui5.mjs + validate-bsp.mjs + validate-ui5.mjs .github/workflows/ sync-app-v2-new.yml # weekly sync from cap2UI5/dev ``` @@ -61,6 +66,122 @@ npm run lint # eslint + ui5lint npm run build # ui5 build --clean-dest ``` +## BSP conversion scripts + +The `scripts/` directory contains four Node 20 ESM scripts (no dependencies) +that convert the UI5 app to and from an abapGit-compatible BSP repository +layout. Run them directly with `node`. + +### `ui5-to-bsp.mjs` — UI5 webapp -> abapGit BSP + +Converts a UI5 build output directory into a BSP repository layout +(subfolder, `FOLDER_LOGIC=FULL`). + +```bash +# build the UI5 app first +cd app_v2_new && npm ci && npm run build && cd .. + +node scripts/ui5-to-bsp.mjs \ + --src app_v2_new/dist \ + --out output \ + --bsp Z2UI5_V2 \ + --pkg '$TMP' \ + --odata /sap/bc/z2ui5 \ + --title "abap2UI5 v2" +``` + +| Flag | Default | Description | +|---|---|---| +| `--src` | (required) | UI5 build output directory (`dist/`) | +| `--out` | `result` | BSP output directory | +| `--bsp` | (required) | BSP application name (`Z***` or `/NAMESPACE/NAME`) | +| `--pkg` | `$TMP` | ABAP package / devclass | +| `--odata` | `/sap/bc/z2ui5` | URI injected into `manifest.json` `dataSources.*.uri` | +| `--title` | `""` | BSP description (`TEXT`/`CTEXT` field) | + +Customer-namespace example produces `output/src/#z2ui5#ui5_apps/`: + +```bash +node scripts/ui5-to-bsp.mjs \ + --src app_v2_new/dist --out output \ + --bsp /Z2UI5/MY_APP --pkg /Z2UI5/UI5_APPS \ + --odata /sap/bc/z2ui5 +``` + +### `bsp-to-ui5.mjs` — abapGit BSP -> UI5 webapp + +Reverse converter. Auto-discovers `STARTING_FOLDER` from `.abapgit.xml`, +locates the `*.wapa.xml` index, and rebuilds the original `webapp/` +folder structure from the `PAGES` list. + +```bash +node scripts/bsp-to-ui5.mjs \ + --src output \ + --out webapp_restored \ + --odata /rest/root/z2ui5 # optional, rewrites manifest URI +``` + +| Flag | Default | Description | +|---|---|---| +| `--src` | `.` | BSP repo root (must contain `.abapgit.xml`) | +| `--out` | `webapp` | UI5 webapp output directory | +| `--odata` | `""` | Optional `manifest.json` `dataSources.*.uri` rewrite | + +### `validate-bsp.mjs` — check generated BSP layout + +Structural validation against the abapGit WAPA invariants. Hard fail on +errors, soft warnings for non-blocking issues. + +```bash +node scripts/validate-bsp.mjs \ + --root output \ + --bsp Z2UI5_V2 \ + --pkg '$TMP' \ + --odata /sap/bc/z2ui5 +``` + +Verifies: `.abapgit.xml` well-formed; package folder with `package.devc.xml`; +WAPA header complete; per page `PAGEKEY = upper(PAGENAME)`, file exists at +the encoded name, exactly one of `PAGETYPE`/`MIMETYPE`, `APPLNAME` matches +header; `PAGES` alphabetically sorted; at most one `IS_START_PAGE` +(`index.html` must be it); no duplicates or orphan files; `manifest.json` +valid JSON with patched odata. + +### `validate-ui5.mjs` — check UI5 webapp coherence + +```bash +node scripts/validate-ui5.mjs --root webapp_restored +``` + +| Flag | Default | Description | +|---|---|---| +| `--root` | `webapp` | UI5 webapp directory to validate | + +Verifies: `manifest.json` exists and parses (BOM-tolerant); `sap.app.id` set; +`Component.js` present for `sap.app.type=application`; `dataSources` URIs +non-empty; `sap.ui5.rootView.viewName` resolves to a file; every +`*.view.xml`'s `controllerName` resolves to a `*.controller.js`; CSS +resources listed in `sap.ui5.resources.css` exist on disk. + +### Round-trip verification + +Prove the encoding is lossless: UI5 -> BSP -> UI5 should byte-match the +original (modulo `manifest.json` re-serialization). + +```bash +cd app_v2_new && npm ci && npm run build && cd .. + +node scripts/ui5-to-bsp.mjs --src app_v2_new/dist --out output \ + --bsp Z2UI5_V2 --pkg '$TMP' --odata /sap/bc/z2ui5 +node scripts/validate-bsp.mjs --root output --bsp Z2UI5_V2 --pkg '$TMP' --odata /sap/bc/z2ui5 + +ORIG_URI=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('app_v2_new/dist/manifest.json','utf8'))['sap.app'].dataSources.http.uri)") +node scripts/bsp-to-ui5.mjs --src output --out restored --odata "$ORIG_URI" +node scripts/validate-ui5.mjs --root restored + +diff -r --brief --exclude=manifest.json app_v2_new/dist restored +``` + ## License Apache-2.0 - see [LICENSE](./LICENSE). From 51574107768a262462ee7af625584dd75f1714d9 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 18:57:51 -0300 Subject: [PATCH 09/10] Remove build-bsp workflow (manual script invocation only) --- .github/workflows/build-bsp.yml | 104 -------------------------------- 1 file changed, 104 deletions(-) delete mode 100644 .github/workflows/build-bsp.yml diff --git a/.github/workflows/build-bsp.yml b/.github/workflows/build-bsp.yml deleted file mode 100644 index 945b095..0000000 --- a/.github/workflows/build-bsp.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Build BSP for abapGit - -on: - workflow_dispatch: - inputs: - source_dir: - description: 'Path to UI5 app source (relative to repo root)' - required: true - default: 'input' - result_dir: - description: 'Output directory for the generated BSP layout' - required: true - default: 'output' - bsp_name: - description: 'BSP application name (Z*** or /NAMESPACE/NAME)' - required: true - default: 'Z2UI5_V2' - package: - description: 'ABAP package / devclass (e.g. $TMP, /Z2UI5/UI5_APPS)' - required: true - default: '$TMP' - odata_path: - description: 'Backend URI for manifest.json dataSource' - required: true - default: '/sap/bc/z2ui5' - app_title: - description: 'BSP application title (TEXT field, optional)' - required: false - default: '' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install UI5 app dependencies - working-directory: ${{ inputs.source_dir }} - run: npm ci - - - name: Validate source UI5 webapp - run: node scripts/validate-ui5.mjs --root ${{ inputs.source_dir }}/webapp - - - name: Build UI5 app (ui5 build) - working-directory: ${{ inputs.source_dir }} - run: npm run build - - - name: Convert UI5 build to abapGit BSP layout - run: | - node scripts/ui5-to-bsp.mjs \ - --src "${{ inputs.source_dir }}/dist" \ - --out "${{ inputs.result_dir }}" \ - --bsp "${{ inputs.bsp_name }}" \ - --pkg "${{ inputs.package }}" \ - --odata "${{ inputs.odata_path }}" \ - --title "${{ inputs.app_title }}" - - - name: Validate generated BSP - run: | - node scripts/validate-bsp.mjs \ - --root "${{ inputs.result_dir }}" \ - --bsp "${{ inputs.bsp_name }}" \ - --pkg "${{ inputs.package }}" \ - --odata "${{ inputs.odata_path }}" - - - name: Round-trip BSP back to UI5 webapp - run: | - ORIG_URI=$(node -e " - const m = JSON.parse(require('fs').readFileSync('${{ inputs.source_dir }}/dist/manifest.json','utf8')); - const ds = m['sap.app'] && m['sap.app'].dataSources; - const first = ds && Object.values(ds)[0]; - process.stdout.write((first && first.uri) || ''); - ") - node scripts/bsp-to-ui5.mjs \ - --src "${{ inputs.result_dir }}" \ - --out restored \ - --odata "$ORIG_URI" - - - name: Validate restored UI5 webapp - run: node scripts/validate-ui5.mjs --root restored - - - name: Diff round-trip vs UI5 build (manifest.json excluded) - run: | - diff -r --brief --exclude=manifest.json "${{ inputs.source_dir }}/dist" restored - - - name: Run abaplint on generated repo - continue-on-error: true - run: npx -y @abaplint/cli --format=summary "${{ inputs.result_dir }}/" - - - name: Upload BSP result - uses: actions/upload-artifact@v4 - with: - name: bsp-${{ inputs.bsp_name }} - path: ${{ inputs.result_dir }}/ - - - name: Upload round-trip restored webapp - uses: actions/upload-artifact@v4 - with: - name: webapp-restored-${{ inputs.bsp_name }} - path: restored/ From 6727c064b9331cd9600097d1ce4264f3fc114ad1 Mon Sep 17 00:00:00 2001 From: oblomov-dev <102328295+oblomov-dev@users.noreply.github.com> Date: Thu, 7 May 2026 18:57:55 -0300 Subject: [PATCH 10/10] Remove restore-ui5 workflow (manual script invocation only) --- .github/workflows/restore-ui5.yml | 44 ------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/restore-ui5.yml diff --git a/.github/workflows/restore-ui5.yml b/.github/workflows/restore-ui5.yml deleted file mode 100644 index 8f8ff27..0000000 --- a/.github/workflows/restore-ui5.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Restore UI5 webapp from BSP - -on: - workflow_dispatch: - inputs: - source_dir: - description: 'Path to BSP repo root (contains .abapgit.xml)' - required: true - default: 'input' - result_dir: - description: 'Output directory for restored UI5 webapp' - required: true - default: 'output' - odata_path: - description: 'Optional: rewrite manifest.json dataSource URI (e.g. /rest/root/z2ui5)' - required: false - default: '' - -jobs: - restore: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Restore UI5 webapp from BSP layout - run: | - ARGS="--src ${{ inputs.source_dir }} --out ${{ inputs.result_dir }}" - if [ -n "${{ inputs.odata_path }}" ]; then - ARGS="$ARGS --odata ${{ inputs.odata_path }}" - fi - node scripts/bsp-to-ui5.mjs $ARGS - - - name: Validate restored UI5 webapp - run: node scripts/validate-ui5.mjs --root ${{ inputs.result_dir }} - - - name: Upload restored webapp as artifact - uses: actions/upload-artifact@v4 - with: - name: ui5-webapp-restored - path: ${{ inputs.result_dir }}/