1: <?php
2:
3: namespace LaravelUi5\Core\Commands;
4:
5: use DOMDocument;
6: use DOMElement;
7: use DOMXPath;
8: use Exception;
9: use Illuminate\Filesystem\Filesystem;
10: use Illuminate\Support\Collection;
11: use Illuminate\Support\Facades\File;
12: use Illuminate\Support\Str;
13: use Symfony\Component\Yaml\Yaml;
14: use Throwable;
15:
16: class GenerateUi5AppCommand extends BaseGenerator
17: {
18: protected $signature = 'ui5:app {name : The name of the ui5 app}
19: {--package-prefix=pragmatiqu : The composer package namespace prefix}
20: {--php-ns-prefix=Pragmatiqu : The namespace prefix for the php package}
21: {--js-ns-prefix=io.pragmatiqu : The JS namespace prefix}
22: {--create : Create a new module from scratch}
23: {--refresh : Overwrite existing files without confirmation}
24: {--vendor="Pragmatiqu IT GmbH" : The vendor of the module}';
25:
26: protected $description = 'Generate a Ui5App implementation from a UI5 frontend project';
27:
28: /**
29: * @var string $appName the name of the generated class extending Ui5AppInterface.
30: */
31: protected string $appName;
32:
33: /**
34: * @var string $ui5AppFolderName the name of the folder containing the Ui5 JS/TS app.
35: */
36: protected string $ui5AppFolderName;
37:
38: /**
39: * @var string $sourcePath the folder containing the Ui5 JS/TS App, per convention `../ui5-{$this->ui5AppFolderName}/`.
40: */
41: protected string $sourcePath;
42:
43: /**
44: * @var string $targetPath the folder containing the generated Ui5App class, per convention `ui5/{$this->appName}/src/`.
45: */
46: protected string $targetPath;
47:
48: /**
49: * @var string $className the name of the PHP class, per convention `{$this->appName}App`.
50: */
51: protected string $className;
52:
53: protected string $moduleClassName;
54:
55: /**
56: * @var string $phpNamespace the namespace of the PHP class, per convention `Pragmatiqu\LaravelUi5\{$this->appName}`.
57: */
58: protected string $phpNamespace;
59:
60: /**
61: * @var string $targetFile the full path of the target file, per convention `{$this->targetPath}{$this->className}.php`.
62: */
63: protected string $targetFile;
64:
65: protected string $targetModuleFile;
66:
67: protected Filesystem $files;
68:
69: public function __construct(Filesystem $files)
70: {
71: parent::__construct();
72: $this->files = $files;
73: }
74:
75: public function handle(): int
76: {
77: try {
78: $this->initConventions();
79: $this->checkSourceFiles();
80: $params = $this->extractParameters()->toArray();
81: $this->checkParams($params);
82: if ($this->isCreate())
83: {
84: $this->create($params);
85: $this->components->success("Generated Ui5App module `{$this->appName}`");
86: }
87: else {
88: $this->update($params);
89: $this->components->success("Updated Ui5App module `{$this->appName}`");
90: }
91: return self::SUCCESS;
92: } catch (Exception $e) {
93: $lines = explode("\n", $e->getMessage());
94: if (!empty($lines)) {
95: $this->components->error($lines[0]);
96:
97: foreach (array_slice($lines, 1) as $line) {
98: $this->components->info($line);
99: }
100: }
101:
102: return self::FAILURE;
103: }
104: }
105:
106: /**
107: * @throws Exception
108: */
109: protected function initConventions(): void
110: {
111: $this->appName = $this->argument('name');
112: $this->assertCamelCase('Ui5App', $this->appName);
113:
114: $phpNamespacePrefix = rtrim($this->option('php-ns-prefix'), '\\');
115: $jsNamespacePrefix = rtrim($this->option('js-ns-prefix'), '.');
116:
117: $this->ui5AppFolderName = Str::kebab($this->appName);
118:
119: $conventionPaths = [
120: base_path("../ui5-{$this->ui5AppFolderName}/"),
121: base_path("../{$jsNamespacePrefix}.{$this->ui5AppFolderName}/"),
122: ];
123: try {
124: $this->sourcePath = collect($conventionPaths)->first(fn($path) => File::exists($path));
125: } catch (Throwable $e) {
126: throw new Exception("Source folder for UI5 app not found. Tried:\n - " . implode("\n - ", $conventionPaths));
127: }
128:
129: $this->targetPath = base_path("ui5/{$this->appName}/src/");
130: $this->className = "{$this->appName}App";
131: $this->moduleClassName = "{$this->appName}Module";
132: $this->phpNamespace = "{$phpNamespacePrefix}\\{$this->appName}";
133: $this->targetFile = "{$this->targetPath}{$this->className}.php";
134: $this->targetModuleFile = "{$this->targetPath}{$this->moduleClassName}.php";
135:
136: if ($this->output->isVerbose()) {
137: $this->info("sourcePath: {$this->sourcePath}");
138: $this->info("targetPath: {$this->targetPath}");
139: $this->info("className: {$this->className}");
140: $this->info("moduleClassName: {$this->moduleClassName}");
141: $this->info("phpNamespace: {$this->phpNamespace}");
142: $this->info("targetFile: {$this->targetFile}");
143: $this->info("targetModuleFile: {$this->targetModuleFile}");
144: }
145: }
146:
147: /**
148: * @throws Exception
149: */
150: protected function checkSourceFiles(): void
151: {
152: $requiredFiles = [
153: 'ui5.yaml' => $this->sourcePath . 'ui5.yaml',
154: 'package.json' => $this->sourcePath . 'package.json',
155: 'dist/index.html' => $this->sourcePath . 'dist/index.html',
156: 'dist/manifest.json' => $this->sourcePath . 'dist/manifest.json',
157: 'dist/i18n/i18n.properties' => $this->sourcePath . 'dist/i18n/i18n.properties',
158: ];
159:
160: $missing = [];
161:
162: foreach ($requiredFiles as $label => $path)
163: if (!File::exists($path)) {
164: $missing[$label] = $path;
165: } elseif ($this->output->isVerbose()) {
166: $this->info("Found: {$label}");
167: }
168:
169: if (!empty($missing)) {
170: $report = collect($missing)
171: ->map(fn($path, $label) => "Missing: {$label}\n expected at: {$path}")
172: ->implode("\n");
173:
174: throw new Exception("Source check failed:\n\n{$report}\n\nHint: Make sure to run `npm run build` inside your UI5 app.");
175: }
176: }
177:
178: protected function extractParameters(): Collection
179: {
180: return collect()
181: ->merge($this->extractFromYaml())
182: ->merge($this->extractFromPackageJson())
183: ->merge($this->extractFromIndexHtml())
184: ->merge($this->extractFromManifest())
185: ->merge($this->extractFromI18n());
186: }
187:
188: protected function extractFromYaml(): array
189: {
190: $yaml = Yaml::parseFile($this->sourcePath . 'ui5.yaml');
191:
192: return [
193: 'ui5Namespace' => $yaml['metadata']['name'] ?? null,
194: 'ui5Version' => $yaml['framework']['version'] ?? '1.0.0',
195: ];
196: }
197:
198: protected function extractFromPackageJson(): array
199: {
200: $path = $this->sourcePath . 'package.json';
201: $json = json_decode(file_get_contents($path), true);
202:
203: return [
204: 'appVersion' => $json['version'] ?? null,
205: ];
206: }
207:
208: protected function extractFromIndexHtml(): array
209: {
210: $html = file_get_contents($this->sourcePath . 'dist/index.html');
211: $dom = new DOMDocument();
212: libxml_use_internal_errors(true);
213: $dom->loadHTML($html);
214: $xpath = new DOMXPath($dom);
215:
216: $script = $xpath->query('//script[@id="sap-ui-bootstrap"]')->item(0);
217:
218: $bootstrap = [];
219: $namespaces = [];
220:
221: if ($script instanceof DOMElement) {
222: foreach ($script->attributes as $attr) {
223: if (str_starts_with($attr->name, 'data-sap-ui-')) {
224: $key = str_replace('data-sap-ui-', '', $attr->name);
225:
226: if ($key === 'resource-roots') {
227: $roots = json_decode($attr->value, true);
228: $namespaces = array_keys($roots);
229: } else {
230: $bootstrap[$key] = $attr->value;
231: }
232: }
233: }
234: }
235:
236: $inlineScript = $xpath->query('//script[not(@src)]')->item(0)?->nodeValue ?? '';
237: $inlineCss = $xpath->query('//style')->item(0)?->nodeValue ?? '';
238:
239: return [
240: 'bootstrapAttributes' => $bootstrap,
241: 'resourceNamespaces' => $namespaces,
242: 'inlineScript' => trim($inlineScript),
243: 'inlineCss' => trim($inlineCss),
244: ];
245: }
246:
247: protected function extractFromManifest(): array
248: {
249: $json = json_decode(file_get_contents($this->sourcePath . 'dist/manifest.json'), true);
250: $sapUi5 = $json['sap.ui5'] ?? [];
251: return [
252: 'sap.ui5' => json_encode($sapUi5, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
253: ];
254: }
255:
256: protected function extractFromI18n(): array
257: {
258: $path = $this->sourcePath . 'dist/i18n/i18n.properties';
259: $title = null;
260: $description = null;
261:
262: foreach (file($path) as $line)
263: if (str_starts_with($line, 'appTitle=')) {
264: $title = trim(substr($line, 9)) ?? 'Empty title';
265: } elseif (str_starts_with($line, 'appDescription=')) {
266: $description = trim(substr($line, 15)) ?? 'Empty description';
267: }
268:
269: return [
270: 'title' => $title,
271: 'description' => $description,
272: ];
273: }
274:
275: /**
276: * @throws Exception
277: */
278: protected function checkParams(array $params): void
279: {
280: $module = Str::kebab($this->appName);
281: $appId = $params['ui5Namespace'];
282: $lastDot = strrpos($appId, '.');
283: $expectedId = $lastDot === false
284: ? $module
285: : substr($appId, 0, $lastDot) . '.' . $module;
286: if (!is_string($appId) || $appId !== $expectedId) {
287: throw new Exception("Mismatch in sap.app/id: expected '{$expectedId}', but found '{$appId}'.");
288: }
289: }
290:
291: /**
292: * @throws Exception
293: */
294: protected function isCreate(): bool
295: {
296: $create = $this->option('create');
297: $refresh = $this->option('refresh');
298: $moduleExists = File::exists($this->targetFile);
299:
300: if ($create && $moduleExists) {
301: throw new Exception("Module already exists. Use --refresh to update.");
302: }
303:
304: if ($refresh && !$moduleExists) {
305: throw new Exception("Module does not exist. Use --create to scaffold.");
306: }
307:
308: if (!$create && !$refresh) {
309: if ($moduleExists) {
310: throw new Exception("Module already exists. Use --refresh to update.");
311: } else {
312: throw new Exception("Module does not exist. Use --create to scaffold.");
313: }
314: }
315:
316: return $create;
317: }
318:
319: protected function create(array $params): void
320: {
321: File::ensureDirectoryExists($this->targetPath);
322:
323: // composer.json
324: $this->files->put("{$this->targetPath}../composer.json", $this->compileStub('composer.stub', [
325: 'packagePrefix' => $this->option('package-prefix'),
326: 'urlKey' => $this->ui5AppFolderName,
327: 'description' => $params['description'],
328: 'namespace' => json_encode($this->phpNamespace),
329: ]));
330:
331: // ServiceProvider
332: $this->files->put("{$this->targetPath}/{$this->appName}ServiceProvider.php", $this->compileStub('ServiceProvider.stub', [
333: 'namespace' => $this->phpNamespace,
334: 'name' => $this->appName,
335: ]));
336:
337: // Module
338: $this->files->put($this->targetFile, $this->compileStub('Ui5ModuleApp.stub', [
339: 'phpNamespace' => $this->phpNamespace,
340: 'class' => $this->className,
341: 'moduleClass' => $this->moduleClassName,
342: ]));
343:
344: // Manifest
345: $this->files->put("{$this->targetPath}/{$this->appName}Manifest.php", $this->compileStub('Ui5Manifest.stub', [
346: 'phpNamespace' => $this->phpNamespace,
347: 'class' => $this->appName
348: ]));
349:
350: $this->update($params);
351: }
352:
353: protected function update(array $params): void
354: {
355: // app
356: $this->files->put($this->targetFile, $this->compileStub('Ui5App.stub', [
357: 'name' => $this->appName,
358: 'namespace' => $this->phpNamespace,
359: 'class' => $this->className,
360: 'ui5Namespace' => $params['ui5Namespace'],
361: 'appVersion' => $params['appVersion'],
362: 'title' => addslashes($params['title']),
363: 'description' => addslashes($params['description']),
364: 'bootstrapAttributes' => var_export($params['bootstrapAttributes'], true),
365: 'resourceNamespaces' => var_export($params['resourceNamespaces'], true),
366: 'sap.ui5' => $params['sap.ui5'],
367: 'inlineScript' => $params['inlineScript'],
368: 'inlineCss' => $params['inlineCss'],
369: 'vendor' => $this->option('vendor'),
370: ]));
371:
372: // dist assets
373: $i18nFiles = collect(File::files($this->sourcePath . 'dist/i18n'))
374: ->filter(fn($f) => Str::endsWith($f->getFilename(), '.properties'))
375: ->map(fn($f) => 'i18n/' . $f->getFilename())
376: ->all();
377:
378: $staticFiles = collect([
379: 'manifest.json',
380: 'Component-preload.js',
381: 'Component-preload.js.map',
382: 'Component-dbg.js',
383: 'Component-dbg.js.map',
384: 'i18n/i18n.properties',
385: ]);
386:
387: $assets = $staticFiles->merge($i18nFiles)->unique()->values()->all();
388:
389: $this->copyDistAssets($this->sourcePath . 'dist', $this->targetPath, $assets);
390: }
391: }
392: