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:
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.
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,
];
}
diff --git a/src/Reference/InternalReferenceExtractor.php b/src/Reference/InternalReferenceExtractor.php
new file mode 100644
index 0000000..0dbcda8
--- /dev/null
+++ b/src/Reference/InternalReferenceExtractor.php
@@ -0,0 +1,132 @@
+
+ * @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);
+
+ if (($idevice['storagePattern'] ?? '') !== 'standard-json') {
+ $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);
+ }
+ }
+}
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.
*
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);
+ }
+ }
+);