diff --git a/.github/workflows/build-site-preview.yaml b/.github/workflows/build-site-preview.yaml index 102b91669d6002..c8cf5814c9ecfa 100644 --- a/.github/workflows/build-site-preview.yaml +++ b/.github/workflows/build-site-preview.yaml @@ -50,6 +50,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Unit tests + run: npm run test:unit + - name: Build PR preview env: GATSBY_PREVIEW: "true" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 3fd7e8199af1e4..27ccfaf02fbb1d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -21,6 +21,9 @@ jobs: - name: Check CONTRIBUTING versions run: npm run check:contributing-versions + - name: Unit tests + run: npm run test:unit + - name: Build run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a92fa42ff0599..27157c1a0ccb2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -518,8 +518,9 @@ Environment variables are named values used to configure how an application beha | Variable | Possible Values | Description | | --------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `BUILD_FULL_SITE` | `true`, `false` | When set to `true`, enables a full site build including all collections. If not explicitly set to `true`, the project defaults to a lightweight build. | -| `LITE_BUILD_PROFILE` | `content`, `core` | Selects which collections are excluded when `BUILD_FULL_SITE=false`. `core` is the default for `make site`, `npm start`, and `npm run dev`, while `content` keeps blog, news, events, and resources enabled. | +| `LITE_BUILD_PROFILE` | `core`, `content`, `blog` | Selects which collections are excluded when `BUILD_FULL_SITE=false`. `core` is the default for `make site`, `npm start`, and `npm run dev` and skips blog, news, events, and resources. `content` keeps blog, news, events, and resources enabled. `blog` keeps the blog and skips news, events, and resources; `make site-blog` uses it. | | `BUILD_COLLECTIONS_EXCLUDE` | comma-separated collection names | Adds extra collections to exclude from a lightweight build without editing project files. | +| `BLOG_YEAR` | comma-separated four-digit years | Limits a lightweight build that includes the blog (`content` or `blog` profile) to posts under `src/collections/blog/`, e.g. `BLOG_YEAR=2026` or `BLOG_YEAR=2025,2026`. Posts from other years are not built and return 404. `make site-blog` defaults it to the latest year. A year with no directory, or a value that is not a year, fails the build. Ignored, with a warning, for full builds and for profiles that exclude the blog. | | `NODE_ENV` | `development`, `production` | Determines the build and rendering mode used by Gatsby. This is automatically set by Gatsby.

