Message-ID: From: "vlsi (@vlsi)" To: "pgjdbc/pgjdbc" Date: Sat, 23 May 2026 08:22:34 +0000 Subject: [pgjdbc/pgjdbc] PR #4088: docs: structure-only rework (file moves, Hugo skeleton, release pipeline) List-Id: X-GitHub-Additions: 5248 X-GitHub-Author-Id: 213894 X-GitHub-Author-Login: vlsi X-GitHub-Base: master X-GitHub-Changed-Files: 115 X-GitHub-Commits: 9 X-GitHub-Deletions: 2022 X-GitHub-Head-Branch: pr-4075-restructure X-GitHub-Head-SHA: d0a171e9e70298ae3dd41caa94c5f31a5f8025ce X-GitHub-Issue: 4088 X-GitHub-Labels: documentation X-GitHub-Repo: pgjdbc/pgjdbc X-GitHub-State: open X-GitHub-Type: pull_request X-GitHub-Url: https://github.com/pgjdbc/pgjdbc/pull/4088 Content-Type: text/plain; charset=utf-8 Documentation rework, structure only: file moves, Hugo template skeleton, lunr-based site search, front-matter-driven release pipeline, and CI wiring. Prose is byte-identical to master in every page that this PR touches. ## What and why The documentation grew around a flat, historically-shaped set of files. Important topics like connection setup, SSL/TLS, prepared statements, and PostgreSQL extensions were either buried in oversized pages or hard to discover from the navigation. The release-presentation side had a parallel problem: home page, download page, and per-version cards were each driven by their own hand-maintained config files (`versions.toml`, `homepagedata.toml`), so cutting a release meant editing several files in lockstep. This PR sets up the new shape of the site so the prose work that follows has somewhere to land: * reorganises `docs/content/documentation/` into task-oriented sections (`connect`, `data-types`, `getting-started`, `postgresql-features`, `query`, `runtime`, `security`); * slices the four largest legacy pages (`use.md`, `setup.md`, `server-prepare.md`, `query.md`) at their H2 boundaries into smaller topical pages, prose verbatim; * installs the Hugo template skeleton (layouts, partials, shortcodes), the SCSS bundle, and the lunr site search with its camelCase / snake_case tokeniser; * replaces `versions.toml` / `homepagedata.toml` with a release pipeline that reads `/changelogs/*-release.md` front-matter directly, plus a `release-history-overlay.yaml` for the facts that cannot be derived from git; * wires `:docs-tools:buildDocs` and `:docs-tools:serveDocs` into a Gradle module and adds the GitHub Pages deploy workflow. ## Reviewing this PR There is no prose to read in this PR. The check is mechanical. 1. Build locally: ``` ./gradlew --quiet :docs-tools:serveDocs ``` Open and follow links from the home page through the sidebar. Every page renders, every legacy URL under `/documentation//` resolves via meta-refresh to its new home, and fragment-preserving redirects work too: `/documentation/use/#unix-sockets` lands on `/documentation/connect/unix-sockets/`. 2. `lintDocsLinks` runs as part of `buildDocs` and fails the build on root-relative `href="/..."` patterns that would skip Hugo's BasePath. The Pages workflow runs the same check on every PR. 3. Pick any row from the provenance map below and run the matching diff recipe. Empty output means the new page is byte-identical to the cited master range.
Provenance map: where every page comes from (and how to verify byte-equality) Every new or renamed page in this PR has a known master source. Type `move` is a whole-file rename: the body is byte-identical to the master file; only the front-matter's `aliases:` field is amended to keep the legacy URL alive. Type `slice` extracts a contiguous range of master lines into a new file with title-only front-matter; the one master line dropped per slice is the H2 whose text becomes the new page's `title:`. Where the master lines column lists two ranges, the first is the master file's intro paragraphs (everything between the front-matter and the first H2), preserved as a preface to the topical body. ### Whole-file renames (8) | New page | Master source | Type | |---|---|---| | `documentation/connect/datasource.md` | `documentation/datasource.md` | move | | `documentation/data-types/binary-bytea.md` | `documentation/binary-data.md` | move | | `documentation/query/stored-procedures.md` | `documentation/callproc.md` | move | | `documentation/query/jdbc-escapes.md` | `documentation/escapes.md` | move | | `documentation/runtime/logging.md` | `documentation/logging.md` | move | | `documentation/runtime/reading.md` | `documentation/reading.md` | move | | `documentation/runtime/thread.md` | `documentation/thread.md` | move | | `documentation/security/ssl-tls.md` | `documentation/ssl.md` | move | ### Slices from `documentation/use.md` (516 lines on master) | New page | Master lines | |---|---| | `documentation/getting-started/importing-jdbc.md` | 11–12, 14–24 | | `documentation/getting-started/loading-the-driver.md` | 26–34 | | `documentation/connect/url-syntax.md` | 36–475 | | `documentation/connect/unix-sockets.md` | 477–496 | | `documentation/connect/failover.md` | 498–516 | ### Slices from `documentation/setup.md` (70 lines on master) | New page | Master lines | |---|---| | `documentation/getting-started/getting-the-driver.md` | 11–12, 14–45 | | `documentation/getting-started/setting-up-the-class-path.md` | 47–60 | | `documentation/getting-started/server-prep.md` | 62–70 | The `server-prep.md` slice spans both the H2 `## Preparing the Database Server for JDBC` (consumed by `title:`) and the in-body H2 `## Creating a Database` that follows; the second H2 stays in the rendered body as a section heading. ### Slices from `documentation/query.md` (223 lines on master) | New page | Master lines | |---|---| | `documentation/query/fetch-size.md` | 44–97 | | `documentation/query/basics.md` | 11–42, 99–175 | | `documentation/data-types/date-time.md` | 177–223 | The `query/basics.md` slice includes the master file's intro paragraphs and the "Example 5.1" code block (lines 11–42), then continues with the body of `## Using the Statement or PreparedStatement Interface` (lines 99–175). The in-body H2s `## Using the ResultSet Interface`, `## Performing Updates`, and `## Creating and Modifying Database Objects` are preserved as section headings inside the page. ### Slices from `documentation/server-prepare.md` (1020 lines on master) | New page | Master lines | |---|---| | `documentation/postgresql-features/extensions-api.md` | 8–10, 12–21 | | `documentation/data-types/infinity.md` | 23–43 | | `documentation/data-types/geometric.md` | 45–94 | | `documentation/data-types/large-objects.md` | 96–106 | | `documentation/postgresql-features/listen-notify.md` | 108–206 | | `documentation/query/prepared-statements.md` | 208–526 | | `documentation/postgresql-features/parameter-status.md` | 528–569 | | `documentation/postgresql-features/replication.md` | 571–938 | | `documentation/data-types/arrays.md` | 940–962 | | `documentation/postgresql-features/copy.md` | 964–1020 | The `parameter-status.md` slice spans the four consecutive master H2s `## Parameter Status Messages`, `## Methods`, `## Example`, `## Other client drivers` (the first becomes `title:`, the rest stay as in-body H2 sub-sections). The `replication.md` slice does the same for `## Physical and Logical replication API` plus its three follow-on H2s (`## Configure database`, `## Logical replication`, `## Physical replication`). ### How to verify any row The new files follow Hugo's convention of one blank line between the closing `---` of the front-matter and the body. The recipes below pipe the new file through `awk` (strip the front-matter) and `tail -n +2` (drop that conventional blank); the resulting body is byte-identical to the master source named in the table. `move` row (whole-file rename; awk both sides, no `tail` needed since master files follow the same convention): ``` diff \ <(git show master:docs/content/documentation/ssl.md | awk '/^---$/{c++;if(c==2){p=1;next}} p') \ <(awk '/^---$/{c++;if(c==2){p=1;next}} p' docs/content/documentation/security/ssl-tls.md) ``` `slice` row, single master range (`sed -n 'A,Bp'` on master, awk+tail on new): ``` diff \ <(git show master:docs/content/documentation/server-prepare.md | sed -n '96,106p') \ <(awk '/^---$/{c++;if(c==2){p=1;next}} p' docs/content/documentation/data-types/large-objects.md | tail -n +2) ``` `slice` row, two master ranges (chain ranges in `sed` with `;`): ``` diff \ <(git show master:docs/content/documentation/query.md | sed -n '11,42p;99,175p') \ <(awk '/^---$/{c++;if(c==2){p=1;next}} p' docs/content/documentation/query/basics.md | tail -n +2) ``` Empty output means the new page is byte-identical to the cited master range. Any non-empty output is a drift the reviewer can flag. For a holistic visual sweep, `git diff master..HEAD -- docs/content/ --color-moved=blocks --color-moved-ws=allow-indentation-change | less -R` colours every line as "moved" (a block deleted in one file and added in another) versus "modified". Every line of every slice should render as moved.
## Build the site locally ``` ./gradlew --quiet :docs-tools:serveDocs ``` Then open . The dev server hot-reloads on every change under `docs/`. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 42f8c114c7..075f5c978d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,20 +20,72 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - + with: + # :docs-tools:generateReleaseHistory enumerates REL42.* tags and + # release/42.*.x branches via JGit to build the compatibility + # matrix. A shallow checkout would surface only `master` and + # produce an empty table on the published site. + fetch-depth: 0 + fetch-tags: true + + # Hugo (extended) goes on PATH; the :docs-tools:buildDocs Gradle task + # invokes it via Exec after the release-history generator has produced + # the data files. Keeping the dedicated Hugo install step (instead of + # e.g. `brew install hugo`) lets us pin a tested release without + # relying on whatever the runner happens to ship. - name: Setup Hugo uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1 with: hugo-version: 'latest' extended: true - + + # JDK is required for the docs build: :docs-tools:generateReleaseHistory + # walks the REL42.* tags via JGit and emits docs/data/release-history.yaml + # before Hugo runs. Same JDK as the main pipeline. + - name: 'Set up JDK 21' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: zulu + java-version: 21 + + # enablement: true lets a fresh fork run the workflow without first + # toggling Settings → Pages by hand; the action calls the Pages REST + # API (requires the `pages: write` permission declared above) and + # creates the site in "build from GitHub Actions" mode if missing. + # The `id: pages` is consumed below to forward the auto-detected + # base URL into Hugo. - name: Setup Pages + id: pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - + with: + enablement: true + + # :docs-tools:buildDocs = :docs-tools:generateReleaseHistory then + # `hugo --gc --minify` in docs/. See docs-tools/build.gradle.kts. + # + # HUGO_BASEURL is read by :docs-tools:buildDocs and forwarded to + # hugo as `--baseURL` (CLI flag — config.toml's baseURL is the + # fallback for forks that don't override it). Order of precedence: + # 1. repo variable WEBSITE_BASE_URL (lets pgjdbc/pgjdbc keep + # pinning the canonical https://jdbc.postgresql.org/ URL + # without touching code); + # 2. otherwise the URL that GitHub considers "home" for this + # repo, taken from actions/configure-pages — handles project + # sites, user/org sites, and custom-domain (CNAME) cases. + # If configure-pages reports `http://` (happens on freshly + # enabled sites whose HTTPS cert isn't ready), turn on + # Settings → Pages → Enforce HTTPS in the repo; we don't want to + # paper over a security setting from CI. - name: Build - run: hugo --minify - working-directory: ./docs - + uses: burrunan/gradle-cache-action@663fbad34e03c8f12b27f4999ac46e3d90f87eca # v3.0.1 + env: + HUGO_BASEURL: ${{ vars.WEBSITE_BASE_URL || steps.pages.outputs.base_url }} + S3_BUILD_CACHE_ACCESS_KEY_ID: ${{ secrets.S3_BUILD_CACHE_ACCESS_KEY_ID }} + S3_BUILD_CACHE_SECRET_KEY: ${{ secrets.S3_BUILD_CACHE_SECRET_KEY }} + with: + job-id: docs-build + arguments: --no-parallel --no-daemon --scan :docs-tools:buildDocs + - name: Upload artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: diff --git a/docs-tools/build.gradle.kts b/docs-tools/build.gradle.kts new file mode 100644 index 0000000000..3a90f6b1f2 --- /dev/null +++ b/docs-tools/build.gradle.kts @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("build-logic.java-library") + id("build-logic.test-junit5") + id("org.jetbrains.kotlin.jvm") +} + +// docs-tools is a build-time-only utility module. It is NOT shipped with +// the driver jar; nothing here is on the runtime classpath of pgjdbc. + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib") + + // GenerateReleaseHistory walks git refs (release/*.x branches and + // REL42.* tags). JGit 7.x has first-class git-worktree support + // (commondir indirection) and a typed API — preferable to shelling + // out to `git`. JGit 7.x bytecode targets Java 17; pgjdbc builds on + // JDK 21, so the runtime is fine. + implementation("org.eclipse.jgit:org.eclipse.jgit:7.5.0.202512021534-r") + + // SLF4J binding for JGit. Without one, SLF4J 2.x prints a per-JVM + // "No SLF4J providers were found" warning and silently routes JGit's + // diagnostics to NOP. slf4j-simple writes to stderr with no config + // file; tune via -Dorg.slf4j.simpleLogger.defaultLogLevel=warn. + runtimeOnly("org.slf4j:slf4j-simple:2.0.17") + + // snakeyaml drives the release-history YAML emitter and parses the + // hand-maintained release-history-overlay.yaml. + implementation("org.yaml:snakeyaml:2.2") +} + +// pgjdbc targets Java 8 bytecode via --release 8; pin Kotlin to the same +// jvmTarget so the two compilers agree (Gradle aborts on a mismatch). +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + freeCompilerArgs.add("-Xjvm-default=all") + } +} + +// docs-tools runs at build time on the project's buildJdk (Java 17+ in +// practice — JGit 7.x already requires Java 17 at runtime). Its tests +// freely use post-Java-8 APIs (e.g. ByteArrayOutputStream#toString(Charset), +// since Java 10), so they cannot run on a Java 8 test toolchain even though +// the produced bytecode is JVM 1.8. +tasks.test { + onlyIf("docs-tools tests use post-Java-8 APIs (e.g. ByteArrayOutputStream#toString(Charset))") { + buildParameters.testJdkVersion > 8 + } +} + +// ===== generateReleaseHistory ============================================ +// +// Scans the local clone's `release/.x` branches and `REL` tags, +// merges with docs/data/release-history-overlay.yaml, emits +// docs/data/release-history.yaml. Consumed by the `release-history` +// Hugo shortcode on the compatibility page. +// +// For CI to produce a complete table the checkout must include all +// `REL42.*` tags and the `release/42.*.x` branches (full-history clone +// or an explicit `git fetch --tags`). +// We pass the project root (where `.git` lives, as either a directory in +// a regular clone or a pointer file in a git worktree); JGit's findGitDir() +// walks up from there and follows the pointer in the worktree case. +val projectRoot = isolated.rootProject.projectDirectory.asFile +val releaseHistoryOverlay = + isolated.rootProject.projectDirectory.dir("docs/data") + .file("release-history-overlay.yaml") +val releaseHistoryYaml = + isolated.rootProject.projectDirectory.dir("docs/data") + .file("release-history.yaml") + +val generateReleaseHistory by tasks.registering(JavaExec::class) { + group = "documentation" + description = "Generate docs/data/release-history.yaml from git refs " + + "(release/* branches, REL* tags) + release-history-overlay.yaml." + + mainClass.set("org.postgresql.tools.docs.GenerateReleaseHistory") + classpath = sourceSets.main.get().runtimeClasspath + dependsOn(tasks.named("classes")) + dependsOn(tasks.named("processJandexIndex")) + // Karaf's :postgresql:generateKar declares the :postgresql jar as + // one of its outputs. In a full-graph CI build (`gradle jandex test + // jacocoReport`) generateKar runs and writes pgjdbc/build/libs/ + // postgresql-.jar, the same path :postgresql:jar produces. + // Gradle 9's strict overlap check then refuses to schedule any + // task that even touches that directory on its classpath without + // an explicit ordering. We do not actually consume the jar here, + // so mustRunAfter (not dependsOn) suffices: if generateKar is in + // the task graph it runs first, and if it is not (e.g. during + // local `serveDocs`) the constraint is a no-op. + mustRunAfter(":postgresql:generateKar") + + argumentProviders.add(CommandLineArgumentProvider { + listOf( + projectRoot.absolutePath, + releaseHistoryOverlay.asFile.absolutePath, + releaseHistoryYaml.asFile.absolutePath + ) + }) + + // The git directory's content drives the output, but Gradle cannot + // track .git efficiently — declare the overlay as the only file input + // and mark the task non-cacheable on the git side via outputs.upToDateWhen. + inputs.file(releaseHistoryOverlay).withPropertyName("overlay") + outputs.file(releaseHistoryYaml).withPropertyName("releaseHistoryYaml") + outputs.upToDateWhen { false } + + standardOutput = System.out + errorOutput = System.err +} + +// ----- Hugo wrappers ------------------------------------------------------- +// +// buildDocs — production build: regenerate release-history.yaml, then +// run a one-shot Hugo build. +// +// serveDocs — local dev server: regenerate release-history.yaml, then +// start the Hugo dev server with hot-reload. +// +// Both require a recent extended Hugo on PATH. The doFirst hook fails +// with a readable error if Hugo is missing, too old, or not the +// extended build. + +val docsDir = isolated.rootProject.projectDirectory.dir("docs").asFile + +// The templates track Hugo's current API rather than its deprecated +// surface. site.Language.Locale arrived in 0.158.0 (the release that +// deprecated the older .LanguageCode) and hugo.Data in 0.156.0, so a +// fixed floor is unavoidable. We pin above both and refuse to fall +// back to deprecated APIs: contributors upgrade Hugo instead. Raise +// this when the templates adopt a newer API; do not lower it to +// accommodate an old local install. +val minHugoVersion = "0.161.0" + +fun Exec.requireHugo() { + doFirst { + val versionLine = try { + val process = ProcessBuilder("hugo", "version") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + process.waitFor() + if (process.exitValue() != 0) { + throw GradleException( + "`hugo version` returned exit code ${process.exitValue()}:\n$output" + ) + } + output.trim() + } catch (e: java.io.IOException) { + throw GradleException( + "hugo binary is not on PATH. Install the extended Hugo " + + "$minHugoVersion or newer: https://gohugo.io/installation/. " + + "Underlying error: ${e.message}" + ) + } + + // `hugo version` prints e.g. + // hugo v0.161.1+extended+withdeploy darwin/arm64 BuildDate=... + val match = Regex("""v(\d+)\.(\d+)\.(\d+)""").find(versionLine) + ?: throw GradleException("Could not parse a Hugo version from: $versionLine") + val found = match.destructured.toList().map(String::toInt) + val required = minHugoVersion.split(".").map(String::toInt) + val tooOld = found.zip(required) + .firstOrNull { (f, r) -> f != r } + ?.let { (f, r) -> f < r } + ?: false + if (tooOld) { + throw GradleException( + "This docs build needs the extended Hugo $minHugoVersion or " + + "newer, but found ${match.value.removePrefix("v")}. The " + + "templates use site.Language.Locale (Hugo 0.158.0+) and " + + "hugo.Data (0.156.0+); we track the current API rather than " + + "Hugo's deprecated surface. Upgrade Hugo: " + + "https://gohugo.io/installation/." + ) + } + + // SCSS under docs/assets/sass/ is transpiled by Hugo Pipes, which + // only the extended build ships. A standard build fails mid-render + // with a less obvious message, so reject it up front. + if (!versionLine.contains("+extended")) { + throw GradleException( + "This docs build needs the *extended* Hugo build (SCSS " + + "support), but found a standard build: $versionLine. " + + "Reinstall the extended edition: " + + "https://gohugo.io/installation/." + ) + } + } +} + +val buildDocs by tasks.registering(Exec::class) { + group = "documentation" + description = "Regenerate release-history.yaml, then run a production " + + "Hugo build into docs/public." + dependsOn(generateReleaseHistory) + workingDir = docsDir + + // -PhugoBaseURL=… (or env HUGO_BASEURL) → `hugo --baseURL `. + // The CLI flag wins over both config.toml and Hugo's env-override + // machinery, which has been unreliable for top-level keys since the + // 0.123 config rewrite. CI uses this to point a fork's deploy at + // .github.io// without editing config.toml; locally it + // can be set with `./gradlew :docs-tools:buildDocs -PhugoBaseURL=…`. + val hugoBaseUrl = providers.gradleProperty("hugoBaseURL") + .orElse(providers.environmentVariable("HUGO_BASEURL")) + argumentProviders.add(CommandLineArgumentProvider { + buildList { + add("--gc") + add("--minify") + if (hugoBaseUrl.isPresent) { + add("--baseURL") + add(hugoBaseUrl.get()) + } + } + }) + commandLine("hugo") + requireHugo() +} + +val serveDocs by tasks.registering(Exec::class) { + group = "documentation" + description = "Start the Hugo dev server with hot-reload " + + "(release-history.yaml regenerated from git refs)." + dependsOn(generateReleaseHistory) + workingDir = docsDir + + // -PdocsPort=NNNN overrides the default 1313. + val port = providers.gradleProperty("docsPort").orElse("1313") + argumentProviders.add(CommandLineArgumentProvider { + listOf( + "server", "--bind", "127.0.0.1", "--port", port.get(), + "--disableFastRender" + ) + }) + commandLine("hugo") + requireHugo() +} + +// Lints source files for site-internal links that bypass Hugo's +// BasePath. Three categories of mistake, all of which produce HTML +// that breaks on a project-page deploy (https://.github.io//) +// but happens to "work" on https://jdbc.postgresql.org/ because the +// BasePath is empty there: +// +// 1. Raw `href="/foo"` / `src="/foo"` in templates, not wrapped in +// `| relURL`. Hugo emits the path verbatim, missing `//`. +// +// 2. `{{ "/foo" | relURL }}` with a leading slash. Hugo's relURL +// deliberately treats `/`-prefixed inputs as "you already know +// the absolute path" and does NOT prepend BasePath — so the +// result is identical to (1). +// +// 3. `url = "/foo"` in TOML / `url: "/foo"` in YAML data. The +// consumer template pipes the value through `| relURL`, but +// because the data still starts with `/`, the relURL is a no-op +// (same trap as (2)) — and the data file is then a quiet failure +// mode that's invisible from the template. +// +// Markdown content (`docs/content/**/*.md`) is intentionally NOT +// linted: root-relative markdown links like `[text](/foo)` are the +// idiomatic source-of-truth shape, and the `_default/_markup/render-link.html` +// hook re-runs them through relURL at render time. We're betting that +// no contributor will hand-write `` as raw HTML inside +// a .md file; if that bet breaks, add a fourth check here. +val lintDocsLinks by tasks.registering { + group = "verification" + description = "Lint docs sources for site-internal links that " + + "bypass Hugo's BasePath (root-relative href/src, leading-slash " + + "relURL arguments, `/`-prefixed URLs in data files). Run as a " + + "dependency of :docs-tools:buildDocs." + dependsOn(buildDocs) + + val rootDirAbs = isolated.rootProject.projectDirectory + val layoutsDir = rootDirAbs.dir("docs/layouts").asFile + val dataDir = rootDirAbs.dir("docs/data").asFile + val menusFile = rootDirAbs.file("docs/config/_default/menus.toml").asFile + val repoRoot = rootDirAbs.asFile + + inputs.dir(layoutsDir).withPropertyName("layouts") + .withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(dataDir).withPropertyName("data") + .withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(menusFile).withPropertyName("menus") + .withPathSensitivity(PathSensitivity.RELATIVE) + + val stamp = layout.buildDirectory.file("lint-docs-links.stamp") + outputs.file(stamp) + + doLast { + val problems = mutableListOf() + + // Anchor on the literal `"` that opens the attribute value: if + // the very next char is `/` and the one after isn't `/` (so + // protocol-relative `//host` doesn't match), the value Hugo + // emits will start with `/` verbatim. Crucially this does NOT + // match `href="{{ $repo }}/edit/…"` — the first char inside + // the quotes is `{`, not `/` — so multi-piece templates that + // happen to interpolate a `/`-prefixed tail stay clean. + val rawRootAttr = Regex("""\b(href|src)\s*=\s*"(/[a-zA-Z0-9_\-][^"]*)"""") + // `{{ "/foo" | relURL }}` — leading `/` in the relURL arg + // makes Hugo skip BasePath. + val relURLLeadingSlash = Regex( + """\{\{[^}]*"\s*/[a-zA-Z0-9_\-][^"]*"[^}]*\|\s*[^}]*relURL[^}]*\}\}""" + ) + // Multi-line Hugo comments: `{{- /* … */ -}}`. The single-line + // strip can't cover these; track open/close across lines and + // blank out the spanning region before matching. + val commentOpen = Regex("""\{\{-?\s*/\*""") + val commentClose = Regex("""\*/\s*-?\}\}""") + + // render-link.html itself analyzes `/`-prefixed strings as its + // input, so the linter has to skip it (otherwise it'd flag the + // hook that fixes the very problem we're guarding against). + val renderLinkRelative = "_default/_markup/render-link.html" + + layoutsDir.walkTopDown() + .filter { it.isFile && it.extension == "html" } + .filter { + !it.toRelativeString(layoutsDir).replace(File.separatorChar, '/') + .equals(renderLinkRelative) + } + .forEach { f -> + val rel = f.relativeTo(repoRoot).invariantSeparatorsPath + var inComment = false + f.useLines { lines -> + lines.forEachIndexed { idx, rawLine -> + var line = rawLine + if (inComment) { + val close = commentClose.find(line) + if (close != null) { + line = line.substring(close.range.last + 1) + inComment = false + } else { + return@forEachIndexed + } + } + commentOpen.find(line)?.let { open -> + val tail = line.substring(open.range.first) + val close = commentClose.find(tail) + if (close != null) { + line = line.substring(0, open.range.first) + + tail.substring(close.range.last + 1) + } else { + line = line.substring(0, open.range.first) + inComment = true + } + } + + rawRootAttr.findAll(line).forEach { m -> + val attr = m.groupValues[1] + val path = m.groupValues[2] + problems += "$rel:${idx + 1}: ${m.value} — " + + "root-relative `$attr` bypasses Hugo's BasePath. " + + "Wrap in `{{ \"${path.trimStart('/')}\" | relURL }}`." + } + relURLLeadingSlash.findAll(line).forEach { m -> + problems += "$rel:${idx + 1}: ${m.value} — " + + "leading `/` in relURL arg; strip it so Hugo " + + "prepends BasePath." + } + } + } + } + + // Data files: bare `/foo` in `url = …` (TOML) / `url: …` (YAML). + val tomlRootURL = Regex("""^\s*url\s*=\s*"(/[a-zA-Z0-9_\-][^"]*)"""") + val yamlRootURL = Regex("""^\s*url\s*:\s*"?(/[a-zA-Z0-9_\-][^"\s]*)""") + val dataFiles = sequence { + yield(menusFile) + dataDir.walkTopDown().filter { it.isFile }.forEach { yield(it) } + } + dataFiles.filter { it.exists() }.forEach { f -> + val rel = f.relativeTo(repoRoot).invariantSeparatorsPath + val re = when (f.extension.lowercase()) { + "toml" -> tomlRootURL + "yaml", "yml" -> yamlRootURL + else -> return@forEach + } + f.useLines { lines -> + lines.forEachIndexed { idx, line -> + re.find(line)?.let { m -> + problems += "$rel:${idx + 1}: ${m.value.trim()} — " + + "strip leading `/` so the consumer's `| relURL` " + + "prepends BasePath." + } + } + } + } + + val stampFile = stamp.get().asFile + if (problems.isNotEmpty()) { + stampFile.delete() + throw GradleException(buildString { + appendLine( + "Found ${problems.size} root-relative link(s) that " + + "bypass Hugo's BasePath:" + ) + problems.forEach { appendLine(" $it") } + appendLine() + appendLine( + "Site-internal links must go through `relURL` so they " + + "include the repo prefix on project-page deploys " + + "(e.g. https://.github.io//). Markdown " + + "content is handled by the render-link hook; " + + "templates and data files need the fix at the source." + ) + }) + } + stampFile.parentFile.mkdirs() + stampFile.writeText("OK\n") + } +} + +tasks.check { + dependsOn(lintDocsLinks) +} diff --git a/docs-tools/src/main/kotlin/org/postgresql/tools/docs/GenerateReleaseHistory.kt b/docs-tools/src/main/kotlin/org/postgresql/tools/docs/GenerateReleaseHistory.kt new file mode 100644 index 0000000000..b4a5e0eb82 --- /dev/null +++ b/docs-tools/src/main/kotlin/org/postgresql/tools/docs/GenerateReleaseHistory.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +@file:JvmName("GenerateReleaseHistory") + +package org.postgresql.tools.docs + +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.ObjectId +import org.eclipse.jgit.lib.Ref +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.revwalk.RevWalk +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.DumperOptions.FlowStyle +import org.yaml.snakeyaml.DumperOptions.ScalarStyle +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.nodes.MappingNode +import org.yaml.snakeyaml.nodes.NodeTuple +import org.yaml.snakeyaml.nodes.ScalarNode +import org.yaml.snakeyaml.nodes.SequenceNode +import org.yaml.snakeyaml.nodes.Tag +import java.io.IOException +import java.io.Writer +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.system.exitProcess + +/** + * Generates `docs/data/release-history.yaml` from local git refs + * (release branches + release tags), merged with the hand-maintained + * `docs/data/release-history-overlay.yaml`. + * + * The output is a flat `rows` list with three columns per row: + * - `release_line` (e.g. `"42.7.x"`), + * - `version_range` (`"42.7.0-42.7.11"` for a line's main row, + * `"42.2.29.jre7"` for a classifier row), + * - `released` (ISO date of the latest tag in the row). + * + * Consumers: the `recent-versions` and `past-versions` Hugo shortcodes + * on the `/download/` page. Nothing else reads this file. The Java + * version of a classifier row is derived from the `.jreN` suffix in + * the template, so it is not part of the data. + * + * The overlay carries only what cannot be derived from git: the list + * of `.jre6` / `.jre7` classifier builds (id, branch, last_version). + */ + +/** `refs/heads/release/42.X.x` or `refs/remotes/origin/release/42.X.x`. */ +private val BRANCH_PATTERN = Regex("""^refs/(?:heads|remotes/[^/]+)/release/(42\.\d+\.x)$""") + +/** Release tag. RC tags (REL42.7.11-rc1 etc.) are filtered out. */ +private val TAG_PATTERN = Regex("""^refs/tags/REL(42\.\d+\.\d+)$""") + +fun main(args: Array) { + if (args.size < 3) { + System.err.println("usage: GenerateReleaseHistory " + + " ") + exitProcess(2) + } + val projectRoot = Paths.get(args[0]) + val overlayYaml = Paths.get(args[1]) + val outputYaml = Paths.get(args[2]) + + val facts = collectGitFacts(projectRoot) + val overlay = OverlayLoader.load(overlayYaml) + val rows = buildRows(facts, overlay) + ReleaseHistoryYamlEmitter.writeTo(rows, outputYaml) + println("Wrote ${rows.size} release-history rows to $outputYaml") +} + +/* ===== Model =========================================================== */ + +internal data class TaggedRelease(val version: String, val commitDate: String) + +internal data class GitFacts( + /** Branch ids, sorted descending: ["42.7.x", "42.6.x", …, "42.2.x"]. */ + val branchIds: List, + val tagsByBranch: Map>, +) + +/** One `.jre6` / `.jre7` classifier build. Emitted as an extra row under + * its parent line. The `id` becomes the version-range suffix + * (`.`); the template derives the Java version from + * the suffix (`jre7` -> 7). */ +internal data class Classifier( + val branch: String, + val lastVersion: String, + val id: String, +) + +internal data class Overlay( + val classifiers: List = emptyList(), +) + +internal data class Row( + val releaseLine: String, + val versionRange: String, + val released: String, +) + +/* ===== Git scan ======================================================== */ + +internal fun collectGitFacts(projectRoot: Path): GitFacts = + Git.open(projectRoot.toFile()).use { scanRefs(it.repository) } + +private fun scanRefs(repo: Repository): GitFacts { + val refs = repo.refDatabase.refs + + val branchIds = sortedSetOf(versionDescending()) + for (ref in refs) { + BRANCH_PATTERN.matchEntire(ref.name)?.let { branchIds += it.groupValues[1] } + } + + val tagsByBranch = mutableMapOf>() + RevWalk(repo).use { walk -> + for (ref in refs) { + val match = TAG_PATTERN.matchEntire(ref.name) ?: continue + val version = match.groupValues[1] + val branchId = toBranchId(version) + branchIds += branchId + val peeled = peelTag(repo, ref) ?: continue + val commit = walk.parseCommit(peeled) + tagsByBranch.getOrPut(branchId, ::mutableListOf) + .add(TaggedRelease(version, formatCommitDate(commit.commitTime))) + } + } + tagsByBranch.values.forEach { list -> list.sortBy { versionKey(it.version) } } + + val orderedBranches = branchIds.toList() + val orderedTags = orderedBranches.associateWith { tagsByBranch[it].orEmpty() } + return GitFacts(orderedBranches, orderedTags) +} + +private fun toBranchId(version: String): String { + val second = version.indexOf('.', version.indexOf('.') + 1) + return version.substring(0, second) + ".x" +} + +private fun peelTag(repo: Repository, ref: Ref): ObjectId? { + val peeled = repo.refDatabase.peel(ref) + return peeled.peeledObjectId ?: ref.objectId +} + +private fun formatCommitDate(epochSecond: Int): String = + DateTimeFormatter.ISO_LOCAL_DATE + .withZone(ZoneOffset.UTC) + .format(Instant.ofEpochSecond(epochSecond.toLong())) + +/* ===== Row building =================================================== */ + +/** + * One row per release line spanning its first..last tag, plus one + * extra row per classifier listed in the overlay. Rows within a + * release line are sorted by `released` descending so the latest + * classifier appears next to the line's main row. + */ +internal fun buildRows(facts: GitFacts, overlay: Overlay): List { + val classifiersByBranch = overlay.classifiers.groupBy { it.branch } + val out = mutableListOf() + for (branchId in facts.branchIds) { + val tags = facts.tagsByBranch[branchId].orEmpty() + if (tags.isEmpty()) continue + + val first = tags.first() + val last = tags.last() + val branchRows = mutableListOf() + branchRows += Row( + releaseLine = branchId, + versionRange = formatRange(first.version, last.version), + released = last.commitDate, + ) + + for (c in classifiersByBranch[branchId].orEmpty()) { + val date = tags.firstOrNull { it.version == c.lastVersion }?.commitDate.orEmpty() + branchRows += Row( + releaseLine = branchId, + versionRange = "${c.lastVersion}.${c.id}", + released = date, + ) + } + + // sortByDescending is stable, so a classifier with the same + // date as the line's main row stays after it (main row goes in + // first, classifier second). + branchRows.sortByDescending { it.released } + out += branchRows + } + return out +} + +private fun formatRange(first: String, last: String): String = + if (first == last) last else "$first-$last" + +/* ===== Version helpers ================================================ */ + +internal fun versionKey(version: String): Long { + val parts = version.split('.').take(5) + val padded = parts + List(5 - parts.size) { "0" } + return padded.fold(0L) { acc, raw -> + val part = if (raw == "x") "0" else raw + acc * 1000 + (part.toLongOrNull() ?: 0L) + } +} + +internal fun versionDescending(): Comparator = + compareByDescending(::versionKey) + +/* ===== Overlay loader (snakeyaml) ===================================== */ + +internal object OverlayLoader { + @Suppress("UNCHECKED_CAST") + fun load(file: Path): Overlay { + if (!Files.isRegularFile(file)) { + System.err.println("Overlay not found at $file — proceeding with no overlay data.") + return Overlay() + } + val root: Map = Files.newBufferedReader(file, StandardCharsets.UTF_8).use { r -> + (Yaml().load(r) as? Map) + ?: throw IOException("Overlay root must be a mapping in $file") + } + return Overlay( + classifiers = (root["classifiers"] as? List>).orEmpty().map { + Classifier( + branch = it["branch"].toString(), + lastVersion = it["last_version"].toString(), + id = it["id"].toString(), + ) + }, + ) + } +} + +/* ===== YAML emitter (snakeyaml) ======================================= */ + +internal object ReleaseHistoryYamlEmitter { + fun writeTo(rows: List, output: Path) { + Files.createDirectories(output.parent) + Files.newBufferedWriter(output, StandardCharsets.UTF_8).use { w -> + writeHeader(w) + Yaml(dumperOptions()).serialize(buildRoot(rows), w) + } + } + + private fun dumperOptions() = DumperOptions().apply { + defaultFlowStyle = FlowStyle.BLOCK + indent = 2 + indicatorIndent = 0 + width = 80 + splitLines = true + lineBreak = DumperOptions.LineBreak.UNIX + } + + private fun writeHeader(w: Writer) { + w.write("# Generated by :docs-tools:generateReleaseHistory — DO NOT EDIT.\n") + w.write("# Source of truth: git refs (release/* branches, REL* tags)\n") + w.write("# + docs/data/release-history-overlay.yaml.\n") + w.write("# Run `./gradlew :docs-tools:generateReleaseHistory` to regenerate.\n") + w.write("\n") + } + + private fun buildRoot(rows: List): MappingNode { + val tuple = NodeTuple( + ScalarNode(Tag.STR, "rows", null, null, ScalarStyle.PLAIN), + SequenceNode(Tag.SEQ, true, rows.map(::buildRow), null, null, FlowStyle.BLOCK), + ) + return MappingNode(Tag.MAP, true, listOf(tuple), null, null, FlowStyle.BLOCK) + } + + private fun buildRow(r: Row): MappingNode = MappingNode( + Tag.MAP, + true, + listOf( + field("release_line", r.releaseLine), + field("version_range", r.versionRange), + field("released", r.released), + ), + null, null, FlowStyle.BLOCK, + ) + + private fun field(key: String, value: String): NodeTuple = NodeTuple( + ScalarNode(Tag.STR, key, null, null, ScalarStyle.PLAIN), + // DOUBLE_QUOTED matches the prior hand-rolled emitter which + // always wrapped values in `"..."` so a future contributor knows + // every value is a string regardless of how it parses. + ScalarNode(Tag.STR, value, null, null, ScalarStyle.DOUBLE_QUOTED), + ) +} diff --git a/docs-tools/src/test/kotlin/org/postgresql/tools/docs/GenerateReleaseHistoryTest.kt b/docs-tools/src/test/kotlin/org/postgresql/tools/docs/GenerateReleaseHistoryTest.kt new file mode 100644 index 0000000000..a8cd2bd5e5 --- /dev/null +++ b/docs-tools/src/test/kotlin/org/postgresql/tools/docs/GenerateReleaseHistoryTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.tools.docs + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GenerateReleaseHistoryTest { + + /* ----- versionKey & ordering ----------------------------------------- */ + + @Test fun `versionKey orders multi-segment versions numerically`() { + // 42.2.10 must sort after 42.2.9, not lexicographically before it. + assertTrue(versionKey("42.2.10") > versionKey("42.2.9")) + assertTrue(versionKey("42.7.0") > versionKey("42.6.99")) + assertTrue(versionKey("42.10.0") > versionKey("42.9.99")) + } + + @Test fun `versionKey treats x as zero in branch ids`() { + // Branch ids like "42.7.x" must sort consistently with their tags. + assertEquals(versionKey("42.7.0"), versionKey("42.7.x")) + } + + @Test fun `versionDescending puts the highest version first`() { + val sorted = listOf("42.2.x", "42.7.x", "42.5.x").sortedWith(versionDescending()) + assertEquals(listOf("42.7.x", "42.5.x", "42.2.x"), sorted) + } + + /* ----- buildRows ----------------------------------------------------- */ + + @Test fun `buildRows emits one row per release line in branch order`() { + val facts = GitFacts( + branchIds = listOf("42.7.x", "42.6.x"), + tagsByBranch = mapOf( + "42.7.x" to listOf( + TaggedRelease("42.7.0", "2024-01-01"), + TaggedRelease("42.7.11", "2026-04-28"), + ), + "42.6.x" to listOf( + TaggedRelease("42.6.0", "2023-03-17"), + TaggedRelease("42.6.2", "2024-03-13"), + ), + ), + ) + val rows = buildRows(facts, Overlay()) + assertEquals( + listOf( + Row("42.7.x", "42.7.0-42.7.11", "2026-04-28"), + Row("42.6.x", "42.6.0-42.6.2", "2024-03-13"), + ), + rows, + ) + } + + @Test fun `buildRows collapses a single-tag line to that tag without a range`() { + val facts = GitFacts( + branchIds = listOf("42.8.x"), + tagsByBranch = mapOf("42.8.x" to listOf(TaggedRelease("42.8.0", "2026-06-01"))), + ) + assertEquals( + listOf(Row("42.8.x", "42.8.0", "2026-06-01")), + buildRows(facts, Overlay()), + ) + } + + @Test fun `buildRows skips branches whose tag list is empty`() { + // A branch declared in branchIds without any tags (e.g. a release + // branch cut but never tagged) is silently dropped rather than + // emitting a row with an empty version_range. + val facts = GitFacts( + branchIds = listOf("42.9.x", "42.7.x"), + tagsByBranch = mapOf( + "42.9.x" to emptyList(), + "42.7.x" to listOf(TaggedRelease("42.7.0", "2024-01-01")), + ), + ) + val rows = buildRows(facts, Overlay()) + assertEquals(listOf("42.7.x"), rows.map { it.releaseLine }) + } + + @Test fun `buildRows appends classifier rows under their parent line`() { + val facts = GitFacts( + branchIds = listOf("42.2.x"), + tagsByBranch = mapOf( + "42.2.x" to listOf( + TaggedRelease("42.2.0", "2018-01-17"), + TaggedRelease("42.2.29", "2024-03-13"), + ), + ), + ) + val overlay = Overlay( + classifiers = listOf( + Classifier(branch = "42.2.x", lastVersion = "42.2.29", id = "jre7"), + Classifier(branch = "42.2.x", lastVersion = "42.2.27", id = "jre6"), + ), + ) + val rows = buildRows(facts, overlay) + assertEquals( + listOf("42.2.0-42.2.29", "42.2.29.jre7", "42.2.27.jre6"), + rows.map { it.versionRange }, + ) + } + + @Test fun `buildRows sorts rows within a line by released descending`() { + // Main row and classifier rows on the same line are ordered by + // their `released` date, newest first. + val facts = GitFacts( + branchIds = listOf("42.2.x"), + tagsByBranch = mapOf( + "42.2.x" to listOf( + TaggedRelease("42.2.27", "2023-01-01"), + TaggedRelease("42.2.29", "2024-03-13"), + ), + ), + ) + val overlay = Overlay( + classifiers = listOf( + Classifier(branch = "42.2.x", lastVersion = "42.2.27", id = "jre6"), + ), + ) + val rows = buildRows(facts, overlay) + // Main row's `released` (2024) is newer than the classifier's + // (2023), so the main row stays first. + assertEquals(listOf("2024-03-13", "2023-01-01"), rows.map { it.released }) + } + + @Test fun `classifier without a matching tag gets an empty released date`() { + // Defensive: if a classifier's last_version is not in the tag list + // (typo or stale overlay), the row still emits with an empty date + // rather than crashing the build. + val facts = GitFacts( + branchIds = listOf("42.2.x"), + tagsByBranch = mapOf("42.2.x" to listOf(TaggedRelease("42.2.29", "2024-03-13"))), + ) + val overlay = Overlay( + classifiers = listOf( + Classifier(branch = "42.2.x", lastVersion = "42.2.999", id = "jre7"), + ), + ) + val classifierRow = buildRows(facts, overlay).single { "jre7" in it.versionRange } + assertEquals("", classifierRow.released) + } +} diff --git a/docs-tools/src/test/kotlin/org/postgresql/tools/docs/OverlayLoaderTest.kt b/docs-tools/src/test/kotlin/org/postgresql/tools/docs/OverlayLoaderTest.kt new file mode 100644 index 0000000000..06f3849d7b --- /dev/null +++ b/docs-tools/src/test/kotlin/org/postgresql/tools/docs/OverlayLoaderTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.tools.docs + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.writeText + +class OverlayLoaderTest { + + @TempDir lateinit var tmp: Path + + @Test fun `missing overlay yields the default empty model`() { + val overlay = OverlayLoader.load(tmp.resolve("nope.yaml")) + assertEquals(emptyList(), overlay.classifiers) + } + + @Test fun `classifiers block parses into the Overlay model`() { + val yaml = tmp.resolve("overlay.yaml") + yaml.writeText( + """ + classifiers: + - {id: jre7, branch: 42.2.x, last_version: 42.2.29} + - {id: jre6, branch: 42.2.x, last_version: 42.2.27} + """.trimIndent() + ) + + val overlay = OverlayLoader.load(yaml) + assertEquals( + listOf( + Classifier(branch = "42.2.x", lastVersion = "42.2.29", id = "jre7"), + Classifier(branch = "42.2.x", lastVersion = "42.2.27", id = "jre6"), + ), + overlay.classifiers, + ) + } + + @Test fun `omitted classifiers section falls back to an empty list`() { + val yaml = tmp.resolve("overlay.yaml") + // An overlay containing only an unrelated top-level key should not + // throw — unknown keys are silently ignored, and the model stays + // at defaults. + yaml.writeText("future_field: ignored\n") + + val overlay = OverlayLoader.load(yaml) + assertEquals(emptyList(), overlay.classifiers) + } +} diff --git a/docs/assets/sass/abstracts/_colors.scss b/docs/assets/sass/abstracts/_colors.scss index 9a12cef5cf..1f2f982c9d 100644 --- a/docs/assets/sass/abstracts/_colors.scss +++ b/docs/assets/sass/abstracts/_colors.scss @@ -27,6 +27,40 @@ $colors: ( 200: hsl(0, 0%, 93%), 300: hsl(0, 0%, 88%), 400: hsl(0, 0%, 74%), - 500: hsl(0, 0%, 52%) + 500: hsl(0, 0%, 52%), + 600: hsl(0, 0%, 32%), + 700: hsl(0, 0%, 18%) + ), + + info: ( + 100: hsl(210, 90%, 96%), + 200: hsl(210, 85%, 88%), + 300: hsl(210, 75%, 50%), + 400: hsl(210, 80%, 38%), + 500: hsl(210, 85%, 22%) + ), + + success: ( + 100: hsl(145, 60%, 96%), + 200: hsl(145, 55%, 85%), + 300: hsl(145, 60%, 38%), + 400: hsl(145, 70%, 28%), + 500: hsl(145, 75%, 18%) + ), + + warning: ( + 100: hsl(43, 100%, 95%), + 200: hsl(43, 95%, 80%), + 300: hsl(38, 92%, 50%), + 400: hsl(32, 90%, 40%), + 500: hsl(28, 90%, 25%) + ), + + danger: ( + 100: hsl(0, 80%, 97%), + 200: hsl(0, 70%, 88%), + 300: hsl(0, 70%, 50%), + 400: hsl(0, 75%, 40%), + 500: hsl(0, 80%, 25%) ), ); \ No newline at end of file diff --git a/docs/assets/sass/abstracts/_constants.scss b/docs/assets/sass/abstracts/_constants.scss index 2678821d1f..d124442c66 100644 --- a/docs/assets/sass/abstracts/_constants.scss +++ b/docs/assets/sass/abstracts/_constants.scss @@ -1,10 +1,30 @@ :root { --container-width: 62.5rem; + --prose-width: 46rem; --section-spacing: 3.5em; --container-spacing: 2.5em; --box-spacing: 2em; --primary-spacing: 1em; + + --space-3xs: 0.25rem; + --space-2xs: 0.5rem; + --space-xs: 0.75rem; + --space-s: 1rem; + --space-m: 1.5rem; + --space-l: 2rem; + --space-xl: 3rem; + + --radius-s: 0.25rem; + --radius-m: 0.5rem; + + --border-subtle: 1px solid var(--clr-neutral-300); + + /* Conservative fallback for the height of the sticky top navbar. + The actual measured height is published by static/js/navbar-height.js + and overrides this value on load. Other sticky elements (e.g. + .param-table thead) read --navbar-height to park flush below it. */ + --navbar-height: 5rem; } $user: '\01F464'; diff --git a/docs/assets/sass/base/_base.scss b/docs/assets/sass/base/_base.scss index d8acd6b108..73d50321b1 100644 --- a/docs/assets/sass/base/_base.scss +++ b/docs/assets/sass/base/_base.scss @@ -35,6 +35,7 @@ code { pre code { display: block; padding: 1em; + background-color: transparent; line-height: 2; overflow-x: auto; } @@ -64,4 +65,4 @@ pre { margin-top: 3rem; } } -} \ No newline at end of file +} diff --git a/docs/assets/sass/base/_syntax.scss b/docs/assets/sass/base/_syntax.scss index 2969abb10a..2fcb7d79b5 100644 --- a/docs/assets/sass/base/_syntax.scss +++ b/docs/assets/sass/base/_syntax.scss @@ -1,6 +1,6 @@ /* Background */ .chroma { - background-color: #ffffff; + background-color: transparent; } /* Other */ @@ -429,4 +429,4 @@ /* TextWhitespace */ .chroma .w { color: #bbbbbb -} \ No newline at end of file +} diff --git a/docs/assets/sass/base/_typography.scss b/docs/assets/sass/base/_typography.scss index ee0c4ebe49..3e455c83b4 100644 --- a/docs/assets/sass/base/_typography.scss +++ b/docs/assets/sass/base/_typography.scss @@ -55,7 +55,6 @@ a { &:hover, &:focus { - text-decoration: none; color: var(--clr-primary-300); } } diff --git a/docs/assets/sass/components/_badge.scss b/docs/assets/sass/components/_badge.scss new file mode 100644 index 0000000000..f291f602fd --- /dev/null +++ b/docs/assets/sass/components/_badge.scss @@ -0,0 +1,43 @@ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-3xs); + + padding: 0.1em 0.5em; + border-radius: 999px; + + font-size: var(--fs-300); + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + + background: var(--clr-neutral-200); + color: var(--clr-neutral-700); + + /* Version badges — readable text, never strikethrough. */ + &--since { + background: var(--clr-success-100); + color: var(--clr-success-500); + } + + &--deprecated-in { + background: var(--clr-warning-100); + color: var(--clr-warning-500); + } + + &--hidden-in { + background: var(--clr-danger-100); + color: var(--clr-danger-500); + } + + /* Legacy aliases — used outside the param-table (since.html, deprecated.html). */ + &--deprecated { + background: var(--clr-warning-100); + color: var(--clr-warning-500); + } + + &--removed { + background: var(--clr-danger-100); + color: var(--clr-danger-500); + } +} diff --git a/docs/assets/sass/components/_button.scss b/docs/assets/sass/components/_button.scss index 68346cdbe1..9dee74ae80 100644 --- a/docs/assets/sass/components/_button.scss +++ b/docs/assets/sass/components/_button.scss @@ -1,36 +1,60 @@ +/* The .btn classes apply directly to either a ` {{ end }} {{ end }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/docs/layouts/index.html b/docs/layouts/index.html index 1136f18925..e48d449c16 100644 --- a/docs/layouts/index.html +++ b/docs/layouts/index.html @@ -1,10 +1,9 @@ {{ define "main" }}
{{ partial "home/hero.html" . }} - {{ partial "home/info.html" . }} - {{ partial "home/feature.html" . }} - {{ partial "home/example.html" . }} - {{ partial "home/report.html" . }} - {{ partial "home/support.html" . }} +
+ {{ partial "home/quick-install.html" . }} + {{ partial "home/release.html" . }} +
-{{ end }} \ No newline at end of file +{{ end }} diff --git a/docs/layouts/index.searchindex.json b/docs/layouts/index.searchindex.json index dff377319e..76fcea0d22 100644 --- a/docs/layouts/index.searchindex.json +++ b/docs/layouts/index.searchindex.json @@ -1,10 +1,71 @@ +{{- /* + Search index consumed by static/js/lunr-search.js. + + Two kinds of entries are emitted: + 1. One entry per regular page (title, plain text body, description, categories). + 2. One entry per connection property from data/connection-properties.yaml, + so the search picks up property names directly and deep-links into the + reference table via #prop-. +*/ -}} [ - {{- range $index, $page := .Site.RegularPages -}} - {{- if gt $index 0 -}} , {{- end -}} - {{- $entry := dict "uri" $page.RelPermalink "title" $page.Title -}} - {{- $entry = merge $entry (dict "content" ($page.Plain | htmlUnescape)) -}} - {{- $entry = merge $entry (dict "description" $page.Description) -}} - {{- $entry = merge $entry (dict "categories" $page.Params.categories) -}} - {{- $entry | jsonify -}} +{{- $first := true -}} +{{- range .Site.RegularPages -}} + {{- if not $first -}},{{- end -}}{{- $first = false -}} + {{/* Strip the {{< review >}} shortcode's output before plainifying so that + file paths and line numbers inside review entries (e.g. PGProperty.java) + don't match unrelated search queries and don't leak into result + snippets as "Reviewed YYYY-MM-DD against source: ...". */}} + {{- $body := replaceRE `

[\s\S]*?

\s*` "" .Content -}} + {{- $entry := dict + "uri" .RelPermalink + "title" .Title + "content" ($body | plainify | htmlUnescape) + "description" .Description + "categories" .Params.categories + -}} + {{- $entry | jsonify -}} +{{- end -}} +{{/* Resolve the connection-properties reference page through Hugo so its + URL carries the configured basePath (e.g. /pgjdbc/ on GitHub Pages + forks). RelPermalink is `/documentation/.../` for the production site + and `/pgjdbc/documentation/.../` for forks — and updates automatically + if the page is ever moved. */}} +{{- $propsPage := .Site.GetPage "/documentation/reference/connection-properties" -}} +{{- $propsRef := $propsPage.RelPermalink -}} +{{- range index .Site.Data "connection-properties" -}} + {{- $status := upper (.status | default "STABLE") -}} + {{/* Skip HIDDEN properties: the reference table renders them but they're + deliberately not advertised to users, so they don't belong in search. */}} + {{- if ne $status "HIDDEN" -}} + {{- if not $first -}},{{- end -}}{{- $first = false -}} + {{- $anchor := anchorize .name -}} + {{- $desc := .description | default "" | plainify | htmlUnescape -}} + {{- $tags := .tags | default slice -}} + {{- $versionBits := slice -}} + {{- with .introducedIn -}}{{- $versionBits = $versionBits | append (printf "since %s" .) -}}{{- end -}} + {{- with .deprecatedIn -}}{{- $versionBits = $versionBits | append (printf "deprecated in %s" .) -}}{{- end -}} + {{- $statusPhrase := "" -}} + {{- if and (ne $status "STABLE") (ne $status "") -}} + {{- $statusPhrase = lower $status -}} {{- end -}} -] \ No newline at end of file + {{/* Content packs the property name (so partial matches light up the body + field too), description, tags, version markers, default value and + status into one string. camelCase splitting is done by the JS-side + identifier-aware tokenizer in lunr-search.js, so no Hugo-side + n-gram expansion is needed here. */}} + {{- $contentBits := slice .name $desc -}} + {{- with $tags -}}{{- $contentBits = $contentBits | append (delimit . " ") -}}{{- end -}} + {{- with $versionBits -}}{{- $contentBits = $contentBits | append (delimit . " ") -}}{{- end -}} + {{- with .default -}}{{- $contentBits = $contentBits | append (printf "default %s" .) -}}{{- end -}} + {{- with $statusPhrase -}}{{- $contentBits = $contentBits | append . -}}{{- end -}} + {{- $entry := dict + "uri" (printf "%s#prop-%s" $propsRef $anchor) + "title" .name + "content" (delimit $contentBits " — ") + "description" $desc + "categories" $tags + -}} + {{- $entry | jsonify -}} + {{- end -}} +{{- end -}} +] diff --git a/docs/layouts/partials/docs/article.html b/docs/layouts/partials/docs/article.html index 9a9b7f9855..2242437e4b 100644 --- a/docs/layouts/partials/docs/article.html +++ b/docs/layouts/partials/docs/article.html @@ -1,10 +1,56 @@
+ {{- partial "docs/page-meta.html" . -}}

{{if eq .Title "Documentation"}} - document + document {{end}} {{ .Title }}

{{ .Content }} + + {{/* On section pages, render a card list of child pages + (subsections and regular pages alike, ordered by weight). + Without this, readers landing on /documentation/connect/ + see only the section's short intro paragraph and have to + retreat to the sidebar to continue. */}} + {{- if .IsSection }} + {{- with .Pages.ByWeight }} +
+ {{- end }} + {{- end }} + {{- partial "previous-next-links.html" . -}}
\ No newline at end of file diff --git a/docs/layouts/partials/docs/page-meta.html b/docs/layouts/partials/docs/page-meta.html new file mode 100644 index 0000000000..b16612faf0 --- /dev/null +++ b/docs/layouts/partials/docs/page-meta.html @@ -0,0 +1,56 @@ +{{/* + page-meta: renders a thin band above the article body containing + breadcrumbs, last_reviewed date, and an "Edit this page on GitHub" link. + + Reads frontmatter fields: + last_reviewed string (ISO date), optional + + Reads Site.Params: + docsRepo (string) github repo URL, e.g. "https://github.com/pgjdbc/pgjdbc" + docsBranch (string) branch name on the repo + docsContentRoot (string) path within the repo where the Hugo content/ lives + + Breadcrumbs walk up Section -> Section.Parent -> ... -> home. +*/}} +{{- $home := .Site.Home -}} +{{- $crumbs := slice -}} +{{- $section := .CurrentSection -}} +{{- range seq 8 -}}{{/* bounded loop to climb up the section tree */}} + {{- if and $section (ne $section.RelPermalink "/") -}} + {{- $crumbs = $crumbs | append $section -}} + {{- $section = $section.Parent -}} + {{- end -}} +{{- end -}} +{{- $reversed := slice -}} +{{- range $i, $c := $crumbs -}} + {{- $reversed = $reversed | append (index $crumbs (sub (sub (len $crumbs) 1) $i)) -}} +{{- end -}} +
+
    +
  1. Home
  2. + {{- range $reversed }} +
  3. {{ .Title }}
  4. + {{- end }} + {{- if and .CurrentSection (ne .RelPermalink .CurrentSection.RelPermalink) }} +
  5. {{ .Title }}
  6. + {{- end }} +
+ + {{- with .Params.last_reviewed }} + + Last reviewed + + {{- end }} + + {{- $repo := .Site.Params.docsRepo -}} + {{- $branch := .Site.Params.docsBranch | default "master" -}} + {{- $root := .Site.Params.docsContentRoot | default "docs/content" -}} + {{- with .File }} + {{- if $repo }} + + Edit this page on GitHub + + {{- end }} + {{- end }} +
diff --git a/docs/layouts/partials/docs/sidebar.html b/docs/layouts/partials/docs/sidebar.html index 0dead43485..05c8dcd719 100644 --- a/docs/layouts/partials/docs/sidebar.html +++ b/docs/layouts/partials/docs/sidebar.html @@ -1,13 +1,60 @@ -{{ $currentPage := . }} +{{/* + Two-level documentation sidebar driven by the actual page tree under + /documentation/, not by menus.toml. - diff --git a/docs/layouts/partials/home/example.html b/docs/layouts/partials/home/example.html deleted file mode 100644 index 6f9edf3b3e..0000000000 --- a/docs/layouts/partials/home/example.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Processing a simple query

- {{.Content}} -
\ No newline at end of file diff --git a/docs/layouts/partials/home/feature.html b/docs/layouts/partials/home/feature.html deleted file mode 100644 index fa81d06ad7..0000000000 --- a/docs/layouts/partials/home/feature.html +++ /dev/null @@ -1,8 +0,0 @@ -
- {{ range $.Site.Data.homepagedata.feature }} -
- feature -

{{ .desc }}

-
- {{ end }} -
\ No newline at end of file diff --git a/docs/layouts/partials/home/hero.html b/docs/layouts/partials/home/hero.html index bd5e41d6a7..59f98e60b2 100644 --- a/docs/layouts/partials/home/hero.html +++ b/docs/layouts/partials/home/hero.html @@ -1,21 +1,41 @@
- elephant + -
\ No newline at end of file + diff --git a/docs/layouts/partials/home/info.html b/docs/layouts/partials/home/info.html deleted file mode 100644 index 5cc7e2a66d..0000000000 --- a/docs/layouts/partials/home/info.html +++ /dev/null @@ -1,29 +0,0 @@ -
-
-

- Why pgJDBC? -

-

- The PostgreSQL JDBC Driver allows Java programs to connect to a PostgreSQL database using standard, database - independent Java code. pgJDBC is an open source JDBC driver written in Pure Java (Type 4), and communicates in the - PostgreSQL native network protocol. Because of this, the driver is platform independent; once compiled, the - driver can be used on any system. -

-
-
-

- Latest Releases -

-

- Current release is 42.7.11 This is a security and maintenance release. This fixes a SCRAM PBKDF2 iteration DoS vulnerability (CVE-2026-42198), adds the require_auth connection property, and includes numerous bug fixes. - See the Changelog for details -

-
    - {{ range $.Site.Data.homepagedata.info }} -
  • - {{ .version }} · {{ .date }} · Notes -
  • - {{ end}} -
-
-
diff --git a/docs/layouts/partials/home/quick-install.html b/docs/layouts/partials/home/quick-install.html new file mode 100644 index 0000000000..ec73a4a7b7 --- /dev/null +++ b/docs/layouts/partials/home/quick-install.html @@ -0,0 +1,76 @@ +{{/* + Quick install block — Maven + Gradle examples stacked rather + than tabbed. The previous tab UI hid two of three snippets + behind clicks; this layout shows both Maven and Gradle in one + scan. Only Kotlin DSL is shown for Gradle — the Groovy DSL + differs by `()` vs `''` quoting, which Groovy-DSL users adapt + trivially. + + Both blocks reuse the `code-tabs` markup (with a single tab + acting as the label) so the existing components/_code-tabs.scss + styling and static/js/code-tabs.js copy-button handling pick + this up unchanged. + + The version string is the same "latest release" the release + card shows, read from /changelogs/*-release.md sorted reverse- + chronological. Single source of truth shared with release.html. +*/}} +{{- $version := "" -}} +{{- range (where site.RegularPages "Section" "changelogs").ByDate.Reverse -}} + {{- if and (not $version) (findRE "^[0-9]" (.Params.version | default "")) -}} + {{- $version = .Params.version -}} + {{- end -}} +{{- end -}} +{{- $maven := printf ` + org.postgresql + postgresql + %s +` $version -}} +{{- $gradleKotlin := printf `implementation("org.postgresql:postgresql:%s")` $version -}} + +
+

