diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95996fc..db91d5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,32 +7,89 @@ on: - "v*" pull_request: +permissions: + id-token: write + pages: write + contents: read + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: coursier/cache-action@v6.4 - uses: VirtusLab/scala-cli-setup@v0.2.1 - - name: Test - run: scala-cli --power test . --cross + - name: Run tests + run: ./mill -i __.publishArtifacts + __.test publish: + if: github.repository == 'Quafadas/scautable' && contains(github.ref, 'refs/tags/') + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + + - name: Setup GPG secrets + run: | + gpg --version + cat <(echo "${{ secrets.GPG_SECRET_KEY }}") | base64 --decode | gpg --batch --import + gpg --list-secret-keys --keyid-format LONG + + - name: Publish to Maven Central + run: ./millw -i mill.scalalib.PublishModule/publishAll plugin.publishArtifacts --sonatypeUri https://s01.oss.sonatype.org/service/local --sonatypeCreds "${{ secrets.SONATYPE_CREDS }}" --gpgArgs "--passphrase=${{ secrets.GPG_SECRET_KEY_PASS}},--batch,--yes,-a,-b,--pinentry-mode,loopback" --readTimeout 600000 --awaitTimeout 600000 --release true --signed true + + site: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: test - if: github.event_name == 'push' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + - uses: actions/checkout@v3 + - run: ./millw site.docJar + - name: Setup Pages + uses: actions/configure-pages@v4 + - uses: actions/upload-artifact@v3 + with: + name: page + path: out/site/docJar.dest/javadoc/ + if-no-files-found: error + + deploy: + needs: site + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v3 with: - fetch-depth: 0 - - uses: coursier/cache-action@v6.4 - - uses: VirtusLab/scala-cli-setup@v0.2.1 - - name: Publish - run: scala-cli --power publish . --cross -v -v - env: - PUBLISH_USER: ${{ secrets.PUBLISH_USER }} - PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} - PUBLISH_SECRET_KEY: ${{ secrets.PUBLISH_SECRET_KEY }} - PUBLISH_SECRET_KEY_PASSWORD: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} + name: page + path: . + - uses: actions/configure-pages@v4 + - uses: actions/upload-pages-artifact@v2 + with: + path: . + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v3 \ No newline at end of file diff --git a/build.sc b/build.sc index 4aa1eed..47865a1 100644 --- a/build.sc +++ b/build.sc @@ -49,6 +49,10 @@ object site extends SiteModule { def scalaVersion = scautable.jvm .scalaVersion - override def moduleDeps = Seq( scautable.jvm ) + override def moduleDeps = Seq( scautable.jvm, scautable.js ) + + override def scalaDocOptions = super.scalaDocOptions() ++ Seq( + "-scastie-configuration", """libraryDependencies += "io.github.quafadas" %% "scautable" % "0.0.5"""" + ) } \ No newline at end of file diff --git a/mill b/mill new file mode 100755 index 0000000..03e5c21 --- /dev/null +++ b/mill @@ -0,0 +1,241 @@ +#!/usr/bin/env sh + +# This is a wrapper script, that automatically download mill from GitHub release pages +# You can give the required mill version with --mill-version parameter +# If no version is given, it falls back to the value of DEFAULT_MILL_VERSION +# +# Project page: https://github.com/lefou/millw +# Script Version: 0.4.11 +# +# If you want to improve this script, please also contribute your changes back! +# +# Licensed under the Apache License, Version 2.0 + +set -e + +if [ -z "${DEFAULT_MILL_VERSION}" ] ; then + DEFAULT_MILL_VERSION="0.11.4" +fi + + +if [ -z "${GITHUB_RELEASE_CDN}" ] ; then + GITHUB_RELEASE_CDN="" +fi + + +MILL_REPO_URL="https://github.com/com-lihaoyi/mill" + +if [ -z "${CURL_CMD}" ] ; then + CURL_CMD=curl +fi + +# Explicit commandline argument takes precedence over all other methods +if [ "$1" = "--mill-version" ] ; then + shift + if [ "x$1" != "x" ] ; then + MILL_VERSION="$1" + shift + else + echo "You specified --mill-version without a version." 1>&2 + echo "Please provide a version that matches one provided on" 1>&2 + echo "${MILL_REPO_URL}/releases" 1>&2 + false + fi +fi + +# Please note, that if a MILL_VERSION is already set in the environment, +# We reuse it's value and skip searching for a value. + +# If not already set, read .mill-version file +if [ -z "${MILL_VERSION}" ] ; then + if [ -f ".mill-version" ] ; then + MILL_VERSION="$(head -n 1 .mill-version 2> /dev/null)" + elif [ -f ".config/mill-version" ] ; then + MILL_VERSION="$(head -n 1 .config/mill-version 2> /dev/null)" + fi +fi + +MILL_USER_CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/mill" + +if [ -z "${MILL_DOWNLOAD_PATH}" ] ; then + MILL_DOWNLOAD_PATH="${MILL_USER_CACHE_DIR}/download" +fi + +# If not already set, try to fetch newest from Github +if [ -z "${MILL_VERSION}" ] ; then + # TODO: try to load latest version from release page + echo "No mill version specified." 1>&2 + echo "You should provide a version via '.mill-version' file or --mill-version option." 1>&2 + + mkdir -p "${MILL_DOWNLOAD_PATH}" + LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" 2>/dev/null || ( + # we might be on OSX or BSD which don't have -d option for touch + # but probably a -A [-][[hh]mm]SS + touch "${MILL_DOWNLOAD_PATH}/.expire_latest"; touch -A -010000 "${MILL_DOWNLOAD_PATH}/.expire_latest" + ) || ( + # in case we still failed, we retry the first touch command with the intention + # to show the (previously suppressed) error message + LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" + ) + + # POSIX shell variant of bash's -nt operator, see https://unix.stackexchange.com/a/449744/6993 + # if [ "${MILL_DOWNLOAD_PATH}/.latest" -nt "${MILL_DOWNLOAD_PATH}/.expire_latest" ] ; then + if [ -n "$(find -L "${MILL_DOWNLOAD_PATH}/.latest" -prune -newer "${MILL_DOWNLOAD_PATH}/.expire_latest")" ]; then + # we know a current latest version + MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) + fi + + if [ -z "${MILL_VERSION}" ] ; then + # we don't know a current latest version + echo "Retrieving latest mill version ..." 1>&2 + LANG=C ${CURL_CMD} -s -i -f -I ${MILL_REPO_URL}/releases/latest 2> /dev/null | grep --ignore-case Location: | sed s'/^.*tag\///' | tr -d '\r\n' > "${MILL_DOWNLOAD_PATH}/.latest" + MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) + fi + + if [ -z "${MILL_VERSION}" ] ; then + # Last resort + MILL_VERSION="${DEFAULT_MILL_VERSION}" + echo "Falling back to hardcoded mill version ${MILL_VERSION}" 1>&2 + else + echo "Using mill version ${MILL_VERSION}" 1>&2 + fi +fi + +MILL="${MILL_DOWNLOAD_PATH}/${MILL_VERSION}" + +try_to_use_system_mill() { + if [ "$(uname)" != "Linux" ]; then + return 0 + fi + + MILL_IN_PATH="$(command -v mill || true)" + + if [ -z "${MILL_IN_PATH}" ]; then + return 0 + fi + + SYSTEM_MILL_FIRST_TWO_BYTES=$(head --bytes=2 "${MILL_IN_PATH}") + if [ "${SYSTEM_MILL_FIRST_TWO_BYTES}" = "#!" ]; then + # MILL_IN_PATH is (very likely) a shell script and not the mill + # executable, ignore it. + return 0 + fi + + SYSTEM_MILL_PATH=$(readlink -e "${MILL_IN_PATH}") + SYSTEM_MILL_SIZE=$(stat --format=%s "${SYSTEM_MILL_PATH}") + SYSTEM_MILL_MTIME=$(stat --format=%y "${SYSTEM_MILL_PATH}") + + if [ ! -d "${MILL_USER_CACHE_DIR}" ]; then + mkdir -p "${MILL_USER_CACHE_DIR}" + fi + + SYSTEM_MILL_INFO_FILE="${MILL_USER_CACHE_DIR}/system-mill-info" + if [ -f "${SYSTEM_MILL_INFO_FILE}" ]; then + parseSystemMillInfo() { + LINE_NUMBER="${1}" + # Select the line number of the SYSTEM_MILL_INFO_FILE, cut the + # variable definition in that line in two halves and return + # the value, and finally remove the quotes. + sed -n "${LINE_NUMBER}p" "${SYSTEM_MILL_INFO_FILE}" |\ + cut -d= -f2 |\ + sed 's/"\(.*\)"/\1/' + } + + CACHED_SYSTEM_MILL_PATH=$(parseSystemMillInfo 1) + CACHED_SYSTEM_MILL_VERSION=$(parseSystemMillInfo 2) + CACHED_SYSTEM_MILL_SIZE=$(parseSystemMillInfo 3) + CACHED_SYSTEM_MILL_MTIME=$(parseSystemMillInfo 4) + + if [ "${SYSTEM_MILL_PATH}" = "${CACHED_SYSTEM_MILL_PATH}" ] \ + && [ "${SYSTEM_MILL_SIZE}" = "${CACHED_SYSTEM_MILL_SIZE}" ] \ + && [ "${SYSTEM_MILL_MTIME}" = "${CACHED_SYSTEM_MILL_MTIME}" ]; then + if [ "${CACHED_SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then + MILL="${SYSTEM_MILL_PATH}" + return 0 + else + return 0 + fi + fi + fi + + SYSTEM_MILL_VERSION=$(${SYSTEM_MILL_PATH} --version | head -n1 | sed -n 's/^Mill.*version \(.*\)/\1/p') + + cat < "${SYSTEM_MILL_INFO_FILE}" +CACHED_SYSTEM_MILL_PATH="${SYSTEM_MILL_PATH}" +CACHED_SYSTEM_MILL_VERSION="${SYSTEM_MILL_VERSION}" +CACHED_SYSTEM_MILL_SIZE="${SYSTEM_MILL_SIZE}" +CACHED_SYSTEM_MILL_MTIME="${SYSTEM_MILL_MTIME}" +EOF + + if [ "${SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then + MILL="${SYSTEM_MILL_PATH}" + fi +} +try_to_use_system_mill + +# If not already downloaded, download it +if [ ! -s "${MILL}" ] ; then + + # support old non-XDG download dir + MILL_OLD_DOWNLOAD_PATH="${HOME}/.mill/download" + OLD_MILL="${MILL_OLD_DOWNLOAD_PATH}/${MILL_VERSION}" + if [ -x "${OLD_MILL}" ] ; then + MILL="${OLD_MILL}" + else + case $MILL_VERSION in + 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.* ) + DOWNLOAD_SUFFIX="" + DOWNLOAD_FROM_MAVEN=0 + ;; + 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M* ) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=0 + ;; + *) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=1 + ;; + esac + + DOWNLOAD_FILE=$(mktemp mill.XXXXXX) + + if [ "$DOWNLOAD_FROM_MAVEN" = "1" ] ; then + DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist/${MILL_VERSION}/mill-dist-${MILL_VERSION}.jar" + else + MILL_VERSION_TAG=$(echo "$MILL_VERSION" | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') + DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}" + unset MILL_VERSION_TAG + fi + + # TODO: handle command not found + echo "Downloading mill ${MILL_VERSION} from ${DOWNLOAD_URL} ..." 1>&2 + ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" "${DOWNLOAD_URL}" + chmod +x "${DOWNLOAD_FILE}" + mkdir -p "${MILL_DOWNLOAD_PATH}" + mv "${DOWNLOAD_FILE}" "${MILL}" + + unset DOWNLOAD_FILE + unset DOWNLOAD_SUFFIX + fi +fi + +if [ -z "$MILL_MAIN_CLI" ] ; then + MILL_MAIN_CLI="${0}" +fi + +MILL_FIRST_ARG="" +if [ "$1" = "--bsp" ] || [ "$1" = "-i" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then + # Need to preserve the first position of those listed options + MILL_FIRST_ARG=$1 + shift +fi + +unset MILL_DOWNLOAD_PATH +unset MILL_OLD_DOWNLOAD_PATH +unset OLD_MILL +unset MILL_VERSION +unset MILL_REPO_URL + +# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes +# shellcheck disable=SC2086 +exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" \ No newline at end of file diff --git a/scautable/js/src/jsSpecific.scala b/scautable/js/src/jsSpecific.scala index c95dbf6..92fdd70 100644 --- a/scautable/js/src/jsSpecific.scala +++ b/scautable/js/src/jsSpecific.scala @@ -2,5 +2,9 @@ package scautable trait PlatformSpecific { + /** + * With scaladoc + */ + def shouldTotallyAppearInDocs(): Unit = () } \ No newline at end of file diff --git a/scautable/src/scautable.scala b/scautable/src/package.scala similarity index 91% rename from scautable/src/scautable.scala rename to scautable/src/package.scala index bf99f05..a36fbf6 100644 --- a/scautable/src/scautable.scala +++ b/scautable/src/package.scala @@ -1,5 +1,4 @@ package scautable - import scalatags.Text.all.* import scala.deriving.Mirror import scala.compiletime.erasedValue @@ -8,6 +7,9 @@ import scala.compiletime.summonInline import java.time.LocalDate import scalatags.Text.TypedTag +/** + * This is a simple library to render a scala case class as an html table. It assumes the presence of a HtmlTableRender instance for each type in the case class. + */ object scautable extends PlatformSpecific { // Aggressively copy-pasta-d from here; https://blog.philipp-martini.de/blog/magic-mirror-scala3/ @@ -30,7 +32,7 @@ object scautable extends PlatformSpecific { getTypeclassInstances[m.MirroredElemTypes] // this traits can just be copy/pasted or reside in a library - trait EasyDerive[TC[_]] { + private trait EasyDerive[TC[_]] { final def apply[A](using tc: TC[A]): TC[A] = tc case class CaseClassElement[A, B]( @@ -77,7 +79,7 @@ object scautable extends PlatformSpecific { val elemInstances = getInstances[m.MirroredElemTypes] val elemLabels = getElemLabels[m.MirroredElemLabels] - inline m match { + inline m match { case p: Mirror.ProductOf[A] => val caseClassElements = elemInstances @@ -108,6 +110,9 @@ object scautable extends PlatformSpecific { } } + /** + * Implement this trait for any type you want to render as part of an html table. See the concrete examples below. + */ trait HtmlTableRender[A] { def tableRow(a: A): scalatags.Text.TypedTag[String] = ??? def tableCell(a: A): scalatags.Text.TypedTag[String] = ??? @@ -132,7 +137,7 @@ object scautable extends PlatformSpecific { // case q: Seq[Product] => // scautable(q)(using this) - } + } override def tableRow(a: A): scalatags.Text.TypedTag[String] = { // println("table row in pretty string") if (productType.elements.isEmpty) tr("empty") @@ -171,25 +176,25 @@ object scautable extends PlatformSpecific { } given optT[A](using inner : HtmlTableRender[A]) : HtmlTableRender[Option[A]]= new HtmlTableRender[Option[A]] { - override def tableCell(a: Option[A]) = - a match + override def tableCell(a: Option[A]) = + a match case None => td("") case Some(aContent) => inner.tableCell(aContent) } given seqT[A](using inner : HtmlTableRender[A]) : HtmlTableRender[Seq[A]]= new HtmlTableRender[Seq[A]] { - override def tableCell(a: Seq[A]) = + override def tableCell(a: Seq[A]) = if (a.isEmpty) td() else a.head match { - case p: Product => + case p: Product => val i = summon[HtmlTableRender[A]] val h = p.productElementNames.toList val header = tr(h.map(th(_))) val rows = a.map(in => i.tableRow(in)) td(table(thead(header), tbody(rows))) - case _ => + case _ => val cells = a.map(in => tr(inner.tableCell(in))) td(table(tbody(cells))) } @@ -243,15 +248,22 @@ object scautable extends PlatformSpecific { } } + /** + * Render a sequence of unknown type as an html table + * + * @param a - A sequence of unknown type you wish to render as an html table + * @param addHeader - If true, add a header row to the table + * @param tableDeriveInstance - An instance of HtmlTableRender for the type `A` + */ def apply[A <: Product](a: Seq[A], addHeader:Boolean = true)(using tableDeriveInstance: HtmlTableRender[A]): TypedTag[String] = val h = a.head.productElementNames.toList val header = tr(h.map(th(_))) val rows = for (r <- a) yield { tableDeriveInstance.tableRow(r) } if(addHeader) { table(thead(header), tbody(rows), id := "scautable", cls := "display") - } else + } else table(thead(header), tbody(rows)) - + def apply[A <: Product](a: A, addHeader:Boolean)(using tableDeriveInstance: HtmlTableRender[A]): TypedTag[String] = apply(Seq(a), addHeader) diff --git a/site/docs/_docs/GettingStarted.md b/site/docs/_docs/GettingStarted.md deleted file mode 100644 index 455ab2f..0000000 --- a/site/docs/_docs/GettingStarted.md +++ /dev/null @@ -1,3 +0,0 @@ -# Hi - -hi \ No newline at end of file diff --git a/site/docs/_docs/GettingStarted.mdoc.md b/site/docs/_docs/GettingStarted.mdoc.md new file mode 100644 index 0000000..b26c2ec --- /dev/null +++ b/site/docs/_docs/GettingStarted.mdoc.md @@ -0,0 +1,36 @@ +--- +title: Getting Started +--- + +# Elevator Pitch + +```scala mdoc +import scautable.{*, given} + +case class ScauTest(anInt: Int, aString: String) +scautable( + Seq(ScauTest(1, "one"), ScauTest(2, "two")), + true +).toString() +``` + +On the JVM in particular, the ability to pop it open in the browser, see and search the actual data... can be useful. Particularly if you're working with a lot of mesy, csv data for example. + +```scala +import scautable.{*, given} + +case class ScauTest(anInt: Int, aString: String) +val soComplex = Seq(ScauTest(1, "one"), ScauTest(2, "two")) + +scautable.desktopShow(soComplex) +``` +Will pop open a browser... using https://datatables.net +![desktop](../_assets/desktop.png) + +And your case classes are now easily visible and searchable. + +# Scala JS + +I love scala JS, so it cross compiles, and gives you back a scalatags table. This is of questionable usefulness, but it's fun. + +TODO : Laminar integration, which nobody wants but would be cool. \ No newline at end of file