From 010136b35a5f7eba3fa607827a39c77f82c63df5 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:56:22 +0100 Subject: [PATCH 1/7] Add internal page reference extraction --- src/Reference/InternalReferenceExtractor.php | 129 +++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/Reference/InternalReferenceExtractor.php diff --git a/src/Reference/InternalReferenceExtractor.php b/src/Reference/InternalReferenceExtractor.php new file mode 100644 index 0000000..13ce84f --- /dev/null +++ b/src/Reference/InternalReferenceExtractor.php @@ -0,0 +1,129 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\Reference; + +/** + * Extract internal exe-node page references from normalized project content. + */ +final class InternalReferenceExtractor +{ + private const INTERNAL_LINK_PATTERN = '/exe-node:([A-Za-z0-9_-]+)/'; + + /** + * Extract internal page references with their page/iDevice origins. + * + * @param array> $pages Parsed pages. + * + * @return array> + */ + public function extract(array $pages): array + { + $links = []; + + foreach ($pages as $page) { + foreach (($page['idevices'] ?? []) as $idevice) { + if (!is_array($idevice)) { + continue; + } + + $sources = []; + $html = (string) ($idevice['html'] ?? ''); + if ($html !== '') { + $sources[] = $html; + } + + $this->collectStringValues($idevice['jsonProperties'] ?? [], $sources); + $this->collectStringValues($idevice['data'] ?? [], $sources); + + foreach ($sources as $source) { + foreach ($this->extractTargets($source) as $targetPageId) { + $key = (string) ($page['id'] ?? '') + . '|' + . (string) ($idevice['id'] ?? '') + . '|' + . $targetPageId; + + $links[$key] ??= [ + 'targetPageId' => $targetPageId, + 'pageId' => $page['id'] ?? '', + 'pageTitle' => $page['title'] ?? '', + 'ideviceId' => $idevice['id'] ?? '', + 'ideviceType' => $idevice['type'] ?? '', + 'occurrences' => 0, + ]; + + $links[$key]['occurrences']++; + } + } + } + } + + return array_values($links); + } + + /** + * Extract unique target page IDs from text. + * + * @param string $source Arbitrary HTML/JSON text. + * + * @return array + */ + private function extractTargets(string $source): array + { + if ($source === '') { + return []; + } + + $decoded = html_entity_decode( + rawurldecode($source), + ENT_QUOTES | ENT_HTML5, + 'UTF-8' + ); + + preg_match_all(self::INTERNAL_LINK_PATTERN, $decoded, $matches); + + return array_values( + array_unique( + array_filter( + array_map('strval', $matches[1] ?? []) + ) + ) + ); + } + + /** + * Recursively collect string values from structured state. + * + * @param mixed $value Value to inspect. + * @param array $strings Collected strings. + * + * @return void + */ + private function collectStringValues(mixed $value, array &$strings): void + { + if (is_string($value)) { + $strings[] = $value; + return; + } + + if (!is_array($value)) { + return; + } + + foreach ($value as $nested) { + $this->collectStringValues($nested, $strings); + } + } +} From 08fe0cacbf570bace1f614dbad1661fad588c894 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:56:46 +0100 Subject: [PATCH 2/7] Expose internal links and package manifest --- src/ELPParser.php | 163 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/src/ELPParser.php b/src/ELPParser.php index 1416342..b0df6d3 100644 --- a/src/ELPParser.php +++ b/src/ELPParser.php @@ -22,6 +22,7 @@ use Exelearning\Model\Project; use Exelearning\Parser\LegacyParser; use Exelearning\Parser\OdeParser; +use Exelearning\Reference\InternalReferenceExtractor; use Exelearning\Support\ProjectInspector; use Exelearning\Support\VersionDetector; use Exelearning\Support\XmlLoader; @@ -97,6 +98,7 @@ class ELPParser implements JsonSerializable private ArchiveLimits $archiveLimits; private ArchiveReader $archiveReader; private AssetReferenceExtractor $assetExtractor; + private InternalReferenceExtractor $internalReferenceExtractor; /** * Create a new parser instance. @@ -198,6 +200,7 @@ protected function parse(): void } $this->assetExtractor = new AssetReferenceExtractor($this->archiveEntries); + $this->internalReferenceExtractor = new InternalReferenceExtractor(); $this->assetsDetailed = $this->assetExtractor->extract($this->pages); $this->assets = array_map( static fn(array $asset): string => (string) $asset['path'], @@ -980,6 +983,163 @@ public function getArchiveEntries(): array return $this->archiveEntries; } + /** + * Get internal exe-node page references with their origins. + * + * @return array> + */ + public function getInternalLinks(): array + { + return $this->internalReferenceExtractor->extract($this->pages); + } + + /** + * Get internal page references whose target does not exist. + * + * @return array> + */ + public function getBrokenInternalLinks(): array + { + $pageIds = []; + + foreach ($this->pages as $page) { + $id = (string) ($page['id'] ?? ''); + if ($id !== '') { + $pageIds[$id] = true; + } + } + + return array_values( + array_filter( + $this->getInternalLinks(), + static fn(array $link): bool => !isset( + $pageIds[(string) ($link['targetPageId'] ?? '')] + ) + ) + ); + } + + /** + * Get the iDevice types used by parsed content. + * + * @return array + */ + public function getUsedIdeviceTypes(): array + { + $types = []; + + foreach ($this->getIdevices() as $idevice) { + $type = (string) ($idevice['type'] ?? ''); + if ($type !== '') { + $types[$type] = true; + } + } + + $types = array_keys($types); + sort($types); + + return $types; + } + + /** + * Get iDevice runtime directories available in the package. + * + * @return array + */ + public function getAvailableIdeviceTypes(): array + { + $types = []; + + foreach ($this->archiveEntries as $entry) { + if (preg_match('#^idevices/([^/]+)/#', $entry, $matches) === 1) { + $types[(string) $matches[1]] = true; + } + } + + $types = array_keys($types); + sort($types); + + return $types; + } + + /** + * Get used iDevice types without a matching packaged runtime directory. + * + * @return array + */ + public function getMissingIdeviceRuntimes(): array + { + return array_values( + array_diff( + $this->getUsedIdeviceTypes(), + $this->getAvailableIdeviceTypes() + ) + ); + } + + /** + * Build a categorized manifest of package entries. + * + * @return array + */ + public function getPackageManifest(): array + { + $manifest = [ + 'rootFiles' => [], + 'themeFiles' => [], + 'libraryFiles' => [], + 'ideviceFiles' => [], + 'resourceFiles' => [], + 'otherFiles' => [], + ]; + + foreach ($this->archiveEntries as $entry) { + if (str_ends_with($entry, '/')) { + continue; + } + + if (!str_contains($entry, '/')) { + $manifest['rootFiles'][] = $entry; + continue; + } + + if (str_starts_with($entry, 'theme/')) { + $manifest['themeFiles'][] = $entry; + continue; + } + + if (str_starts_with($entry, 'libs/')) { + $manifest['libraryFiles'][] = $entry; + continue; + } + + if (preg_match('#^idevices/([^/]+)/#', $entry, $matches) === 1) { + $type = (string) $matches[1]; + $manifest['ideviceFiles'][$type][] = $entry; + continue; + } + + if (str_starts_with($entry, 'content/resources/')) { + $manifest['resourceFiles'][] = $entry; + continue; + } + + $manifest['otherFiles'][] = $entry; + } + + foreach (['rootFiles', 'themeFiles', 'libraryFiles', 'resourceFiles', 'otherFiles'] as $key) { + sort($manifest[$key]); + } + + ksort($manifest['ideviceFiles']); + + return $manifest + [ + 'usedIdeviceTypes' => $this->getUsedIdeviceTypes(), + 'availableIdeviceTypes' => $this->getAvailableIdeviceTypes(), + 'missingIdeviceRuntimes' => $this->getMissingIdeviceRuntimes(), + ]; + } + /** * Get the project title. * @@ -1101,6 +1261,9 @@ public function toDetailedArray(): array 'orphanAssets' => $this->getOrphanAssets(), 'missingAssets' => $this->getMissingAssets(), 'brokenReferences' => $this->getBrokenReferences(), + 'internalLinks' => $this->getInternalLinks(), + 'brokenInternalLinks' => $this->getBrokenInternalLinks(), + 'packageManifest' => $this->getPackageManifest(), 'archiveEntries' => $this->archiveEntries, ]; } From 6ad5ff9649185ba659193c0f83140fe1ce8361bb Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:56:55 +0100 Subject: [PATCH 3/7] Validate internal links and iDevice runtimes --- src/Validation/PackageValidator.php | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/Validation/PackageValidator.php b/src/Validation/PackageValidator.php index 3fbe445..122a2a4 100644 --- a/src/Validation/PackageValidator.php +++ b/src/Validation/PackageValidator.php @@ -44,6 +44,8 @@ public function validate(ELPParser $parser): array $this->validateIdentifiers($parser, $errors, $warnings); $this->validatePageHierarchy($parser, $errors, $warnings); $this->validateOrdersAndRelationships($parser, $errors, $warnings); + $this->validateInternalLinks($parser, $errors); + $this->validateIdeviceRuntimes($parser, $warnings); $this->validateIdeviceState($parser, $warnings); $this->validatePackageBaseline($parser, $warnings); @@ -272,6 +274,48 @@ private function validateOrdersAndRelationships( } } + /** + * Report internal exe-node links that target missing pages. + * + * @param ELPParser $parser Parsed project. + * @param array> $errors Validation errors. + * + * @return void + */ + private function validateInternalLinks(ELPParser $parser, array &$errors): void + { + foreach ($parser->getBrokenInternalLinks() as $link) { + $errors[] = [ + 'code' => 'broken_internal_link', + 'message' => 'Internal exe-node link targets a page that does not exist.', + 'context' => $link, + ]; + } + } + + /** + * Report iDevice types used without a matching packaged runtime. + * + * @param ELPParser $parser Parsed project. + * @param array> $warnings Validation warnings. + * + * @return void + */ + private function validateIdeviceRuntimes(ELPParser $parser, array &$warnings): void + { + if ($parser->isLegacyFormat()) { + return; + } + + foreach ($parser->getMissingIdeviceRuntimes() as $type) { + $warnings[] = [ + 'code' => 'missing_idevice_runtime', + 'message' => 'An iDevice type is used without a matching packaged runtime directory.', + 'context' => ['type' => $type], + ]; + } + } + /** * Report iDevice state payloads that could not be decoded. * From bd707e03f0397ff69f5c1258b082c1fe6ae5cb75 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:57:18 +0100 Subject: [PATCH 4/7] Test internal links and package manifest --- tests/Unit/InternalReferencesTest.php | 223 ++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 tests/Unit/InternalReferencesTest.php diff --git a/tests/Unit/InternalReferencesTest.php b/tests/Unit/InternalReferencesTest.php new file mode 100644 index 0000000..060a9cb --- /dev/null +++ b/tests/Unit/InternalReferencesTest.php @@ -0,0 +1,223 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\ElpParser\Tests\Unit; + +use Exelearning\ELPParser; +use RuntimeException; +use ZipArchive; + +/** + * Create a modern fixture with internal links and packaged iDevice runtimes. + * + * @return string + */ +function createInternalReferenceFixture(): string +{ + $temporaryFile = tempnam(sys_get_temp_dir(), 'elp-links-'); + if ($temporaryFile === false) { + throw new RuntimeException('Unable to create temporary file.'); + } + + @unlink($temporaryFile); + $archivePath = $temporaryFile . '.elpx'; + + $xml = <<<'XML' + + + + exe_version4.0.0 + + + pp_titleInternal links + + + + PAGE-A + + 1 + Page A + + + + PAGE-A + BLOCK-A + 1 + Block A + + + PAGE-A + BLOCK-A + TEXT-A + text + 1 + Valid

+

Broken

+ ]]>
+ + +
+ + PAGE-A + BLOCK-A + CUSTOM-A + custom-runtime + 2 + Custom

]]>
+ + +
+
+ +
+
+
+ + PAGE-B + PAGE-A + 2 + Page B + + + +
+
+XML; + + $zip = new ZipArchive(); + if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create temporary ELPX archive.'); + } + + $entries = [ + 'content.xml' => $xml, + 'content.dtd' => '', + 'index.html' => '', + 'screenshot.png' => 'png', + 'theme/style.css' => 'body{}', + 'libs/common.js' => 'console.log("common");', + 'idevices/text/config.xml' => '', + 'idevices/text/export/text.js' => 'console.log("text");', + 'content/resources/image.jpg' => 'image', + 'search_index.js' => '[]', + ]; + + foreach ($entries as $path => $data) { + $zip->addFromString($path, $data); + } + + $zip->close(); + + return $archivePath; +} + +it( + 'extracts valid and broken internal exe-node references', + function () { + $archive = createInternalReferenceFixture(); + + try { + $parser = ELPParser::fromFile($archive); + $links = $parser->getInternalLinks(); + + expect($links)->toHaveCount(2); + + $targets = array_column($links, 'targetPageId'); + sort($targets); + + expect($targets)->toBe(['MISSING-PAGE', 'PAGE-B']); + + $valid = null; + foreach ($links as $link) { + if ($link['targetPageId'] === 'PAGE-B') { + $valid = $link; + } + } + + expect($valid)->toBeArray(); + expect($valid['occurrences'])->toBe(2); + + $broken = $parser->getBrokenInternalLinks(); + expect($broken)->toHaveCount(1); + expect($broken[0]['targetPageId'])->toBe('MISSING-PAGE'); + expect($broken[0]['pageId'])->toBe('PAGE-A'); + expect($broken[0]['ideviceId'])->toBe('TEXT-A'); + } finally { + @unlink($archive); + } + } +); + +it( + 'compares used idevice types with packaged runtime directories', + function () { + $archive = createInternalReferenceFixture(); + + try { + $parser = ELPParser::fromFile($archive); + + expect($parser->getUsedIdeviceTypes())->toBe([ + 'custom-runtime', + 'text', + ]); + expect($parser->getAvailableIdeviceTypes())->toBe(['text']); + expect($parser->getMissingIdeviceRuntimes())->toBe([ + 'custom-runtime', + ]); + } finally { + @unlink($archive); + } + } +); + +it( + 'builds a categorized package manifest', + function () { + $archive = createInternalReferenceFixture(); + + try { + $manifest = ELPParser::fromFile($archive)->getPackageManifest(); + + expect($manifest['rootFiles'])->toContain('content.xml'); + expect($manifest['rootFiles'])->toContain('index.html'); + expect($manifest['themeFiles'])->toBe(['theme/style.css']); + expect($manifest['libraryFiles'])->toBe(['libs/common.js']); + expect($manifest['ideviceFiles']['text'])->toContain('idevices/text/config.xml'); + expect($manifest['resourceFiles'])->toBe(['content/resources/image.jpg']); + expect($manifest['otherFiles'])->toBe([]); + expect($manifest['missingIdeviceRuntimes'])->toBe(['custom-runtime']); + } finally { + @unlink($archive); + } + } +); + +it( + 'reports broken internal links and missing runtimes through validation', + function () { + $archive = createInternalReferenceFixture(); + + try { + $result = ELPParser::fromFile($archive)->validate(); + + expect(array_column($result['errors'], 'code')) + ->toContain('broken_internal_link'); + expect(array_column($result['warnings'], 'code')) + ->toContain('missing_idevice_runtime'); + } finally { + @unlink($archive); + } + } +); From 4ab46f9cd505c9fd6dc3218a00beaa0f9361fa5d Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:57:29 +0100 Subject: [PATCH 5/7] Document internal links and package manifest --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 47e557a..80f685a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,9 @@ $assetsDetailed = $parser->getAssetsDetailed(); $orphanAssets = $parser->getOrphanAssets(); $missingAssets = $parser->getMissingAssets(); $brokenReferences = $parser->getBrokenReferences(); +$internalLinks = $parser->getInternalLinks(); +$brokenInternalLinks = $parser->getBrokenInternalLinks(); +$manifest = $parser->getPackageManifest(); $metadata = $parser->getMetadata(); $userPreferences = $parser->getUserPreferences(); $odeResources = $parser->getOdeResources(); @@ -140,7 +143,7 @@ if (!$result['valid']) { print_r($result['warnings']); ``` -The validator reports unresolved assets, duplicate identifiers, broken page-parent relationships, hierarchy cycles, relationship/order inconsistencies and missing v4 baseline files/directories. +The validator reports unresolved assets, broken internal `exe-node:` links, duplicate identifiers, broken page-parent relationships, hierarchy cycles, relationship/order inconsistencies, missing iDevice runtime directories and missing v4 baseline files/directories. Schema validation is optional and only uses a caller-supplied trusted local schema: From 79699fee20c5a8cdb5364268e30c43be83a0b637 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:57:31 +0100 Subject: [PATCH 6/7] Document package reference APIs --- docs/api.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api.md b/docs/api.md index f798771..2655c95 100644 --- a/docs/api.md +++ b/docs/api.md @@ -82,6 +82,12 @@ Read core format/version/project metadata without fully normalizing pages, iDevi - `getMissingAssets(): array` - `getBrokenReferences(): array` - `getArchiveEntries(): array` +- `getInternalLinks(): array` +- `getBrokenInternalLinks(): array` +- `getUsedIdeviceTypes(): array` +- `getAvailableIdeviceTypes(): array` +- `getMissingIdeviceRuntimes(): array` +- `getPackageManifest(): array` Asset references are returned only when they resolve to an entry in the project archive. Both the v3 long form (`{{context_path}}/content/resources/...`) and the v4 form (`{{context_path}}/`) are resolved. From 32740894c451cd54dd1b23586b5044599432e2ca Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 19 Sep 2026 20:59:31 +0100 Subject: [PATCH 7/7] Avoid duplicate normalized internal links --- src/Reference/InternalReferenceExtractor.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Reference/InternalReferenceExtractor.php b/src/Reference/InternalReferenceExtractor.php index 13ce84f..0dbcda8 100644 --- a/src/Reference/InternalReferenceExtractor.php +++ b/src/Reference/InternalReferenceExtractor.php @@ -45,7 +45,10 @@ public function extract(array $pages): array } $this->collectStringValues($idevice['jsonProperties'] ?? [], $sources); - $this->collectStringValues($idevice['data'] ?? [], $sources); + + if (($idevice['storagePattern'] ?? '') !== 'standard-json') { + $this->collectStringValues($idevice['data'] ?? [], $sources); + } foreach ($sources as $source) { foreach ($this->extractTargets($source) as $targetPageId) {