diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..e023c9c --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,177 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via +[GitHub issues](https://github.com/refinedmods/refinedarchitect-template/issues), [Discord](https://discordapp.com/invite/VYzsydb), +or any other method with the owners of this repository before making a change. + +## Pull requests + +- Keep your pull request (PR) as small as possible, this makes reviewing easier. +- Commits serve a clear purpose and have a fitting commit message. +- Branches are kept up to date by rebasing (updating a branch by merging makes for a confusing Git history). +- PRs are merged by merging the commits on top of the target branch (which is `develop`). +- Remember to add your changes in `CHANGELOG.md`. If your changes are merely technical, it's not necessary to update the + changelog as it's not relevant for users. + +### Commit messages + +Commit messages must adhere to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). We +use [Commitlint](https://commitlint.js.org/) to validate commit messages. + +We use +the [conventional configuration](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) +for Commitlint. + +It is recommended to install +the [Conventional Commit plugin](https://plugins.jetbrains.com/plugin/13389-conventional-commit) to make it +easier to write commit messages. + +### Branch names + +Because we use merge commits when merging a PR, branch names will be part of the history of the repository. That is why +branch names must follow a certain standard. + +The format is `{category}/GH-{issue number}/{lowercase-description}` and a branch name can be maximum 50 characters of +length. + +Category must match a +category [used in our Commitlint config](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional#type-enum). + +Valid examples are: + +- `fix/GH-123/add-branch-linting` +- `docs/GH-123/cleanup` + +## Versioning + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +### Version metadata + +The code doesn't contain version metadata: `build.gradle` specifies a version of `0.0.0` (via Refined Architect). +The versioning information is entirely contained in Git by using tags. + +Per [Semantic Versioning](https://semver.org/spec/v2.0.0.html), the version number being released depends on the changes +in that release. We usually can't predict those +changes at the start of a release cycle, so we can't bump the version at the start of a release cycle. That means that +the version number being released is determined at release time. + +Because the version number is determined at release time, we can't store any versioning metadata in the +code (`build.gradle`). If we did, `build.gradle` would have the version number of the latest released version during the +release cycle of the new version, which isn't correct. + +## Changelog + +The changelog is kept in `CHANGELOG.md`. + +Keeping a readable, relevant and user-friendly changelog is essential for our end users +to stay up to date with the project. + +Please refrain from using technical terminology or adding entries for technical changes +that are (generally) not relevant to the end-user. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## Gitflow + +This project uses [Gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow). + +## Code style + +We use [Checkstyle](https://checkstyle.sourceforge.io/) in our build workflow to validate coding style. + +It is recommended to import the [config/checkstyle/checkstyle.xml](../config/checkstyle/checkstyle.xml) file into your +IDE, so that formatting rules are respected. + +Moreover, the [CheckStyle-IDEA plugin](https://plugins.jetbrains.com/plugin/1065-checkstyle-idea) can be used to check +if there are no style violations. + +## Release process + +The release process is automated and follows Gitflow. + +Before running the "Draft release" workflow to start the release process make sure `CHANGELOG.md` contains all the +unreleased changes. + +To determine the version number to be released, the workflow will ask you which release type this is (major, minor, +patch). +The latest version from `CHANGELOG.md` will be used as a base, and that will be incremented +depending on the release type. + +`CHANGELOG.md` will be updated by this workflow, you can review this in the resulting release PR. + +If you merge the release PR, the "Publish release" workflow will automatically publish the release. An additional PR +will be created to merge the changes in `CHANGELOG.md` back into `develop`. + +## Hotfix process + +The hotfix process is semi-automated and follows Gitflow: + +- Create a hotfix branch off `main`. +- Commit your changes on this branch. +- Update `CHANGELOG.md` (with version number and release date) manually on this branch. +- Push the branch and create a PR for it, merging into `main`. + +The "Publish release" workflow will take care of the rest. + +## Workflows + +We have a few GitHub workflows: + +- Build (PRs, pushes to `develop` and `main`) +- Draft release (manual trigger) +- Publish release (merging a PR to `main`) +- Validate changelog (PRs) + - To validate if `CHANGELOG.md` is valid and updated. + - Not every pull request needs a changelog change, so the `skip-changelog` label can be added to the pull request to + ignore this. +- Validate commit messages (PRs) + - Validates whether the commits on a pull request + respect [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). + - We use + the [conventional configuration](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional). +- Validate branch names (PRs) +- Issue for unsupported version (issues) + - Posts a message on a GitHub issue if the issue is about an unsupported version. +- Lock resolved issues and PRs (every night) + +### Build + +The build workflow triggers when a pull request is updated or when a commit is pushed to `develop` or `main`. + +The build workflow takes care of the following: + +- Running a Gradle build. +- Code style validation with Checkstyle. +- Uploading the artifacts on the action. + +### Draft release + +The draft release workflow is a manual workflow which will create a release branch from `develop`. + +To determine the version number to be released, it will extract the latest version number from `CHANGELOG.md` and +increment it depending on the release type selected. + +This workflow takes care of the following: + +- Creating the release branch. +- Updating the changelog on this release branch. +- Creating a pull request merging the release branch into `main`. + +### Publish release + +The "publish release" workflow is triggered when a release or hotfix PR is merged to `main`. Usually, this will be the +PR created earlier in the "Draft release" workflow. + +The workflow takes care of the following: + +- Extracting the version number from the release or hotfix branch name that is merged in the PR. +- Extracting the changelog entry for this version number. +- Running a build. +- Publishing on [GitHub packages](https://github.com/refinedmods/refinedarchitect-template/packages) and + CreeperHost Maven. +- Publishing Javadoc on [GitHub pages](https://github.com/refinedmods/refinedarchitect-template/tree/gh-pages). +- Deploying on [GitHub releases](https://github.com/refinedmods/refinedarchitect-template/releases). +- Announcing the release on Discord and Twitter. +- Creating a PR that merges `main` back into `develop` to get the changes to `CHANGELOG.md` and `build.gradle` + into `develop` from the draft release workflow. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..bc4961c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +patreon: raoulvdberge \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..a3535df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,59 @@ +name: Bug report +description: Found a bug or encountered a crash? Please report it here. +labels: [ bug ] +body: + - type: markdown + attributes: + value: | + Provide a summary of the issue in the title above. + - type: textarea + id: description + attributes: + label: Describe the bug + description: | + Be as detailed as possible. + If applicable, also tell us what you expected to happen instead. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: How can we reproduce this bug or crash? + description: | + Provide us with steps on how to reproduce this issue. + placeholder: | + 1. + 2. + 3. + validations: + required: true + - type: dropdown + id: minecraft + attributes: + label: What Minecraft version is this happening on? + description: | + If your Minecraft version isn't listed here, it means that it's no longer supported. In that case, don't create an issue. + options: + - Minecraft 1.19.3 + validations: + required: true + - type: input + id: modloader-version + attributes: + label: What Forge or Fabric version is this happening on? + validations: + required: true + - type: input + id: version + attributes: + label: What version is this happening on? + description: | + Ensure that you are using the latest version. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9e5add6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Discord Community Support + url: https://discordapp.com/invite/VYzsydb + about: Please ask and answer questions here. Issues should be used for bugs and feature requests. diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 0000000..5509c3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,23 @@ +name: Enhancement +description: Do you have a suggestion for a new feature or improvement? Let us know. +labels: [ enhancement ] +body: + - type: markdown + attributes: + value: | + Provide a summary of the enhancement in the title above. + + Please follow following guidelines before proposing an enchancement: + 1) Ensure that you are running on the latest version (to ensure that the enhancement does not exist yet). + 2) Ensure that your enhancement hasn't already been posted. Please look in the closed issues as well (for enhancements that have been denied). + + We might close your issue, without explanation, if you do not follow these guidelines. + - type: textarea + id: describe + attributes: + label: Describe your enhancement + description: | + Be as detailed as possible. + Tell us how your idea should work. Why should we consider this? + validations: + required: true diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..775c575 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,11 @@ +# Support + +If you have a problem and need help, we offer various channels where you can ask for help. + +## I have a question + +Questions can be asked on [Discord](https://discordapp.com/invite/VYzsydb). + +## I have found a bug + +If you have found a bug, please report it on [GitHub issues](https://github.com/refinedmods/refinedarchitect-template/issues). \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..08b79a8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,14 @@ +name: Build +on: + push: + branches: + - develop + - main + pull_request: + types: [ opened, synchronize, reopened ] +jobs: + build: + uses: refinedmods/refinedarchitect/.github/workflows/build.yml@v0.7.1 + with: + mutation-testing: false + secrets: inherit diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 0000000..9ad68df --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,24 @@ +name: Draft release +on: + workflow_dispatch: + inputs: + release-type: + description: 'Release type' + required: true + default: 'minor' + type: choice + options: + - major + - minor + - patch + version-number-override: + description: 'Version number override' + required: false + type: string +jobs: + draft: + uses: refinedmods/refinedarchitect/.github/workflows/draft-release.yml@v0.7.1 + with: + release-type: ${{ inputs.release-type }} + version-number-override: ${{ inputs.version-number-override }} + secrets: inherit diff --git a/.github/workflows/issue-for-unsupported-version.yml b/.github/workflows/issue-for-unsupported-version.yml new file mode 100644 index 0000000..682945e --- /dev/null +++ b/.github/workflows/issue-for-unsupported-version.yml @@ -0,0 +1,7 @@ +name: Issue for unsupported version +on: + issues: + types: [ labeled, unlabeled, reopened ] +jobs: + unsupported-labeler: + uses: refinedmods/refinedarchitect/.github/workflows/issue-for-unsupported-version.yml@v0.7.1 \ No newline at end of file diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..a1633cf --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,15 @@ +name: Publish release +on: + pull_request: + branches: + - main + types: + - closed +jobs: + publish-release: + uses: refinedmods/refinedarchitect/.github/workflows/publish-release.yml@v0.7.1 + secrets: inherit + with: + project-name: 'Refined Sites' + announce: false + mutation-testing: false \ No newline at end of file diff --git a/.github/workflows/resolved-issue-locking.yml b/.github/workflows/resolved-issue-locking.yml new file mode 100644 index 0000000..a3c881c --- /dev/null +++ b/.github/workflows/resolved-issue-locking.yml @@ -0,0 +1,7 @@ +name: Lock resolved issues and PRs +on: + schedule: + - cron: '0 0 * * *' +jobs: + lock: + uses: refinedmods/refinedarchitect/.github/workflows/resolved-issue-locking.yml@v0.7.1 \ No newline at end of file diff --git a/.github/workflows/validate-branch-name.yml b/.github/workflows/validate-branch-name.yml new file mode 100644 index 0000000..02b7d4c --- /dev/null +++ b/.github/workflows/validate-branch-name.yml @@ -0,0 +1,5 @@ +name: Validate branch name +on: [ pull_request ] +jobs: + validate-branch-name: + uses: refinedmods/refinedarchitect/.github/workflows/validate-branch-name.yml@v0.7.1 \ No newline at end of file diff --git a/.github/workflows/validate-changelog.yml b/.github/workflows/validate-changelog.yml new file mode 100644 index 0000000..9fcfcab --- /dev/null +++ b/.github/workflows/validate-changelog.yml @@ -0,0 +1,7 @@ +name: Validate changelog +on: + pull_request: + types: [ opened, synchronize, reopened, ready_for_review, labeled, unlabeled ] +jobs: + validate-changelog: + uses: refinedmods/refinedarchitect/.github/workflows/validate-changelog.yml@v0.7.1 \ No newline at end of file diff --git a/.github/workflows/validate-commit-messages.yml b/.github/workflows/validate-commit-messages.yml new file mode 100644 index 0000000..2eb1843 --- /dev/null +++ b/.github/workflows/validate-commit-messages.yml @@ -0,0 +1,5 @@ +name: Validate commit messages +on: [ pull_request ] +jobs: + validate-commit-messages: + uses: refinedmods/refinedarchitect/.github/workflows/validate-commit-messages.yml@v0.7.1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5ff508 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.gradle/ +.nb-gradle/ +.settings/ +build/ +eclipse/ +.classpath +.nb-gradle-properties +.project +*.launch +*.iml +*.ipr +*.iws +.idea/ +out/ +/bin/ +logs/ +.cache/ +node_modules/ +.parcel-cache/ +testing/output/ +testing/dist/ +testing/work/ +testing/gh-** \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd6a9fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..58a0839 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +# The MIT License (MIT) + +Copyright © 2023 Refined Mods + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the “Software”), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..239c013 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Refined Sites + +## About + +Refined Sites is a static site generator used by Refined Mods. + +## Links + +- [GitHub](https://github.com/refinedmods/refinedsites) + - [Releases](https://github.com/refinedmods/refinedsites/releases) + - [Packages](https://github.com/refinedmods/refinedsites/packages) + - [Issues](https://github.com/refinedmods/refinedsites/issues) + - [Refined Mods on GitHub](https://github.com/refinedmods) +- [Discord](https://discordapp.com/invite/VYzsydb) +- [Twitter](https://twitter.com/refinedmods) +- [Mastodon](https://anvil.social/@refinedmods) + +## Building + +Clone the repository and import the Gradle project. + +## Contributing + +See [CONTRIBUTING.md](.github/CONTRIBUTING.md). + +## Support + +See [SUPPORT.md](.github/SUPPORT.md). + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6fad444 --- /dev/null +++ b/build.gradle @@ -0,0 +1,28 @@ +plugins { + id "application" +} + +apply from: "https://raw.githubusercontent.com/refinedmods/refinedarchitect/v0.7.1/helper.gradle" + +application { + mainClassName = "com.refinedmods.refinedsites.RefinedSites" +} + +group = 'com.refinedmods' +archivesBaseName = 'refinedsites' + +dependencies { + compileOnly 'org.projectlombok:lombok:1.18.30' + implementation 'com.google.code.gson:gson:2.10.1' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + implementation 'org.thymeleaf:thymeleaf:3.1.2.RELEASE' + implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.3.0' + implementation 'org.asciidoctor:asciidoctorj:2.5.10' + implementation 'com.github.slugify:slugify:3.0.6' + implementation 'com.vdurmont:semver4j:3.1.0' + implementation 'org.slf4j:slf4j-api:2.0.9' + implementation 'org.slf4j:slf4j-simple:2.0.9' + implementation 'org.kohsuke:github-api:1.318' + implementation 'org.eclipse.jgit:org.eclipse.jgit:6.7.0.202309050840-r' + implementation 'org.commonmark:commonmark:0.21.0' +} \ No newline at end of file diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..5db25a3 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a8d260d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +# Gradle +org.gradle.jvmargs=-Xmx1G diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fae0804 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/com/refinedmods/refinedsites/RefinedSites.java b/src/main/java/com/refinedmods/refinedsites/RefinedSites.java new file mode 100644 index 0000000..259eba0 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/RefinedSites.java @@ -0,0 +1,27 @@ +package com.refinedmods.refinedsites; + +import com.refinedmods.refinedsites.model.Site; +import com.refinedmods.refinedsites.playbook.SiteFactory; +import com.refinedmods.refinedsites.render.Renderer; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class RefinedSites { + private RefinedSites() { + } + + public static void main(final String[] args) { + log.info("Loading playbook from {}", args[0]); + final Path rootPath = Paths.get(args[0]); + final SiteFactory siteFactory = new SiteFactory(rootPath); + final Site site = siteFactory.getSite(); + log.info("Loaded site {}", site); + final Renderer renderer = new Renderer(rootPath, rootPath.resolve("output/")); + renderer.render(site); + log.info("Done!"); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/Component.java b/src/main/java/com/refinedmods/refinedsites/model/Component.java new file mode 100644 index 0000000..61fe716 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/Component.java @@ -0,0 +1,36 @@ +package com.refinedmods.refinedsites.model; + +import java.nio.file.Path; +import java.util.List; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Builder +@Getter +@ToString(exclude = "pagesPath") +public class Component { + private final String name; + private final boolean root; + private final Version version; + private final List pages; + private final List navigationItems; + private final Path rootPath; + private final Path pagesPath; + private final Path assetsPath; + private final Path changelogPath; + @Setter + private boolean latest; + @Setter + private String slug; + + public String getAssetsOutputPath() { + return slug + "." + version.friendlyName(); + } + + public String getRelativePagePath(final Path from, final Path to) { + return from.relativize(to).toString().replace(".adoc", ".html"); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/NavigationItem.java b/src/main/java/com/refinedmods/refinedsites/model/NavigationItem.java new file mode 100644 index 0000000..7971807 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/NavigationItem.java @@ -0,0 +1,24 @@ +package com.refinedmods.refinedsites.model; + +import java.nio.file.Path; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@RequiredArgsConstructor +@AllArgsConstructor +@Builder +@Getter +public class NavigationItem { + private final Path path; + private final List children; + @Setter + private String name; + @Setter + private String url; + private String icon; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/Site.java b/src/main/java/com/refinedmods/refinedsites/model/Site.java new file mode 100644 index 0000000..4b7847a --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/Site.java @@ -0,0 +1,61 @@ +package com.refinedmods.refinedsites.model; + +import com.refinedmods.refinedsites.model.release.Releases; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import com.vdurmont.semver4j.Semver; +import lombok.Getter; +import lombok.ToString; + +@ToString(exclude = "componentsByName") +public class Site { + @Getter + private final String name; + @Getter + private final String url; + @Getter + private final List components; + private final Map> componentsByName; + private final Map releasesByComponentName; + + public Site(final String name, + final String url, + final List components, + final Map> componentsByName, + final Map releasesByComponentName) { + this.name = name; + this.url = url; + this.components = components; + this.componentsByName = componentsByName; + componentsByName.forEach((componentName, componentsWithSameName) -> sortComponents(componentsWithSameName)); + this.releasesByComponentName = releasesByComponentName; + } + + public List getComponents(final Component component) { + return componentsByName.get(component.getName()); + } + + public Releases getReleases(final Component component) { + return releasesByComponentName.get(component.getName()); + } + + public Collection getReleases() { + return releasesByComponentName.values(); + } + + private void sortComponents(final List componentsWithSameName) { + // sort componentsWithSameName based on NEWEST-first semver version + componentsWithSameName.sort((component1, component2) -> { + if (component1.getVersion().snapshot()) { + return -1; + } + final Semver semver1 = new Semver(component1.getVersion().name()); + final Semver semver2 = new Semver(component2.getVersion().name()); + return semver2.compareTo(semver1); + }); + componentsWithSameName.get(0).setLatest(true); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/Version.java b/src/main/java/com/refinedmods/refinedsites/model/Version.java new file mode 100644 index 0000000..1b2b012 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/Version.java @@ -0,0 +1,4 @@ +package com.refinedmods.refinedsites.model; + +public record Version(String name, String friendlyName, boolean snapshot) { +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/AbstractSourceData.java b/src/main/java/com/refinedmods/refinedsites/model/release/AbstractSourceData.java new file mode 100644 index 0000000..b332b44 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/AbstractSourceData.java @@ -0,0 +1,17 @@ +package com.refinedmods.refinedsites.model.release; + +import java.util.Date; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public abstract class AbstractSourceData { + protected final String source; + protected final transient String name; + protected final String url; + protected Date createdAt; + + public abstract long getDownloads(); +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/Release.java b/src/main/java/com/refinedmods/refinedsites/model/release/Release.java new file mode 100644 index 0000000..fc91f25 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/Release.java @@ -0,0 +1,34 @@ +package com.refinedmods.refinedsites.model.release; + +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class Release { + private final String name; + private final ReleaseType type; + private final Set sources; + private final Date createdAt; + private final Stats stats; + private final List sourceData; + + public Release(final String name, final List sourceData) { + this.name = name; + this.type = name.contains("alpha") ? ReleaseType.ALPHA + : name.contains("beta") ? ReleaseType.BETA + : ReleaseType.RELEASE; + this.sourceData = sourceData.stream() + .sorted(Comparator.comparing(AbstractSourceData::getCreatedAt)) + .collect(Collectors.toList()); + this.sources = this.sourceData.stream().map(AbstractSourceData::getUrl).collect(Collectors.toSet()); + this.createdAt = this.sourceData.get(0).getCreatedAt(); + this.stats = Stats.of(this.sourceData); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/ReleaseType.java b/src/main/java/com/refinedmods/refinedsites/model/release/ReleaseType.java new file mode 100644 index 0000000..065afca --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/ReleaseType.java @@ -0,0 +1,7 @@ +package com.refinedmods.refinedsites.model.release; + +public enum ReleaseType { + RELEASE, + BETA, + ALPHA +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/Releases.java b/src/main/java/com/refinedmods/refinedsites/model/release/Releases.java new file mode 100644 index 0000000..9f49f4d --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/Releases.java @@ -0,0 +1,16 @@ +package com.refinedmods.refinedsites.model.release; + +import java.util.Date; +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class Releases { + private final Date indexedAt; + private final List releases; + private final String componentName; + private final Stats stats; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/SourceDataProvider.java b/src/main/java/com/refinedmods/refinedsites/model/release/SourceDataProvider.java new file mode 100644 index 0000000..af56214 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/SourceDataProvider.java @@ -0,0 +1,8 @@ +package com.refinedmods.refinedsites.model.release; + +import java.util.List; + +@FunctionalInterface +public interface SourceDataProvider { + List getSourceData(); +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/Stats.java b/src/main/java/com/refinedmods/refinedsites/model/release/Stats.java new file mode 100644 index 0000000..2966bcf --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/Stats.java @@ -0,0 +1,63 @@ +package com.refinedmods.refinedsites.model.release; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class Stats { + private final List downloads; + private final long totalDownloads; + + public Stats(final List downloads, final long totalDownloads) { + this.downloads = downloads; + this.totalDownloads = totalDownloads; + } + + public static Stats of(final List sourceData) { + return new Stats( + sourceData.stream().map(source -> new SourceDownloads(source.getSource(), source.getDownloads())).toList(), + sourceData.stream().mapToLong(AbstractSourceData::getDownloads).sum() + ); + } + + public static Stats of(final Releases releases) { + final Map downloadsBySource = new HashMap<>(); + for (final Release release : releases.getReleases()) { + for (final SourceDownloads downloads : release.getStats().getDownloads()) { + downloadsBySource.put( + downloads.source(), + downloadsBySource.getOrDefault(downloads.source(), 0L) + downloads.downloads() + ); + } + } + return new Stats( + downloadsBySource.entrySet().stream().map(entry -> new SourceDownloads(entry.getKey(), entry.getValue())) + .toList(), + releases.getReleases().stream().mapToLong(r -> r.getStats().getTotalDownloads()).sum() + ); + } + + public static Stats of(final Collection releases) { + final Map downloadsBySource = new HashMap<>(); + for (final Releases release : releases) { + for (final SourceDownloads downloads : release.getStats().getDownloads()) { + downloadsBySource.put( + downloads.source(), + downloadsBySource.getOrDefault(downloads.source(), 0L) + downloads.downloads() + ); + } + } + return new Stats( + downloadsBySource.entrySet().stream().map(entry -> new SourceDownloads(entry.getKey(), entry.getValue())) + .toList(), + releases.stream().mapToLong(r -> r.getStats().getTotalDownloads()).sum() + ); + } + + public record SourceDownloads(String source, long downloads) { + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeFileStatus.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeFileStatus.java new file mode 100644 index 0000000..1d0237e --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeFileStatus.java @@ -0,0 +1,41 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +public enum CurseForgeFileStatus { + // CurseForge core docs: + // https://docs.curseforge.com/#tocS_FileStatus + + PROCESSING(1), + CHANGES_REQUIRED(2), + UNDER_REVIEW(3), + APPROVED(4), + REJECTED(5), + MALWARE_DETECTED(6), + DELETED(7), + ARCHIVED(8), + TESTING(9), + RELEASED(10), + READY_FOR_REVIEW(11), + DEPRECATED(12), + BAKING(13), + AWAITING_PUBLISHING(14), + FAILED_PUBLISHING(15); + + private final int id; + + CurseForgeFileStatus(final int id) { + this.id = id; + } + + public int getId() { + return this.id; + } + + public static CurseForgeFileStatus fromId(final int id) { + for (final CurseForgeFileStatus status : values()) { + if (status.getId() == id) { + return status; + } + } + return null; + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeRelease.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeRelease.java new file mode 100644 index 0000000..6d59d49 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeRelease.java @@ -0,0 +1,28 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +import java.util.Date; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +class CurseForgeRelease { + private long id; + private Date dateCreated; + private Date dateModified; + private String displayName; + private long fileLength; + private String fileName; + private int status; + private List gameVersions; + private List gameVersionTypeIds; + private int releaseType; + private long totalDownloads; + private CurseForgeUser user; + private long additionalFilesCount; + private boolean hasServerPack; + private long additionalServerPackFilesCount; + private boolean isEarlyAccessContent; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeReleaseType.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeReleaseType.java new file mode 100644 index 0000000..abd2d9d --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeReleaseType.java @@ -0,0 +1,22 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +enum CurseForgeReleaseType { + RELEASE(1), + BETA(2), + ALPHA(3); + + private final int id; + + CurseForgeReleaseType(final int id) { + this.id = id; + } + + public static CurseForgeReleaseType fromId(final int id) { + for (final CurseForgeReleaseType type : values()) { + if (type.id == id) { + return type; + } + } + return null; + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeResponse.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeResponse.java new file mode 100644 index 0000000..8fbbf9e --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeResponse.java @@ -0,0 +1,13 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +class CurseForgeResponse { + private List data; + private CurseForgeResponsePagination pagination; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeResponsePagination.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeResponsePagination.java new file mode 100644 index 0000000..9f99d42 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeResponsePagination.java @@ -0,0 +1,12 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +class CurseForgeResponsePagination { + private int index; + private int pageSize; + private int totalCount; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeSourceData.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeSourceData.java new file mode 100644 index 0000000..bb3a45c --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeSourceData.java @@ -0,0 +1,52 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +import com.refinedmods.refinedsites.model.release.AbstractSourceData; + +import java.util.Date; +import java.util.List; + +import lombok.Getter; + +@Getter +public class CurseForgeSourceData extends AbstractSourceData { + private final long id; + private final Date dateModified; + private final long fileLength; + private final String fileName; + private final CurseForgeFileStatus status; + private final List gameVersions; + private final CurseForgeReleaseType releaseType; + private final transient long totalDownloads; + private final CurseForgeUser user; + private final long additionalFilesCount; + private final boolean hasServerPack; + private final long additionalServerPackFilesCount; + private final boolean isEarlyAccessContent; + private final String downloadUrl; + private final String htmlUrl; + + CurseForgeSourceData(final String projectId, final String projectSlug, final CurseForgeRelease release) { + super("curseforge", release.getDisplayName(), "https://www.curseforge.com/api/v1/mods/" + projectId + "/files/" + release.getId()); + this.id = release.getId(); + this.createdAt = release.getDateCreated(); + this.dateModified = release.getDateModified(); + this.fileLength = release.getFileLength(); + this.fileName = release.getFileName(); + this.status = CurseForgeFileStatus.fromId(release.getStatus()); + this.gameVersions = release.getGameVersions(); + this.releaseType = CurseForgeReleaseType.fromId(release.getReleaseType()); + this.totalDownloads = release.getTotalDownloads(); + this.user = release.getUser(); + this.additionalFilesCount = release.getAdditionalFilesCount(); + this.hasServerPack = release.isHasServerPack(); + this.additionalServerPackFilesCount = release.getAdditionalServerPackFilesCount(); + this.isEarlyAccessContent = release.isEarlyAccessContent(); + this.downloadUrl = "https://www.curseforge.com/minecraft/mc-mods/" + projectSlug + "/download/" + release.getId(); + this.htmlUrl = "https://www.curseforge.com/minecraft/mc-mods/" + projectSlug + "/files/" + release.getId(); + } + + @Override + public long getDownloads() { + return totalDownloads; + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeSourceDataProvider.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeSourceDataProvider.java new file mode 100644 index 0000000..d6fdb8b --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeSourceDataProvider.java @@ -0,0 +1,58 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +import com.refinedmods.refinedsites.model.release.SourceDataProvider; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class CurseForgeSourceDataProvider implements SourceDataProvider { + private static final Gson GSON = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + .setPrettyPrinting() + .create(); + + private final String projectId; + private final String projectSlug; + + @Override + public List getSourceData() { + int currentPage = 0; + int totalPages; + final List result = new ArrayList<>(); + do { + try { + final String url = "https://www.curseforge.com/api/v1/mods/" + projectId + "/files?pageIndex=" + currentPage + "&pageSize=50&sort=dateCreated&sortDescending=true&removeAlphas=false"; + final HttpResponse rawResponse = HttpClient.newHttpClient().send(HttpRequest.newBuilder() + .GET() + .uri(URI.create(url)) + .build(), HttpResponse.BodyHandlers.ofString()); + if (rawResponse.statusCode() != 200) { + throw new RuntimeException(rawResponse.body()); + } + final CurseForgeResponse response = GSON.fromJson(rawResponse.body(), CurseForgeResponse.class); + totalPages = response.getPagination().getTotalCount() / response.getPagination().getPageSize(); + log.info("Retrieved CurseForge page {} of {}", currentPage + 1, totalPages + 1); + response.getData().forEach(release -> { + log.info("Found release {} for {}", release.getDisplayName(), projectId); + result.add(new CurseForgeSourceData(projectId, projectSlug, release)); + }); + currentPage++; + } catch (final IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } while (currentPage <= totalPages); + return result; + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeUser.java b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeUser.java new file mode 100644 index 0000000..0a43559 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/curseforge/CurseForgeUser.java @@ -0,0 +1,11 @@ +package com.refinedmods.refinedsites.model.release.curseforge; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CurseForgeUser { + private String username; + private String id; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubAsset.java b/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubAsset.java new file mode 100644 index 0000000..ad098df --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubAsset.java @@ -0,0 +1,29 @@ +package com.refinedmods.refinedsites.model.release.github; + +import lombok.Getter; +import org.kohsuke.github.GHAsset; + +@Getter +public class GitHubAsset { + private final long id; + private final String nodeId; + private final String name; + private final String label; + private final String state; + private final String contentType; + private final long size; + private final long downloadCount; + private final String downloadUrl; + + public GitHubAsset(final GHAsset asset) { + this.id = asset.getId(); + this.nodeId = asset.getNodeId(); + this.name = asset.getName(); + this.label = asset.getLabel(); + this.state = asset.getState(); + this.contentType = asset.getContentType(); + this.size = asset.getSize(); + this.downloadCount = asset.getDownloadCount(); + this.downloadUrl = asset.getBrowserDownloadUrl(); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubSourceData.java b/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubSourceData.java new file mode 100644 index 0000000..c1b1442 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubSourceData.java @@ -0,0 +1,60 @@ +package com.refinedmods.refinedsites.model.release.github; + +import com.refinedmods.refinedsites.model.release.AbstractSourceData; + +import java.io.IOException; +import java.util.Date; +import java.util.List; + +import lombok.Getter; +import org.kohsuke.github.GHRelease; + +@Getter +public class GitHubSourceData extends AbstractSourceData { + private final long id; + private final String nodeId; + private final String htmlUrl; + private final String assetsUrl; + private final List assets; + private final String uploadUrl; + private final String tagName; + private final String targetCommitish; + private final String body; + private final boolean draft; + private final boolean prerelease; + private final Date updatedAt; + private final Date publishedAt; + private final String tarballUrl; + private final String zipballUrl; + private final String discussionUrl; + + public GitHubSourceData(final GHRelease release) { + super("github", release.getName(), release.getUrl().toString()); + try { + this.id = release.getId(); + this.nodeId = release.getNodeId(); + this.htmlUrl = release.getHtmlUrl().toString(); + this.assetsUrl = release.getAssetsUrl(); + this.uploadUrl = release.getUploadUrl(); + this.tagName = release.getTagName(); + this.targetCommitish = release.getTargetCommitish(); + this.body = release.getBody(); + this.draft = release.isDraft(); + this.prerelease = release.isPrerelease(); + this.createdAt = release.getCreatedAt(); + this.updatedAt = release.getUpdatedAt(); + this.publishedAt = release.getPublished_at(); + this.tarballUrl = release.getTarballUrl(); + this.zipballUrl = release.getZipballUrl(); + this.discussionUrl = release.getDiscussionUrl(); + this.assets = release.listAssets().toList().stream().map(GitHubAsset::new).toList(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public long getDownloads() { + return assets.stream().mapToLong(GitHubAsset::getDownloadCount).sum(); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubSourceDataProvider.java b/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubSourceDataProvider.java new file mode 100644 index 0000000..26e1b73 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/github/GitHubSourceDataProvider.java @@ -0,0 +1,33 @@ +package com.refinedmods.refinedsites.model.release.github; + +import com.refinedmods.refinedsites.model.release.SourceDataProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GHRelease; +import org.kohsuke.github.GitHub; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Slf4j +public class GitHubSourceDataProvider implements SourceDataProvider { + private final String repositoryName; + private final String token; + + @Override + public List getSourceData() { + try { + final GitHub gitHub = GitHub.connectUsingOAuth(token); + final List result = new ArrayList<>(); + for (final GHRelease ghRelease : gitHub.getRepository(repositoryName).listReleases()) { + log.info("Retrieved GitHub release {}", ghRelease.getName()); + result.add(new GitHubSourceData(ghRelease)); + } + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthFile.java b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthFile.java new file mode 100644 index 0000000..144815e --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthFile.java @@ -0,0 +1,14 @@ +package com.refinedmods.refinedsites.model.release.modrinth; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +class ModrinthFile { + private ModrinthHashes hashes; + private String url; + private String filename; + private boolean primary; + private int size; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthHashes.java b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthHashes.java new file mode 100644 index 0000000..4cbcfed --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthHashes.java @@ -0,0 +1,11 @@ +package com.refinedmods.refinedsites.model.release.modrinth; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +class ModrinthHashes { + private String sha512; + private String sha1; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthRelease.java b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthRelease.java new file mode 100644 index 0000000..0f54455 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthRelease.java @@ -0,0 +1,26 @@ +package com.refinedmods.refinedsites.model.release.modrinth; + +import java.util.Date; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +class ModrinthRelease { + private String id; + private String projectId; + private String authorId; + private boolean featured; + private String name; + private String versionNumber; + private String changelog; + private Date datePublished; + private int downloads; + private String versionType; + private String status; + private List files; + private List gameVersions; + private List loaders; +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthSourceData.java b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthSourceData.java new file mode 100644 index 0000000..6371821 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthSourceData.java @@ -0,0 +1,40 @@ +package com.refinedmods.refinedsites.model.release.modrinth; + +import com.refinedmods.refinedsites.model.release.AbstractSourceData; + +import java.util.List; + +import lombok.Getter; + +@Getter +public class ModrinthSourceData extends AbstractSourceData { + private final String id; + private final String projectId; + private final String authorId; + private final String versionNumber; + private final String changelog; + private final transient long downloads; + private final String versionType; + private final String status; + private final List files; + private final List gameVersions; + private final List loaders; + private final String htmlUrl; + + ModrinthSourceData(final String projectSlug, final ModrinthRelease release) { + super("modrinth", release.getName(), "https://api.modrinth.com/v2/project/" + projectSlug + "/version/" + release.getId()); + this.id = release.getId(); + this.createdAt = release.getDatePublished(); + this.projectId = release.getProjectId(); + this.authorId = release.getAuthorId(); + this.versionNumber = release.getVersionNumber(); + this.changelog = release.getChangelog(); + this.downloads = release.getDownloads(); + this.versionType = release.getVersionType(); + this.status = release.getStatus(); + this.files = release.getFiles(); + this.gameVersions = release.getGameVersions(); + this.loaders = release.getLoaders(); + this.htmlUrl = "https://modrinth.com/mod/" + projectSlug + "/version/" + release.getVersionNumber(); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthSourceDataProvider.java b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthSourceDataProvider.java new file mode 100644 index 0000000..a793936 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/model/release/modrinth/ModrinthSourceDataProvider.java @@ -0,0 +1,45 @@ +package com.refinedmods.refinedsites.model.release.modrinth; + +import com.refinedmods.refinedsites.model.release.SourceDataProvider; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ModrinthSourceDataProvider implements SourceDataProvider { + private static final Gson GSON = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); + + private final String projectSlug; + + @Override + public List getSourceData() { + try { + final String url = "https://api.modrinth.com/v2/project/" + projectSlug + "/version"; + final HttpResponse rawResponse = HttpClient.newHttpClient().send(HttpRequest.newBuilder() + .GET() + .uri(URI.create(url)) + .build(), HttpResponse.BodyHandlers.ofString()); + if (rawResponse.statusCode() != 200) { + throw new RuntimeException(rawResponse.body()); + } + final ModrinthRelease[] response = GSON.fromJson(rawResponse.body(), ModrinthRelease[].class); + return Arrays.stream(response) + .map(release -> new ModrinthSourceData(projectSlug, release)) + .toList(); + } catch (final IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/ComponentConfig.java b/src/main/java/com/refinedmods/refinedsites/playbook/ComponentConfig.java new file mode 100644 index 0000000..55e4fa6 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/ComponentConfig.java @@ -0,0 +1,15 @@ +package com.refinedmods.refinedsites.playbook; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +@Builder +class ComponentConfig { + private final String name; + private final String version; + private final String path; + private final GitHubConfig github; +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/ComponentFactory.java b/src/main/java/com/refinedmods/refinedsites/playbook/ComponentFactory.java new file mode 100644 index 0000000..796e6ab --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/ComponentFactory.java @@ -0,0 +1,10 @@ +package com.refinedmods.refinedsites.playbook; + +import com.refinedmods.refinedsites.model.Component; + +import java.util.stream.Stream; + +@FunctionalInterface +interface ComponentFactory { + Stream getComponents(); +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/CurseForgeReleaseConfig.java b/src/main/java/com/refinedmods/refinedsites/playbook/CurseForgeReleaseConfig.java new file mode 100644 index 0000000..36d5c44 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/CurseForgeReleaseConfig.java @@ -0,0 +1,11 @@ +package com.refinedmods.refinedsites.playbook; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class CurseForgeReleaseConfig { + private final String id; + private final String slug; +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/GitHubConfig.java b/src/main/java/com/refinedmods/refinedsites/playbook/GitHubConfig.java new file mode 100644 index 0000000..100cb60 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/GitHubConfig.java @@ -0,0 +1,17 @@ +package com.refinedmods.refinedsites.playbook; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +class GitHubConfig { + private final String organization; + private final String repository; + private final String minimumVersion; + private final String snapshotBranch; + + public String getFullRepository() { + return organization + "/" + repository; + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/GithubComponentFactory.java b/src/main/java/com/refinedmods/refinedsites/playbook/GithubComponentFactory.java new file mode 100644 index 0000000..c3f7dd6 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/GithubComponentFactory.java @@ -0,0 +1,101 @@ +package com.refinedmods.refinedsites.playbook; + +import com.refinedmods.refinedsites.model.Component; +import com.refinedmods.refinedsites.model.Version; +import com.vdurmont.semver4j.Semver; +import com.vdurmont.semver4j.SemverException; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +@Slf4j +class GithubComponentFactory implements ComponentFactory { + private final Path rootPath; + private final String name; + private final List validTags = new ArrayList<>(); + private final GHRepository repo; + + GithubComponentFactory(final Path rootPath, final GitHubConfig config, final String name, final String token) { + try { + this.rootPath = rootPath; + this.name = name; + final GitHub github = GitHub.connectUsingOAuth(token); + this.repo = github.getRepository(config.getFullRepository()); + final Semver minVersion = new Semver(config.getMinimumVersion()); + for (final var tag : repo.listTags().toList()) { + final String tagName = tag.getName(); + if (!tagName.startsWith("v")) { + log.warn("Ignoring tag {}", tagName); + continue; + } + final Semver version; + try { + version = new Semver(tagName.substring(1)); + } catch (SemverException e) { + log.warn("Invalid semver version detected {}", tagName.substring(1), e); + continue; + } + if (version.isGreaterThanOrEqualTo(minVersion)) { + log.info("Found valid version {}", version); + validTags.add(new Tag( + tagName, + version.getValue(), + "v" + version.getValue(), + false + )); + } + } + validTags.add(new Tag( + config.getSnapshotBranch(), + "snapshot", + "snapshot", + true + )); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public Stream getComponents() { + final List components = new ArrayList<>(); + for (final Tag validTag : validTags) { + final String path = "gh-" + UUID.randomUUID(); + log.info("Cloning version {} into {}", validTag, path); + try { + Git.cloneRepository() + .setURI(repo.getHtmlUrl().toString()) + .setDirectory(rootPath.resolve(path).toFile()) + .setBranch(validTag.tagName()) + .setDepth(1) + .call(); + } catch (GitAPIException e) { + throw new RuntimeException(e); + } + final LocalComponentFactory factory = new LocalComponentFactory( + rootPath.resolve(path), + name, + false, + new Version( + validTag.version, + validTag.friendlyVersion, + validTag.snapshot + ) + ); + components.addAll(factory.getComponents().toList()); + } + return components.stream(); + } + + private record Tag(String tagName, String version, String friendlyVersion, boolean snapshot) { + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/LocalComponentFactory.java b/src/main/java/com/refinedmods/refinedsites/playbook/LocalComponentFactory.java new file mode 100644 index 0000000..cf0d298 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/LocalComponentFactory.java @@ -0,0 +1,78 @@ +package com.refinedmods.refinedsites.playbook; + +import com.refinedmods.refinedsites.model.Component; +import com.refinedmods.refinedsites.model.Version; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class LocalComponentFactory implements ComponentFactory { + private static final Gson GSON = new GsonBuilder().create(); + + private final Path docsPath; + private final String name; + private final boolean root; + private final Version version; + + LocalComponentFactory(final Path path, + final String name, + final boolean root, + final Version version) { + this.docsPath = path.resolve("docs/"); + this.name = name; + this.root = root; + this.version = version; + } + + @Override + public Stream getComponents() { + if (!Files.exists(docsPath)) { + log.warn("No docs found for component {} {}", name, version); + return Stream.empty(); + } + try { + final Path pagesPath = docsPath.resolve("pages/"); + final List pages = Files.walk(pagesPath) + .filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".adoc")) + .toList(); + final List navItems = getNavigationItems(); + return Stream.of(Component.builder() + .name(name) + .rootPath(docsPath) + .pagesPath(pagesPath) + .changelogPath(docsPath.resolve("../CHANGELOG.md")) + .assetsPath(docsPath.resolve("assets/")) + .root(root) + .version(version) + .navigationItems(navItems.stream().map(item -> item.toNavigationItem(pagesPath)).toList()) + .pages(pages) + .build()); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + private List getNavigationItems() throws IOException { + final Path navigationConfig = docsPath.resolve("nav.json"); + if (!navigationConfig.toFile().exists()) { + return Collections.emptyList(); + } + return GSON.fromJson( + Files.readString(navigationConfig), + new TypeToken>() { + }.getType() + ); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/NavigationItemConfig.java b/src/main/java/com/refinedmods/refinedsites/playbook/NavigationItemConfig.java new file mode 100644 index 0000000..f7cb21d --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/NavigationItemConfig.java @@ -0,0 +1,29 @@ +package com.refinedmods.refinedsites.playbook; + +import com.refinedmods.refinedsites.model.NavigationItem; + +import java.nio.file.Path; +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +class NavigationItemConfig { + private final String title; + private final String icon; + private final String ref; + private final List children; + + public NavigationItem toNavigationItem(final Path pagesPath) { + return NavigationItem.builder() + .name(title) + .path(ref != null ? pagesPath.resolve(ref) : null) + .children(children == null + ? null + : children.stream().map(child -> child.toNavigationItem(pagesPath)).toList()) + .icon(icon) + .build(); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/PlaybookConfig.java b/src/main/java/com/refinedmods/refinedsites/playbook/PlaybookConfig.java new file mode 100644 index 0000000..4156de1 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/PlaybookConfig.java @@ -0,0 +1,16 @@ +package com.refinedmods.refinedsites.playbook; + +import java.util.List; +import java.util.Map; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +class PlaybookConfig { + private final String name; + private final String url; + private final List components; + private final Map releases; +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/ReleaseConfig.java b/src/main/java/com/refinedmods/refinedsites/playbook/ReleaseConfig.java new file mode 100644 index 0000000..05245cf --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/ReleaseConfig.java @@ -0,0 +1,12 @@ +package com.refinedmods.refinedsites.playbook; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ReleaseConfig { + private final String github; + private final CurseForgeReleaseConfig curseforge; + private final String modrinth; +} diff --git a/src/main/java/com/refinedmods/refinedsites/playbook/SiteFactory.java b/src/main/java/com/refinedmods/refinedsites/playbook/SiteFactory.java new file mode 100644 index 0000000..6a88f1a --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/playbook/SiteFactory.java @@ -0,0 +1,141 @@ +package com.refinedmods.refinedsites.playbook; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.refinedmods.refinedsites.model.Component; +import com.refinedmods.refinedsites.model.Site; +import com.refinedmods.refinedsites.model.Version; +import com.refinedmods.refinedsites.model.release.*; +import com.refinedmods.refinedsites.model.release.curseforge.CurseForgeSourceDataProvider; +import com.refinedmods.refinedsites.model.release.github.GitHubSourceDataProvider; +import com.refinedmods.refinedsites.model.release.modrinth.ModrinthSourceDataProvider; +import lombok.AllArgsConstructor; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@AllArgsConstructor +public class SiteFactory { + private static final Gson GSON = new GsonBuilder().create(); + + private final Path rootPath; + + public Site getSite() { + try { + final Path playbookPath = rootPath.resolve("playbook.json"); + final PlaybookConfig json = GSON.fromJson(Files.readString(playbookPath), PlaybookConfig.class); + final List components = json.getComponents().stream().flatMap(component -> { + final ComponentFactory factory = getComponentFactory(component); + return factory.getComponents(); + }).collect(Collectors.toList()); + addRootComponent(components, json); + final Map> componentsByName = components.stream().collect(Collectors.groupingBy( + Component::getName + )); + final Map releasesByComponentName = getReleases(json); + return new Site( + json.getName(), + json.getUrl(), + components, + componentsByName, + releasesByComponentName + ); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + private Map getReleases(final PlaybookConfig json) { + final Map releasesByComponentName = new HashMap<>(); + for (final var releaseEntry : json.getReleases().entrySet()) { + final ReleaseConfig releaseConfig = releaseEntry.getValue(); + final String componentName = releaseEntry.getKey(); + final Releases releases = getReleases(componentName, releaseConfig); + releasesByComponentName.put(componentName, releases); + } + return releasesByComponentName; + } + + private Releases getReleases(final String componentName, final ReleaseConfig releaseConfig) { + final List> sourceDataProviders = getSourceDataProviders(releaseConfig); + final Date indexedAt = new Date(); + final List sourceData = sourceDataProviders.stream().flatMap( + provider -> provider.getSourceData().stream() + ).toList(); + final Map> sourceDataByName = sourceData.stream().collect( + Collectors.groupingBy(AbstractSourceData::getName) + ); + final List releases = sourceDataByName.entrySet() + .stream() + .map(entry -> new Release(entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(Release::getCreatedAt)) + .toList(); + return new Releases(indexedAt, releases, componentName, Stats.of(sourceData)); + } + + private List> getSourceDataProviders(final ReleaseConfig releaseConfig) { + final List> sourceDataProviders = new ArrayList<>(); + if (releaseConfig.getGithub() != null) { + sourceDataProviders.add(new GitHubSourceDataProvider(releaseConfig.getGithub(), getGhToken())); + } + if (releaseConfig.getCurseforge() != null) { + sourceDataProviders.add(new CurseForgeSourceDataProvider( + releaseConfig.getCurseforge().getId(), + releaseConfig.getCurseforge().getSlug() + )); + } + if (releaseConfig.getModrinth() != null) { + sourceDataProviders.add(new ModrinthSourceDataProvider(releaseConfig.getModrinth())); + } + return sourceDataProviders; + } + + private static String getGhToken() { + final String token = System.getenv("GITHUB_TOKEN"); + if (token == null) { + throw new RuntimeException("GITHUB_TOKEN environment variable is not set"); + } + return token; + } + + private ComponentFactory getComponentFactory(final ComponentConfig component) { + if (component.getGithub() != null) { + return new GithubComponentFactory(rootPath, component.getGithub(), component.getName(), getGhToken()); + } + if (component.getPath() != null) { + return new LocalComponentFactory( + rootPath.resolve(component.getPath()), + component.getName(), + false, + new Version( + component.getVersion(), + "v" + component.getVersion(), + false + ) + ); + } + return () -> Stream.of(Component.builder() + .name(component.getName()) + .rootPath(null) + .pagesPath(null) + .changelogPath(null) + .assetsPath(null) + .root(false) + .version(new Version("0.0.0", "v0.0.0", false)) + .navigationItems(Collections.emptyList()) + .pages(Collections.emptyList()) + .build()); + } + + private void addRootComponent(final List components, final PlaybookConfig json) { + components.addAll(new LocalComponentFactory(rootPath, json.getName(), true, new Version( + "0.0.0", + "v0.0.0", + false + )).getComponents().toList()); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/Breadcrumb.java b/src/main/java/com/refinedmods/refinedsites/render/Breadcrumb.java new file mode 100644 index 0000000..afbe88b --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/Breadcrumb.java @@ -0,0 +1,15 @@ +package com.refinedmods.refinedsites.render; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@RequiredArgsConstructor +@Getter +class Breadcrumb { + private final String name; + private final String url; + @Setter + private boolean active; + private final String icon; +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/IconReferences.java b/src/main/java/com/refinedmods/refinedsites/render/IconReferences.java new file mode 100644 index 0000000..098cefb --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/IconReferences.java @@ -0,0 +1,55 @@ +package com.refinedmods.refinedsites.render; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; + +import org.asciidoctor.Asciidoctor; + +class IconReferences { + private final Map icons = new HashMap<>(); + + public Function> createResolver(final Asciidoctor asciidoctor, + final PageAttributeCache pageAttributeCache) { + return path -> resolve(asciidoctor, pageAttributeCache, path); + } + + private Optional resolve(final Asciidoctor asciidoctor, + final PageAttributeCache pageAttributeCache, + final Path path) { + if (icons.containsKey(path)) { + return Optional.of(icons.get(path).key); + } + final Optional icon = pageAttributeCache.getIcon(asciidoctor, path); + return icon.map(i -> addReference(path, i)); + } + + private UUID addReference(final Path path, final String icon) { + final UUID key = UUID.randomUUID(); + icons.put(path, new IconReference(key, icon)); + return key; + } + + public String getHtml(final Path componentAssetsOutputPath, final Path pageOutputPath) { + if (icons.isEmpty()) { + return ""; + } + final StringBuilder result = new StringBuilder("
"); + icons.values().forEach(iconReference -> { + result.append("
"); + final Path assetPath = componentAssetsOutputPath.resolve(iconReference.icon); + result.append(""); + result.append("
"); + }); + result.append("
"); + return result.toString(); + } + + record IconReference(UUID key, String icon) { + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/IncludeProcessorImpl.java b/src/main/java/com/refinedmods/refinedsites/render/IncludeProcessorImpl.java new file mode 100644 index 0000000..9ae3a1a --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/IncludeProcessorImpl.java @@ -0,0 +1,43 @@ +package com.refinedmods.refinedsites.render; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.regex.Pattern; + +import org.asciidoctor.ast.Document; +import org.asciidoctor.extension.IncludeProcessor; +import org.asciidoctor.extension.PreprocessorReader; + +class IncludeProcessorImpl extends IncludeProcessor { + private static final Pattern XREF_PATTERN = Pattern.compile("xref:(.*)"); + + @Override + public boolean handles(final String target) { + return true; + } + + @Override + public void process(final Document document, + final PreprocessorReader reader, + final String target, + final Map attributes) { + final Path path = Paths.get(reader.getDir()).resolve(target); + try { + final String content = Files.readString(path); + // ensure that included files can xref to other files, from the standpoint of the included file path + // (and not from the standpoint of the includer) + final String fixedContent = XREF_PATTERN.matcher(content).replaceAll(matchResult -> { + final String xrefPath = matchResult.group(1); + final Path xrefPathFull = path.getParent().resolve(xrefPath); + final Path relativeToSource = Paths.get(reader.getDir()).relativize(xrefPathFull); + return "xref:" + relativeToSource; + }); + reader.pushInclude(fixedContent, path.toString(), path.toString(), 1, attributes); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/LinkBuilderImpl.java b/src/main/java/com/refinedmods/refinedsites/render/LinkBuilderImpl.java new file mode 100644 index 0000000..4614603 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/LinkBuilderImpl.java @@ -0,0 +1,38 @@ +package com.refinedmods.refinedsites.render; + +import java.nio.file.Path; +import java.util.Map; + +import lombok.Setter; +import org.thymeleaf.context.IExpressionContext; +import org.thymeleaf.linkbuilder.ILinkBuilder; + +public class LinkBuilderImpl implements ILinkBuilder { + private final Path outputPath; + @Setter + private Path currentPageOutputPath; + + public LinkBuilderImpl(final Path outputPath) { + this.outputPath = outputPath; + } + + @Override + public String getName() { + return "refinedsites"; + } + + @Override + public Integer getOrder() { + return 1; + } + + @Override + public String buildLink( + final IExpressionContext context, + final String base, + final Map parameters + ) { + final Path assetLocation = outputPath.resolve(base); + return currentPageOutputPath.getParent().relativize(assetLocation).toString(); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/NavigationItemRender.java b/src/main/java/com/refinedmods/refinedsites/render/NavigationItemRender.java new file mode 100644 index 0000000..34593b1 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/NavigationItemRender.java @@ -0,0 +1,18 @@ +package com.refinedmods.refinedsites.render; + +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class NavigationItemRender { + private final String name; + private final String nameSlug; + private final String url; + private final List children; + private final String key; + private final String icon; + private final boolean active; +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/PageAttributeCache.java b/src/main/java/com/refinedmods/refinedsites/render/PageAttributeCache.java new file mode 100644 index 0000000..1b08100 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/PageAttributeCache.java @@ -0,0 +1,39 @@ +package com.refinedmods.refinedsites.render; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import lombok.RequiredArgsConstructor; +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.Options; +import org.asciidoctor.ast.Document; + +@RequiredArgsConstructor +public class PageAttributeCache { + private final Map attributes = new HashMap<>(); + + public String getName(final Asciidoctor asciidoctor, final Path path) { + return getAttributes(asciidoctor, path).name; + } + + public Optional getIcon(final Asciidoctor asciidoctor, final Path path) { + return getAttributes(asciidoctor, path).icon; + } + + public PageAttributes getAttributes(final Asciidoctor asciidoctor, final Path path) { + return attributes.computeIfAbsent(path, p -> doLoadAttributes(asciidoctor, path)); + } + + private PageAttributes doLoadAttributes(final Asciidoctor asciidoctor, final Path path) { + final Document document = asciidoctor.loadFile(path.toFile(), Options.builder().build()); + return new PageAttributes( + document.getDoctitle(), + Optional.ofNullable(document.getAttribute("icon")).map(Object::toString) + ); + } + + public record PageAttributes(String name, Optional icon) { + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/PageInfo.java b/src/main/java/com/refinedmods/refinedsites/render/PageInfo.java new file mode 100644 index 0000000..f1184b4 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/PageInfo.java @@ -0,0 +1,14 @@ +package com.refinedmods.refinedsites.render; + +import java.util.List; + +import lombok.Builder; + +@Builder +record PageInfo(String title, + String parsedContent, + String relativePath, + String icon, + IconReferences iconReferences, + List tableOfContents) { +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/Renderer.java b/src/main/java/com/refinedmods/refinedsites/render/Renderer.java new file mode 100644 index 0000000..1018dcb --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/Renderer.java @@ -0,0 +1,451 @@ +package com.refinedmods.refinedsites.render; + +import com.refinedmods.refinedsites.model.Component; +import com.refinedmods.refinedsites.model.NavigationItem; +import com.refinedmods.refinedsites.model.Site; +import com.refinedmods.refinedsites.model.release.Release; +import com.refinedmods.refinedsites.model.release.Releases; +import com.refinedmods.refinedsites.model.release.curseforge.CurseForgeSourceData; +import com.refinedmods.refinedsites.model.release.github.GitHubSourceData; +import com.refinedmods.refinedsites.model.release.modrinth.ModrinthSourceData; +import com.refinedmods.refinedsites.render.release.ParsedRelease; +import com.refinedmods.refinedsites.render.release.ProjectRelease; +import com.refinedmods.refinedsites.render.release.ProjectReleasesIndex; +import com.refinedmods.refinedsites.render.release.ReleasesIndex; + +import javax.annotation.Nullable; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import com.github.slugify.Slugify; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.Options; +import org.asciidoctor.ast.Document; +import org.asciidoctor.ast.StructuralNode; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.templateresolver.FileTemplateResolver; + +@RequiredArgsConstructor +@Slf4j +public class Renderer { + private static final Slugify SLUGIFY = Slugify.builder().lowerCase(true).build(); + private static final Gson GSON = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + .setPrettyPrinting() + .create(); + + private final TemplateEngine templateEngine; + private final Path sourcePath; + private final Path outputPath; + private final LinkBuilderImpl linkBuilder; + + public Renderer(final Path sourcePath, final Path outputPath) { + this.sourcePath = sourcePath; + this.outputPath = outputPath; + this.linkBuilder = new LinkBuilderImpl(outputPath); + final FileTemplateResolver resolver = new FileTemplateResolver(); + resolver.setPrefix(sourcePath.toString() + "/"); + this.templateEngine = new TemplateEngine(); + templateEngine.addDialect(new LayoutDialect()); + templateEngine.setTemplateResolver(resolver); + templateEngine.setLinkBuilder(linkBuilder); + } + + public void render(final Site site) { + log.info("Rendering site {}", site); + try { + Files.createDirectories(outputPath); + final Path assetsPath = copyRootAssets(); + renderReleasesIndex(site); + for (final Component component : site.getComponents()) { + renderComponentPre(component); + } + for (final Component component : site.getComponents()) { + renderComponent(component, assetsPath, site); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void renderReleasesIndex(final Site site) { + try { + final Path releasesPath = outputPath.resolve("releases.json"); + Files.writeString(releasesPath, GSON.toJson(new ReleasesIndex( + site.getUrl(), + site.getReleases().stream().map(Releases::getIndexedAt).sorted().findFirst().orElse(null), + SLUGIFY, + site.getReleases() + ))); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + private Path copyRootAssets() throws IOException { + final Path assetsSourcePath = sourcePath.resolve("assets/"); + final Path assetsDestinationPath = outputPath.resolve("assets/"); + Files.walk(assetsSourcePath).forEach(path -> { + final Path relativePath = assetsSourcePath.relativize(path); + final Path targetPath = assetsDestinationPath.resolve(relativePath); + try { + Files.createDirectories(targetPath.getParent()); + Files.copy(path, targetPath); + } catch (final IOException e) { + throw new RuntimeException(e); + } + }); + return assetsDestinationPath; + } + + private void renderComponentPre(final Component component) { + final String componentSlug = SLUGIFY.slugify(component.getName()); + component.setSlug(componentSlug); + } + + private void renderComponent(final Component component, final Path assetsOutputPath, final Site site) + throws IOException { + log.info("Rendering component {}", component); + final Path componentOutputPath; + if (component.isRoot()) { + componentOutputPath = outputPath; + } else if (component.isLatest()) { + componentOutputPath = outputPath.resolve(component.getSlug()); + Files.createDirectories(componentOutputPath); + } else { + componentOutputPath = outputPath.resolve(component.getSlug()).resolve( + component.getVersion().friendlyName() + ); + Files.createDirectories(componentOutputPath); + } + if (component.getAssetsPath() != null) { + copyComponentAssets(component, assetsOutputPath); + } + final Releases releases = site.getReleases(component); + if (component.isLatest() && releases != null) { + renderComponentReleases(releases, site, componentOutputPath); + } + final List parsedReleases = releases == null ? Collections.emptyList() : parseReleases(releases); + final ParsedRelease releaseMatchingComponentVersion = findReleaseMatchingComponentVersion( + component, + parsedReleases + ); + final PageAttributeCache pageAttributeCache = new PageAttributeCache(); + final Map pageInfo = new HashMap<>(); + for (final Path pagePath : component.getPages()) { + pageInfo.put(pagePath, renderPagePre(pagePath, component, pageAttributeCache)); + } + prepareNavigationItems(component.getNavigationItems(), pageInfo); + for (final Path pagePath : component.getPages()) { + renderPage( + pagePath, + component, + componentOutputPath, + assetsOutputPath, + site, + pageInfo, + parsedReleases, + releaseMatchingComponentVersion + ); + } + } + + @Nullable + private ParsedRelease findReleaseMatchingComponentVersion(final Component component, + final List parsedReleases) { + return parsedReleases.stream() + .filter(release -> release.getRelease().getName().equals(component.getVersion().friendlyName())) + .findFirst() + .orElse(null); + } + + private void renderComponentReleases(final Releases releases, + final Site site, + final Path componentOutputPath) { + try { + final Path releasesIndexPath = componentOutputPath.resolve("releases.json"); + Files.writeString(releasesIndexPath, GSON.toJson(new ProjectReleasesIndex( + site.getUrl(), + releases.getIndexedAt(), + SLUGIFY, + releases + ))); + final Path releasesPath = componentOutputPath.resolve("releases/"); + Files.createDirectories(releasesPath); + for (final Release release : releases.getReleases()) { + final Path releasePath = releasesPath.resolve(release.getName() + ".json"); + Files.writeString(releasePath, GSON.toJson(new ProjectRelease( + site.getUrl() + "/" + SLUGIFY.slugify(releases.getComponentName()) + + "/releases/" + release.getName() + ".json", + release.getName(), + releases.getIndexedAt(), + release.getType(), + release.getSourceData(), + release.getStats() + ))); + } + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + private List parseReleases(final Releases releases) { + final Parser markdownParser = Parser.builder().build(); + final HtmlRenderer renderer = HtmlRenderer.builder().build(); + return releases.getReleases().stream().map(release -> new ParsedRelease( + release, + SLUGIFY.slugify(release.getName()), + release.getSourceData().stream().filter(sd -> sd instanceof CurseForgeSourceData) + .map(sd -> ((CurseForgeSourceData) sd).getHtmlUrl()) + .findFirst() + .orElse(null), + release.getSourceData().stream().filter(sd -> sd instanceof GitHubSourceData) + .map(sd -> ((GitHubSourceData) sd).getHtmlUrl()) + .findFirst() + .orElse(null), + release.getSourceData().stream().filter(sd -> sd instanceof ModrinthSourceData) + .map(sd -> ((ModrinthSourceData) sd).getHtmlUrl()) + .findFirst() + .orElse(null), + release.getSourceData().stream().filter(sd -> sd instanceof GitHubSourceData) + .map(sd -> ((GitHubSourceData) sd).getBody()) + .map(body -> renderer.render(markdownParser.parse(body))) + .findFirst() + .orElse(null) + )).toList(); + } + + private void prepareNavigationItems(final List navigationItems, + final Map pageInfo) { + navigationItems.forEach(navigationItem -> { + if (navigationItem.getPath() != null && navigationItem.getName() == null) { + final Path navigationItemPath = navigationItem.getPath(); + final PageInfo info = pageInfo.get(navigationItemPath); + navigationItem.setName(info.title()); + } + if (navigationItem.getChildren() != null) { + prepareNavigationItems(navigationItem.getChildren(), pageInfo); + } + }); + } + + private void copyComponentAssets(final Component component, + final Path assetsOutputPath) throws IOException { + if (!Files.exists(component.getAssetsPath())) { + return; + } + final Path componentAssetsPath = assetsOutputPath.resolve( + component.getAssetsOutputPath() + "/" + ); + Files.walk(component.getAssetsPath()).forEach(path -> { + final Path relativePath = component.getAssetsPath().relativize(path); + final Path targetPath = componentAssetsPath.resolve(relativePath); + try { + Files.createDirectories(targetPath.getParent()); + Files.copy(path, targetPath); + } catch (final IOException e) { + throw new RuntimeException(e); + } + }); + } + + private PageInfo renderPagePre(final Path pagePath, + final Component component, + final PageAttributeCache attributeCache) { + log.info("Parsing Asciidoc for page {}", pagePath); + final String relativePath = component.getRelativePagePath(component.getPagesPath(), pagePath); + final IconReferences icons = new IconReferences(); + try (Asciidoctor asciidoctor = Asciidoctor.Factory.create()) { + final PageAttributeCache.PageAttributes pageAttributes = attributeCache.getAttributes( + asciidoctor, + pagePath + ); + asciidoctor.javaExtensionRegistry().includeProcessor(new IncludeProcessorImpl()); + asciidoctor.javaExtensionRegistry().inlineMacro(new XRefInlineMacroProcessor( + component, + pagePath, + icons.createResolver(asciidoctor, attributeCache), + path -> attributeCache.getName(asciidoctor, path) + )); + final Document document = asciidoctor.loadFile(pagePath.toFile(), Options.builder().build()); + final List toc = document.getBlocks() + .stream() + .filter(block -> block.getTitle() != null) + .map(this::getTableOfContents) + .toList(); + final String parsedContent = (String) document.getContent(); + return PageInfo.builder() + .title(pageAttributes.name()) + .tableOfContents(toc) + .iconReferences(icons) + .parsedContent(parsedContent + .replace(" pageInfo, + final List releases, + final ParsedRelease releaseMatchingComponentVersion) throws IOException { + log.info("Rendering page {}", pagePath); + final Context context = new Context(); + final PageInfo info = pageInfo.get(pagePath); + final Path pageOutputPath = componentOutputPath.resolve(info.relativePath()); + Files.createDirectories(pageOutputPath.getParent()); + final FileWriter fileWriter = new FileWriter(pageOutputPath.toFile()); + context.setVariable("title", info.title()); + context.setVariable("icon", info.icon()); + final String iconsHtml = info.iconReferences().getHtml( + assetsOutputPath.resolve(component.getAssetsOutputPath() + "/"), + pageOutputPath + ); + context.setVariable("toc", info.tableOfContents()); + context.setVariable("content", info.parsedContent() + iconsHtml); + context.setVariable("currentComponent", component); + context.setVariable("otherComponents", site.getComponents(component)); + context.setVariable("navigationItems", component.getNavigationItems().stream().map( + item -> mapNavigationItem(item, info, pageInfo) + ).toList()); + context.setVariable("breadcrumbs", getBreadcrumbsTopLevel(component, info, pageInfo)); + context.setVariable("currentRelease", releaseMatchingComponentVersion); + final List otherReleases = releases.stream().filter(r -> r != releaseMatchingComponentVersion) + .collect(Collectors.toList()); + Collections.reverse(otherReleases); + context.setVariable("otherReleases", otherReleases); + linkBuilder.setCurrentPageOutputPath(pageOutputPath); + final String template = getTemplate(pagePath); + templateEngine.process(template, context, fileWriter); + } + + private String getTemplate(final Path pagePath) { + final Path potentialTemplateOverridePath = Path.of( + pagePath.toString().replace(".adoc", ".html") + ); + if (!Files.exists(potentialTemplateOverridePath)) { + return "page.html"; + } + return sourcePath.relativize(potentialTemplateOverridePath).toString(); + } + + private List getBreadcrumbsTopLevel(final Component component, + final PageInfo currentPageInfo, + final Map pageInfo) { + final List breadcrumbs = new ArrayList<>(); + for (final NavigationItem item : component.getNavigationItems()) { + if (getBreadcrumbs(currentPageInfo, item, breadcrumbs, pageInfo)) { + break; + } + } + return breadcrumbs; + } + + private boolean getBreadcrumbs(final PageInfo currentPageInfo, + final NavigationItem item, + final List breadcrumbs, + final Map pageInfo) { + if (isContainedInItem(item, currentPageInfo, pageInfo)) { + return getBreadcrumbsContained(currentPageInfo, item, breadcrumbs, pageInfo); + } + return false; + } + + private boolean getBreadcrumbsContained(final PageInfo currentPageInfo, + final NavigationItem item, + final List breadcrumbs, + final Map pageInfo) { + final PageInfo itemPageInfo = pageInfo.get(item.getPath()); + final Breadcrumb breadcrumb = new Breadcrumb( + item.getName(), + itemPageInfo.relativePath(), + item.getIcon() + ); + breadcrumbs.add(breadcrumb); + breadcrumb.setActive(true); + if (item.getChildren() != null) { + for (final NavigationItem child : item.getChildren()) { + if (getBreadcrumbs(currentPageInfo, child, breadcrumbs, pageInfo)) { + breadcrumb.setActive(false); + break; + } + } + } + return true; + } + + private boolean isContainedInItem(final NavigationItem item, + final PageInfo currentPageInfo, + final Map pageInfo) { + final PageInfo navigationItemPageInfo = pageInfo.get( + item.getPath() + ); + if (navigationItemPageInfo == currentPageInfo) { + return true; + } + if (item.getChildren() != null) { + for (final NavigationItem child : item.getChildren()) { + if (isContainedInItem(child, currentPageInfo, pageInfo)) { + return true; + } + } + } + return false; + } + + private NavigationItemRender mapNavigationItem(final NavigationItem navigationItem, + final PageInfo currentPageInfo, + final Map pageInfo) { + final PageInfo navigationItemPageInfo = pageInfo.get(navigationItem.getPath()); + final List children = navigationItem.getChildren() == null + ? null + : navigationItem.getChildren().stream() + .map(nestedItem -> mapNavigationItem(nestedItem, currentPageInfo, pageInfo)) + .toList(); + final boolean active = currentPageInfo == navigationItemPageInfo + || (children != null && children.stream().anyMatch(NavigationItemRender::isActive)); + return new NavigationItemRender( + navigationItem.getName(), + SLUGIFY.slugify(navigationItem.getName()), + navigationItemPageInfo == null ? null : navigationItemPageInfo.relativePath(), + children, + UUID.randomUUID().toString(), + navigationItem.getIcon() != null + ? navigationItem.getIcon() + : (navigationItemPageInfo == null ? null : navigationItemPageInfo.icon()), + active + ); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/TableOfContents.java b/src/main/java/com/refinedmods/refinedsites/render/TableOfContents.java new file mode 100644 index 0000000..ed63bea --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/TableOfContents.java @@ -0,0 +1,15 @@ +package com.refinedmods.refinedsites.render; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class TableOfContents { + private final String title; + private final String id; + private final List children = new ArrayList<>(); +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/XRefInlineMacroProcessor.java b/src/main/java/com/refinedmods/refinedsites/render/XRefInlineMacroProcessor.java new file mode 100644 index 0000000..ec71a61 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/XRefInlineMacroProcessor.java @@ -0,0 +1,58 @@ +package com.refinedmods.refinedsites.render; + +import com.refinedmods.refinedsites.model.Component; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; + +import lombok.RequiredArgsConstructor; +import org.asciidoctor.ast.ContentNode; +import org.asciidoctor.extension.InlineMacroProcessor; +import org.asciidoctor.extension.Name; + +@Name("xref") +@RequiredArgsConstructor +class XRefInlineMacroProcessor extends InlineMacroProcessor { + private final Component component; + private final Path currentPagePath; + private final Function> iconResolver; + private final Function nameResolver; + + @Override + public Object process(final ContentNode parent, final String target, final Map attributes) { + final String actualTarget; + final String anchor; + if (target.contains("#")) { + actualTarget = target.substring(0, target.indexOf('#')); + anchor = target.substring(target.indexOf('#') + 1); + } else { + actualTarget = target; + anchor = null; + } + final Path referencedPagePath = currentPagePath.getParent().resolve(actualTarget).normalize(); + final StringBuilder result = new StringBuilder(); + result.append(" result + .append(" data-icon-id=\"") + .append(iconId) + .append("\"")); + result.append(">"); + result.append(!attributes.containsKey("1") + ? nameResolver.apply(referencedPagePath) + : (String) attributes.get("1")); + result.append(""); + return result.toString(); + } + + private String getTarget(final Path referencedPagePath) { + return component.getRelativePagePath(currentPagePath.getParent(), referencedPagePath); + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/release/ParsedRelease.java b/src/main/java/com/refinedmods/refinedsites/render/release/ParsedRelease.java new file mode 100644 index 0000000..f05a587 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/release/ParsedRelease.java @@ -0,0 +1,23 @@ +package com.refinedmods.refinedsites.render.release; + +import com.refinedmods.refinedsites.model.release.Release; + +import javax.annotation.Nullable; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ParsedRelease { + private final Release release; + private final String slug; + @Nullable + private final String curseforgeUrl; + @Nullable + private final String githubUrl; + @Nullable + private final String modrinthUrl; + @Nullable + private final String changelogHtml; +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/release/ProjectRelease.java b/src/main/java/com/refinedmods/refinedsites/render/release/ProjectRelease.java new file mode 100644 index 0000000..6b04844 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/release/ProjectRelease.java @@ -0,0 +1,22 @@ +package com.refinedmods.refinedsites.render.release; + +import com.refinedmods.refinedsites.model.release.AbstractSourceData; +import com.refinedmods.refinedsites.model.release.ReleaseType; +import com.refinedmods.refinedsites.model.release.Stats; + +import java.util.Date; +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ProjectRelease { + private final String url; + private final String name; + private final Date indexedAt; + private final ReleaseType type; + private final List sources; + private final Stats stats; +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/release/ProjectReleasesIndex.java b/src/main/java/com/refinedmods/refinedsites/render/release/ProjectReleasesIndex.java new file mode 100644 index 0000000..e122ebd --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/release/ProjectReleasesIndex.java @@ -0,0 +1,53 @@ +package com.refinedmods.refinedsites.render.release; + +import com.refinedmods.refinedsites.model.release.Release; +import com.refinedmods.refinedsites.model.release.ReleaseType; +import com.refinedmods.refinedsites.model.release.Releases; +import com.refinedmods.refinedsites.model.release.Stats; + +import java.util.Date; +import java.util.List; +import java.util.Set; + +import com.github.slugify.Slugify; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class ProjectReleasesIndex { + private final String url; + private final String name; + private final Date indexedAt; + private final List releases; + private final Stats stats; + + public ProjectReleasesIndex(final String url, + final Date indexedAt, + final Slugify slugify, + final Releases releases) { + final String componentUrl = url + "/" + slugify.slugify(releases.getComponentName()); + this.url = componentUrl + "/releases.json"; + this.name = releases.getComponentName(); + this.indexedAt = indexedAt; + this.releases = releases.getReleases().stream().map(release -> new Item(componentUrl, release)).toList(); + this.stats = Stats.of(releases); + } + + @RequiredArgsConstructor + @Getter + public static class Item { + private final String name; + private final ReleaseType type; + private final String url; + private final Set sources; + private final Date createdAt; + + public Item(final String url, final Release release) { + this.name = release.getName(); + this.type = release.getType(); + this.url = url + "/releases/" + name + ".json"; + this.sources = release.getSources(); + this.createdAt = release.getCreatedAt(); + } + } +} diff --git a/src/main/java/com/refinedmods/refinedsites/render/release/ReleasesIndex.java b/src/main/java/com/refinedmods/refinedsites/render/release/ReleasesIndex.java new file mode 100644 index 0000000..2f94151 --- /dev/null +++ b/src/main/java/com/refinedmods/refinedsites/render/release/ReleasesIndex.java @@ -0,0 +1,40 @@ +package com.refinedmods.refinedsites.render.release; + +import com.refinedmods.refinedsites.model.release.Releases; +import com.refinedmods.refinedsites.model.release.Stats; + +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import com.github.slugify.Slugify; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +public class ReleasesIndex { + private final String url; + private final Date indexedAt; + private final List projects; + private final Stats stats; + + public ReleasesIndex(final String url, + final Date indexedAt, + final Slugify slugify, + final Collection projectReleases) { + this.url = url + "/releases.json"; + this.indexedAt = indexedAt; + this.projects = projectReleases.stream().map(release -> new Item( + url + "/" + slugify.slugify(release.getComponentName()) + "/releases.json", + release.getComponentName() + )).toList(); + this.stats = Stats.of(projectReleases); + } + + @RequiredArgsConstructor + @Getter + public static class Item { + private final String url; + private final String name; + } +}