• `development` - Uses **Deferred Static Generation (DSG)** i.e pages built on demand for faster startup.
• `production` - Uses **Server-Side Rendering (SSR)** i.e pages rendered on each request for fresh content. | | `CI` | `true`, `false` | Indicates that the build is running in a **Continuous Integration (CI)** environment (e.g., GitHub Actions). When set to `true`, special logic is applied to page paths and redirects for GitHub Pages compatibility. This is typically set automatically by the CI system and does not need to be configured manually. | @@ -579,7 +580,7 @@ make setup make site ``` -This will run a local webserver with "live reload" conveniently enabled. +This will run a local webserver with "live reload" conveniently enabled. `make site` skips the blog, news, events, and resources collections to keep builds fast. To work on a blog post, run `make site-blog`, which builds only the latest year's blog posts; choose other years with `BLOG_YEAR=2025,2026 make site-blog`. Run `make` to list every target. **11.** Track your changes. diff --git a/Makefile b/Makefile index 5097d496b51ded..6b45bbbe244755 100644 --- a/Makefile +++ b/Makefile @@ -25,10 +25,13 @@ site: @echo " Use LITE_BUILD_PROFILE=content make site to include content collections while still skipping the heaviest routes." @npx cross-env BUILD_FULL_SITE=false LITE_BUILD_PROFILE=$(or $(LITE_BUILD_PROFILE),core) BLOG_YEAR=$(or $(BLOG_YEAR),) GATSBY_CPU_COUNT=4 SHARP_CONCURRENCY=4 UV_THREADPOOL_SIZE=4 NODE_OPTIONS=--max-old-space-size=8192 env-cmd -f .env.development gatsby develop -## Run blog-only dev server (2026 posts only, much faster builds). +# Latest year directory under src/collections/blog, e.g. 2026. +LATEST_BLOG_YEAR := $(shell ls src/collections/blog | grep -E '^[0-9]{4}$$' | sort | tail -n 1) + +## Run blog-only dev server scoped to the latest blog year (override: BLOG_YEAR=2025,2026). site-blog: - @echo "🏗️ Building lightweight site version with blog collection only..." - LITE_BUILD_PROFILE=blog BLOG_YEAR=2026 $(MAKE) site + @echo "🏗️ Building lightweight site version with blog posts from $(or $(BLOG_YEAR),$(LATEST_BLOG_YEAR)) only..." + LITE_BUILD_PROFILE=blog BLOG_YEAR=$(or $(BLOG_YEAR),$(LATEST_BLOG_YEAR)) $(MAKE) site # "make site-full" forces the dev server to include every collection. ## Run a full build of layer5.io on your local machine. diff --git a/gatsby-config.js b/gatsby-config.js index 19c2baad345d46..07f6294a33e6c1 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -2,6 +2,7 @@ const { DEFAULT_LITE_BUILD_PROFILE, + getBlogYearFilter, getExcludedCollections, isFullSiteBuild, } = require("./src/utils/build-collections"); @@ -25,9 +26,14 @@ const isLiteDevBuild = isDevelopment && !shouldBuildFullSite; const excludedCollections = getExcludedCollections({ isFullSiteBuild: shouldBuildFullSite, }); -const collectionIgnoreGlobs = excludedCollections.map( - (name) => `**/${name}/**`, -); +const blogYearFilter = getBlogYearFilter({ + isFullSiteBuild: shouldBuildFullSite, + excludedCollections, +}); +const collectionIgnoreGlobs = [ + ...excludedCollections.map((name) => `**/${name}/**`), + ...blogYearFilter.ignoreGlobs, +]; const devFlags = isDevelopment ? { PARALLEL_SOURCING: false, @@ -40,6 +46,15 @@ collectionIgnoreGlobs.length > 0 `Build Scope excludes (${process.env.LITE_BUILD_PROFILE || DEFAULT_LITE_BUILD_PROFILE}): ${excludedCollections.join(", ")}`, ) : console.info("Build Scope includes all collections"); +if (blogYearFilter.years.length > 0) { + console.info( + `Build Scope blog years (BLOG_YEAR): ${blogYearFilter.years.join(", ")}`, + ); +} else if (blogYearFilter.inactiveReason) { + console.warn( + `BLOG_YEAR=${process.env.BLOG_YEAR} ignored: ${blogYearFilter.inactiveReason}`, + ); +} module.exports = { ...(pathPrefix != null ? { pathPrefix } : {}), siteMetadata: { diff --git a/package.json b/package.json index 32a63f2222b0c6..b3118597685ce6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "lint": "eslint --fix .", "checklint": "eslint .", "check:contributing-versions": "node .github/scripts/check-contributing-versions.cjs", + "test:unit": "node --test src/utils/build-collections.test.js", "pretest": "eslint --ignore-path .gitignore .", "preload-fonts": "gatsby-preload-fonts", "deploy": "gatsby build && gh-pages -d public -b master", diff --git a/src/utils/build-collections.js b/src/utils/build-collections.js index dad3133afcd4fd..ee332f90da73ba 100644 --- a/src/utils/build-collections.js +++ b/src/utils/build-collections.js @@ -1,3 +1,8 @@ +/* eslint-env node */ + +const fs = require("fs"); +const path = require("path"); + const DEFAULT_LITE_BUILD_PROFILE = "core"; const LITE_BUILD_PROFILES = Object.freeze({ @@ -6,6 +11,10 @@ const LITE_BUILD_PROFILES = Object.freeze({ core: ["members", "integrations", "blog", "news", "events", "resources"], }); +const BLOG_COLLECTION = "blog"; +const BLOG_COLLECTION_DIR = path.join(__dirname, "..", "collections", BLOG_COLLECTION); +const BLOG_YEAR_PATTERN = /^\d{4}$/; + const isFullSiteBuild = (buildFullSite = process.env.BUILD_FULL_SITE) => buildFullSite === "true"; @@ -33,9 +42,71 @@ const getExcludedCollections = ({ ).sort(); }; +// Year directories (src/collections/blog/) that currently hold posts. +const listBlogYears = (blogDir = BLOG_COLLECTION_DIR) => + fs + .readdirSync(blogDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && BLOG_YEAR_PATTERN.test(entry.name)) + .map((entry) => entry.name) + .sort(); + +const parseBlogYears = (value = "") => { + const years = parseCsv(value); + const invalid = years.filter((year) => !BLOG_YEAR_PATTERN.test(year)); + if (invalid.length > 0) { + throw new Error( + `BLOG_YEAR must be a comma-separated list of four-digit years (e.g. "2026" or "2025,2026"); invalid: ${invalid.join(", ")}`, + ); + } + return Array.from(new Set(years)).sort(); +}; + +// Narrows a lightweight build that includes the blog collection to the posts +// of the years listed in BLOG_YEAR, by ignoring every other year directory. +// Returns the years kept, the ignore globs for the collections source, and, +// when BLOG_YEAR is set but cannot apply, why it was not applied. +const getBlogYearFilter = ({ + blogYear = process.env.BLOG_YEAR, + isFullSiteBuild: shouldBuildFullSite = isFullSiteBuild(), + excludedCollections = getExcludedCollections({ + isFullSiteBuild: shouldBuildFullSite, + }), + availableYears = listBlogYears(), +} = {}) => { + const years = parseBlogYears(blogYear); + const inactive = (reason) => ({ years: [], ignoreGlobs: [], inactiveReason: reason }); + + if (years.length === 0) { + return inactive(null); + } + if (shouldBuildFullSite) { + return inactive("BUILD_FULL_SITE=true builds every blog year"); + } + if (excludedCollections.includes(BLOG_COLLECTION)) { + return inactive("the blog collection is excluded from this build"); + } + + const missing = years.filter((year) => !availableYears.includes(year)); + if (missing.length > 0) { + throw new Error( + `BLOG_YEAR ${missing.join(", ")} has no directory under src/collections/blog; available years: ${availableYears.join(", ")}`, + ); + } + + return { + years, + ignoreGlobs: availableYears + .filter((year) => !years.includes(year)) + .map((year) => `**/collections/${BLOG_COLLECTION}/${year}/**`), + inactiveReason: null, + }; +}; + module.exports = { DEFAULT_LITE_BUILD_PROFILE, LITE_BUILD_PROFILES, + getBlogYearFilter, getExcludedCollections, isFullSiteBuild, -}; \ No newline at end of file + listBlogYears, +}; diff --git a/src/utils/build-collections.test.js b/src/utils/build-collections.test.js new file mode 100644 index 00000000000000..7b393aacc0d2b6 --- /dev/null +++ b/src/utils/build-collections.test.js @@ -0,0 +1,149 @@ +/* eslint-env node */ + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + getBlogYearFilter, + getExcludedCollections, + listBlogYears, +} = require("./build-collections"); + +const availableYears = ["2024", "2025", "2026"]; +const blogProfileExclusions = getExcludedCollections({ + isFullSiteBuild: false, + liteBuildProfile: "blog", + buildCollectionsExclude: "", +}); + +describe("getExcludedCollections", () => { + it("excludes nothing for a full site build", () => { + assert.deepEqual(getExcludedCollections({ isFullSiteBuild: true }), []); + }); + + it("keeps the blog collection in the blog profile", () => { + assert.ok(!blogProfileExclusions.includes("blog")); + }); + + it("falls back to the core profile for an unknown profile", () => { + assert.deepEqual( + getExcludedCollections({ + isFullSiteBuild: false, + liteBuildProfile: "nope", + buildCollectionsExclude: "", + }), + getExcludedCollections({ + isFullSiteBuild: false, + liteBuildProfile: "core", + buildCollectionsExclude: "", + }), + ); + }); + + it("adds BUILD_COLLECTIONS_EXCLUDE entries without duplicates", () => { + assert.deepEqual( + getExcludedCollections({ + isFullSiteBuild: false, + liteBuildProfile: "content", + buildCollectionsExclude: " workshops, members ", + }), + ["integrations", "members", "workshops"], + ); + }); +}); + +describe("getBlogYearFilter", () => { + const filter = (overrides) => + getBlogYearFilter({ + isFullSiteBuild: false, + excludedCollections: blogProfileExclusions, + availableYears, + ...overrides, + }); + + it("is inactive without BLOG_YEAR", () => { + for (const blogYear of [undefined, "", " , "]) { + assert.deepEqual(filter({ blogYear }), { + years: [], + ignoreGlobs: [], + inactiveReason: null, + }); + } + }); + + it("ignores every other year directory for a single year", () => { + assert.deepEqual(filter({ blogYear: "2026" }), { + years: ["2026"], + ignoreGlobs: [ + "**/collections/blog/2024/**", + "**/collections/blog/2025/**", + ], + inactiveReason: null, + }); + }); + + it("accepts a comma-separated list, trimmed, deduplicated, and sorted", () => { + const result = filter({ blogYear: " 2026,2025 ,2026" }); + assert.deepEqual(result.years, ["2025", "2026"]); + assert.deepEqual(result.ignoreGlobs, ["**/collections/blog/2024/**"]); + }); + + it("does not apply to a full site build, and says why", () => { + const result = filter({ blogYear: "2026", isFullSiteBuild: true }); + assert.deepEqual(result.ignoreGlobs, []); + assert.match(result.inactiveReason, /BUILD_FULL_SITE/); + }); + + it("does not apply when the blog collection is excluded, and says why", () => { + const result = filter({ + blogYear: "2026", + excludedCollections: ["blog", "news"], + }); + assert.deepEqual(result.ignoreGlobs, []); + assert.match(result.inactiveReason, /blog collection is excluded/); + }); + + it("rejects values that are not four-digit years", () => { + assert.throws(() => filter({ blogYear: "2026,26" }), /invalid: 26/); + assert.throws(() => filter({ blogYear: "latest" }), /invalid: latest/); + }); + + it("rejects years without a blog directory instead of building nothing", () => { + assert.throws( + () => filter({ blogYear: "2019" }), + /BLOG_YEAR 2019 has no directory.*available years: 2024, 2025, 2026/, + ); + }); + + it("validates BLOG_YEAR even when the filter cannot apply", () => { + assert.throws( + () => filter({ blogYear: "abc", isFullSiteBuild: true }), + /invalid: abc/, + ); + }); +}); + +describe("listBlogYears", () => { + it("lists only four-digit year directories, sorted", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "blog-years-")); + try { + for (const name of ["2026", "2019", "blog-template"]) { + fs.mkdirSync(path.join(dir, name)); + } + fs.writeFileSync(path.join(dir, "Blog.style.js"), ""); + fs.writeFileSync(path.join(dir, "2020"), ""); + assert.deepEqual(listBlogYears(dir), ["2019", "2026"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("finds the repository's blog years", () => { + const years = listBlogYears(); + assert.ok(years.length > 0); + assert.ok(years.every((year) => /^\d{4}$/.test(year))); + }); +});