From 0f3e0c8ec25e540655638358a9a58f124a30885c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 15 Jul 2026 14:00:51 +0200 Subject: [PATCH 01/16] Add Composer package distribution for AI-agent-facing Markdown docs Trims released archives to just the generated clean Markdown (via .gitattributes export-ignore) and adds a workflow that builds the docs, syncs the llms.txt-generated Markdown into a tracked markdown/ directory, and tags a release when the content changes. The require section is now empty since the shipped payload is Markdown only, not PHP code. Co-Authored-By: Claude Sonnet 5 --- .gitattributes | 6 ++ .../workflows/release_composer_package.yaml | 76 +++++++++++++++++++ composer.json | 4 +- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/release_composer_package.yaml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..aa64a6b84b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/* export-ignore +/markdown export-ignore=false +/llms.txt export-ignore=false +/llms-full.txt export-ignore=false +/composer.json export-ignore=false +/LICENSE export-ignore=false diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml new file mode 100644 index 0000000000..dd9e61142f --- /dev/null +++ b/.github/workflows/release_composer_package.yaml @@ -0,0 +1,76 @@ +name: "Release Composer package" + +on: + push: + branches: + - master + - "[0-9]+.[0-9]+" + schedule: + # Daily at 03:17 UTC. Only tags/commits if the generated Markdown actually changed. + - cron: "17 3 * * *" + workflow_dispatch: ~ + +jobs: + release: + runs-on: ubuntu-latest + permissions: + # Needed to commit the generated Markdown and push a tag. + contents: write + + steps: + - uses: actions/checkout@v4 + with: + # Full history + tags, so we can diff against the previous release commit. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Build documentation + run: mkdocs build --strict + + - name: Sync generated Markdown into tracked directory + run: | + rsync -a --delete --include='*/' --include='*.md' --exclude='*' site/ markdown/ + cp site/llms.txt llms.txt + cp site/llms-full.txt llms-full.txt + + - name: Check for changes + id: changes + run: | + git add markdown llms.txt llms-full.txt + if git diff --cached --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit generated Markdown + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Update generated Markdown docs for Composer package" + + - name: Tag release + if: steps.changes.outputs.changed == 'true' + run: | + branch="${GITHUB_REF_NAME}" + # Version branches look like "5.0"; anything else (e.g. master) falls back to "0.0" for testing. + if [[ "$branch" =~ ^[0-9]+\.[0-9]+$ ]]; then + version_prefix="$branch" + else + version_prefix="0.0" + fi + # 4-segment version (X.Y.Z.BUILD): Composer resolves/orders this correctly, unlike + # semver "+build" metadata, which is ignored for precedence. + tag="${version_prefix}.0.$(date -u +%Y%m%d)" + git tag "$tag" + git push origin "$branch" "$tag" diff --git a/composer.json b/composer.json index 1135d00aa9..c65e6a1ed9 100644 --- a/composer.json +++ b/composer.json @@ -14,9 +14,7 @@ "url": "https://updates.ibexa.co" } ], - "require": { - "php": "^8.3" - }, + "require": {}, "require-dev": { "phpunit/phpunit": "^11.0", "symfony/yaml": "^7.0", From 21ae28306b9ea8144f3b621a10da9f228fd2c76d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 15 Jul 2026 14:10:27 +0200 Subject: [PATCH 02/16] Only tag on schedule/manual dispatch, derive version from latest existing tag Drops the push trigger and the master/main fallback for tagging, and switches the release workflow to append today's date to whatever patch version was last tagged for the branch, instead of hardcoding it. Also updates .gitattributes to match the docs/ sync target. Co-Authored-By: Claude Sonnet 5 --- .gitattributes | 2 +- .../workflows/release_composer_package.yaml | 29 +++++++++++-------- composer.json | 1 + 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.gitattributes b/.gitattributes index aa64a6b84b..30b47fd809 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ /* export-ignore -/markdown export-ignore=false +/doc export-ignore=false /llms.txt export-ignore=false /llms-full.txt export-ignore=false /composer.json export-ignore=false diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index dd9e61142f..4e8cecdb33 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -1,10 +1,6 @@ name: "Release Composer package" on: - push: - branches: - - master - - "[0-9]+.[0-9]+" schedule: # Daily at 03:17 UTC. Only tags/commits if the generated Markdown actually changed. - cron: "17 3 * * *" @@ -38,14 +34,14 @@ jobs: - name: Sync generated Markdown into tracked directory run: | - rsync -a --delete --include='*/' --include='*.md' --exclude='*' site/ markdown/ + rsync -a --delete --include='*/' --include='*.md' --exclude='*' site/ doc/ cp site/llms.txt llms.txt cp site/llms-full.txt llms-full.txt - name: Check for changes id: changes run: | - git add markdown llms.txt llms-full.txt + git add doc llms.txt llms-full.txt if git diff --cached --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" else @@ -63,14 +59,23 @@ jobs: if: steps.changes.outputs.changed == 'true' run: | branch="${GITHUB_REF_NAME}" - # Version branches look like "5.0"; anything else (e.g. master) falls back to "0.0" for testing. - if [[ "$branch" =~ ^[0-9]+\.[0-9]+$ ]]; then - version_prefix="$branch" + if [[ ! "$branch" =~ ^[0-9]+\.[0-9]+$ ]]; then + echo "::error::Branch '$branch' is not a version branch (X.Y) — refusing to tag." + exit 1 + fi + + # Base version is the latest existing tag for this branch with its date segment + # stripped (or "X.Y.0" if none exists yet), so patch bumps made elsewhere are picked + # up automatically instead of being hardcoded here. + latest_tag=$(git tag --list "${branch}.*" | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) + if [[ -z "$latest_tag" ]]; then + base_version="${branch}.0" else - version_prefix="0.0" + base_version="${latest_tag%.*}" fi - # 4-segment version (X.Y.Z.BUILD): Composer resolves/orders this correctly, unlike + + # 4-segment version (base + date): Composer resolves/orders this correctly, unlike # semver "+build" metadata, which is ignored for precedence. - tag="${version_prefix}.0.$(date -u +%Y%m%d)" + tag="${base_version}.$(date -u +%Y%m%d)" git tag "$tag" git push origin "$branch" "$tag" diff --git a/composer.json b/composer.json index c65e6a1ed9..1f772e70e4 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ ], "require": {}, "require-dev": { + "php": "^8.3", "phpunit/phpunit": "^11.0", "symfony/yaml": "^7.0", "ibexa/connector-gemini": "5.0.x-dev", From 849ecc95b23bd36a0655729a36249314eab48aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 15 Jul 2026 14:16:05 +0200 Subject: [PATCH 03/16] Tag the doc package with the exact DXP version instead of a derived date Versions must line up 1:1 with the ibexa/* product packages (5.0.9, not 5.0.1203), so the release is now a manual workflow_dispatch that takes the target DXP version directly (mirroring api_refs.yaml's version input), derives the branch to build from it, and tags exactly that version. Co-Authored-By: Claude Sonnet 5 --- .../workflows/release_composer_package.yaml | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 4e8cecdb33..55a11f5234 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -1,10 +1,14 @@ name: "Release Composer package" on: - schedule: - # Daily at 03:17 UTC. Only tags/commits if the generated Markdown actually changed. - - cron: "17 3 * * *" - workflow_dispatch: ~ + workflow_dispatch: + inputs: + version: + # The DXP version this doc release should match (e.g. v5.0.9 or 5.0.9). + # The branch to build from is derived from it (5.0.9 -> 5.0). + description: 'Version (e.g. v5.0.9 or 5.0.9)' + required: true + type: string jobs: release: @@ -14,11 +18,32 @@ jobs: contents: write steps: + - name: Set version and branch + id: version_and_branch + run: | + # Strip leading 'v' to get a plain version number (e.g. 5.0.9) + version="${{ inputs.version }}" + version="${version#v}" + base_branch="$(echo $version | sed 's/\(.*\..*\)\..*/\1/')" + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "base_branch=${base_branch}" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 with: - # Full history + tags, so we can diff against the previous release commit. + ref: ${{ steps.version_and_branch.outputs.base_branch }} + # Full history + tags, so we can tell whether this version was already tagged. fetch-depth: 0 + - name: Check tag doesn't already exist + env: + VERSION: ${{ steps.version_and_branch.outputs.version }} + run: | + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "::error::Tag $VERSION already exists — refusing to overwrite it." + exit 1 + fi + - name: Set up Python uses: actions/setup-python@v3 with: @@ -53,29 +78,19 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "Update generated Markdown docs for Composer package" + git commit -m "Update generated Markdown docs for ${{ steps.version_and_branch.outputs.version }}" - name: Tag release - if: steps.changes.outputs.changed == 'true' + env: + VERSION: ${{ steps.version_and_branch.outputs.version }} + BASE_BRANCH: ${{ steps.version_and_branch.outputs.base_branch }} run: | - branch="${GITHUB_REF_NAME}" - if [[ ! "$branch" =~ ^[0-9]+\.[0-9]+$ ]]; then - echo "::error::Branch '$branch' is not a version branch (X.Y) — refusing to tag." - exit 1 - fi - - # Base version is the latest existing tag for this branch with its date segment - # stripped (or "X.Y.0" if none exists yet), so patch bumps made elsewhere are picked - # up automatically instead of being hardcoded here. - latest_tag=$(git tag --list "${branch}.*" | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) - if [[ -z "$latest_tag" ]]; then - base_version="${branch}.0" + # The tag matches the DXP product version exactly (e.g. 5.0.9) — no derived or + # date-based suffix — so the doc package version always lines up 1:1 with the + # ibexa/* package versions it documents. + git tag "$VERSION" + if [[ "${{ steps.changes.outputs.changed }}" == "true" ]]; then + git push origin "$BASE_BRANCH" "$VERSION" else - base_version="${latest_tag%.*}" + git push origin "$VERSION" fi - - # 4-segment version (base + date): Composer resolves/orders this correctly, unlike - # semver "+build" metadata, which is ignored for precedence. - tag="${base_version}.$(date -u +%Y%m%d)" - git tag "$tag" - git push origin "$branch" "$tag" From 21721d3a25a4dff6cdebcd5e660c2229664ea2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 15 Jul 2026 14:29:50 +0200 Subject: [PATCH 04/16] Derive doc release version from mkdocs.yml instead of manual input Reads extra.latest_tag_X_Y from each branch's own mkdocs.yml as the DXP version to mirror, so doc tags stay aligned with the actual product version (5.0.9, not an arbitrary date-only scheme). Retags when either the generated Markdown changed or the DXP version moved since the last tag. Scheduled runs fan out via a branch matrix with explicit ref checkouts, since GitHub only fires `schedule` on the default branch. Co-Authored-By: Claude Sonnet 5 --- .../workflows/release_composer_package.yaml | 115 +++++++++++------- 1 file changed, 74 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 55a11f5234..ccc68c4b8b 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -1,49 +1,48 @@ name: "Release Composer package" on: - workflow_dispatch: - inputs: - version: - # The DXP version this doc release should match (e.g. v5.0.9 or 5.0.9). - # The branch to build from is derived from it (5.0.9 -> 5.0). - description: 'Version (e.g. v5.0.9 or 5.0.9)' - required: true - type: string + schedule: + # Daily at 03:17 UTC. Fans out to every maintained version branch (see determine-branches). + - cron: "17 3 * * *" + workflow_dispatch: ~ jobs: + determine-branches: + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.set.outputs.branches }} + steps: + - id: set + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual runs only release the branch the workflow was dispatched from. + branches='["${{ github.ref_name }}"]' + else + # Scheduled runs always execute in the default branch's context, so they must + # explicitly check out each maintained version branch instead of relying on + # GITHUB_REF. Extend this list as new branches become active / drop EOL ones. + branches='["5.0", "4.6"]' + fi + echo "branches=${branches}" >> "$GITHUB_OUTPUT" + release: + needs: determine-branches runs-on: ubuntu-latest permissions: # Needed to commit the generated Markdown and push a tag. contents: write + strategy: + fail-fast: false + matrix: + branch: ${{ fromJson(needs.determine-branches.outputs.branches) }} steps: - - name: Set version and branch - id: version_and_branch - run: | - # Strip leading 'v' to get a plain version number (e.g. 5.0.9) - version="${{ inputs.version }}" - version="${version#v}" - base_branch="$(echo $version | sed 's/\(.*\..*\)\..*/\1/')" - - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "base_branch=${base_branch}" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v4 with: - ref: ${{ steps.version_and_branch.outputs.base_branch }} - # Full history + tags, so we can tell whether this version was already tagged. + ref: ${{ matrix.branch }} + # Full history + tags, so we can compare against the previous release. fetch-depth: 0 - - name: Check tag doesn't already exist - env: - VERSION: ${{ steps.version_and_branch.outputs.version }} - run: | - if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "::error::Tag $VERSION already exists — refusing to overwrite it." - exit 1 - fi - - name: Set up Python uses: actions/setup-python@v3 with: @@ -54,6 +53,13 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt + - name: Determine base version from mkdocs.yml + id: base_version + run: | + key="latest_tag_$(echo '${{ matrix.branch }}' | tr '.' '_')" + base_version=$(python3 -c "import yaml; print(yaml.safe_load(open('mkdocs.yml'))['extra']['$key'])") + echo "base_version=${base_version}" >> "$GITHUB_OUTPUT" + - name: Build documentation run: mkdocs build --strict @@ -63,7 +69,7 @@ jobs: cp site/llms.txt llms.txt cp site/llms-full.txt llms-full.txt - - name: Check for changes + - name: Check for content changes id: changes run: | git add doc llms.txt llms-full.txt @@ -73,24 +79,51 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi + - name: Check for a DXP version bump + id: version_bump + env: + BRANCH: ${{ matrix.branch }} + BASE_VERSION: ${{ steps.base_version.outputs.base_version }} + run: | + # Compare against the last tag pushed for this branch (regardless of its date + # segment) to detect whether latest_tag_X_Y moved since then, even if the + # generated Markdown happens to be unchanged. + latest_tag=$(git tag --list "${BRANCH}.*" | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) + if [[ -z "$latest_tag" ]]; then + bumped=true + elif [[ "${latest_tag%.*}" != "$BASE_VERSION" ]]; then + bumped=true + else + bumped=false + fi + echo "bumped=${bumped}" >> "$GITHUB_OUTPUT" + - name: Commit generated Markdown if: steps.changes.outputs.changed == 'true' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "Update generated Markdown docs for ${{ steps.version_and_branch.outputs.version }}" + git commit -m "Update generated Markdown docs for ${{ steps.base_version.outputs.base_version }}" - name: Tag release + if: steps.changes.outputs.changed == 'true' || steps.version_bump.outputs.bumped == 'true' env: - VERSION: ${{ steps.version_and_branch.outputs.version }} - BASE_BRANCH: ${{ steps.version_and_branch.outputs.base_branch }} + BRANCH: ${{ matrix.branch }} + BASE_VERSION: ${{ steps.base_version.outputs.base_version }} + CHANGED: ${{ steps.changes.outputs.changed }} run: | - # The tag matches the DXP product version exactly (e.g. 5.0.9) — no derived or - # date-based suffix — so the doc package version always lines up 1:1 with the - # ibexa/* package versions it documents. - git tag "$VERSION" - if [[ "${{ steps.changes.outputs.changed }}" == "true" ]]; then - git push origin "$BASE_BRANCH" "$VERSION" + # 4-segment version (base + date): Composer resolves/orders this correctly, unlike + # semver "+build" metadata, which is ignored for precedence. + tag="${BASE_VERSION}.$(date -u +%Y%m%d)" + + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "Tag $tag already exists, skipping." + exit 0 + fi + + git tag "$tag" + if [[ "$CHANGED" == "true" ]]; then + git push origin "$BRANCH" "$tag" else - git push origin "$VERSION" + git push origin "$tag" fi From 369f5b04ef1b8fdc757674ecff3cd4bf1058d0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 15 Jul 2026 14:36:07 +0200 Subject: [PATCH 05/16] Manual changes --- .../workflows/release_composer_package.yaml | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index ccc68c4b8b..54c5f2b885 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -21,7 +21,7 @@ jobs: # Scheduled runs always execute in the default branch's context, so they must # explicitly check out each maintained version branch instead of relying on # GITHUB_REF. Extend this list as new branches become active / drop EOL ones. - branches='["5.0", "4.6"]' + branches='["5.0"]' fi echo "branches=${branches}" >> "$GITHUB_OUTPUT" @@ -79,25 +79,6 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Check for a DXP version bump - id: version_bump - env: - BRANCH: ${{ matrix.branch }} - BASE_VERSION: ${{ steps.base_version.outputs.base_version }} - run: | - # Compare against the last tag pushed for this branch (regardless of its date - # segment) to detect whether latest_tag_X_Y moved since then, even if the - # generated Markdown happens to be unchanged. - latest_tag=$(git tag --list "${BRANCH}.*" | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) - if [[ -z "$latest_tag" ]]; then - bumped=true - elif [[ "${latest_tag%.*}" != "$BASE_VERSION" ]]; then - bumped=true - else - bumped=false - fi - echo "bumped=${bumped}" >> "$GITHUB_OUTPUT" - - name: Commit generated Markdown if: steps.changes.outputs.changed == 'true' run: | @@ -106,14 +87,12 @@ jobs: git commit -m "Update generated Markdown docs for ${{ steps.base_version.outputs.base_version }}" - name: Tag release - if: steps.changes.outputs.changed == 'true' || steps.version_bump.outputs.bumped == 'true' + if: steps.changes.outputs.changed == 'true' env: BRANCH: ${{ matrix.branch }} BASE_VERSION: ${{ steps.base_version.outputs.base_version }} CHANGED: ${{ steps.changes.outputs.changed }} run: | - # 4-segment version (base + date): Composer resolves/orders this correctly, unlike - # semver "+build" metadata, which is ignored for precedence. tag="${BASE_VERSION}.$(date -u +%Y%m%d)" if git rev-parse "$tag" >/dev/null 2>&1; then From b79e2e07e438c9fba143347b35032a0b0b8b8046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 15 Jul 2026 15:46:29 +0200 Subject: [PATCH 06/16] Updated workflow --- .../workflows/release_composer_package.yaml | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 54c5f2b885..cf67245424 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -40,8 +40,10 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ matrix.branch }} - # Full history + tags, so we can compare against the previous release. + # Full history + tags: the generated Markdown is never pushed to the branch + # itself (see below), only tagged, so tags are the only place to diff against. fetch-depth: 0 + fetch-tags: true - name: Set up Python uses: actions/setup-python@v3 @@ -69,11 +71,26 @@ jobs: cp site/llms.txt llms.txt cp site/llms-full.txt llms-full.txt - - name: Check for content changes + - name: Find the last tag for this branch + id: last_tag + env: + BRANCH: ${{ matrix.branch }} + run: | + latest_tag=$(git tag --list "${BRANCH}.*" | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) + echo "latest_tag=${latest_tag}" >> "$GITHUB_OUTPUT" + + - name: Check for content changes since the last tag id: changes + env: + LATEST_TAG: ${{ steps.last_tag.outputs.latest_tag }} run: | + # The generated Markdown is never committed/pushed to the branch itself (only + # tagged), so there's nothing on the branch to diff against — compare the freshly + # built content to what the last tag captured instead. git add doc llms.txt llms-full.txt - if git diff --cached --quiet; then + if [[ -z "$LATEST_TAG" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + elif git diff --cached --quiet "$LATEST_TAG" -- doc llms.txt llms-full.txt; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" @@ -89,10 +106,10 @@ jobs: - name: Tag release if: steps.changes.outputs.changed == 'true' env: - BRANCH: ${{ matrix.branch }} BASE_VERSION: ${{ steps.base_version.outputs.base_version }} - CHANGED: ${{ steps.changes.outputs.changed }} run: | + # Push only the tag, never the branch — the branch stays exactly as authored, + # the generated Markdown exists solely as the commit this tag points to. tag="${BASE_VERSION}.$(date -u +%Y%m%d)" if git rev-parse "$tag" >/dev/null 2>&1; then @@ -101,8 +118,4 @@ jobs: fi git tag "$tag" - if [[ "$CHANGED" == "true" ]]; then - git push origin "$BRANCH" "$tag" - else - git push origin "$tag" - fi + git push origin "$tag" From afa3798e26ea81bc3801dfea311725d1711e49db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Jul 2026 09:55:23 +0200 Subject: [PATCH 07/16] Changed versioning strategy --- .github/workflows/release_composer_package.yaml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index cf67245424..7674fe31f0 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -55,13 +55,6 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - - name: Determine base version from mkdocs.yml - id: base_version - run: | - key="latest_tag_$(echo '${{ matrix.branch }}' | tr '.' '_')" - base_version=$(python3 -c "import yaml; print(yaml.safe_load(open('mkdocs.yml'))['extra']['$key'])") - echo "base_version=${base_version}" >> "$GITHUB_OUTPUT" - - name: Build documentation run: mkdocs build --strict @@ -101,16 +94,16 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "Update generated Markdown docs for ${{ steps.base_version.outputs.base_version }}" + git commit -m "Update generated Markdown docs for ${{ matrix.branch }}" - name: Tag release if: steps.changes.outputs.changed == 'true' env: - BASE_VERSION: ${{ steps.base_version.outputs.base_version }} + BRANCH: ${{ matrix.branch }} run: | # Push only the tag, never the branch — the branch stays exactly as authored, # the generated Markdown exists solely as the commit this tag points to. - tag="${BASE_VERSION}.$(date -u +%Y%m%d)" + tag="${BRANCH}.$(date -u +%Y%m%d)" if git rev-parse "$tag" >/dev/null 2>&1; then echo "Tag $tag already exists, skipping." From 71e6e079d0f23493538c120d70cebe504cd06e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Jul 2026 10:51:48 +0200 Subject: [PATCH 08/16] Added dummy change --- docs/ai/ai_actions/extend_ai_actions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ai/ai_actions/extend_ai_actions.md b/docs/ai/ai_actions/extend_ai_actions.md index b64718fe83..80b704eb95 100644 --- a/docs/ai/ai_actions/extend_ai_actions.md +++ b/docs/ai/ai_actions/extend_ai_actions.md @@ -9,6 +9,8 @@ By extending [AI Actions](ai_actions_guide.md), you can make regular content man You can start by integrating additional AI services to the existing action types or develop custom ones that impact completely new areas of application. For example, you can create a handler that connects to a translation model and use it to translate your website on-the-fly, or generate illustrations based on a body of an article. +Waldo is here. + ## Execute Actions You can execute AI Actions by using the [ActionServiceInterface](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ConnectorAi-ActionServiceInterface.html) service, as in the following example: From 4782800742e8a443fbae286c18e36786f4b697cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Jul 2026 14:21:56 +0200 Subject: [PATCH 09/16] Changed links to relative ones --- .gitattributes | 2 +- .../workflows/release_composer_package.yaml | 50 ++- .gitignore | 3 + build_package_docs.py | 306 ++++++++++++++++++ composer.json | 8 +- tests/python/test_build_package_docs.py | 177 ++++++++++ tools/llm_package/README.package.md | 55 ++++ tools/llm_package/dump_class_paths.php | 76 +++++ 8 files changed, 665 insertions(+), 12 deletions(-) create mode 100644 build_package_docs.py create mode 100644 tests/python/test_build_package_docs.py create mode 100644 tools/llm_package/README.package.md create mode 100644 tools/llm_package/dump_class_paths.php diff --git a/.gitattributes b/.gitattributes index 30b47fd809..1895fc25a5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ /* export-ignore /doc export-ignore=false /llms.txt export-ignore=false -/llms-full.txt export-ignore=false /composer.json export-ignore=false /LICENSE export-ignore=false +/README.md export-ignore=false diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 7674fe31f0..795c9ce749 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -55,14 +55,46 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AUTOMATION_CLIENT_ID }} + private-key: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + owner: ${{ github.repository_owner }} + + - name: Add composer keys for private packagist + run: | + composer config --global http-basic.updates.ibexa.co $SATIS_NETWORK_KEY $SATIS_NETWORK_TOKEN + composer config --global github-oauth.github.com $GITHUB_TOKEN + env: + SATIS_NETWORK_KEY: ${{ secrets.SATIS_NETWORK_KEY }} + SATIS_NETWORK_TOKEN: ${{ secrets.SATIS_NETWORK_TOKEN }} + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + + # Needed to resolve the PHP API classes referenced by the docs to + # their vendor/ source paths (dump_class_paths.php). + - uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + - name: Build documentation run: mkdocs build --strict - - name: Sync generated Markdown into tracked directory - run: | - rsync -a --delete --include='*/' --include='*.md' --exclude='*' site/ doc/ - cp site/llms.txt llms.txt - cp site/llms-full.txt llms-full.txt + - name: Resolve PHP API classes to vendor paths + run: php tools/llm_package/dump_class_paths.php site class_paths.json + + - name: Build package Markdown (doc/, llms.txt) + run: python build_package_docs.py --version "${{ matrix.branch }}" + + - name: Copy package README + run: cp tools/llm_package/README.package.md README.md - name: Find the last tag for this branch id: last_tag @@ -79,11 +111,13 @@ jobs: run: | # The generated Markdown is never committed/pushed to the branch itself (only # tagged), so there's nothing on the branch to diff against — compare the freshly - # built content to what the last tag captured instead. - git add doc llms.txt llms-full.txt + # built content to what the last tag captured instead. -f because doc/ and + # llms.txt are gitignored to keep local builds out of the working tree. + git add -f doc llms.txt + git add README.md if [[ -z "$LATEST_TAG" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT" - elif git diff --cached --quiet "$LATEST_TAG" -- doc llms.txt llms-full.txt; then + elif git diff --cached --quiet "$LATEST_TAG" -- doc llms.txt README.md; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 1f2f58534f..446d0ee69f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ lychee-report.md code_samples/_inline_php/ /build/ *.egg-info/ +/doc/ +/llms.txt +/class_paths.json diff --git a/build_package_docs.py b/build_package_docs.py new file mode 100644 index 0000000000..459bcc4e87 --- /dev/null +++ b/build_package_docs.py @@ -0,0 +1,306 @@ +""" +Build the Composer-package Markdown docs from the MkDocs build output. + +The website output (site/) keeps absolute https://doc.ibexa.co URLs; the +Composer package must work offline inside vendor/ibexa/documentation-developer, +so this script copies every generated Markdown page into doc/ (plus llms.txt to +the repo root) while rewriting links: + +- Internal page links (``https://doc.ibexa.co/en/latest//[#anchor]``) + become relative links to the corresponding ``doc//index.md`` file. + URLs matching a mkdocs-redirects entry are resolved through the redirect + first. URLs with no local page (other doc.ibexa.co projects, images, the + separately hosted API reference HTML) are left untouched. +- PHP API class links (``…/php_api_reference/classes/.html``) become + relative links to the class source file in the *installed project's* + vendor/ directory, using the FQCN→path map produced by + ``tools/llm_package/dump_class_paths.php``. The link text is upgraded to the + backticked FQCN (when it was just the short class name) so the class stays + greppable even if the target package isn't installed in the user's project. + Unmapped classes keep their absolute URL. + +Run after ``mkdocs build``: + + php tools/llm_package/dump_class_paths.php site class_paths.json + python build_package_docs.py + +Like llmstxt_preprocess.py, all transformations are pure functions so they can +be unit-tested (tests/python/test_build_package_docs.py). +""" + +import argparse +import json +import posixpath +import re +import shutil +import sys +from pathlib import Path, PurePosixPath + +import yaml + +BASE_URL = "https://doc.ibexa.co/en/latest/" + +# Number of path segments from the package root up to vendor/ +# (vendor/ibexa/documentation-developer/ -> vendor/). +_PACKAGE_DEPTH_IN_VENDOR = 2 + +# [text](url) or ![alt](url); group 1 distinguishes images. URLs never contain +# whitespace or parentheses in the generated Markdown. +_MD_LINK_RE = re.compile(r"(!?)\[([^\]]*)\]\(([^()\s]+)\)") + +_API_CLASS_URL_RE = re.compile(r"php_api_reference/classes/([A-Za-z0-9-]+)\.html$") + +# Same fence detection as llmstxt_preprocess.renumber_ordered_lists. +_FENCE_OPEN_RE = re.compile(r"^(\s*)(`{3,}|~{3,})") + + +def load_redirect_maps(plugins_path): + """Read mkdocs-redirects ``redirect_maps`` from plugins.yml as URL paths. + + Entries map docs-relative source files to target files + ('guide/images.md' -> 'content_management/images/images.md'); with + use_directory_urls both sides publish as directory URLs, so the returned + dict maps 'guide/images/' -> 'content_management/images/images/'. + """ + with open(plugins_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + + for plugin in data.get("plugins", []): + if isinstance(plugin, dict) and "redirects" in plugin: + redirect_maps = (plugin["redirects"] or {}).get("redirect_maps") or {} + return { + _md_to_url_path(src): _md_to_url_path(target) + for src, target in redirect_maps.items() + } + return {} + + +def _md_to_url_path(md_path): + """'guide/images.md' -> 'guide/images/' (mkdocs directory URL).""" + path = md_path[: -len(".md")] if md_path.endswith(".md") else md_path + if path.endswith("/index"): + path = path[: -len("index")] + return path.rstrip("/") + "/" if path else "" + + +def _page_file(url_path, existing_pages, redirects): + """Map a site-relative URL path to its Markdown file, or None. + + ``url_path`` is the part after BASE_URL without the anchor. Only page URLs + (directory URLs or explicit .md paths) are considered; anything else + (images, API reference HTML, files) returns None. + """ + if url_path.endswith(".md"): + candidate = url_path + elif url_path == "" or url_path.endswith("/"): + candidate = url_path + "index.md" + else: + return None + + if candidate in existing_pages: + return candidate + + redirect_target = redirects.get(url_path) + if redirect_target is not None: + candidate = redirect_target + "index.md" + if candidate in existing_pages: + return candidate + return None + + +def _split_anchor(url): + if "#" in url: + base, anchor = url.split("#", 1) + return base, "#" + anchor + return url, "" + + +def _rewrite_api_link(text, url, anchor, page_rel, class_paths): + """Rewrite one PHP API class link; returns the full markdown link.""" + slug = _API_CLASS_URL_RE.search(url).group(1) + fqcn = slug.replace("-", "\\") + short_name = fqcn.rsplit("\\", 1)[-1] + + # Upgrade bare class-name link text to the greppable FQCN; keep richer + # texts (e.g. `publishVersion()`) as authored. + if text.strip("`").strip() in (short_name, fqcn): + text = f"`{fqcn}`" + + vendor_path = class_paths.get(fqcn) + if vendor_path is None: + return f"[{text}]({url}{anchor})" + + # Steps up from the page's directory to vendor/: the page lives at + # vendor/ibexa/documentation-developer/doc/. + ups = len(PurePosixPath("doc", page_rel).parent.parts) + _PACKAGE_DEPTH_IN_VENDOR + # HTML anchors (#method_…) have no equivalent in the source file; drop them. + return f"[{text}]({'../' * ups}{vendor_path})" + + +def rewrite_links(line, page_rel, existing_pages, redirects, class_paths, base_urls=(BASE_URL,)): + """Rewrite all links on one (non-code) Markdown line. + + ``base_urls`` are the URL prefixes considered "this documentation" — + en/latest, plus the branch's own version (release notes hardcode + version-pinned URLs like en/5.0). Links to other versions stay absolute. + """ + + def _replace(match): + bang, text, url = match.groups() + if bang: + return match.group(0) + + bare_url, anchor = _split_anchor(url) + base = next((b for b in base_urls if bare_url.startswith(b)), None) + if base is not None: + if _API_CLASS_URL_RE.search(bare_url): + return _rewrite_api_link(text, bare_url, anchor, page_rel, class_paths) + + target = _page_file(bare_url[len(base):], existing_pages, redirects) + if target is not None: + relative = posixpath.relpath(target, posixpath.dirname(page_rel) or ".") + return f"[{text}]({relative}{anchor})" + + return match.group(0) + + return _MD_LINK_RE.sub(_replace, line) + + +def rewrite_page(content, page_rel, existing_pages, redirects, class_paths, base_urls=(BASE_URL,)): + """Rewrite a page's links, leaving fenced code blocks untouched. + + ``page_rel`` is the page's path relative to doc/ (e.g. 'search/index.md'). + """ + lines = content.split("\n") + result = [] + fence = None # (fence_char, fence_length) while inside a fenced code block + + for line in lines: + if fence is not None: + stripped = line.strip() + if stripped and set(stripped) == {fence[0]} and len(stripped) >= fence[1]: + fence = None + result.append(line) + continue + + fence_match = _FENCE_OPEN_RE.match(line) + if fence_match: + marker = fence_match.group(2) + fence = (marker[0], len(marker)) + result.append(line) + continue + + result.append( + rewrite_links(line, page_rel, existing_pages, redirects, class_paths, base_urls) + ) + + return "\n".join(result) + + +def rewrite_llms_txt(content, existing_pages): + """Point llms.txt links at the packaged doc/ files (llms.txt sits at the package root).""" + + def _replace(match): + bang, text, url = match.groups() + if bang or not url.startswith(BASE_URL): + return match.group(0) + candidate = url[len(BASE_URL):] + if candidate in existing_pages: + return f"[{text}](doc/{candidate})" + return match.group(0) + + return _MD_LINK_RE.sub(_replace, content) + + +def check_relative_doc_links(pages): + """Verify every relative .md link in the rewritten pages resolves. + + ``pages`` maps page_rel -> content. Returns a list of error strings. + Vendor class links (.php) can't be checked against the docs tree and are + skipped. + """ + errors = [] + for page_rel, content in pages.items(): + for match in _MD_LINK_RE.finditer(content): + url, _ = _split_anchor(match.group(3)) + if "://" in url or url.startswith("#") or not url.endswith(".md"): + continue + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(page_rel), url)) + if resolved.startswith("..") or resolved not in pages: + errors.append(f"{page_rel}: broken relative link {match.group(3)}") + return errors + + +def build(site_dir, out_dir, llms_out, plugins_path, class_map_path, version=None): + site = Path(site_dir) + if not site.is_dir(): + sys.exit(f"Site directory not found: {site_dir} (run mkdocs build first)") + + base_urls = [BASE_URL] + if version: + base_urls.append(f"https://doc.ibexa.co/en/{version}/") + + redirects = load_redirect_maps(plugins_path) + class_paths = json.loads(Path(class_map_path).read_text(encoding="utf-8")) + + source_pages = { + path.relative_to(site).as_posix(): path + for path in sorted(site.rglob("*.md")) + } + existing_pages = set(source_pages) + + rewritten = { + page_rel: rewrite_page( + path.read_text(encoding="utf-8"), + page_rel, + existing_pages, + redirects, + class_paths, + base_urls, + ) + for page_rel, path in source_pages.items() + } + + errors = check_relative_doc_links(rewritten) + + llms_source = site / "llms.txt" + llms_content = rewrite_llms_txt(llms_source.read_text(encoding="utf-8"), existing_pages) + + if errors: + for error in errors: + print(error, file=sys.stderr) + sys.exit(f"{len(errors)} broken relative links, not writing output") + + out = Path(out_dir) + if out.exists(): + shutil.rmtree(out) + for page_rel, content in rewritten.items(): + target = out / page_rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + Path(llms_out).write_text(llms_content, encoding="utf-8") + + remaining = sum( + content.count(base) for content in rewritten.values() for base in base_urls + ) + print(f"Wrote {len(rewritten)} pages to {out_dir}/ and {llms_out}") + print(f"{remaining} links intentionally left absolute (no local page)") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n", 2)[1]) + parser.add_argument("--site", default="site", help="MkDocs build output directory") + parser.add_argument("--out", default="doc", help="Package docs output directory") + parser.add_argument("--llms", default="llms.txt", help="Package llms.txt output path") + parser.add_argument("--plugins", default="plugins.yml", help="plugins.yml with redirect_maps") + parser.add_argument("--class-map", default="class_paths.json", + help="FQCN→vendor path map from dump_class_paths.php") + parser.add_argument("--version", default=None, + help="This branch's documentation version (e.g. 5.0): links pinned to " + "it (en/5.0/…) are rewritten like en/latest ones") + args = parser.parse_args() + build(args.site, args.out, args.llms, args.plugins, args.class_map, args.version) + + +if __name__ == "__main__": + main() diff --git a/composer.json b/composer.json index 1f772e70e4..cd67da1566 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,8 @@ { "name": "ibexa/documentation-developer", - "description": "This repository is the source for the developer documentation for eZ Platform, an open source CMS based on the Symfony Full Stack Framework in PHP.", + "description": "Ibexa DXP developer documentation as Markdown files, for offline reading and AI coding assistants working inside your project.", "type": "library", + "keywords": ["ibexa", "ibexa-dxp", "documentation", "markdown", "llm", "ai", "agents"], "license": "GNU General Public License v2.0", "autoload-dev": { "psr-4": { @@ -14,7 +15,6 @@ "url": "https://updates.ibexa.co" } ], - "require": {}, "require-dev": { "php": "^8.3", "phpunit/phpunit": "^11.0", @@ -98,7 +98,9 @@ "ibexa/ckeditor-premium": "~5.0.x-dev", "ibexa/measurement": "~5.0.x-dev", "ibexa/connector-actito": "~5.0.x-dev", - "ibexa/fastly": "~5.0.x-dev" + "ibexa/fastly": "~5.0.x-dev", + "ibexa/connect": "~5.0.x-dev", + "ibexa/connector-qualifio": "~5.0.x-dev" }, "scripts": { "fix-cs": [ diff --git a/tests/python/test_build_package_docs.py b/tests/python/test_build_package_docs.py new file mode 100644 index 0000000000..961ed106f5 --- /dev/null +++ b/tests/python/test_build_package_docs.py @@ -0,0 +1,177 @@ +from build_package_docs import ( + check_relative_doc_links, + rewrite_llms_txt, + rewrite_page, +) + +PAGES = { + "index.md", + "search/search/index.md", + "search/search_api/index.md", + "administration/back_office/back_office_menus/add_menu_item/index.md", + "administration/back_office/back_office_menus/back_office_menus/index.md", + "content_management/images/images/index.md", +} + +REDIRECTS = { + "guide/images/": "content_management/images/images/", +} + +CLASS_PATHS = { + "Ibexa\\Contracts\\AdminUi\\Tab\\AbstractTab": "ibexa/admin-ui/src/contracts/Tab/AbstractTab.php", + "Ibexa\\Contracts\\Core\\Repository\\SearchService": "ibexa/core/src/contracts/Repository/SearchService.php", +} + + +BASE_URLS = ("https://doc.ibexa.co/en/latest/", "https://doc.ibexa.co/en/5.0/") + + +def rewrite(content, page_rel="search/search/index.md"): + return rewrite_page(content, page_rel, PAGES, REDIRECTS, CLASS_PATHS, BASE_URLS) + + +class TestInternalLinks: + def test_page_link_becomes_relative(self): + assert ( + rewrite("See [Search API](https://doc.ibexa.co/en/latest/search/search_api/).") + == "See [Search API](../search_api/index.md)." + ) + + def test_anchor_is_kept(self): + content = "[events](https://doc.ibexa.co/en/latest/administration/back_office/back_office_menus/back_office_menus/#menu-events)" + assert rewrite(content) == ( + "[events](../../administration/back_office/back_office_menus/back_office_menus/index.md#menu-events)" + ) + + def test_site_root_link(self): + assert ( + rewrite("[home](https://doc.ibexa.co/en/latest/)") + == "[home](../../index.md)" + ) + + def test_link_from_root_page(self): + assert ( + rewrite("[search](https://doc.ibexa.co/en/latest/search/search/)", page_rel="index.md") + == "[search](search/search/index.md)" + ) + + def test_redirected_url_resolves_through_redirect_map(self): + assert ( + rewrite("[images](https://doc.ibexa.co/en/latest/guide/images/)") + == "[images](../../content_management/images/images/index.md)" + ) + + def test_unknown_page_stays_absolute(self): + content = "[gone](https://doc.ibexa.co/en/latest/no/such/page/)" + assert rewrite(content) == content + + def test_other_doc_sites_stay_absolute(self): + content = "[user docs](https://doc.ibexa.co/projects/userguide/en/latest/)" + assert rewrite(content) == content + + def test_own_version_is_rewritten_like_latest(self): + assert ( + rewrite("[Search API](https://doc.ibexa.co/en/5.0/search/search_api/)") + == "[Search API](../search_api/index.md)" + ) + + def test_other_versions_stay_absolute(self): + content = "[4.6 docs](https://doc.ibexa.co/en/4.6/search/search/)" + assert rewrite(content) == content + + def test_other_version_api_links_stay_absolute(self): + content = "[`X`](https://doc.ibexa.co/en/4.6/api/php_api/php_api_reference/classes/Ibexa-Contracts-AdminUi-Tab-AbstractTab.html)" + assert rewrite(content) == content + + def test_external_links_stay_absolute(self): + content = "[Symfony](https://symfony.com/doc/current/index.html)" + assert rewrite(content) == content + + def test_images_are_not_rewritten(self): + content = "![Admin panel](https://doc.ibexa.co/en/latest/search/search/)" + assert rewrite(content) == content + + def test_links_in_fenced_code_blocks_are_untouched(self): + content = "\n".join( + [ + "```markdown", + "[Search](https://doc.ibexa.co/en/latest/search/search_api/)", + "```", + "[Search](https://doc.ibexa.co/en/latest/search/search_api/)", + ] + ) + assert rewrite(content) == "\n".join( + [ + "```markdown", + "[Search](https://doc.ibexa.co/en/latest/search/search_api/)", + "```", + "[Search](../search_api/index.md)", + ] + ) + + +class TestApiLinks: + URL = "https://doc.ibexa.co/en/latest/api/php_api/php_api_reference/classes/Ibexa-Contracts-AdminUi-Tab-AbstractTab.html" + + def test_resolved_class_links_to_vendor_source_with_fqcn_text(self): + # From doc/search/search/index.md: 3 dirs inside the package + 2 up to vendor/. + assert rewrite(f"[`AbstractTab`]({self.URL})") == ( + "[`Ibexa\\Contracts\\AdminUi\\Tab\\AbstractTab`]" + "(../../../../../ibexa/admin-ui/src/contracts/Tab/AbstractTab.php)" + ) + + def test_depth_follows_page_location(self): + page = "administration/back_office/back_office_menus/add_menu_item/index.md" + result = rewrite(f"[`AbstractTab`]({self.URL})", page_rel=page) + assert result.endswith("(../../../../../../../ibexa/admin-ui/src/contracts/Tab/AbstractTab.php)") + + def test_method_anchor_is_dropped_and_text_kept(self): + url = "https://doc.ibexa.co/en/latest/api/php_api/php_api_reference/classes/Ibexa-Contracts-Core-Repository-SearchService.html" + assert rewrite(f"[`findContent()`]({url}#method_findContent)") == ( + "[`findContent()`](../../../../../ibexa/core/src/contracts/Repository/SearchService.php)" + ) + + def test_unresolved_class_keeps_url_but_gets_fqcn_text(self): + url = "https://doc.ibexa.co/en/latest/api/php_api/php_api_reference/classes/Ibexa-Contracts-Connect-ConnectClientInterface.html" + assert rewrite(f"[`ConnectClientInterface`]({url})") == ( + f"[`Ibexa\\Contracts\\Connect\\ConnectClientInterface`]({url})" + ) + + +class TestLlmsTxt: + def test_page_links_point_into_doc(self): + content = "- [Search](https://doc.ibexa.co/en/latest/search/search/index.md)" + assert rewrite_llms_txt(content, PAGES) == "- [Search](doc/search/search/index.md)" + + def test_root_page(self): + content = "- [Home](https://doc.ibexa.co/en/latest/index.md)" + assert rewrite_llms_txt(content, PAGES) == "- [Home](doc/index.md)" + + def test_unknown_page_stays_absolute(self): + content = "- [Gone](https://doc.ibexa.co/en/latest/no/such/index.md)" + assert rewrite_llms_txt(content, PAGES) == content + + +class TestSelfCheck: + def test_valid_links_pass(self): + pages = { + "a/b/index.md": "[ok](../c/index.md)", + "a/c/index.md": "[ok](../b/index.md#anchor)", + } + assert check_relative_doc_links(pages) == [] + + def test_broken_link_is_reported(self): + pages = {"a/b/index.md": "[broken](../missing/index.md)"} + errors = check_relative_doc_links(pages) + assert len(errors) == 1 + assert "a/b/index.md" in errors[0] + + def test_link_escaping_doc_tree_is_reported(self): + pages = {"a/index.md": "[escape](../../outside.md)"} + assert len(check_relative_doc_links(pages)) == 1 + + def test_vendor_and_external_links_are_skipped(self): + pages = { + "a/index.md": "[php](../../../ibexa/core/src/S.php) [web](https://example.com/x.md)" + } + assert check_relative_doc_links(pages) == [] diff --git a/tools/llm_package/README.package.md b/tools/llm_package/README.package.md new file mode 100644 index 0000000000..9ac00844c6 --- /dev/null +++ b/tools/llm_package/README.package.md @@ -0,0 +1,55 @@ +# Ibexa DXP developer documentation + +This package contains the official [Ibexa DXP developer documentation](https://doc.ibexa.co/en/latest/) +as plain Markdown files, generated from the same sources as the website. Install +it into an Ibexa DXP project so that AI coding assistants (and humans) can read +the documentation offline, right next to the code it describes: + +```bash +composer require --dev ibexa/documentation-developer:~5.0 +``` + +## Layout + +- `llms.txt` — the table of contents for the whole documentation, with relative + links into `doc/`. Start here to find the right page. +- `doc/` — one Markdown file per documentation page, mirroring the URL + structure of the website: `doc/
/...//index.md` corresponds to + `https://doc.ibexa.co/en/latest/
/...//`. + +## Conventions + +- Links between documentation pages are relative Markdown links and work + offline. +- PHP API references are links to the class source in your project's `vendor/` + directory, with the fully qualified class name as the link text, for example + ``[`Ibexa\Contracts\Core\Repository\SearchService`](../../../../core/src/contracts/Repository/SearchService.php)``. + If your project doesn't include the package a class belongs to, the link + target won't exist — the class name in the link text still tells you what the + reference is. +- A few links have no local equivalent and stay absolute: images, the + [PHP API reference](https://doc.ibexa.co/en/latest/api/php_api/php_api_reference/) + itself, and other documentation sites (user documentation, Ibexa Connect). + +## Using with AI agents + +Point your agent at this package, for example by adding to your project's +`CLAUDE.md` / `AGENTS.md`: + +```markdown +Ibexa DXP developer documentation is installed locally in +vendor/ibexa/documentation-developer/. To find a topic, read +vendor/ibexa/documentation-developer/llms.txt (table of contents) or search +vendor/ibexa/documentation-developer/doc/, then follow the relative links. +PHP API links in the docs point at the class sources in vendor/. +``` + +## Versioning + +A new tag is published whenever the generated documentation content changes; +tag names combine the documentation branch and the build date. Use a `~5.0` +constraint to stay on the documentation matching your Ibexa DXP version. + +The documentation sources live on the version branches of +[ibexa/documentation-developer](https://github.com/ibexa/documentation-developer) +— contributions are welcome there. diff --git a/tools/llm_package/dump_class_paths.php b/tools/llm_package/dump_class_paths.php new file mode 100644 index 0000000000..0c9006eb48 --- /dev/null +++ b/tools/llm_package/dump_class_paths.php @@ -0,0 +1,76 @@ +] [] + * + * Scans the MkDocs build output for `php_api_reference/classes/.html` + * links, converts each slug to a FQCN (dashes become namespace separators; + * PHP class names cannot contain dashes, so this is unambiguous) and resolves + * it through this repository's Composer autoloader. The resulting JSON maps + * FQCNs to paths relative to vendor/, e.g. + * "Ibexa\\Contracts\\AdminUi\\Tab\\AbstractTab": "ibexa/admin-ui/src/contracts/Tab/AbstractTab.php". + * + * Classes that don't resolve (their package is missing from composer.json) + * are listed on stderr and omitted from the map; build_package_docs.py keeps + * the absolute URL for them. + */ + +declare(strict_types=1); + +$siteDir = $argv[1] ?? 'site'; +$outputPath = $argv[2] ?? 'class_paths.json'; + +$root = dirname(__DIR__, 2); +$loader = require $root . '/vendor/autoload.php'; +$vendorDir = realpath($root . '/vendor'); + +if (!is_dir($siteDir)) { + fwrite(STDERR, "Site directory not found: $siteDir (run mkdocs build first)\n"); + exit(1); +} + +$slugs = []; +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($siteDir, FilesystemIterator::SKIP_DOTS) +); +foreach ($iterator as $file) { + if ($file->getExtension() !== 'md') { + continue; + } + $content = file_get_contents($file->getPathname()); + if (preg_match_all('~php_api_reference/classes/([A-Za-z0-9-]+)\.html~', $content, $matches)) { + foreach ($matches[1] as $slug) { + $slugs[$slug] = true; + } + } +} +ksort($slugs); + +$map = []; +$unresolved = []; +foreach (array_keys($slugs) as $slug) { + $fqcn = str_replace('-', '\\', $slug); + $path = $loader->findFile($fqcn); + $realPath = $path ? realpath($path) : false; + if ($realPath === false || !str_starts_with($realPath, $vendorDir . DIRECTORY_SEPARATOR)) { + $unresolved[] = $fqcn; + continue; + } + $map[$fqcn] = str_replace(DIRECTORY_SEPARATOR, '/', substr($realPath, strlen($vendorDir) + 1)); +} + +file_put_contents( + $outputPath, + json_encode($map, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" +); + +printf("Resolved %d/%d API-referenced classes into %s\n", count($map), count($slugs), $outputPath); +if ($unresolved !== []) { + fwrite(STDERR, "Unresolved classes (their links keep absolute URLs):\n"); + foreach ($unresolved as $fqcn) { + fwrite(STDERR, " $fqcn\n"); + } +} From a71f53e10fbd67c67217814e4b855a6e248e2bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Jul 2026 15:17:21 +0200 Subject: [PATCH 10/16] Added two placeholder skills --- .../external/ibexa-documentation/SKILL.md | 28 +++++++++++++++++++ .../skills/external/ibexa-php-api/SKILL.md | 27 ++++++++++++++++++ .gitattributes | 1 + .../workflows/release_composer_package.yaml | 4 ++- tools/llm_package/README.package.md | 3 ++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/external/ibexa-documentation/SKILL.md create mode 100644 .agents/skills/external/ibexa-php-api/SKILL.md diff --git a/.agents/skills/external/ibexa-documentation/SKILL.md b/.agents/skills/external/ibexa-documentation/SKILL.md new file mode 100644 index 0000000000..128e2a3009 --- /dev/null +++ b/.agents/skills/external/ibexa-documentation/SKILL.md @@ -0,0 +1,28 @@ +--- +name: ibexa-documentation +description: Find answers in the locally installed Ibexa DXP developer documentation. Use when working on an Ibexa DXP project and you need to look up how a feature, API, configuration, or extension point works. +--- + +# Ibexa DXP developer documentation + + + +The full Ibexa DXP developer documentation is installed locally in +`vendor/ibexa/documentation-developer/`. + +## Finding a topic + +1. Read `vendor/ibexa/documentation-developer/llms.txt` — the table of contents + for the whole documentation, grouped by section, with relative links into `doc/`. +2. Or search directly: `grep -ri "" vendor/ibexa/documentation-developer/doc/`. +3. Follow the relative links between pages; they all work offline. + +## Conventions + +- One page per directory: `doc/
/...//index.md`. +- PHP API references are links to the class source in `vendor/`, with the fully + qualified class name as the link text. If the target file doesn't exist, that + package isn't installed in this project — the class name still tells you what + to look for. +- Prefer the documentation matching this project's installed version; this + package is versioned together with Ibexa DXP. diff --git a/.agents/skills/external/ibexa-php-api/SKILL.md b/.agents/skills/external/ibexa-php-api/SKILL.md new file mode 100644 index 0000000000..a86fe9e126 --- /dev/null +++ b/.agents/skills/external/ibexa-php-api/SKILL.md @@ -0,0 +1,27 @@ +--- +name: ibexa-php-api +description: Work with the Ibexa DXP PHP API - resolve interfaces, services, and value objects from vendor sources before writing code. Use when writing or reviewing PHP code that uses Ibexa DXP APIs. +--- + +# Ibexa DXP PHP API + + + +Before writing code against an Ibexa DXP API, check the real contracts in this +project's `vendor/` directory instead of guessing signatures. + +## Resolving a class + +- Namespaces map to packages via PSR-4, e.g. `Ibexa\Contracts\ProductCatalog\…` + → `vendor/ibexa/product-catalog/src/contracts/…`. +- Public, stable APIs live under `Ibexa\Contracts\…` (`src/contracts/` in each + package); classes outside `Contracts` are internal implementation. +- Find service methods: `grep -n "public function" vendor/ibexa//src/contracts/<…>Interface.php`. + +## Guidelines + +- Inject the `…ServiceInterface` contracts rather than concrete classes. +- Distinguish create/update structs (`…CreateStruct`, `…UpdateStruct`) from + read-only value objects. +- Usage documentation and examples for each API are available locally — see the + `ibexa-documentation` skill. diff --git a/.gitattributes b/.gitattributes index 1895fc25a5..3fd282c585 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ /* export-ignore +/.agents export-ignore=false /doc export-ignore=false /llms.txt export-ignore=false /composer.json export-ignore=false diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 795c9ce749..c3b2637083 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -117,7 +117,9 @@ jobs: git add README.md if [[ -z "$LATEST_TAG" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT" - elif git diff --cached --quiet "$LATEST_TAG" -- doc llms.txt README.md; then + # .agents (agent skills, tracked on the branch) is part of the package too; + # include it so skill-only edits also produce a release. + elif git diff --cached --quiet "$LATEST_TAG" -- doc llms.txt README.md .agents; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" diff --git a/tools/llm_package/README.package.md b/tools/llm_package/README.package.md index 9ac00844c6..b9b8fd4fb5 100644 --- a/tools/llm_package/README.package.md +++ b/tools/llm_package/README.package.md @@ -16,6 +16,9 @@ composer require --dev ibexa/documentation-developer:~5.0 - `doc/` — one Markdown file per documentation page, mirroring the URL structure of the website: `doc/
/...//index.md` corresponds to `https://doc.ibexa.co/en/latest/
/...//`. +- `.agents/skills/external/` — agent skills for working with Ibexa DXP. When + the package is installed with Symfony Flex, its recipe copies them into your + project's `.agents/skills/ibexa/` directory. ## Conventions From 31bb96953482032ec95bc0cac27e71ec3d90e9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Jul 2026 15:35:56 +0200 Subject: [PATCH 11/16] Speed up. TMP workflow token change --- .../workflows/release_composer_package.yaml | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index c3b2637083..7cd074d33c 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -37,13 +37,13 @@ jobs: branch: ${{ fromJson(needs.determine-branches.outputs.branches) }} steps: + # Shallow checkout of the branch tip only. The generated Markdown is never + # pushed to the branch itself (see below), only tagged, so the diff target is + # the latest tag — fetched individually in "Find the last tag" instead of + # pulling the full history + every daily tag snapshot (which took minutes). - uses: actions/checkout@v4 with: ref: ${{ matrix.branch }} - # Full history + tags: the generated Markdown is never pushed to the branch - # itself (see below), only tagged, so tags are the only place to diff against. - fetch-depth: 0 - fetch-tags: true - name: Set up Python uses: actions/setup-python@v3 @@ -61,13 +61,13 @@ jobs: php-version: "8.3" coverage: none - - name: Generate token - id: generate_token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.AUTOMATION_CLIENT_ID }} - private-key: ${{ secrets.AUTOMATION_CLIENT_SECRET }} - owner: ${{ github.repository_owner }} + # - name: Generate token + # id: generate_token + # uses: actions/create-github-app-token@v2 + # with: + # app-id: ${{ secrets.AUTOMATION_CLIENT_ID }} + # private-key: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + # owner: ${{ github.repository_owner }} - name: Add composer keys for private packagist run: | @@ -76,7 +76,7 @@ jobs: env: SATIS_NETWORK_KEY: ${{ secrets.SATIS_NETWORK_KEY }} SATIS_NETWORK_TOKEN: ${{ secrets.SATIS_NETWORK_TOKEN }} - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} #${{ steps.generate_token.outputs.token }} # Needed to resolve the PHP API classes referenced by the docs to # their vendor/ source paths (dump_class_paths.php). @@ -101,7 +101,14 @@ jobs: env: BRANCH: ${{ matrix.branch }} run: | - latest_tag=$(git tag --list "${BRANCH}.*" | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) + # List tag names on the remote (no object download), then fetch only the + # latest one — the single commit the change check diffs against. + latest_tag=$(git ls-remote --tags origin "refs/tags/${BRANCH}.*" \ + | awk '{print $2}' | sed 's|^refs/tags/||' | grep -v '\^{}$' \ + | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n | tail -1) + if [[ -n "$latest_tag" ]]; then + git fetch --depth=1 origin "refs/tags/${latest_tag}:refs/tags/${latest_tag}" + fi echo "latest_tag=${latest_tag}" >> "$GITHUB_OUTPUT" - name: Check for content changes since the last tag From ba023f9e9f25aa16d2e08d49c556b1d845d48cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Jul 2026 16:56:46 +0200 Subject: [PATCH 12/16] Added user doc --- .../external/ibexa-documentation/SKILL.md | 27 +- .gitattributes | 4 +- .../workflows/release_composer_package.yaml | 27 +- .gitignore | 3 + build_package_docs.py | 245 +++++++++++------- tests/python/test_build_package_docs.py | 158 +++++++---- tools/llm_package/README.package.md | 43 +-- tools/llm_package/dump_class_paths.php | 41 +-- 8 files changed, 349 insertions(+), 199 deletions(-) diff --git a/.agents/skills/external/ibexa-documentation/SKILL.md b/.agents/skills/external/ibexa-documentation/SKILL.md index 128e2a3009..acaa498900 100644 --- a/.agents/skills/external/ibexa-documentation/SKILL.md +++ b/.agents/skills/external/ibexa-documentation/SKILL.md @@ -1,25 +1,34 @@ --- name: ibexa-documentation -description: Find answers in the locally installed Ibexa DXP developer documentation. Use when working on an Ibexa DXP project and you need to look up how a feature, API, configuration, or extension point works. +description: Find answers in the locally installed Ibexa DXP documentation (developer and user docs). Use when working on an Ibexa DXP project and you need to look up how a feature, API, configuration, extension point, or back-office workflow works. --- -# Ibexa DXP developer documentation +# Ibexa DXP documentation -The full Ibexa DXP developer documentation is installed locally in -`vendor/ibexa/documentation-developer/`. +The full Ibexa DXP documentation is installed locally in +`vendor/ibexa/documentation-developer/`: + +- `developer/` — developer documentation: APIs, extension points, + configuration, tutorials. +- `user/` — user documentation: back office, content management, commerce + workflows. ## Finding a topic -1. Read `vendor/ibexa/documentation-developer/llms.txt` — the table of contents - for the whole documentation, grouped by section, with relative links into `doc/`. -2. Or search directly: `grep -ri "" vendor/ibexa/documentation-developer/doc/`. -3. Follow the relative links between pages; they all work offline. +1. Read the set's table of contents: + `vendor/ibexa/documentation-developer/developer/llms.txt` or + `vendor/ibexa/documentation-developer/user/llms.txt` — grouped by section, + with relative links. +2. Or search directly: + `grep -ri "" vendor/ibexa/documentation-developer/developer/`. +3. Follow the relative links between pages (including cross-links between the + developer and user docs); they all work offline. ## Conventions -- One page per directory: `doc/
/...//index.md`. +- One page per directory: `/
/...//index.md`. - PHP API references are links to the class source in `vendor/`, with the fully qualified class name as the link text. If the target file doesn't exist, that package isn't installed in this project — the class name still tells you what diff --git a/.gitattributes b/.gitattributes index 3fd282c585..701ed09182 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ /* export-ignore /.agents export-ignore=false -/doc export-ignore=false -/llms.txt export-ignore=false +/developer export-ignore=false +/user export-ignore=false /composer.json export-ignore=false /LICENSE export-ignore=false /README.md export-ignore=false diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 7cd074d33c..cdc260156e 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -45,6 +45,13 @@ jobs: with: ref: ${{ matrix.branch }} + - name: Check out user documentation + uses: actions/checkout@v4 + with: + repository: ibexa/documentation-user + ref: ${{ matrix.branch }} + path: user-docs + - name: Set up Python uses: actions/setup-python@v3 with: @@ -53,7 +60,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.txt -r user-docs/requirements.txt - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -84,13 +91,17 @@ jobs: with: dependency-versions: highest - - name: Build documentation + - name: Build developer documentation + run: mkdocs build --strict + + - name: Build user documentation run: mkdocs build --strict + working-directory: user-docs - name: Resolve PHP API classes to vendor paths - run: php tools/llm_package/dump_class_paths.php site class_paths.json + run: php tools/llm_package/dump_class_paths.php site user-docs/site class_paths.json - - name: Build package Markdown (doc/, llms.txt) + - name: Build package Markdown (developer/, user/) run: python build_package_docs.py --version "${{ matrix.branch }}" - name: Copy package README @@ -118,15 +129,15 @@ jobs: run: | # The generated Markdown is never committed/pushed to the branch itself (only # tagged), so there's nothing on the branch to diff against — compare the freshly - # built content to what the last tag captured instead. -f because doc/ and - # llms.txt are gitignored to keep local builds out of the working tree. - git add -f doc llms.txt + # built content to what the last tag captured instead. -f because developer/ and + # user/ are gitignored to keep local builds out of the working tree. + git add -f developer user git add README.md if [[ -z "$LATEST_TAG" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT" # .agents (agent skills, tracked on the branch) is part of the package too; # include it so skill-only edits also produce a release. - elif git diff --cached --quiet "$LATEST_TAG" -- doc llms.txt README.md .agents; then + elif git diff --cached --quiet "$LATEST_TAG" -- developer user README.md .agents; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 446d0ee69f..f48666154a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ code_samples/_inline_php/ *.egg-info/ /doc/ /llms.txt +/developer/ +/user/ /class_paths.json +/user-docs/ diff --git a/build_package_docs.py b/build_package_docs.py index 459bcc4e87..593a47f2ef 100644 --- a/build_package_docs.py +++ b/build_package_docs.py @@ -1,16 +1,25 @@ """ -Build the Composer-package Markdown docs from the MkDocs build output. - -The website output (site/) keeps absolute https://doc.ibexa.co URLs; the -Composer package must work offline inside vendor/ibexa/documentation-developer, -so this script copies every generated Markdown page into doc/ (plus llms.txt to -the repo root) while rewriting links: - -- Internal page links (``https://doc.ibexa.co/en/latest//[#anchor]``) - become relative links to the corresponding ``doc//index.md`` file. - URLs matching a mkdocs-redirects entry are resolved through the redirect - first. URLs with no local page (other doc.ibexa.co projects, images, the - separately hosted API reference HTML) are left untouched. +Build the Composer-package Markdown docs from the MkDocs build outputs. + +The package bundles two documentation sets, each produced by its own MkDocs +build with the llmstxt plugin: + +- ``developer/`` — ibexa/documentation-developer (this repository, built into + ``site/``) +- ``user/`` — ibexa/documentation-user (checked out and built next to it, into + ``user-docs/site/``) + +Each set gets its own ``llms.txt`` table of contents at its root. The website +outputs keep absolute https://doc.ibexa.co URLs; the Composer package must work +offline inside vendor/ibexa/documentation-developer, so links are rewritten: + +- Page links (``https://doc.ibexa.co/en/latest//[#anchor]`` for the + developer docs, ``https://doc.ibexa.co/projects/userguide/en/latest/…`` for + the user docs) become relative links to the corresponding + ``//index.md`` file — including cross-set links between the two + documentations. URLs matching a mkdocs-redirects entry are resolved through + the redirect first. URLs with no local page (other doc.ibexa.co projects, + images, the separately hosted API reference HTML) are left untouched. - PHP API class links (``…/php_api_reference/classes/.html``) become relative links to the class source file in the *installed project's* vendor/ directory, using the FQCN→path map produced by @@ -19,10 +28,10 @@ greppable even if the target package isn't installed in the user's project. Unmapped classes keep their absolute URL. -Run after ``mkdocs build``: +Run after both ``mkdocs build``s: - php tools/llm_package/dump_class_paths.php site class_paths.json - python build_package_docs.py + php tools/llm_package/dump_class_paths.php site user-docs/site class_paths.json + python build_package_docs.py --version 5.0 Like llmstxt_preprocess.py, all transformations are pure functions so they can be unit-tested (tests/python/test_build_package_docs.py). @@ -35,11 +44,10 @@ import shutil import sys from pathlib import Path, PurePosixPath +from typing import NamedTuple import yaml -BASE_URL = "https://doc.ibexa.co/en/latest/" - # Number of path segments from the package root up to vendor/ # (vendor/ibexa/documentation-developer/ -> vendor/). _PACKAGE_DEPTH_IN_VENDOR = 2 @@ -54,6 +62,15 @@ _FENCE_OPEN_RE = re.compile(r"^(\s*)(`{3,}|~{3,})") +class DocSet(NamedTuple): + """One documentation set shipped in the package.""" + + root: str # top-level package directory, e.g. 'developer' + base_urls: tuple # URL prefixes owned by this set, e.g. ('https://doc.ibexa.co/en/latest/',) + pages: frozenset # page paths relative to the set root, e.g. 'search/search/index.md' + redirects: dict # URL path -> URL path, from mkdocs-redirects + + def load_redirect_maps(plugins_path): """Read mkdocs-redirects ``redirect_maps`` from plugins.yml as URL paths. @@ -83,12 +100,12 @@ def _md_to_url_path(md_path): return path.rstrip("/") + "/" if path else "" -def _page_file(url_path, existing_pages, redirects): +def _page_file(url_path, pages, redirects): """Map a site-relative URL path to its Markdown file, or None. - ``url_path`` is the part after BASE_URL without the anchor. Only page URLs - (directory URLs or explicit .md paths) are considered; anything else - (images, API reference HTML, files) returns None. + ``url_path`` is the part after the set's base URL without the anchor. Only + page URLs (directory URLs or explicit .md paths) are considered; anything + else (images, API reference HTML, files) returns None. """ if url_path.endswith(".md"): candidate = url_path @@ -97,13 +114,13 @@ def _page_file(url_path, existing_pages, redirects): else: return None - if candidate in existing_pages: + if candidate in pages: return candidate redirect_target = redirects.get(url_path) if redirect_target is not None: candidate = redirect_target + "index.md" - if candidate in existing_pages: + if candidate in pages: return candidate return None @@ -115,7 +132,7 @@ def _split_anchor(url): return url, "" -def _rewrite_api_link(text, url, anchor, page_rel, class_paths): +def _rewrite_api_link(text, url, anchor, page_path, class_paths): """Rewrite one PHP API class link; returns the full markdown link.""" slug = _API_CLASS_URL_RE.search(url).group(1) fqcn = slug.replace("-", "\\") @@ -131,18 +148,20 @@ def _rewrite_api_link(text, url, anchor, page_rel, class_paths): return f"[{text}]({url}{anchor})" # Steps up from the page's directory to vendor/: the page lives at - # vendor/ibexa/documentation-developer/doc/. - ups = len(PurePosixPath("doc", page_rel).parent.parts) + _PACKAGE_DEPTH_IN_VENDOR + # vendor/ibexa/documentation-developer/. + ups = len(PurePosixPath(page_path).parent.parts) + _PACKAGE_DEPTH_IN_VENDOR # HTML anchors (#method_…) have no equivalent in the source file; drop them. return f"[{text}]({'../' * ups}{vendor_path})" -def rewrite_links(line, page_rel, existing_pages, redirects, class_paths, base_urls=(BASE_URL,)): +def rewrite_links(line, page_path, docsets, class_paths): """Rewrite all links on one (non-code) Markdown line. - ``base_urls`` are the URL prefixes considered "this documentation" — - en/latest, plus the branch's own version (release notes hardcode - version-pinned URLs like en/5.0). Links to other versions stay absolute. + ``page_path`` is the page's package-relative path (e.g. + 'developer/search/search/index.md'). ``docsets`` are all sets in the + package — links to any of their base URLs are localized, so cross-set + links (developer docs → user docs and back) become relative too. Links to + other doc versions or other doc.ibexa.co projects stay absolute. """ def _replace(match): @@ -151,26 +170,33 @@ def _replace(match): return match.group(0) bare_url, anchor = _split_anchor(url) - base = next((b for b in base_urls if bare_url.startswith(b)), None) - if base is not None: - if _API_CLASS_URL_RE.search(bare_url): - return _rewrite_api_link(text, bare_url, anchor, page_rel, class_paths) + owner = next( + ( + (docset, base) + for docset in docsets + for base in docset.base_urls + if bare_url.startswith(base) + ), + None, + ) + if owner is None: + return match.group(0) + docset, base = owner - target = _page_file(bare_url[len(base):], existing_pages, redirects) - if target is not None: - relative = posixpath.relpath(target, posixpath.dirname(page_rel) or ".") - return f"[{text}]({relative}{anchor})" + if _API_CLASS_URL_RE.search(bare_url): + return _rewrite_api_link(text, bare_url, anchor, page_path, class_paths) - return match.group(0) + target = _page_file(bare_url[len(base):], docset.pages, docset.redirects) + if target is None: + return match.group(0) + relative = posixpath.relpath(f"{docset.root}/{target}", posixpath.dirname(page_path)) + return f"[{text}]({relative}{anchor})" return _MD_LINK_RE.sub(_replace, line) -def rewrite_page(content, page_rel, existing_pages, redirects, class_paths, base_urls=(BASE_URL,)): - """Rewrite a page's links, leaving fenced code blocks untouched. - - ``page_rel`` is the page's path relative to doc/ (e.g. 'search/index.md'). - """ +def rewrite_page(content, page_path, docsets, class_paths): + """Rewrite a page's links, leaving fenced code blocks untouched.""" lines = content.split("\n") result = [] fence = None # (fence_char, fence_length) while inside a fenced code block @@ -190,23 +216,29 @@ def rewrite_page(content, page_rel, existing_pages, redirects, class_paths, base result.append(line) continue - result.append( - rewrite_links(line, page_rel, existing_pages, redirects, class_paths, base_urls) - ) + result.append(rewrite_links(line, page_path, docsets, class_paths)) return "\n".join(result) -def rewrite_llms_txt(content, existing_pages): - """Point llms.txt links at the packaged doc/ files (llms.txt sits at the package root).""" +def rewrite_llms_txt(content, docset): + """Point a set's llms.txt links at its packaged pages. + + llms.txt sits at the set's root, so targets are simply the page paths + relative to that root. + """ def _replace(match): bang, text, url = match.groups() - if bang or not url.startswith(BASE_URL): + if bang: return match.group(0) - candidate = url[len(BASE_URL):] - if candidate in existing_pages: - return f"[{text}](doc/{candidate})" + bare_url, anchor = _split_anchor(url) + for base in docset.base_urls: + if bare_url.startswith(base): + target = _page_file(bare_url[len(base):], docset.pages, docset.redirects) + if target is not None: + return f"[{text}]({target}{anchor})" + break return match.group(0) return _MD_LINK_RE.sub(_replace, content) @@ -215,91 +247,106 @@ def _replace(match): def check_relative_doc_links(pages): """Verify every relative .md link in the rewritten pages resolves. - ``pages`` maps page_rel -> content. Returns a list of error strings. - Vendor class links (.php) can't be checked against the docs tree and are - skipped. + ``pages`` maps package-relative page paths -> content. Returns a list of + error strings. Vendor class links (.php) can't be checked against the docs + tree and are skipped. """ errors = [] - for page_rel, content in pages.items(): + for page_path, content in pages.items(): for match in _MD_LINK_RE.finditer(content): url, _ = _split_anchor(match.group(3)) if "://" in url or url.startswith("#") or not url.endswith(".md"): continue - resolved = posixpath.normpath(posixpath.join(posixpath.dirname(page_rel), url)) + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(page_path), url)) if resolved.startswith("..") or resolved not in pages: - errors.append(f"{page_rel}: broken relative link {match.group(3)}") + errors.append(f"{page_path}: broken relative link {match.group(3)}") return errors -def build(site_dir, out_dir, llms_out, plugins_path, class_map_path, version=None): +def _version_bases(url_prefix, version): + """Base URLs owned by a doc set: en/latest plus the branch's own version.""" + bases = [f"{url_prefix}en/latest/"] + if version: + bases.append(f"{url_prefix}en/{version}/") + return tuple(bases) + + +def _load_docset(root, site_dir, plugins_path, base_urls): site = Path(site_dir) if not site.is_dir(): sys.exit(f"Site directory not found: {site_dir} (run mkdocs build first)") + pages = frozenset(path.relative_to(site).as_posix() for path in site.rglob("*.md")) + return DocSet(root, base_urls, pages, load_redirect_maps(plugins_path)), site - base_urls = [BASE_URL] - if version: - base_urls.append(f"https://doc.ibexa.co/en/{version}/") - redirects = load_redirect_maps(plugins_path) +def build(dev_site, dev_plugins, user_site, user_plugins, class_map_path, version=None): + developer, dev_dir = _load_docset( + "developer", dev_site, dev_plugins, _version_bases("https://doc.ibexa.co/", version) + ) + user, user_dir = _load_docset( + "user", + user_site, + user_plugins, + _version_bases("https://doc.ibexa.co/projects/userguide/", version), + ) + docsets = ((developer, dev_dir), (user, user_dir)) class_paths = json.loads(Path(class_map_path).read_text(encoding="utf-8")) - source_pages = { - path.relative_to(site).as_posix(): path - for path in sorted(site.rglob("*.md")) - } - existing_pages = set(source_pages) - - rewritten = { - page_rel: rewrite_page( - path.read_text(encoding="utf-8"), - page_rel, - existing_pages, - redirects, - class_paths, - base_urls, + rewritten = {} + llms = {} + for docset, site in docsets: + for page_rel in sorted(docset.pages): + page_path = f"{docset.root}/{page_rel}" + rewritten[page_path] = rewrite_page( + (site / page_rel).read_text(encoding="utf-8"), + page_path, + (developer, user), + class_paths, + ) + llms[docset.root] = rewrite_llms_txt( + (site / "llms.txt").read_text(encoding="utf-8"), docset ) - for page_rel, path in source_pages.items() - } errors = check_relative_doc_links(rewritten) - - llms_source = site / "llms.txt" - llms_content = rewrite_llms_txt(llms_source.read_text(encoding="utf-8"), existing_pages) - if errors: for error in errors: print(error, file=sys.stderr) sys.exit(f"{len(errors)} broken relative links, not writing output") - out = Path(out_dir) - if out.exists(): - shutil.rmtree(out) - for page_rel, content in rewritten.items(): - target = out / page_rel + for docset, _ in docsets: + out = Path(docset.root) + if out.exists(): + shutil.rmtree(out) + out.mkdir() + (out / "llms.txt").write_text(llms[docset.root], encoding="utf-8") + + for page_path, content in rewritten.items(): + target = Path(page_path) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") - Path(llms_out).write_text(llms_content, encoding="utf-8") - remaining = sum( - content.count(base) for content in rewritten.values() for base in base_urls - ) - print(f"Wrote {len(rewritten)} pages to {out_dir}/ and {llms_out}") + all_bases = developer.base_urls + user.base_urls + remaining = sum(content.count(base) for content in rewritten.values() for base in all_bases) + print(f"Wrote {len(rewritten)} pages to developer/ and user/ (each with its llms.txt)") print(f"{remaining} links intentionally left absolute (no local page)") def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n", 2)[1]) - parser.add_argument("--site", default="site", help="MkDocs build output directory") - parser.add_argument("--out", default="doc", help="Package docs output directory") - parser.add_argument("--llms", default="llms.txt", help="Package llms.txt output path") - parser.add_argument("--plugins", default="plugins.yml", help="plugins.yml with redirect_maps") + parser.add_argument("--site", default="site", help="Developer docs MkDocs build output") + parser.add_argument("--plugins", default="plugins.yml", + help="Developer docs plugins.yml with redirect_maps") + parser.add_argument("--user-site", default="user-docs/site", + help="User docs MkDocs build output") + parser.add_argument("--user-plugins", default="user-docs/plugins.yml", + help="User docs plugins.yml with redirect_maps") parser.add_argument("--class-map", default="class_paths.json", help="FQCN→vendor path map from dump_class_paths.php") parser.add_argument("--version", default=None, help="This branch's documentation version (e.g. 5.0): links pinned to " "it (en/5.0/…) are rewritten like en/latest ones") args = parser.parse_args() - build(args.site, args.out, args.llms, args.plugins, args.class_map, args.version) + build(args.site, args.plugins, args.user_site, args.user_plugins, args.class_map, args.version) if __name__ == "__main__": diff --git a/tests/python/test_build_package_docs.py b/tests/python/test_build_package_docs.py index 961ed106f5..62e8d003f3 100644 --- a/tests/python/test_build_package_docs.py +++ b/tests/python/test_build_package_docs.py @@ -1,21 +1,43 @@ from build_package_docs import ( + DocSet, check_relative_doc_links, rewrite_llms_txt, rewrite_page, ) -PAGES = { - "index.md", - "search/search/index.md", - "search/search_api/index.md", - "administration/back_office/back_office_menus/add_menu_item/index.md", - "administration/back_office/back_office_menus/back_office_menus/index.md", - "content_management/images/images/index.md", -} +DEVELOPER = DocSet( + root="developer", + base_urls=("https://doc.ibexa.co/en/latest/", "https://doc.ibexa.co/en/5.0/"), + pages=frozenset( + { + "index.md", + "search/search/index.md", + "search/search_api/index.md", + "administration/back_office/back_office_menus/add_menu_item/index.md", + "administration/back_office/back_office_menus/back_office_menus/index.md", + "content_management/images/images/index.md", + } + ), + redirects={"guide/images/": "content_management/images/images/"}, +) -REDIRECTS = { - "guide/images/": "content_management/images/images/", -} +USER = DocSet( + root="user", + base_urls=( + "https://doc.ibexa.co/projects/userguide/en/latest/", + "https://doc.ibexa.co/projects/userguide/en/5.0/", + ), + pages=frozenset( + { + "index.md", + "image_management/edit_images/index.md", + "getting_started/get_started/index.md", + } + ), + redirects={"getting_started/": "getting_started/get_started/"}, +) + +DOCSETS = (DEVELOPER, USER) CLASS_PATHS = { "Ibexa\\Contracts\\AdminUi\\Tab\\AbstractTab": "ibexa/admin-ui/src/contracts/Tab/AbstractTab.php", @@ -23,11 +45,8 @@ } -BASE_URLS = ("https://doc.ibexa.co/en/latest/", "https://doc.ibexa.co/en/5.0/") - - -def rewrite(content, page_rel="search/search/index.md"): - return rewrite_page(content, page_rel, PAGES, REDIRECTS, CLASS_PATHS, BASE_URLS) +def rewrite(content, page_path="developer/search/search/index.md"): + return rewrite_page(content, page_path, DOCSETS, CLASS_PATHS) class TestInternalLinks: @@ -49,9 +68,10 @@ def test_site_root_link(self): == "[home](../../index.md)" ) - def test_link_from_root_page(self): + def test_link_from_set_root_page(self): assert ( - rewrite("[search](https://doc.ibexa.co/en/latest/search/search/)", page_rel="index.md") + rewrite("[search](https://doc.ibexa.co/en/latest/search/search/)", + page_path="developer/index.md") == "[search](search/search/index.md)" ) @@ -61,26 +81,18 @@ def test_redirected_url_resolves_through_redirect_map(self): == "[images](../../content_management/images/images/index.md)" ) - def test_unknown_page_stays_absolute(self): - content = "[gone](https://doc.ibexa.co/en/latest/no/such/page/)" - assert rewrite(content) == content - - def test_other_doc_sites_stay_absolute(self): - content = "[user docs](https://doc.ibexa.co/projects/userguide/en/latest/)" - assert rewrite(content) == content - def test_own_version_is_rewritten_like_latest(self): assert ( rewrite("[Search API](https://doc.ibexa.co/en/5.0/search/search_api/)") == "[Search API](../search_api/index.md)" ) - def test_other_versions_stay_absolute(self): - content = "[4.6 docs](https://doc.ibexa.co/en/4.6/search/search/)" + def test_unknown_page_stays_absolute(self): + content = "[gone](https://doc.ibexa.co/en/latest/no/such/page/)" assert rewrite(content) == content - def test_other_version_api_links_stay_absolute(self): - content = "[`X`](https://doc.ibexa.co/en/4.6/api/php_api/php_api_reference/classes/Ibexa-Contracts-AdminUi-Tab-AbstractTab.html)" + def test_other_versions_stay_absolute(self): + content = "[4.6 docs](https://doc.ibexa.co/en/4.6/search/search/)" assert rewrite(content) == content def test_external_links_stay_absolute(self): @@ -110,19 +122,66 @@ def test_links_in_fenced_code_blocks_are_untouched(self): ) +class TestCrossSetLinks: + def test_developer_page_links_to_user_doc(self): + content = "[edit images](https://doc.ibexa.co/projects/userguide/en/latest/image_management/edit_images/)" + assert rewrite(content) == "[edit images](../../../user/image_management/edit_images/index.md)" + + def test_user_page_links_to_developer_doc(self): + content = "[Search API](https://doc.ibexa.co/en/latest/search/search_api/)" + assert ( + rewrite(content, page_path="user/getting_started/get_started/index.md") + == "[Search API](../../../developer/search/search_api/index.md)" + ) + + def test_user_internal_link_with_version_and_redirect(self): + content = "[start](https://doc.ibexa.co/projects/userguide/en/5.0/getting_started/)" + assert ( + rewrite(content, page_path="user/image_management/edit_images/index.md") + == "[start](../../getting_started/get_started/index.md)" + ) + + def test_developer_link_to_user_doc_with_matching_version(self): + content = "[edit images](https://doc.ibexa.co/projects/userguide/en/5.0/image_management/edit_images/)" + assert rewrite(content) == "[edit images](../../../user/image_management/edit_images/index.md)" + + def test_developer_link_to_user_doc_with_other_version_stays_absolute(self): + content = "[old](https://doc.ibexa.co/projects/userguide/en/4.6/image_management/edit_images/)" + assert rewrite(content) == content + + def test_user_link_to_developer_doc_with_matching_version(self): + content = "[Search API](https://doc.ibexa.co/en/5.0/search/search_api/)" + assert ( + rewrite(content, page_path="user/index.md") + == "[Search API](../developer/search/search_api/index.md)" + ) + + def test_user_link_to_developer_doc_with_other_version_stays_absolute(self): + content = "[old](https://doc.ibexa.co/en/4.6/search/search_api/)" + assert rewrite(content, page_path="user/index.md") == content + + def test_unknown_user_page_stays_absolute(self): + content = "[gone](https://doc.ibexa.co/projects/userguide/en/latest/no/such/)" + assert rewrite(content) == content + + def test_other_projects_stay_absolute(self): + content = "[connect](https://doc.ibexa.co/projects/connect/en/latest/)" + assert rewrite(content) == content + + class TestApiLinks: URL = "https://doc.ibexa.co/en/latest/api/php_api/php_api_reference/classes/Ibexa-Contracts-AdminUi-Tab-AbstractTab.html" def test_resolved_class_links_to_vendor_source_with_fqcn_text(self): - # From doc/search/search/index.md: 3 dirs inside the package + 2 up to vendor/. + # From developer/search/search/index.md: 3 dirs inside the package + 2 up to vendor/. assert rewrite(f"[`AbstractTab`]({self.URL})") == ( "[`Ibexa\\Contracts\\AdminUi\\Tab\\AbstractTab`]" "(../../../../../ibexa/admin-ui/src/contracts/Tab/AbstractTab.php)" ) def test_depth_follows_page_location(self): - page = "administration/back_office/back_office_menus/add_menu_item/index.md" - result = rewrite(f"[`AbstractTab`]({self.URL})", page_rel=page) + page = "developer/administration/back_office/back_office_menus/add_menu_item/index.md" + result = rewrite(f"[`AbstractTab`]({self.URL})", page_path=page) assert result.endswith("(../../../../../../../ibexa/admin-ui/src/contracts/Tab/AbstractTab.php)") def test_method_anchor_is_dropped_and_text_kept(self): @@ -137,41 +196,50 @@ def test_unresolved_class_keeps_url_but_gets_fqcn_text(self): f"[`Ibexa\\Contracts\\Connect\\ConnectClientInterface`]({url})" ) + def test_other_version_api_links_stay_absolute(self): + content = "[`X`](https://doc.ibexa.co/en/4.6/api/php_api/php_api_reference/classes/Ibexa-Contracts-AdminUi-Tab-AbstractTab.html)" + assert rewrite(content) == content + class TestLlmsTxt: - def test_page_links_point_into_doc(self): + def test_page_links_are_relative_to_set_root(self): content = "- [Search](https://doc.ibexa.co/en/latest/search/search/index.md)" - assert rewrite_llms_txt(content, PAGES) == "- [Search](doc/search/search/index.md)" + assert rewrite_llms_txt(content, DEVELOPER) == "- [Search](search/search/index.md)" - def test_root_page(self): + def test_set_root_page(self): content = "- [Home](https://doc.ibexa.co/en/latest/index.md)" - assert rewrite_llms_txt(content, PAGES) == "- [Home](doc/index.md)" + assert rewrite_llms_txt(content, DEVELOPER) == "- [Home](index.md)" + + def test_user_set_uses_its_own_base(self): + content = "- [Edit images](https://doc.ibexa.co/projects/userguide/en/latest/image_management/edit_images/index.md)" + assert rewrite_llms_txt(content, USER) == "- [Edit images](image_management/edit_images/index.md)" def test_unknown_page_stays_absolute(self): content = "- [Gone](https://doc.ibexa.co/en/latest/no/such/index.md)" - assert rewrite_llms_txt(content, PAGES) == content + assert rewrite_llms_txt(content, DEVELOPER) == content class TestSelfCheck: def test_valid_links_pass(self): pages = { - "a/b/index.md": "[ok](../c/index.md)", - "a/c/index.md": "[ok](../b/index.md#anchor)", + "developer/a/index.md": "[ok](../b/index.md) [cross](../../user/c/index.md)", + "developer/b/index.md": "[ok](../a/index.md#anchor)", + "user/c/index.md": "[cross](../../developer/a/index.md)", } assert check_relative_doc_links(pages) == [] def test_broken_link_is_reported(self): - pages = {"a/b/index.md": "[broken](../missing/index.md)"} + pages = {"developer/a/index.md": "[broken](../missing/index.md)"} errors = check_relative_doc_links(pages) assert len(errors) == 1 - assert "a/b/index.md" in errors[0] + assert "developer/a/index.md" in errors[0] - def test_link_escaping_doc_tree_is_reported(self): - pages = {"a/index.md": "[escape](../../outside.md)"} + def test_link_escaping_package_tree_is_reported(self): + pages = {"developer/a/index.md": "[escape](../../../outside.md)"} assert len(check_relative_doc_links(pages)) == 1 def test_vendor_and_external_links_are_skipped(self): pages = { - "a/index.md": "[php](../../../ibexa/core/src/S.php) [web](https://example.com/x.md)" + "developer/a/index.md": "[php](../../../ibexa/core/src/S.php) [web](https://example.com/x.md)" } assert check_relative_doc_links(pages) == [] diff --git a/tools/llm_package/README.package.md b/tools/llm_package/README.package.md index b9b8fd4fb5..212d85f18f 100644 --- a/tools/llm_package/README.package.md +++ b/tools/llm_package/README.package.md @@ -1,9 +1,9 @@ -# Ibexa DXP developer documentation +# Ibexa DXP documentation -This package contains the official [Ibexa DXP developer documentation](https://doc.ibexa.co/en/latest/) -as plain Markdown files, generated from the same sources as the website. Install -it into an Ibexa DXP project so that AI coding assistants (and humans) can read -the documentation offline, right next to the code it describes: +This package contains the official Ibexa DXP documentation as plain Markdown +files, generated from the same sources as the website. Install it into an Ibexa +DXP project so that AI coding assistants (and humans) can read the +documentation offline, right next to the code it describes: ```bash composer require --dev ibexa/documentation-developer:~5.0 @@ -11,11 +11,18 @@ composer require --dev ibexa/documentation-developer:~5.0 ## Layout -- `llms.txt` — the table of contents for the whole documentation, with relative - links into `doc/`. Start here to find the right page. -- `doc/` — one Markdown file per documentation page, mirroring the URL - structure of the website: `doc/
/...//index.md` corresponds to - `https://doc.ibexa.co/en/latest/
/...//`. +- `developer/` — the [developer documentation](https://doc.ibexa.co/en/latest/): + APIs, extension points, configuration, tutorials. +- `user/` — the [user documentation](https://doc.ibexa.co/projects/userguide/en/latest/): + working with the back office, content management, commerce. +- Each set has its own `llms.txt` at its root (`developer/llms.txt`, + `user/llms.txt`) — a table of contents with relative links. Start there to + find the right page. +- One Markdown file per page, mirroring the URL structure of the website: + `developer/
/...//index.md` corresponds to + `https://doc.ibexa.co/en/latest/
/...//`, and + `user/
/...//index.md` to + `https://doc.ibexa.co/projects/userguide/en/latest/
/...//`. - `.agents/skills/external/` — agent skills for working with Ibexa DXP. When the package is installed with Symfony Flex, its recipe copies them into your project's `.agents/skills/ibexa/` directory. @@ -23,7 +30,7 @@ composer require --dev ibexa/documentation-developer:~5.0 ## Conventions - Links between documentation pages are relative Markdown links and work - offline. + offline — including cross-links between the developer and user documentation. - PHP API references are links to the class source in your project's `vendor/` directory, with the fully qualified class name as the link text, for example ``[`Ibexa\Contracts\Core\Repository\SearchService`](../../../../core/src/contracts/Repository/SearchService.php)``. @@ -32,7 +39,7 @@ composer require --dev ibexa/documentation-developer:~5.0 reference is. - A few links have no local equivalent and stay absolute: images, the [PHP API reference](https://doc.ibexa.co/en/latest/api/php_api/php_api_reference/) - itself, and other documentation sites (user documentation, Ibexa Connect). + itself, and other documentation sites (e.g. Ibexa Connect). ## Using with AI agents @@ -40,11 +47,12 @@ Point your agent at this package, for example by adding to your project's `CLAUDE.md` / `AGENTS.md`: ```markdown -Ibexa DXP developer documentation is installed locally in -vendor/ibexa/documentation-developer/. To find a topic, read -vendor/ibexa/documentation-developer/llms.txt (table of contents) or search -vendor/ibexa/documentation-developer/doc/, then follow the relative links. -PHP API links in the docs point at the class sources in vendor/. +Ibexa DXP documentation is installed locally in +vendor/ibexa/documentation-developer/: developer documentation in developer/, +user documentation in user/. To find a topic, read the set's table of contents +(developer/llms.txt or user/llms.txt) or search the Markdown files, then follow +the relative links. PHP API links in the docs point at the class sources in +vendor/. ``` ## Versioning @@ -55,4 +63,5 @@ constraint to stay on the documentation matching your Ibexa DXP version. The documentation sources live on the version branches of [ibexa/documentation-developer](https://github.com/ibexa/documentation-developer) +and [ibexa/documentation-user](https://github.com/ibexa/documentation-user) — contributions are welcome there. diff --git a/tools/llm_package/dump_class_paths.php b/tools/llm_package/dump_class_paths.php index 0c9006eb48..0d7e04f467 100644 --- a/tools/llm_package/dump_class_paths.php +++ b/tools/llm_package/dump_class_paths.php @@ -4,9 +4,9 @@ * Dump a map of PHP API classes referenced by the generated Markdown docs to * their vendor-relative source paths. * - * Usage: php tools/llm_package/dump_class_paths.php [] [] + * Usage: php tools/llm_package/dump_class_paths.php [...] [] * - * Scans the MkDocs build output for `php_api_reference/classes/.html` + * Scans the MkDocs build output(s) for `php_api_reference/classes/.html` * links, converts each slug to a FQCN (dashes become namespace separators; * PHP class names cannot contain dashes, so this is unambiguous) and resolves * it through this repository's Composer autoloader. The resulting JSON maps @@ -20,30 +20,33 @@ declare(strict_types=1); -$siteDir = $argv[1] ?? 'site'; -$outputPath = $argv[2] ?? 'class_paths.json'; +$args = array_slice($argv, 1); +$outputPath = count($args) >= 2 ? array_pop($args) : 'class_paths.json'; +$siteDirs = $args !== [] ? $args : ['site']; $root = dirname(__DIR__, 2); $loader = require $root . '/vendor/autoload.php'; $vendorDir = realpath($root . '/vendor'); -if (!is_dir($siteDir)) { - fwrite(STDERR, "Site directory not found: $siteDir (run mkdocs build first)\n"); - exit(1); -} - $slugs = []; -$iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($siteDir, FilesystemIterator::SKIP_DOTS) -); -foreach ($iterator as $file) { - if ($file->getExtension() !== 'md') { - continue; +foreach ($siteDirs as $siteDir) { + if (!is_dir($siteDir)) { + fwrite(STDERR, "Site directory not found: $siteDir (run mkdocs build first)\n"); + exit(1); } - $content = file_get_contents($file->getPathname()); - if (preg_match_all('~php_api_reference/classes/([A-Za-z0-9-]+)\.html~', $content, $matches)) { - foreach ($matches[1] as $slug) { - $slugs[$slug] = true; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($siteDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->getExtension() !== 'md') { + continue; + } + $content = file_get_contents($file->getPathname()); + if (preg_match_all('~php_api_reference/classes/([A-Za-z0-9-]+)\.html~', $content, $matches)) { + foreach ($matches[1] as $slug) { + $slugs[$slug] = true; + } } } } From 17fbaf044131a0de2ed1d142668006ea7806abd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Fri, 17 Jul 2026 12:33:18 +0200 Subject: [PATCH 13/16] Refined skill --- .../external/ibexa-documentation/SKILL.md | 68 ++++++++++++------- .../skills/external/ibexa-php-api/SKILL.md | 27 -------- 2 files changed, 44 insertions(+), 51 deletions(-) delete mode 100644 .agents/skills/external/ibexa-php-api/SKILL.md diff --git a/.agents/skills/external/ibexa-documentation/SKILL.md b/.agents/skills/external/ibexa-documentation/SKILL.md index acaa498900..5b494cce42 100644 --- a/.agents/skills/external/ibexa-documentation/SKILL.md +++ b/.agents/skills/external/ibexa-documentation/SKILL.md @@ -1,37 +1,57 @@ --- name: ibexa-documentation -description: Find answers in the locally installed Ibexa DXP documentation (developer and user docs). Use when working on an Ibexa DXP project and you need to look up how a feature, API, configuration, extension point, or back-office workflow works. +description: Look up features, concepts, configuration, extension points, and back office workflows in the locally installed Ibexa DXP documentation. Use whenever working on an Ibexa DXP project and you need to know how something in Ibexa works before writing code, configuration, or answers. --- # Ibexa DXP documentation - +The official Ibexa DXP documentation is installed as Markdown files in `vendor/ibexa/documentation-developer/`. +It matches this project's Ibexa DXP release line (e.g. 5.0). +Always consult it before web searches or memory: it works offline, matches the installed version, and links directly to the code in `vendor/`. -The full Ibexa DXP documentation is installed locally in -`vendor/ibexa/documentation-developer/`: +It contains two documentation sets: -- `developer/` — developer documentation: APIs, extension points, - configuration, tutorials. -- `user/` — user documentation: back office, content management, commerce - workflows. +- `developer/` — APIs, configuration, extension points, tutorials. Mirrors https://doc.ibexa.co/en/latest/ +- `user/` — back office, editorial and commerce workflows. Mirrors https://doc.ibexa.co/projects/userguide/en/latest/ -## Finding a topic +Use the documentation for what the code cannot tell you: -1. Read the set's table of contents: - `vendor/ibexa/documentation-developer/developer/llms.txt` or - `vendor/ibexa/documentation-developer/user/llms.txt` — grouped by section, - with relative links. -2. Or search directly: - `grep -ri "" vendor/ibexa/documentation-developer/developer/`. -3. Follow the relative links between pages (including cross-links between the - developer and user docs); they all work offline. +- Concepts and the content model: content types, fields, locations, + versions, languages, and how they relate. +- Release notes and changes between versions: `developer/release_notes/`. +- Update and migration instructions: `developer/update_and_migration/`. +- Project and feature setup, configuration, and best practices: `developer/infrastructure_and_maintenance/`. + +For method signatures and contracts, the code in `vendor/` is authoritative. +Use the documentation for the intent, concepts, and configuration around them. +When the documentation and the installed code don't match, the code is right. + +## Finding topics + +- Read the set's table of contents: `vendor/ibexa/documentation-developer/developer/llms.txt` (or `user/llms.txt`). Page titles are grouped by section, with relative links. +- Search the sets directly: `grep -ril "" vendor/ibexa/documentation-developer/`. ## Conventions -- One page per directory: `/
/...//index.md`. -- PHP API references are links to the class source in `vendor/`, with the fully - qualified class name as the link text. If the target file doesn't exist, that - package isn't installed in this project — the class name still tells you what - to look for. -- Prefer the documentation matching this project's installed version; this - package is versioned together with Ibexa DXP. +- Links between pages are relative and work offline. Follow them for related topics. +- PHP API references link to the class source in this project's `vendor/` directory. If the target file doesn't exist, that package isn't installed. + +## Keep documentation up to date + +The package is versioned as `MAJOR_DXP_VERSION.MINOR_DXP_VERSION.DATE_OF_TAGGING_YYYYMMDD` and must be used with the matching Ibexa DXP release line. +For example, `5.0.20260101` is for Ibexa DXP 5.0.x releases. +New version is released when the documentation content is updated. + +To check whether the documentation contains all the latest updates, run: + +``` bash +composer outdated --direct "ibexa/documentation-*" +``` + +To update it, run: + +```bash +composer update ibexa/documentation-developer --no-scripts +``` + +Always pass `--no-scripts`: the package contains only Markdown files, so the slow post-update scripts (cache clear, asset and frontend builds) are unnecessary. diff --git a/.agents/skills/external/ibexa-php-api/SKILL.md b/.agents/skills/external/ibexa-php-api/SKILL.md deleted file mode 100644 index a86fe9e126..0000000000 --- a/.agents/skills/external/ibexa-php-api/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: ibexa-php-api -description: Work with the Ibexa DXP PHP API - resolve interfaces, services, and value objects from vendor sources before writing code. Use when writing or reviewing PHP code that uses Ibexa DXP APIs. ---- - -# Ibexa DXP PHP API - - - -Before writing code against an Ibexa DXP API, check the real contracts in this -project's `vendor/` directory instead of guessing signatures. - -## Resolving a class - -- Namespaces map to packages via PSR-4, e.g. `Ibexa\Contracts\ProductCatalog\…` - → `vendor/ibexa/product-catalog/src/contracts/…`. -- Public, stable APIs live under `Ibexa\Contracts\…` (`src/contracts/` in each - package); classes outside `Contracts` are internal implementation. -- Find service methods: `grep -n "public function" vendor/ibexa//src/contracts/<…>Interface.php`. - -## Guidelines - -- Inject the `…ServiceInterface` contracts rather than concrete classes. -- Distinguish create/update structs (`…CreateStruct`, `…UpdateStruct`) from - read-only value objects. -- Usage documentation and examples for each API are available locally — see the - `ibexa-documentation` skill. From 1a1f3e916d923fe1ee63b92379584a02fc520e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Fri, 17 Jul 2026 13:31:45 +0200 Subject: [PATCH 14/16] Fixed build --- .github/workflows/release_composer_package.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index cdc260156e..0c0b385742 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -60,7 +60,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt -r user-docs/requirements.txt + # user-docs pins ibexa-llms-txt from the upstream git repo; drop that line — + # the editable install from this checkout (requirements.txt: -e .) already + # provides the plugin, and pip refuses two direct references to one package. + grep -v '^ibexa-llms-txt @' user-docs/requirements.txt > user-reqs-filtered.txt + pip install -r requirements.txt -r user-reqs-filtered.txt - name: Setup PHP uses: shivammathur/setup-php@v2 From fff6a19b845adbf1a39ec8723784b94d5a16d750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Fri, 17 Jul 2026 15:06:07 +0200 Subject: [PATCH 15/16] Set READTHEDOCS_VERSION_NAME --- .github/workflows/release_composer_package.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release_composer_package.yaml b/.github/workflows/release_composer_package.yaml index 0c0b385742..2fe08136cf 100644 --- a/.github/workflows/release_composer_package.yaml +++ b/.github/workflows/release_composer_package.yaml @@ -97,10 +97,16 @@ jobs: - name: Build developer documentation run: mkdocs build --strict + env: + # The cards() macro versions its links from this (as on RTD); + # without it they'd point at en/latest instead of this branch. + READTHEDOCS_VERSION_NAME: ${{ matrix.branch }} - name: Build user documentation run: mkdocs build --strict working-directory: user-docs + env: + READTHEDOCS_VERSION_NAME: ${{ matrix.branch }} - name: Resolve PHP API classes to vendor paths run: php tools/llm_package/dump_class_paths.php site user-docs/site class_paths.json From e6910f4718e99fa5ae31663b7d2018768363aeaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Tue, 21 Jul 2026 16:13:58 +0200 Subject: [PATCH 16/16] Removed TMP change --- docs/ai/ai_actions/extend_ai_actions.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/ai/ai_actions/extend_ai_actions.md b/docs/ai/ai_actions/extend_ai_actions.md index 80b704eb95..b64718fe83 100644 --- a/docs/ai/ai_actions/extend_ai_actions.md +++ b/docs/ai/ai_actions/extend_ai_actions.md @@ -9,8 +9,6 @@ By extending [AI Actions](ai_actions_guide.md), you can make regular content man You can start by integrating additional AI services to the existing action types or develop custom ones that impact completely new areas of application. For example, you can create a handler that connects to a translation model and use it to translate your website on-the-fly, or generate illustrations based on a body of an article. -Waldo is here. - ## Execute Actions You can execute AI Actions by using the [ActionServiceInterface](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ConnectorAi-ActionServiceInterface.html) service, as in the following example: