diff --git a/buildSrc/src/main/groovy/website/model/guides/GuidesFetcher.groovy b/buildSrc/src/main/groovy/website/model/guides/GuidesFetcher.groovy
index 32d41bc59ef..cd6d15c301e 100644
--- a/buildSrc/src/main/groovy/website/model/guides/GuidesFetcher.groovy
+++ b/buildSrc/src/main/groovy/website/model/guides/GuidesFetcher.groovy
@@ -26,248 +26,170 @@ import org.yaml.snakeyaml.Yaml
import website.utils.DateUtils
+/**
+ * Loads the local guide registry ({@code conf/guides.yml}) and produces the
+ * flat list of {@link Guide} objects rendered by {@link GuidesPage}.
+ *
+ *
Each top-level YAML entry maps to exactly one {@link Guide}:
+ *
+ * - One {@code versions:} child → {@link SingleGuide}
+ * - Two or more {@code versions:} children → {@link GrailsVersionedGuide}
+ * which renders one link per version (the YAML version key is used
+ * directly as the integer major-version, so the registry's primary key
+ * is stable across Grails major releases).
+ *
+ *
+ * Guides are identified by their YAML {@code name} field. {@code sampleRef.repo}
+ * is treated only as the "Get the Code" target on the rendered guide chrome
+ * — it is NOT used to identify or group guides. Two YAML entries that
+ * happen to point at the same external repo are still rendered as two separate
+ * guides.
+ */
@CompileStatic
class GuidesFetcher {
private static final String DEFAULT_BRANCH = 'master'
- /** Maps git branch names to Grails major version numbers. */
- private static final Map BRANCH_TO_MAJOR = [
- grails3: 3,
- grails4: 4,
- master : 4,
- grails5: 5,
- grails6: 6,
- ]
-
- /**
- * Internal DTO for a single guide-version. The metadata file is hierarchical
- * (one entry per guide, with a nested {@code versions} map), but the
- * downstream rendering logic expects a flat list of one DTO per
- * (slug, branch) pair, so {@link #parseGuides} flattens during load.
- */
- private static final class GuideDto {
- String category
- String githubBranch
- String githubSlug
- String grailsVersion
- String name
- String publicationDate
- String subtitle
- String title
-
- List authors
- List tags
- }
-
/**
- * Loads and parses all guides from the local YAML metadata file.
- * Groups guides by their GitHub slug and creates either a {@link SingleGuide}
- * or {@link GrailsVersionedGuide} depending on whether multiple branches exist.
+ * Loads and parses every guide entry in the YAML registry.
*
* @param guidesYml the YAML metadata file (typically {@code conf/guides.yml})
- * @param skipFuture if {@code true}, excludes guides with publication dates in the future
- * @return list of guides sorted by publication date in descending order (newest first)
+ * @param skipFuture if {@code true}, drops guides whose publication date is
+ * in the future (today + 1)
+ * @return list of guides sorted by publication date in descending order
+ * (newest first)
*/
static List fetchGuides(File guidesYml, boolean skipFuture = true) {
- def entries = parseGuides(guidesYml)
- def slugsToBranches = [:] as Map>
- entries.each { entry ->
- def slug = entry.githubSlug
- def branch = entry.githubBranch ?: DEFAULT_BRANCH
- slugsToBranches.computeIfAbsent(slug) { [] as Set }.add(branch)
- }
-
- def guides = [] as List
- for (def slug : slugsToBranches.keySet()) {
- def branches = slugsToBranches[slug]
- if (branches.size() == 1) {
- def branch = branches.first()
- def guideDto = entries.find {
- it.githubSlug == slug && (!it.githubBranch || it.githubBranch == branch)
- }
- def guide = guideDto ? toSingleGuide(guideDto) : null
- if (guide) {
- guides << guide
- }
- } else {
- def guide = toVersionedGuide(entries, slug, branches)
- if (guide) {
- guides << guide
- }
- }
- }
-
+ List guides = parseGuides(guidesYml)
if (skipFuture) {
- guides = guides.findAll { it.publicationDate.before(tomorrow()) }
- }
- guides.sort { a, b ->
- b.publicationDate <=> a.publicationDate
+ Date cutoff = tomorrow()
+ guides = guides.findAll { Guide g ->
+ g.publicationDate != null && g.publicationDate.before(cutoff)
+ }
}
+ guides.sort { Guide a, Guide b -> b.publicationDate <=> a.publicationDate }
}
/**
- * Parses the local YAML metadata file and flattens each guide-version
- * pair into a {@link GuideDto}.
- *
- * The file's top-level shape is:
- *
- * defaults:
- * category: '...'
- * authors: []
- * tags: []
- * guides:
- * - name: my-guide
- * title: '...'
- * subtitle: '...'
- * authors: ['Author']
- * category: '...'
- * publicationDate: '2020-01-15'
- * versions:
- * '3':
- * sourcePath: guides/my-guide/v3
- * tags: [grails3]
- * sampleRef:
- * repo: grails-guides/my-guide
- * branch: grails3
- *
+ * Walks each top-level YAML guide entry and builds one {@link Guide} per entry.
*
- * Each {@code versions.} entry produces one {@link GuideDto} whose
- * {@code githubSlug} comes from {@code sampleRef.repo} (defaulting to
- * "grails-guides/") and {@code githubBranch} from
- * {@code sampleRef.branch} (defaulting to "master").
- *
- * @param yamlFile the YAML metadata file
- * @return list of parsed guide DTOs, one per (guide, version) pair
+ * Per-version fields ({@code tags}, {@code sampleRef}, {@code publicationDate})
+ * are read from the version block; per-guide fields ({@code title},
+ * {@code subtitle}, {@code authors}, {@code category}, {@code publicationDate})
+ * are read from the guide entry, with the {@code defaults:} block as fallback.
*/
@CompileDynamic
- private static List parseGuides(File yamlFile) {
- Map root = yamlFile.withReader('UTF-8') { reader -> new Yaml().load(reader) as Map }
+ private static List parseGuides(File yamlFile) {
+ Map root = yamlFile.withReader('UTF-8') { reader ->
+ new Yaml().load(reader) as Map
+ }
Map defaults = (root.defaults ?: [:]) as Map
- List guides = (root.guides ?: []) as List
+ List entries = (root.guides ?: []) as List
- List result = []
- guides.each { Map guide ->
- String name = guide.name as String
- Map versions = (guide.versions ?: [:]) as Map
- versions.each { Object versionKeyObj, Object versionObj ->
- if (!(versionObj instanceof Map)) {
- return
+ List result = []
+ for (Map entry : entries) {
+ String name = entry.name as String
+ if (!name) {
+ continue
+ }
+ Map validVersions = [:]
+ ((entry.versions ?: [:]) as Map).each { Object versionKeyObj, Object versionObj ->
+ if (versionObj instanceof Map) {
+ validVersions[versionKeyObj as String] = versionObj as Map
}
- Map version = versionObj as Map
- Map sampleRef = (version.sampleRef ?: [:]) as Map
-
- String slug = (sampleRef.repo ?: "grails-guides/${name}") as String
- String branch = (sampleRef.branch ?: DEFAULT_BRANCH) as String
-
- List tags = (version.tags ?: defaults.tags ?: []) as List
- List authors = (guide.authors ?: defaults.authors ?: []) as List
- String category = (guide.category ?: defaults.category) as String
- String pubDate = (version.publicationDate ?: guide.publicationDate) as String
+ }
+ if (validVersions.isEmpty()) {
+ continue
+ }
- result << new GuideDto(
- grailsVersion: versionKeyObj as String,
- authors: authors,
- category: category,
- githubSlug: slug,
- githubBranch: branch,
- name: name,
- title: guide.title as String,
- subtitle: guide.subtitle as String,
- tags: tags,
- publicationDate: pubDate
- )
+ if (validVersions.size() == 1) {
+ Map.Entry only = validVersions.entrySet().iterator().next()
+ result << buildSingleGuide(entry, defaults, only.key, only.value)
+ } else {
+ result << buildVersionedGuide(entry, defaults, validVersions)
}
}
result
}
- /**
- * Converts a {@link GuideDto} into a {@link SingleGuide} domain object.
- * Used when a guide exists only for a single Grails version/branch.
- *
- * @param dto the guide DTO to convert
- * @return a new {@link SingleGuide} instance with all fields populated
- */
- private static SingleGuide toSingleGuide(GuideDto dto) {
- def guide = new SingleGuide(
- versionNumber: dto.grailsVersion,
- authors: dto.authors,
- category: dto.category,
- githubSlug: dto.githubSlug,
- githubBranch: dto.githubBranch,
- name: dto.name,
- title: dto.title,
- subtitle: dto.subtitle,
- tags: dto.tags
+ @CompileDynamic
+ private static SingleGuide buildSingleGuide(
+ Map entry, Map defaults, String versionKey, Map version) {
+ Map sampleRef = (version.sampleRef ?: [:]) as Map
+ new SingleGuide(
+ versionNumber: versionKey,
+ authors: (entry.authors ?: defaults.authors ?: []) as List,
+ category: (entry.category ?: defaults.category) as String,
+ githubSlug: (sampleRef.repo ?: "grails-guides/${entry.name}") as String,
+ githubBranch: (sampleRef.branch ?: DEFAULT_BRANCH) as String,
+ name: entry.name as String,
+ title: entry.title as String,
+ subtitle: entry.subtitle as String,
+ tags: (version.tags ?: defaults.tags ?: []) as List,
+ publicationDate: parsePublicationDate(
+ (version.publicationDate ?: entry.publicationDate) as String)
)
- setPublicationDate(guide, dto)
- guide
}
/**
- * Creates a {@link GrailsVersionedGuide} from multiple branch-specific DTOs.
- * Used when a guide exists across multiple Grails versions (e.g., grails3, grails4).
- * Aggregates tags from each branch and maps them to their respective major versions.
+ * Builds a {@link GrailsVersionedGuide} from every version block under a
+ * single YAML entry. The map key in {@code grailsMayorVersionTags} is the
+ * YAML version key parsed as an integer (e.g. {@code '8' -> 8}); non-numeric
+ * version keys are silently skipped because the rendered URL slot
+ * ({@code /guides///...}) requires an integer.
*
- * @param entries all guide DTOs to search through
- * @param slug the GitHub slug identifying the guide repository
- * @param branches the set of branch names (e.g., grails3, grails4) for this guide
- * @return a new {@link GrailsVersionedGuide} or {@code null} if no matching DTOs found
+ * The "primary" version surfaced as {@code versionNumber} /
+ * {@code githubBranch} / {@code githubSlug} on the guide is the version
+ * with the most recent publication date (or the last-iterated version if
+ * dates are missing/equal). This drives the "Read More" link in the
+ * {@code Latest Guides} sidebar.
*/
- private static GrailsVersionedGuide toVersionedGuide(
- List entries,
- String slug,
- Set branches
- ) {
- def guide = null
- for (def branch : branches) {
- def dto = entries.find {
- it.githubSlug == slug && it.githubBranch == branch
- }
- if (!dto) {
- continue
- }
- if (guide == null) {
- guide = new GrailsVersionedGuide()
- }
- guide.versionNumber = dto.grailsVersion
- guide.authors = dto.authors
- guide.category = dto.category
- guide.githubSlug = dto.githubSlug
- guide.githubBranch = dto.githubBranch
- guide.name = dto.name
- guide.title = dto.title
- guide.subtitle = dto.subtitle
+ @CompileDynamic
+ private static GrailsVersionedGuide buildVersionedGuide(
+ Map entry, Map defaults, Map versions) {
+ GrailsVersionedGuide guide = new GrailsVersionedGuide(
+ authors: (entry.authors ?: defaults.authors ?: []) as List,
+ category: (entry.category ?: defaults.category) as String,
+ name: entry.name as String,
+ title: entry.title as String,
+ subtitle: entry.subtitle as String,
+ )
- def majorVersion = BRANCH_TO_MAJOR[branch]
- if (majorVersion) {
- guide.grailsMayorVersionTags[majorVersion] = dto.tags
+ Date latestPubDate = null
+ String latestVersionKey = null
+ String latestBranch = DEFAULT_BRANCH
+ String latestSlug = "grails-guides/${entry.name}"
+
+ versions.each { String versionKey, Map version ->
+ if (!versionKey.isInteger()) {
+ return
+ }
+ Integer majorVersion = versionKey.toInteger()
+ guide.grailsMayorVersionTags[majorVersion] =
+ (version.tags ?: defaults.tags ?: []) as List
+
+ Date pubDate = parsePublicationDate(
+ (version.publicationDate ?: entry.publicationDate) as String)
+ if (pubDate != null && (latestPubDate == null || pubDate.after(latestPubDate))) {
+ latestPubDate = pubDate
+ latestVersionKey = versionKey
+ Map sampleRef = (version.sampleRef ?: [:]) as Map
+ latestBranch = (sampleRef.branch ?: DEFAULT_BRANCH) as String
+ latestSlug = (sampleRef.repo ?: "grails-guides/${entry.name}") as String
}
- setPublicationDate(guide, dto)
}
+
+ guide.publicationDate = latestPubDate
+ guide.versionNumber = latestVersionKey
+ guide.githubBranch = latestBranch
+ guide.githubSlug = latestSlug
guide
}
- /**
- * Parses and sets the publication date on a guide from the DTO's date string.
- * Uses {@link DateUtils#parseDate} to handle the date parsing.
- *
- * @param guide the guide to update
- * @param dto the DTO containing the publication date string
- */
- private static void setPublicationDate(Guide guide, GuideDto dto) {
- if (dto.publicationDate) {
- guide.publicationDate = DateUtils.parseDate(dto.publicationDate)
- }
+ private static Date parsePublicationDate(String dateStr) {
+ dateStr ? DateUtils.parseDate(dateStr) : null
}
- /**
- * Returns a {@link Date} representing tomorrow (current date plus one day).
- * Used for filtering out guides with future publication dates.
- *
- * @return tomorrow's date
- */
@CompileDynamic
static Date tomorrow() {
use(TimeCategory) {
diff --git a/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy b/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
index ce7263d5bb3..a891aeae074 100644
--- a/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
+++ b/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
@@ -19,6 +19,7 @@
package website.model.guides
import java.text.SimpleDateFormat
+import java.util.regex.Pattern
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@@ -32,23 +33,41 @@ import static website.utils.RenderUtils.renderHtml
class GuidesPage {
public static final Integer NUMBER_OF_LATEST_GUIDES = 8
+ public static final Integer TAG_CLOUD_LIMIT = 50
public static final String GUIDES_URL = 'https://grails.apache.org/guides'
+ /**
+ * Tag slugs that are version labels masquerading as topics (e.g. {@code grails3},
+ * {@code grails8}). These are filtered out of the tag cloud because they
+ * dwarf real topic tags by occurrence count and add no navigational value -
+ * the version is already implicit in the guide URL.
+ */
+ private static final Pattern VERSION_TAG_PATTERN = ~/^grails\d+$/
+
+ /**
+ * The category-image map. Categories listed here are rendered both on the
+ * guides index page AND as standalone category pages under
+ * {@code /guides/categories/.html}. Categories that exist in
+ * {@code conf/guides.yml} but are NOT listed here are reachable only via
+ * tags / search / Latest Guides.
+ *
+ * The set has been pruned to the categories that actually carry traffic
+ * in the current Grails 7/8 era. Single-guide legacy categories
+ * (Grails + RIA, Grails + Android, Grails + Angular, Grails + AngularJS,
+ * Grails + iOS) have been dropped - those guides remain accessible via
+ * their tag pages and are still indexed by search engines via direct URLs.
+ */
static Map categories = [
advanced: new Category(name: 'Advanced Grails', image: 'advancedgrails.svg'),
- android: new Category(name: 'Grails + Android', image: 'grails_android.svg'),
- angular: new Category(name: 'Grails + Angular', image: 'grailsangular.svg'),
- angularjs: new Category(name: 'Grails + AngularJS', image: 'grailsangular.svg'),
apprentice: new Category(name: 'Grails Apprentice', image: 'grailaprrentice.svg'),
async: new Category(name: 'Grails Async', image: 'async.svg'),
devops: new Category(name: 'Grails + DevOps', image: 'grailsdevops.svg'),
googlecloud: new Category(name: 'Grails + Google Cloud', image: 'googlecloud.svg'),
gorm: new Category(name: 'GORM', image: 'gorm.svg'),
- ios: new Category(name: 'Grails + iOS', image: 'ios.svg'),
react: new Category(name: 'Grails + React', image: 'react.svg'),
- ria: new Category(name: 'Grails + RIA (Rich Internet Application)', image: 'ria.svg'),
testing: new Category(name: 'Grails Testing', image: 'testing.svg'),
vue: new Category(name: 'Grails + Vue.js', image: 'vue.svg'),
+ weblayer: new Category(name: 'Web Layer', image: 'views.svg'),
]
@@ -145,6 +164,13 @@ class GuidesPage {
}
}
}
+ // The two-column grid pairs a "primary" category on the left with
+ // a complementary one on the right. Reading order goes top-down by
+ // pair, so the first pair (apprentice / advanced) is the most
+ // prominent. Categories that no longer earn a section in this grid
+ // (Grails + Android, Grails + iOS, Grails + Angular, Grails + AngularJS,
+ // Grails + RIA) have been removed entirely; their guides remain
+ // reachable via tags, search, and the Latest Guides sidebar.
div(class: 'two-columns') {
div(class: 'column') {
if (!(tag || category)) {
@@ -158,6 +184,18 @@ class GuidesPage {
}
}
}
+ div(class: 'two-columns') {
+ div(class: 'column') {
+ if (!(tag || category)) {
+ mkp.yieldUnescaped(guideGroupByCategory(categories.weblayer, guides, true, 'margin-top: 0'))
+ }
+ }
+ div(class: 'column') {
+ if (!(tag || category)) {
+ mkp.yieldUnescaped(guideGroupByCategory(categories.devops, guides, true, 'margin-top: 0'))
+ }
+ }
+ }
div(class: 'two-columns') {
div(class: 'column') {
if (!(tag || category)) {
@@ -165,28 +203,21 @@ class GuidesPage {
}
}
div(class: 'column') {
- if ( !(tag || category) ) {
+ if (!(tag || category)) {
mkp.yieldUnescaped(guideGroupByCategory(categories.testing, guides, true, 'margin-top: 0'))
-
}
}
}
div(class: 'two-columns') {
div(class: 'column') {
- if ( !(tag || category) ) {
- mkp.yieldUnescaped(guideGroupByCategory(categories.devops, guides, true, 'margin-top: 0'))
+ if (!(tag || category)) {
+ mkp.yieldUnescaped(guideGroupByCategory(categories.vue, guides, true, 'margin-top: 0'))
mkp.yieldUnescaped(guideGroupByCategory(categories.googlecloud, guides))
- mkp.yieldUnescaped(guideGroupByCategory(categories.ios, guides))
- mkp.yieldUnescaped(guideGroupByCategory(categories.android, guides))
- mkp.yieldUnescaped(guideGroupByCategory(categories.ria, guides))
}
}
div(class: 'column') {
if (!(tag || category)) {
- mkp.yieldUnescaped(guideGroupByCategory(categories.vue, guides, true, 'margin-top: 0'))
- mkp.yieldUnescaped(guideGroupByCategory(categories.angular, guides, true, 'margin-top: 0'))
- mkp.yieldUnescaped(guideGroupByCategory(categories.angularjs, guides))
- mkp.yieldUnescaped(guideGroupByCategory(categories.react, guides))
+ mkp.yieldUnescaped(guideGroupByCategory(categories.react, guides, true, 'margin-top: 0'))
}
}
}
@@ -242,13 +273,25 @@ class GuidesPage {
}
}
+ /**
+ * Renders the tag cloud sidebar. Filters out version-label tags
+ * (e.g. {@code grails3}, {@code grails8}) that artificially dominate
+ * the cloud by occurrence count without adding navigational value, and
+ * caps the visible set to the {@link #TAG_CLOUD_LIMIT} most-used tags
+ * before re-sorting alphabetically for display.
+ */
@CompileDynamic
static String tagCloud(Set tags) {
+ List curated = tags
+ .findAll { Tag t -> t.title && !VERSION_TAG_PATTERN.matcher(t.title).matches() }
+ .sort { Tag a, Tag b -> b.occurrence <=> a.occurrence ?: a.title <=> b.title }
+ .take(TAG_CLOUD_LIMIT)
+ .sort { Tag a, Tag b -> a.title <=> b.title }
renderHtml {
div(class: 'tags-by-topic') {
h3(class: 'column-header', 'Guides by Tag')
ul(class: 'tag-cloud') {
- tags.sort { it.slug }.each { tag ->
+ curated.each { tag ->
li(class: "tag$tag.occurrence") {
a(href: "$GUIDES_URL/tags/${tag.slug.toLowerCase()}.html", tag.title)
}
diff --git a/buildSrc/src/test/groovy/website/model/guides/GuidesFetcherSpec.groovy b/buildSrc/src/test/groovy/website/model/guides/GuidesFetcherSpec.groovy
new file mode 100644
index 00000000000..d8526371192
--- /dev/null
+++ b/buildSrc/src/test/groovy/website/model/guides/GuidesFetcherSpec.groovy
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package website.model.guides
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class GuidesFetcherSpec extends Specification {
+
+ @TempDir
+ File tempDir
+
+ def 'fetches a single-version guide as a SingleGuide'() {
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'demo'
+ title: 'Demo Guide'
+ subtitle: 'Demo subtitle'
+ authors: ['Author']
+ category: 'Web Layer'
+ publicationDate: '2026-05-03'
+ versions:
+ '8':
+ sourcePath: guides/demo/v8
+ tags: ['htmx']
+ sampleRef:
+ repo: 'grails-guides/demo'
+ branch: 'grails8'
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+
+ then:
+ guides.size() == 1
+ guides[0] instanceof SingleGuide
+ guides[0].name == 'demo'
+ guides[0].versionNumber == '8'
+ guides[0].category == 'Web Layer'
+ guides[0].tags == ['htmx']
+ guides[0].githubBranch == 'grails8'
+ }
+
+ def 'fetches a multi-version guide as a GrailsVersionedGuide and uses the YAML version key as the major'() {
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'multi'
+ title: 'Multi Guide'
+ authors: ['Author']
+ category: 'Advanced Grails'
+ publicationDate: '2026-05-03'
+ versions:
+ '6':
+ sourcePath: guides/multi/v6
+ tags: ['old']
+ sampleRef:
+ repo: 'grails-guides/multi'
+ branch: 'master'
+ '8':
+ sourcePath: guides/multi/v8
+ tags: ['new']
+ sampleRef:
+ repo: 'grails-guides/multi'
+ branch: 'grails8'
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+
+ then:
+ guides.size() == 1
+ guides[0] instanceof GrailsVersionedGuide
+ def versioned = guides[0] as GrailsVersionedGuide
+ versioned.name == 'multi'
+ // The YAML version key is preserved verbatim as the integer major version.
+ // This is the regression-fix for the BRANCH_TO_MAJOR table that hardcoded
+ // master->4 and was missing grails7/grails8.
+ versioned.grailsMayorVersionTags.keySet() == [6, 8] as Set
+ versioned.grailsMayorVersionTags[6] == ['old']
+ versioned.grailsMayorVersionTags[8] == ['new']
+ }
+
+ def 'two YAML guides that share sampleRef.repo are still rendered as two separate guides'() {
+ // Regression test for the slug-collision bug where the legacy
+ // grails-as-docker-container guides shared a sampleRef.repo with the
+ // new grails-docker-bootbuildimage guide and were silently merged
+ // into one rendered card with broken /3/ and /4/ links.
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'old-guide'
+ title: 'Old Guide'
+ authors: ['Author']
+ category: 'Advanced Grails'
+ publicationDate: '2018-01-15'
+ versions:
+ '3':
+ sourcePath: guides/old-guide/v3
+ tags: ['legacy']
+ sampleRef:
+ repo: 'grails-guides/shared-sample'
+ branch: 'grails3'
+ - name: 'new-guide'
+ title: 'New Guide'
+ authors: ['Author']
+ category: 'Advanced Grails'
+ publicationDate: '2026-05-03'
+ versions:
+ '8':
+ sourcePath: guides/new-guide/v8
+ tags: ['fresh']
+ sampleRef:
+ repo: 'grails-guides/shared-sample'
+ branch: 'grails8'
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+ def names = guides*.name as Set
+
+ then:
+ guides.size() == 2
+ names == ['old-guide', 'new-guide'] as Set
+ }
+
+ def 'two YAML versions that share the same branch are both kept when they belong to the same guide'() {
+ // Regression test for grails-mock-basics: v3 and v4 both had branch=master
+ // so the old (slug, branch)-set logic collapsed them to size==1 and
+ // toSingleGuide silently returned only the first match (v3).
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'mock-basics'
+ title: 'Mock Basics'
+ authors: ['Author']
+ category: 'Grails Testing'
+ publicationDate: '2017-04-24'
+ versions:
+ '3':
+ sourcePath: guides/mock-basics/v3
+ tags: ['v3']
+ sampleRef:
+ repo: 'grails-guides/mock-basics'
+ branch: 'master'
+ '4':
+ sourcePath: guides/mock-basics/v4
+ tags: ['v4']
+ sampleRef:
+ repo: 'grails-guides/mock-basics'
+ branch: 'master'
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+
+ then:
+ guides.size() == 1
+ guides[0] instanceof GrailsVersionedGuide
+ def versioned = guides[0] as GrailsVersionedGuide
+ versioned.grailsMayorVersionTags.keySet() == [3, 4] as Set
+ versioned.grailsMayorVersionTags[3] == ['v3']
+ versioned.grailsMayorVersionTags[4] == ['v4']
+ }
+
+ def 'a future-dated guide is filtered out when skipFuture is true (the default)'() {
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'future'
+ title: 'Future Guide'
+ authors: ['Author']
+ category: 'Web Layer'
+ publicationDate: '2099-01-01'
+ versions:
+ '8':
+ sourcePath: guides/future/v8
+ tags: []
+ sampleRef:
+ repo: 'grails-guides/future'
+ branch: 'grails8'
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+
+ then:
+ guides.isEmpty()
+ }
+
+ def 'a guide entry with no versions is silently skipped'() {
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'empty'
+ title: 'Empty'
+ authors: ['Author']
+ category: 'Advanced Grails'
+ publicationDate: '2026-05-03'
+ versions: {}
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+
+ then:
+ guides.isEmpty()
+ }
+
+ def 'guides are sorted by publication date descending'() {
+ given:
+ File yml = writeYaml '''
+guides:
+ - name: 'older'
+ title: 'Older'
+ authors: ['Author']
+ category: 'Advanced Grails'
+ publicationDate: '2017-01-23'
+ versions:
+ '3':
+ sourcePath: guides/older/v3
+ tags: []
+ sampleRef:
+ repo: 'grails-guides/older'
+ branch: 'grails3'
+ - name: 'newer'
+ title: 'Newer'
+ authors: ['Author']
+ category: 'Advanced Grails'
+ publicationDate: '2026-05-03'
+ versions:
+ '8':
+ sourcePath: guides/newer/v8
+ tags: []
+ sampleRef:
+ repo: 'grails-guides/newer'
+ branch: 'grails8'
+'''
+ when:
+ def guides = GuidesFetcher.fetchGuides(yml)
+
+ then:
+ guides*.name == ['newer', 'older']
+ }
+
+ private File writeYaml(String content) {
+ File f = new File(tempDir, 'guides.yml')
+ f.text = content
+ f
+ }
+}
diff --git a/conf/guides.yml b/conf/guides.yml
index ff302d1fa55..156d53ec12d 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -1048,7 +1048,7 @@ guides:
- 'gradle'
- 'grails3'
sampleRef:
- repo: 'grails-guides/grails-docker-bootbuildimage'
+ repo: 'grails-guides/grails-as-docker-container'
branch: 'grails3'
toc:
training:
@@ -1076,7 +1076,7 @@ guides:
- 'gradle'
- 'grails4'
sampleRef:
- repo: 'grails-guides/grails-docker-bootbuildimage'
+ repo: 'grails-guides/grails-as-docker-container'
branch: 'grails4'
toc:
training:
@@ -3127,7 +3127,7 @@ guides:
- 'multi-project'
- 'grails4'
sampleRef:
- repo: 'grails-guides/grails-multi-module'
+ repo: 'grails-guides/grails-multi-project-build'
branch: 'master'
toc:
training:
@@ -3312,7 +3312,7 @@ guides:
- 'geb'
- 'grails4'
sampleRef:
- repo: 'grails-guides/grails-github-actions-cicd'
+ repo: 'grails-guides/grails-on-github-actions'
branch: 'master'
toc:
training:
@@ -4848,7 +4848,7 @@ guides:
- 'gorm'
- 'grails3'
sampleRef:
- repo: 'grails-guides/grails-rest-library'
+ repo: 'grails-guides/rest-hibernate'
branch: 'grails3'
toc:
training:
@@ -4885,7 +4885,7 @@ guides:
- 'gorm'
- 'grails4'
sampleRef:
- repo: 'grails-guides/grails-rest-library'
+ repo: 'grails-guides/rest-hibernate'
branch: 'grails4'
toc:
training: