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). 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'}`); 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-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(); 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();