Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gatsby-config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
require("dotenv").config()
const { loadConfig } = require("./src/common")
const { resolveGitCommit } = require("./src/resolveGitCommit")

const config = loadConfig("./config.yaml", "./config.default.yaml")
const gitCommit = resolveGitCommit() || ""
const repositoryUrl = process.env.GATSBY_RESPOSITORY_URL || ""

module.exports = {
siteMetadata: {
Expand All @@ -16,6 +19,8 @@ module.exports = {
searchableAttributes: config.searchableAttributes,
customDomain: config.customDomain,
failOnValidation: config.failOnValidation,
gitCommit,
repositoryUrl,
},
pathPrefix: `${process.env.BASEURL || ""}`,
plugins: [
Expand Down
16 changes: 16 additions & 0 deletions src/buildInfo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
function formatBuildTime(iso) {
if (!iso) return ""
return new Date(iso).toISOString().slice(0, 16).replace("T", " ") + " UTC"
}

function shortSha(sha) {
if (!sha) return ""
return sha.slice(0, 7)
}

function commitUrl(repositoryUrl, sha) {
if (!repositoryUrl || !sha) return ""
return `${repositoryUrl.replace(/\/$/, "")}/commit/${sha}`
}

module.exports = { formatBuildTime, shortSha, commitUrl }
31 changes: 24 additions & 7 deletions src/components/footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import PropTypes from "prop-types"
import React from "react"

import { getConfigAndConceptSchemes } from "../hooks/configAndConceptSchemes"
import { commitUrl, formatBuildTime, shortSha } from "../buildInfo"

const Footer = () => {
const { config } = getConfigAndConceptSchemes()
const { config, buildTime } = getConfigAndConceptSchemes()
const { repositoryUrl, gitCommit } = config

const formattedTime = formatBuildTime(buildTime)
const sha = shortSha(gitCommit)
const href = commitUrl(repositoryUrl, gitCommit)

const style = css`
background: ${config.colors.skoHubMiddleColor};
Expand Down Expand Up @@ -54,17 +60,28 @@ const Footer = () => {
<footer css={style}>
<div className="footerContent">
<ul>
{process.env.GATSBY_RESPOSITORY_URL && (
{repositoryUrl && (
<li>
<a
href={process.env.GATSBY_RESPOSITORY_URL}
target="_blank"
rel="noopener noreferrer"
>
<a href={repositoryUrl} target="_blank" rel="noopener noreferrer">
Source
</a>
</li>
)}
{formattedTime && (
<li>
Last built: {formattedTime}
{sha && href && (
<>
{" ("}
<a href={href} target="_blank" rel="noopener noreferrer">
{sha}
</a>
{")"}
</>
)}
{sha && !href && ` (${sha})`}
</li>
)}
{links.map((link, idx) => (
<li key={idx} className={idx === 0 ? "push-right" : ""}>
<a
Expand Down
16 changes: 13 additions & 3 deletions src/hooks/configAndConceptSchemes.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,20 @@ import { useStaticQuery, graphql } from "gatsby"
* },
* searchableAttributes: string[],
* customDomain: string,
* failOnValidation: boolean
* failOnValidation: boolean,
* gitCommit: string,
* repositoryUrl: string
* },
* buildTime: string,
* conceptSchemes: Object<string, { languages: string[] }>
* }} An object containing `config` and `conceptSchemes`
* }} An object containing `config`, `buildTime` and `conceptSchemes`
*
*/
export const getConfigAndConceptSchemes = () => {
const { site, allConceptScheme } = useStaticQuery(graphql`
query Colors {
site {
buildTime
siteMetadata {
colors {
skoHubWhite
Expand Down Expand Up @@ -85,6 +89,8 @@ export const getConfigAndConceptSchemes = () => {
searchableAttributes
customDomain
failOnValidation
gitCommit
repositoryUrl
}
}
allConceptScheme {
Expand All @@ -104,5 +110,9 @@ export const getConfigAndConceptSchemes = () => {
[node.id]: { languages: node.fields.languages },
}))
.reduce((prev, curr) => ({ ...prev, ...curr }), {})
return { config: site.siteMetadata, conceptSchemes }
return {
config: site.siteMetadata,
buildTime: site.buildTime,
conceptSchemes,
}
}
16 changes: 16 additions & 0 deletions src/resolveGitCommit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { execSync: defaultExecSync } = require("child_process")

function resolveGitCommit({ execSync = defaultExecSync } = {}) {
if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA
if (process.env.CI_COMMIT_SHA) return process.env.CI_COMMIT_SHA
try {
return execSync("git rev-parse HEAD", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim()
} catch {
return null
}
}

module.exports = { resolveGitCommit }
113 changes: 113 additions & 0 deletions test/buildInfo.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"
import { commitUrl, formatBuildTime, shortSha } from "../src/buildInfo"
import { resolveGitCommit } from "../src/resolveGitCommit"

describe("formatBuildTime", () => {
it("formats an ISO timestamp as 'YYYY-MM-DD HH:mm UTC'", () => {
expect(formatBuildTime("2026-04-30T14:30:42.123Z")).toBe(
"2026-04-30 14:30 UTC"
)
})

it("drops seconds and fractional seconds", () => {
expect(formatBuildTime("2026-01-01T00:00:59.999Z")).toBe(
"2026-01-01 00:00 UTC"
)
})

it("returns empty string for falsy input", () => {
expect(formatBuildTime("")).toBe("")
expect(formatBuildTime(null)).toBe("")
expect(formatBuildTime(undefined)).toBe("")
})
})

describe("shortSha", () => {
it("truncates a full git SHA to 7 chars", () => {
expect(shortSha("a1b2c3d4e5f67890abcdef1234567890abcdef12")).toBe("a1b2c3d")
})

it("returns the input unchanged when shorter than 7 chars", () => {
expect(shortSha("abc")).toBe("abc")
})

it("returns empty string for falsy input", () => {
expect(shortSha("")).toBe("")
expect(shortSha(null)).toBe("")
expect(shortSha(undefined)).toBe("")
})
})

describe("commitUrl", () => {
it("joins repo URL and SHA with /commit/", () => {
expect(
commitUrl("https://github.com/skohub-io/skohub-vocabs", "a1b2c3d")
).toBe("https://github.com/skohub-io/skohub-vocabs/commit/a1b2c3d")
})

it("strips a single trailing slash from the repo URL", () => {
expect(
commitUrl("https://github.com/skohub-io/skohub-vocabs/", "a1b2c3d")
).toBe("https://github.com/skohub-io/skohub-vocabs/commit/a1b2c3d")
})

it("returns empty string when either input is missing", () => {
expect(commitUrl("", "a1b2c3d")).toBe("")
expect(commitUrl("https://example.com/r", "")).toBe("")
expect(commitUrl(null, null)).toBe("")
})
})

describe("resolveGitCommit", () => {
const ORIGINAL_ENV = process.env
let execSync

beforeEach(() => {
process.env = { ...ORIGINAL_ENV }
delete process.env.GITHUB_SHA
delete process.env.CI_COMMIT_SHA
execSync = vi.fn()
})

afterEach(() => {
process.env = ORIGINAL_ENV
})

it("returns GITHUB_SHA when set", () => {
process.env.GITHUB_SHA = "github1111111111111111111111111111111111"
expect(resolveGitCommit({ execSync })).toBe(
"github1111111111111111111111111111111111"
)
expect(execSync).not.toHaveBeenCalled()
})

it("returns CI_COMMIT_SHA when GITHUB_SHA is absent", () => {
process.env.CI_COMMIT_SHA = "gitlab2222222222222222222222222222222222"
expect(resolveGitCommit({ execSync })).toBe(
"gitlab2222222222222222222222222222222222"
)
expect(execSync).not.toHaveBeenCalled()
})

it("prefers GITHUB_SHA over CI_COMMIT_SHA", () => {
process.env.GITHUB_SHA = "gh"
process.env.CI_COMMIT_SHA = "gl"
expect(resolveGitCommit({ execSync })).toBe("gh")
})

it("falls back to `git rev-parse HEAD` when no env var is set", () => {
execSync.mockReturnValue("local333333333\n")
expect(resolveGitCommit({ execSync })).toBe("local333333333")
expect(execSync).toHaveBeenCalledWith("git rev-parse HEAD", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
})

it("returns null when env vars are missing and git fails", () => {
execSync.mockImplementation(() => {
throw new Error("not a git repository")
})
expect(resolveGitCommit({ execSync })).toBeNull()
})
})
98 changes: 93 additions & 5 deletions test/footer.test.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import React from "react"
import Footer from "../src/components/footer"
Expand All @@ -7,14 +7,102 @@ import { mockConfig } from "./mocks/mockConfig"

const useStaticQuery = vi.spyOn(Gatsby, `useStaticQuery`)

const withMock = (overrides = {}) => ({
...mockConfig,
site: {
...mockConfig.site,
...(overrides.site || {}),
siteMetadata: {
...mockConfig.site.siteMetadata,
...((overrides.site && overrides.site.siteMetadata) || {}),
},
},
})

describe("Footer", () => {
beforeEach(() => {
useStaticQuery.mockImplementation(() => mockConfig)
delete process.env.GATSBY_RESPOSITORY_URL
})

afterEach(() => {
delete process.env.GATSBY_RESPOSITORY_URL
})

it("renders footer", () => {
process.env.GATSBY_RESPOSITORY_URL = "http://test.com"
render(<Footer></Footer>)
it("renders Source link when repositoryUrl is set", () => {
useStaticQuery.mockImplementation(() =>
withMock({
site: {
siteMetadata: {
repositoryUrl: "https://github.com/skohub-io/skohub-vocabs",
},
},
})
)
render(<Footer />)
expect(screen.getByRole("link", { name: "Source" })).toBeInTheDocument()
})

it("renders timestamp only when no commit info is available", () => {
useStaticQuery.mockImplementation(() =>
withMock({
site: {
buildTime: "2026-04-30T14:30:42.000Z",
siteMetadata: { gitCommit: "", repositoryUrl: "" },
},
})
)
render(<Footer />)
expect(
screen.getByText("Last built: 2026-04-30 14:30 UTC")
).toBeInTheDocument()
expect(
screen.queryByRole("link", { name: /a1b2c3d/ })
).not.toBeInTheDocument()
})

it("renders timestamp + plain short SHA when no repositoryUrl", () => {
useStaticQuery.mockImplementation(() =>
withMock({
site: {
buildTime: "2026-04-30T14:30:42.000Z",
siteMetadata: {
gitCommit: "a1b2c3d4e5f67890abcdef1234567890abcdef12",
repositoryUrl: "",
},
},
})
)
render(<Footer />)
expect(
screen.getByText("Last built: 2026-04-30 14:30 UTC (a1b2c3d)")
).toBeInTheDocument()
})

it("renders timestamp + linked short SHA when both are set", () => {
useStaticQuery.mockImplementation(() =>
withMock({
site: {
buildTime: "2026-04-30T14:30:42.000Z",
siteMetadata: {
gitCommit: "a1b2c3d4e5f67890abcdef1234567890abcdef12",
repositoryUrl: "https://github.com/skohub-io/skohub-vocabs",
},
},
})
)
render(<Footer />)
const link = screen.getByRole("link", { name: "a1b2c3d" })
expect(link).toHaveAttribute(
"href",
"https://github.com/skohub-io/skohub-vocabs/commit/a1b2c3d4e5f67890abcdef1234567890abcdef12"
)
})

it("does not render the Last built line when buildTime is missing", () => {
useStaticQuery.mockImplementation(() =>
withMock({ site: { buildTime: null } })
)
render(<Footer />)
expect(screen.queryByText(/Last built:/)).not.toBeInTheDocument()
})
})
3 changes: 3 additions & 0 deletions test/mocks/mockConfig.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export const mockConfig = {
site: {
buildTime: "2026-04-30T14:30:00.000Z",
siteMetadata: {
searchableAttributes: ["prefLabel"],
customDomain: "",
gitCommit: "",
repositoryUrl: "",
colors: {
skoHubWhite: "rgb(255, 255, 255)",
skoHubDarkColor: "rgb(15, 85, 75)",
Expand Down
Loading