diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 7d8fbd5..1436028 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -54,4 +54,4 @@ jobs: run: python -m pip install --requirement requirements-docs.txt - name: Build documentation - run: python -m mkdocs build --strict + run: zensical build --clean --strict diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 0000000..8fadf4d --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,133 @@ +name: Publish documentation + +on: + workflow_call: + inputs: + rc_ref: + description: Git ref containing the release-candidate documentation + required: true + type: string + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: starmap-github-pages + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@v6 + + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.rc_ref }} + path: rc-source + fetch-depth: 0 + fetch-tags: true + + - name: Find latest release tag + id: stable + working-directory: rc-source + run: | + stable_tag=$(git tag --list | awk '/^[0-9]+\.[0-9]+\.[0-9]+$/ { print }' | sort -V | tail -n 1) + echo "stable_tag=$stable_tag" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v7 + if: steps.stable.outputs.stable_tag != '' + with: + ref: ${{ steps.stable.outputs.stable_tag }} + path: stable-source + fetch-depth: 1 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install documentation dependencies + run: python -m pip install --requirement rc-source/requirements-docs.txt + + - name: Build latest release documentation + if: steps.stable.outputs.stable_tag != '' + working-directory: stable-source + run: zensical build --clean --strict + + - name: Build initial stable documentation + if: steps.stable.outputs.stable_tag == '' + working-directory: rc-source + run: zensical build --clean --strict + + - name: Assemble stable documentation + env: + STABLE_TAG: ${{ steps.stable.outputs.stable_tag }} + run: | + mkdir -p "$GITHUB_WORKSPACE/site" + if [[ -n "$STABLE_TAG" ]]; then + cp -a stable-source/site/. "$GITHUB_WORKSPACE/site/" + else + cp -a rc-source/site/. "$GITHUB_WORKSPACE/site/" + fi + + - name: Create release-candidate configuration + working-directory: rc-source + run: | + python - <<'PY' + import re + from pathlib import Path + + config_path = Path("mkdocs.yml") + config = config_path.read_text(encoding="utf-8") + site_url = re.search(r"(?m)^site_url:\s*(\S+)\s*$", config) + if site_url is None: + raise SystemExit("mkdocs.yml must define site_url") + + rc_url = site_url.group(1).rstrip("/") + "/rc/" + config = ( + config[:site_url.start()] + + f"site_url: {rc_url}" + + config[site_url.end():] + ) + site_name = re.search(r"(?m)^site_name:.*$", config) + if site_name is None: + raise SystemExit("mkdocs.yml must define site_name") + config = ( + config[:site_name.start()] + + "site_name: StarMap RC" + + config[site_name.end():] + ) + site_dir = re.search(r"(?m)^site_dir:.*$", config) + if site_dir is None: + config = "site_dir: rc-site\n" + config + else: + config = ( + config[:site_dir.start()] + + "site_dir: rc-site" + + config[site_dir.end():] + ) + Path("mkdocs-rc.yml").write_text(config, encoding="utf-8") + PY + + - name: Build release-candidate documentation + working-directory: rc-source + run: zensical build --config-file mkdocs-rc.yml --clean --strict + + - name: Add release-candidate documentation + run: | + mkdir -p "$GITHUB_WORKSPACE/site/rc" + cp -a rc-source/rc-site/. "$GITHUB_WORKSPACE/site/rc/" + + - name: Upload documentation artifact + uses: actions/upload-pages-artifact@v5 + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/rc-build.yml b/.github/workflows/rc-build.yml index 4160645..8d8909f 100644 --- a/.github/workflows/rc-build.yml +++ b/.github/workflows/rc-build.yml @@ -172,3 +172,13 @@ jobs: ./build_artifacts/version.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-RC-docs: + needs: build + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/publish-docs.yml + with: + rc_ref: ${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11347ca..a54545c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,6 @@ jobs: prev_version: ${{ steps.version.outputs.prev_version }} release_tag: ${{ steps.version.outputs.release_tag }} api_changed: ${{ steps.api_check.outputs.api_changed }} - docs_release: ${{ steps.version.outputs.docs_release }} steps: - uses: actions/checkout@v4 with: @@ -53,21 +52,12 @@ jobs: | tail -n 1) fi - docs_release=false - if [[ -z "$previous_tag" || "${RELEASE_TAG%.*}" != "${previous_tag%.*}" ]]; then - docs_release=true - fi - if ! git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then - docs_release=true - fi - echo "Release version: $RELEASE_TAG" echo "Previous release tag: ${previous_tag:-none}" { echo "release_tag=$RELEASE_TAG" echo "new_version=$RELEASE_TAG" echo "prev_version=$previous_tag" - echo "docs_release=$docs_release" } >> "$GITHUB_OUTPUT" - name: Setup NuGet source @@ -208,36 +198,14 @@ jobs: StarMap-${{ needs.build.outputs.new_version }}.zip publish-docs: - runs-on: ubuntu-latest needs: [build, release-zip] - if: needs.build.outputs.docs_release == 'true' permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install documentation dependencies - run: python -m pip install --requirement requirements-docs.txt - - - name: Configure Git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Publish versioned documentation - env: - RELEASE_VERSION: ${{ needs.build.outputs.new_version }} - run: | - docs_version="${RELEASE_VERSION%.*}" - mike deploy --push --update-aliases "$docs_version" latest - mike set-default --push latest + contents: read + pages: write + id-token: write + uses: ./.github/workflows/publish-docs.yml + with: + rc_ref: main build-release-installer: runs-on: windows-latest diff --git a/README.md b/README.md index f1ae064..3f4546b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The [StarMap documentation](https://starmaploader.github.io/StarMap/) covers ins To publish a release, create and push a semantic-version tag such as `1.2.3` on the commit to release. The release workflow builds the release and installer from that commit; tags can point to commits on any branch. -The documentation site keeps a version for each major/minor release and updates the `latest` version alias. +The documentation site tracks the current release; older releases do not have separate documentation snapshots. The current release-candidate documentation is available at [the RC docs site](https://starmaploader.github.io/StarMap/rc/). ## Installation diff --git a/StarMap.API/Registries/IModServiceRegistry.cs b/StarMap.API/Registries/IModServiceRegistry.cs new file mode 100644 index 0000000..70857e5 --- /dev/null +++ b/StarMap.API/Registries/IModServiceRegistry.cs @@ -0,0 +1,9 @@ +namespace StarMap.API.Registries +{ + public interface IModServiceRegistry + { + public void RegisterService(T implementation); + + public T Get(); + } +} diff --git a/StarMap.Core/API/ModServiceRegistry.cs b/StarMap.Core/API/ModServiceRegistry.cs new file mode 100644 index 0000000..c9d0f3a --- /dev/null +++ b/StarMap.Core/API/ModServiceRegistry.cs @@ -0,0 +1,28 @@ +using StarMap.API.Registries; +using StarMap.Core.ModRepository; +using StarMap.Core.Services; + +namespace StarMap.Core.API +{ + internal class ModServiceRegistry : IModServiceRegistry + { + private readonly RuntimeMod _mod; + private readonly IModServices _modServices; + + public ModServiceRegistry(IModContext modContext, IModServices modServices) + { + _mod = modContext.Mod; + _modServices = modServices; + } + + public T Get() + { + return _modServices.Get(); + } + + public void RegisterService(T implementation) + { + _modServices.Register(_mod, implementation); + } + } +} diff --git a/StarMap.Core/Services/ModServices.cs b/StarMap.Core/Services/ModServices.cs new file mode 100644 index 0000000..37d2262 --- /dev/null +++ b/StarMap.Core/Services/ModServices.cs @@ -0,0 +1,55 @@ +using StarMap.Core.ModRepository; +using System.Collections.Concurrent; +using System.Runtime.Loader; + +namespace StarMap.Core.Services +{ + internal interface IModServices + { + void Register(RuntimeMod mod, T implementation); + T Get(); + } + + internal class ModServices : IModServices + { + private readonly ConcurrentDictionary _modServices = []; + + public void Register(RuntimeMod mod, T implementation) + { + ArgumentNullException.ThrowIfNull(implementation); + + var type = typeof(T); + if (AssemblyLoadContext.GetLoadContext(typeof(T).Assembly) != mod.ModAssemblyLoadContext) + throw new InvalidOperationException("The service contract must be defined in the registering mod's assembly load context."); + + var modService = new ModService() + { + Implementation = implementation + }; + + if (!_modServices.TryAdd(type, modService)) + throw new InvalidOperationException($"A service for type '{type}' was already registered."); + } + + public T Get() + { + if (!_modServices.TryGetValue(typeof(T), out var service)) + { + throw new InvalidOperationException($"No service for type '{typeof(T)}' has been registered."); + } + + if (service is not ModService modService) + throw new InvalidOperationException($"The registered service for type '{typeof(T)}' has an unexpected type."); + + return modService.Implementation; + } + + private interface IModService { } + + private class ModService : IModService + { + public required T Implementation { get; init; } + + } + } +} diff --git a/StarMap.Core/StarMapCore.cs b/StarMap.Core/StarMapCore.cs index 05a0dd7..45d6855 100644 --- a/StarMap.Core/StarMapCore.cs +++ b/StarMap.Core/StarMapCore.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using StarMap.API.Registries; using StarMap.API.Services; using StarMap.Core.API; using StarMap.Core.ModRepository; @@ -23,11 +24,13 @@ public StarMapCore(AssemblyLoadContext coreAssemblyLoadContext) serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddSingleton(); diff --git a/docs/index.md b/docs/index.md index 12b160f..5e92689 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,4 +11,4 @@ StarMap is an actively developed prototype. Check that a mod targets a compatibl - [Create your first mod](modding/first-mod.md) - [Browse the modding overview and API](modding/index.md) -The version selector in the header lets you view documentation for a particular major/minor release. `latest` points to the most recently published documentation version. +This site documents the current StarMap release or release-candidate build. Older release snapshots are not currently published. diff --git a/docs/maintainers.md b/docs/maintainers.md index efb94b7..1bfad00 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -6,31 +6,32 @@ Install the Python documentation dependencies and serve the site: ```powershell python -m pip install --requirement requirements-docs.txt -python -m mkdocs serve +zensical serve ``` Before opening a pull request, run the same strict build used by CI: ```powershell -python -m mkdocs build --strict +zensical build --clean --strict ``` Keep documentation changes in the same pull request as the feature or behavior change they describe. The PR workflow builds the documentation to catch configuration errors and warnings. -## Versioned publishing +Zensical reads the existing MkDocs-compatible `mkdocs.yml`, so the project does not need a second configuration file. Do not add MkDocs-only plugins such as Mike: Zensical currently has no native version selector, so older release snapshots are not maintained. -The release workflow runs from semantic-version tags in `X.Y.Z` format. It publishes a documentation snapshot when the major/minor portion changes from the previous reachable release tag. Mike names the snapshot `X.Y`, preserves older snapshots, updates the `latest` alias, and makes that alias the site's default. Patch releases do not create additional snapshots. If the `gh-pages` branch does not exist yet, the first tagged release bootstraps the documentation site. +## Publishing -The workflow publishes documentation after the release ZIP has been created. It pushes generated site files to the `gh-pages` branch; the source Markdown remains in `docs/`. +The Pages artifact contains the latest tagged release at the site root and documentation from `main` under `/rc/`. Release tags update the stable site; pushes to `main` update the RC site. Both trees are rebuilt for each deployment because GitHub Pages deploys the complete artifact rather than merging individual paths. -After the first deployment, configure GitHub Pages once in the repository settings: +The release workflow publishes after the release ZIP has been created, and the RC workflow publishes after a push to `main`. Both use GitHub Pages artifacts; the source Markdown remains in `docs/`. + +Configure GitHub Pages once in the repository settings: 1. Open **Settings → Pages**. -2. Under **Build and deployment**, select **Deploy from a branch**. -3. Select the `gh-pages` branch and the `/(root)` folder, then save. +2. Under **Build and deployment**, select **GitHub Actions** as the source. The site URL configured in `mkdocs.yml` is `https://starmaploader.github.io/StarMap/`. -## Theme maintenance +## Zensical configuration -The current Material for MkDocs release announces end-of-life on May 5, 2027. Mike provides the versioning workflow separately from the theme, so review theme support before that date. The [Zensical MkDocs compatibility guide](https://zensical.org/docs/compatibility/mkdocs/migration/) describes the successor project's migration path. +The [Zensical MkDocs compatibility guide](https://zensical.org/docs/compatibility/mkdocs/migration/) explains how Zensical reads `mkdocs.yml`. Zensical silently ignores unsupported MkDocs plugins, so verify plugin support before adding one. diff --git a/docs/modding/constructor-injection.md b/docs/modding/constructor-injection.md index a4876f0..e24e43c 100644 --- a/docs/modding/constructor-injection.md +++ b/docs/modding/constructor-injection.md @@ -4,6 +4,7 @@ StarMap creates one instance of each mod's `[StarMapMod]` entry class with `Acti The services currently registered in that scope are: +- `IModServiceRegistry` for registering and retrieving services shared between mods - `IModMenuService` for adding items to KSA's **Mods** menu - `IMediationService` for publishing and subscribing to typed events - `IPatchService` for registering method patches @@ -32,5 +33,5 @@ Use one public constructor, and ensure its parameters can be resolved from the s Constructor injection applies only to the `[StarMapMod]` entry class. Lifecycle callbacks are called with their documented fixed signatures; StarMap does not inject their parameters. See [lifecycle callbacks](lifecycle.md). -Each mod has its own dependency-injection scope. Keep injected services on the entry class instance, and dispose handles returned by services, such as menu items, event publishers, and event subscriptions, in `[StarMapUnload]`. +Each mod has its own dependency-injection scope. Keep injected services on the entry class instance, and dispose handles returned by services, such as menu items, event publishers, and event subscriptions, in `[StarMapUnload]`. For cross-mod service contracts and registration, see [mod-provided services](mod-services.md). diff --git a/docs/modding/dependencies.md b/docs/modding/dependencies.md index cc8c03a..d422c9a 100644 --- a/docs/modding/dependencies.md +++ b/docs/modding/dependencies.md @@ -45,3 +45,7 @@ StarMap loads each mod in a separate assembly load context. Declare inter-mod re When using [`IMediationService`](event-mediation.md), the event type must be defined in an assembly loaded by the publishing mod and shared with each subscribing mod. Add that assembly to the provider's `ExportedAssemblies` and to the consumer's `ImportedAssemblies`; make the provider a required dependency if the consumer subscribes during startup. The consumer also needs a compile-time reference to the provider's event contract assembly. See [typed event mediation](event-mediation.md) for publisher and subscriber examples. + +## Sharing mod service contracts + +When a mod provides a service through `IModServiceRegistry`, the service contract must be defined in an assembly loaded by the provider and shared with each consumer. Add the contract assembly to the provider's `ExportedAssemblies` and the consumer's `ImportedAssemblies`, and declare the provider as a required dependency when the consumer needs the service during initialization. The consumer also needs a compile-time reference to the contract assembly. See [mod-provided services](mod-services.md) for examples. diff --git a/docs/modding/index.md b/docs/modding/index.md index a9c890a..1c7922d 100644 --- a/docs/modding/index.md +++ b/docs/modding/index.md @@ -15,7 +15,7 @@ HelloStarMap/ `-- any private dependency assemblies ``` -Start with the [first-mod walkthrough](first-mod.md). Then read about [lifecycle callbacks](lifecycle.md), [services and mechanics](services.md), and [mod dependencies](dependencies.md). The service overview links to separate guides for [constructor injection](constructor-injection.md), [menu items](mod-menu.md), [event mediation](event-mediation.md), and [method patching](patching.md). +Start with the [first-mod walkthrough](first-mod.md). Then read about [lifecycle callbacks](lifecycle.md), [services and mechanics](services.md), and [mod dependencies](dependencies.md). The service overview links to separate guides for [constructor injection](constructor-injection.md), [mod-provided services](mod-services.md), [menu items](mod-menu.md), [event mediation](event-mediation.md), and [method patching](patching.md). ## Entry assembly and mod ID diff --git a/docs/modding/lifecycle.md b/docs/modding/lifecycle.md index 0dfe2af..3fe00f1 100644 --- a/docs/modding/lifecycle.md +++ b/docs/modding/lifecycle.md @@ -18,4 +18,4 @@ The callback methods return `void`. Use the exact parameter types and counts; me ## Constructor-injected services -StarMap provides `IModMenuService`, `IMediationService`, and `IPatchService` through the entry class constructor. See [constructor injection](constructor-injection.md) for the injection mechanism and the individual pages for [menu items](mod-menu.md), [event mediation](event-mediation.md), and [method patching](patching.md) for their usage. +StarMap provides `IModServiceRegistry`, `IModMenuService`, `IMediationService`, and `IPatchService` through the entry class constructor. See [constructor injection](constructor-injection.md) for the injection mechanism and the individual pages for [mod-provided services](mod-services.md), [menu items](mod-menu.md), [event mediation](event-mediation.md), and [method patching](patching.md) for their usage. diff --git a/docs/modding/mod-services.md b/docs/modding/mod-services.md new file mode 100644 index 0000000..91c2185 --- /dev/null +++ b/docs/modding/mod-services.md @@ -0,0 +1,71 @@ +# Mod-provided services + +`IModServiceRegistry` lets a provider mod expose an object through a shared service contract so dependent mods can use it. This is separate from StarMap's built-in services such as `IModMenuService` and `IMediationService`. + +## Define and share a contract + +Put the public interface in an assembly loaded by the provider. A small contracts assembly makes it possible to share the API without sharing the provider's implementation: + +```csharp +namespace ProviderMod.Contracts; + +public interface IGreetingService +{ + string Greet(string name); +} +``` + +Configure the provider to export this assembly and the consumer to import it. The consumer must also reference the contract assembly at compile time. See [sharing mod service contracts](dependencies.md#sharing-mod-service-contracts) for the dependency configuration. + +## Register the service + +Inject `IModServiceRegistry` into the provider's `[StarMapMod]` entry class and register the implementation: + +```csharp +using ProviderMod.Contracts; +using StarMap.API; +using StarMap.API.Registries; + +[StarMapMod] +public sealed class ProviderMod +{ + public ProviderMod(IModServiceRegistry services) + { + services.RegisterService(new GreetingService()); + } +} + +internal sealed class GreetingService : IGreetingService +{ + public string Greet(string name) => $"Hello, {name}!"; +} +``` + +Specify the contract type explicitly in `RegisterService()`. Without the type argument, C# infers the implementation's concrete type, which consumers won't be able to retrieve as `IGreetingService`. + +The contract type must belong to the registering mod's assembly load context. Register each contract type once; a second registration for the same contract fails. + +## Retrieve the service + +A consumer with a required dependency on the provider can retrieve the service from its entry class: + +```csharp +using ProviderMod.Contracts; +using StarMap.API; +using StarMap.API.Registries; + +[StarMapMod] +public sealed class ConsumerMod +{ + private readonly IGreetingService _greetings; + + public ConsumerMod(IModServiceRegistry services) + { + _greetings = services.Get(); + } +} +``` + +The provider must register before the consumer calls `Get()`. A missing registration throws `InvalidOperationException`; declare the provider as a required dependency when the consumer needs the service during initialization. Only one provider can register a given contract type, and the registry does not support named or multiple implementations of the same contract. + +The registry does not manage the service object's lifetime or dispose it. The provider owns its implementation and should release any resources it owns from its `[StarMapUnload]` callback. See [lifecycle callbacks](lifecycle.md). diff --git a/docs/modding/services.md b/docs/modding/services.md index 9098f06..cf43e41 100644 --- a/docs/modding/services.md +++ b/docs/modding/services.md @@ -1,8 +1,9 @@ # Services and mechanics -StarMap provides three services to mod entry classes through constructor injection. Each has its own guide: +StarMap provides core services and a registry for services shared between mods. Entry classes receive these APIs through constructor injection. Each has its own guide: - [Constructor injection](constructor-injection.md) +- [Mod-provided services](mod-services.md) with `IModServiceRegistry` - [In-game menu items](mod-menu.md) with `IModMenuService` - [Typed event mediation](event-mediation.md) with `IMediationService` - [Method patching](patching.md) with `IPatchService` diff --git a/mkdocs.yml b/mkdocs.yml index 8c50898..f1d65f2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,9 +13,6 @@ theme: plugins: - search - - mike: - canonical_version: latest - version_selector: true markdown_extensions: - admonition @@ -24,10 +21,6 @@ markdown_extensions: - pymdownx.highlight - pymdownx.superfences -extra: - version: - provider: mike - nav: - Home: index.md - Installation: installation.md @@ -39,6 +32,7 @@ nav: - Services and mechanics: - Overview: modding/services.md - Constructor injection: modding/constructor-injection.md + - Mod-provided services: modding/mod-services.md - In-game menu items: modding/mod-menu.md - Typed event mediation: modding/event-mediation.md - Method patching: modding/patching.md diff --git a/requirements-docs.txt b/requirements-docs.txt index f40a85f..78c3dd9 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,2 +1 @@ -mkdocs-material>=9.5,<10 -mike>=2.1,<3 +zensical>=0.0.72,<0.1