Add it to your build

+ + {{/* Snippets share a single grid track sized to the widest + block (Maven XML), so the Gradle one-liner stretches to + match instead of leaving a stepped right edge. */}} +
+
+
    + +
+
+ +
+ {{ transform.Highlight $maven "xml" "" }} +
+
+
+ +
+
    + +
+
+ +
+ {{ transform.Highlight $gradleKotlin "kotlin" "" }} +
+
+
+
+
diff --git a/docs/layouts/partials/home/release.html b/docs/layouts/partials/home/release.html new file mode 100644 index 0000000000..76cbf434f2 --- /dev/null +++ b/docs/layouts/partials/home/release.html @@ -0,0 +1,54 @@ +{{/* + Current release card. + + Single source of truth for "the latest release" is the + /changelogs/ section's content tree. Each release file's + front-matter carries `version`, `date`, and `summary`; the + permalink is the URL. Listing in `.ByDate.Reverse` and taking + the first entry whose version starts with a digit (so any + non-release status notices filed in the section in the past + are skipped) gives the same data the homepage used to read from + homepagedata.toml, minus the duplication. + + When a new version ships, only one file changes: the new + /changelogs/--release.md. The homepage + picks it up automatically on the next build. +*/}} +{{- $releases := slice -}} +{{- range (where site.RegularPages "Section" "changelogs").ByDate.Reverse -}} + {{- if findRE "^[0-9]" (.Params.version | default "") -}} + {{- $releases = $releases | append . -}} + {{- end -}} +{{- end -}} +{{- $latest := index $releases 0 -}} +{{- $recent := first 3 (after 1 $releases) -}} + +
+

Latest release

+ +
+
+ {{ $latest.Params.version }} + {{ $latest.Date.Format "2 January 2006" }} +
+ + {{- with $latest.Params.summary }} +

{{ . }}

+ {{- end }} + + + + {{- if $recent }} +

+ Recent: + {{- range $i, $r := $recent }} + {{ if $i }}·{{ end }} + {{ $r.Params.version }} + {{- end }} +

+ {{- end }} +
+
diff --git a/docs/layouts/partials/home/report.html b/docs/layouts/partials/home/report.html deleted file mode 100644 index c315bd0a28..0000000000 --- a/docs/layouts/partials/home/report.html +++ /dev/null @@ -1,23 +0,0 @@ -
-

Report a bug or Contribute ?

-
- - -
-
\ No newline at end of file diff --git a/docs/layouts/partials/home/support.html b/docs/layouts/partials/home/support.html deleted file mode 100644 index 593fca09b2..0000000000 --- a/docs/layouts/partials/home/support.html +++ /dev/null @@ -1,9 +0,0 @@ -
- support -
-

Support Us

-

PostgreSQL is free.
Please support our work by making a - donation. -

-
-
\ No newline at end of file diff --git a/docs/layouts/partials/nav-mobile.html b/docs/layouts/partials/nav-mobile.html index e4dbb86bb2..93bf6f247e 100644 --- a/docs/layouts/partials/nav-mobile.html +++ b/docs/layouts/partials/nav-mobile.html @@ -2,21 +2,32 @@
+ {{/* Mirror the desktop sidebar: section landings + their pages, + driven by the page tree instead of menus.toml. */}} + {{- $docsRoot := .Site.GetPage "/documentation" -}}
    - {{ range .Site.Menus.docs.ByWeight }} + {{- range $docsRoot.RegularPages.ByWeight }}
  • - - {{.Name }} - + {{ .Title }}
  • - {{- end}} + {{- end }} + {{- range $docsRoot.Sections.ByWeight }} +
  • + {{ .Title }} +
  • + {{- range .RegularPages.ByWeight }} +
  • + {{ .Title }} +
  • + {{- end }} + {{- end }}