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}:

+ * + * + *

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: