You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ma...@apache.org on 2026/02/04 16:10:01 UTC

(grails-static-website) 02/04: Refactor and update Plugins page and some styling

This is an automated email from the ASF dual-hosted git repository.

matrei pushed a commit to branch refactor-and-plugins
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 78f3608453caaba8378f76c3b579e54cfadd4985
Author: Mattias Reichel <ma...@gmail.com>
AuthorDate: Tue Feb 3 15:00:35 2026 +0100

    Refactor and update Plugins page and some styling
---
 assets/javascripts/navigation.js                   |   4 +-
 assets/javascripts/paginate.js                     |  83 ++-
 assets/javascripts/plugins-search.js               | 244 +++----
 assets/javascripts/plugins.js                      |  81 ++-
 assets/javascripts/search.js                       | 326 +++++----
 assets/stylesheets/foundation.css                  |   2 +-
 assets/stylesheets/paginate.css                    |  39 +-
 assets/stylesheets/plugin.css                      | 660 +++++++++++++++++-
 assets/stylesheets/screen.css                      | 739 +++++++++++----------
 assets/stylesheets/support-schedule.css            |   2 +-
 assets/stylesheets/support.css                     |   2 +-
 buildSrc/build.gradle                              |   2 +-
 buildSrc/settings.gradle                           |   0
 buildSrc/src/main/groovy/org/grails/Content.groovy |  17 -
 .../groovy/org/grails/ContentAndMetadata.groovy    |   9 -
 .../src/main/groovy/org/grails/HtmlMinutes.groovy  |   7 -
 .../src/main/groovy/org/grails/HtmlPost.groovy     |  12 -
 .../main/groovy/org/grails/MinutesMetadata.groovy  |  22 -
 .../org/grails/MinutesMetadataAdaptor.groovy       |  38 --
 .../src/main/groovy/org/grails/PageElement.groovy  |   9 -
 .../src/main/groovy/org/grails/PostMetadata.groovy |  22 -
 .../groovy/org/grails/PostMetadataAdapter.groovy   |  43 --
 .../grails/documentation/DocumentationPage.groovy  | 167 -----
 .../org/grails/documentation/DownloadPage.groovy   | 240 -------
 .../org/grails/documentation/GuideGroup.groovy     |  12 -
 .../org/grails/documentation/GuideGroupHtml.groovy |  61 --
 .../org/grails/documentation/GuideGroupItem.groovy |  12 -
 .../org/grails/documentation/ProfilesPage.groovy   | 104 ---
 .../groovy/org/grails/documentation/SiteMap.groovy |  62 --
 .../org/grails/documentation/Snapshot.groovy       |  63 --
 .../src/main/groovy/org/grails/events/Event.groovy |  19 -
 .../groovy/org/grails/events/EventsPage.groovy     |  47 --
 .../groovy/org/grails/gradle/tasks/BlogTask.groovy | 701 -------------------
 .../org/grails/gradle/tasks/CopyAssetsTask.groovy  | 145 ----
 .../org/grails/gradle/tasks/PluginsTask.groovy     | 221 ------
 .../main/groovy/org/grails/guides/Category.groovy  |  17 -
 .../org/grails/guides/GrailsVersionedGuide.groovy  |  30 -
 .../src/main/groovy/org/grails/guides/Guide.groovy |  15 -
 .../groovy/org/grails/guides/GuidesFetcher.groovy  | 121 ----
 .../org/grails/guides/MultiLanguageGuide.groovy    |  23 -
 .../org/grails/guides/ProgrammingLanguage.groovy   |  13 -
 .../groovy/org/grails/guides/SingleGuide.groovy    |  19 -
 .../org/grails/guides/SingleLanguageGuide.groovy   |  19 -
 .../main/groovy/org/grails/guides/TagUtils.groovy  |  61 --
 .../main/groovy/org/grails/guides/Training.groovy  |  19 -
 .../src/main/groovy/org/grails/plugin/Owner.groovy |   9 -
 .../main/groovy/org/grails/plugin/Plugin.groovy    |  34 -
 .../groovy/org/grails/plugin/PluginsPage.groovy    | 328 ---------
 .../groovy/org/grails/questions/Question.groovy    |   9 -
 .../org/grails/questions/QuestionHtml.groovy       |  32 -
 .../org/grails/questions/QuestionsPage.groovy      |  36 -
 .../src/main/groovy/org/grails/tags/Tag.groovy     |  30 -
 .../main/groovy/org/grails/tags/TagCloud.groovy    |  28 -
 .../gradle/GrailsWebsiteExtension.groovy           |   6 +-
 .../gradle/GrailsWebsitePlugin.groovy              |  66 +-
 .../gradle/tasks/AssetsTask.groovy}                |  58 +-
 .../groovy/website/gradle/tasks/BlogTask.groovy    | 523 +++++++++++++++
 .../gradle/tasks/DocumentationTask.groovy          |  19 +-
 .../gradle/tasks/DownloadTask.groovy               |  18 +-
 .../gradle/tasks/GrailsWebsiteTask.groovy          |   6 +-
 .../gradle/tasks/GuidesTask.groovy                 |  55 +-
 .../gradle/tasks/HtaccessTask.groovy               |  29 +-
 .../gradle/tasks/MinutesTask.groovy                |  54 +-
 .../groovy/website/gradle/tasks/PluginsTask.groovy | 266 ++++++++
 .../gradle/tasks/ProfilesTask.groovy               |  25 +-
 .../gradle/tasks/QuestionsTask.groovy              |  18 +-
 .../gradle/tasks/RenderSiteTask.groovy             |  41 +-
 .../gradle/tasks/SitemapTask.groovy                |  10 +-
 .../model/Content.groovy}                          |  17 +-
 .../model/ContentAndMetadata.groovy}               |  14 +-
 .../model/HtmlMinutes.groovy}                      |  11 +-
 .../model/HtmlPost.groovy}                         |  16 +-
 .../model}/MarkdownMinutes.groovy                  |   2 +-
 .../grails => website/model}/MarkdownPost.groovy   |   4 +-
 .../model/MinutesMetadata.groovy}                  |  24 +-
 .../model/MinutesMetadataAdaptor.groovy}           |  43 +-
 .../{org/grails => website/model}/Page.groovy      |   9 +-
 .../model/PageElement.groovy}                      |  11 +-
 .../model/PostMetadata.groovy}                     |  26 +-
 .../model/PostMetadataAdapter.groovy}              |  48 +-
 .../model/documentation/DocumentationPage.groovy   | 254 +++++++
 .../model/documentation/DownloadPage.groovy        | 286 ++++++++
 .../model/documentation/GuideGroup.groovy}         |  16 +-
 .../model/documentation/GuideGroupHtml.groovy      |  84 +++
 .../model/documentation/GuideGroupItem.groovy}     |  15 +-
 .../model/documentation/ProfilesPage.groovy        | 138 ++++
 .../website/model/documentation/SiteMap.groovy     |  61 ++
 .../website/model/documentation/Snapshot.groovy    |  56 ++
 .../model}/documentation/SoftwareVersion.groovy    |  24 +-
 .../model/events/Event.groovy}                     |  26 +-
 .../groovy/website/model/events/EventsPage.groovy  |  69 ++
 .../model/guides/Category.groovy}                  |  14 +-
 .../model/guides/GrailsVersionedGuide.groovy}      |  32 +-
 .../model/guides/Guide.groovy}                     |  29 +-
 .../website/model/guides/GuidesFetcher.groovy      | 226 +++++++
 .../model}/guides/GuidesPage.groovy                | 145 ++--
 .../model/guides/MultiLanguageGuide.groovy}        |  39 +-
 .../model/guides/ProgrammingLanguage.groovy}       |  26 +-
 .../model/guides/SingleGuide.groovy}               |  34 +-
 .../model/guides/SingleLanguageGuide.groovy}       |  33 +-
 .../groovy/website/model/guides/TagUtils.groovy    |  51 ++
 .../model/guides/Training.groovy}                  |  24 +-
 .../model/plugin/Owner.groovy}                     |  15 +-
 .../main/groovy/website/model/plugin/Plugin.groovy |  75 +++
 .../model/plugin/PluginVersion.groovy}             |  33 +-
 .../groovy/website/model/plugin/PluginsPage.groovy | 351 ++++++++++
 .../model/questions/Question.groovy}               |  13 +-
 .../model/questions/QuestionHtml.groovy}           |  39 +-
 .../website/model/questions/QuestionsPage.groovy   |  54 ++
 .../model/tags/Tag.groovy}                         |  36 +-
 .../model/tags/TagCloud.groovy}                    |  42 +-
 .../{org/grails => website}/utils/DateUtils.groovy |  53 +-
 .../grails => website}/utils/MarkdownUtils.groovy  |   2 +-
 .../grails => website}/utils/ReadFileUtils.groovy  |  10 +-
 .../main/groovy/website/utils/RenderUtils.groovy   |  61 ++
 .../documentation/SoftwareVersionSpec.groovy       |   2 +-
 pages/404.html                                     |   2 +-
 .../casestudies/boosting-agility-with-grails.html  |  12 +-
 ...y-enterprise-scale-application-development.html |  12 +-
 ...rehensive-solution-for-a-public-university.html |   8 +-
 .../grails-increases-developer-productivity.html   |  12 +-
 ...ghput-and-delivery-performance-with-grails.html |  20 +-
 pages/casestudies/index.html                       |   2 +-
 pages/community.html                               |  50 +-
 pages/index.html                                   |  16 +-
 pages/learning.html                                |  14 +-
 pages/legacy-plugins.html                          |   2 +-
 pages/maturity.html                                |   2 +-
 pages/search.html                                  |   2 +-
 pages/support-schedule.html                        |   4 +-
 pages/support.html                                 |   2 +-
 pages/trademark-policy.html                        |   2 +-
 settings.gradle                                    |   4 +
 templates/document.html                            |  71 +-
 134 files changed, 4859 insertions(+), 4325 deletions(-)

diff --git a/assets/javascripts/navigation.js b/assets/javascripts/navigation.js
index 885e4926bbc..4770d15d7a4 100644
--- a/assets/javascripts/navigation.js
+++ b/assets/javascripts/navigation.js
@@ -1,4 +1,4 @@
 function show(showId, hideId) {
-    document.getElementById(showId).style.display = "block"
-    document.getElementById(hideId).style.display = "none"
+    document.getElementById(showId).style.display = 'block'
+    document.getElementById(hideId).style.display = 'none'
 }
\ No newline at end of file
diff --git a/assets/javascripts/paginate.js b/assets/javascripts/paginate.js
index cedeb231b4a..a7ec7b6a9ae 100644
--- a/assets/javascripts/paginate.js
+++ b/assets/javascripts/paginate.js
@@ -1,53 +1,70 @@
-const paginationContainerClass = ".pagination-container";
-const max = 20;
+const max = 20
+const paginationContainerClass = '.pagination-container'
+
 function paginate(items, itemsPerPage, itemsContainer, paginationContainer) {
-    let currentPage = 1;
-    const totalPages = Math.ceil(items.length / itemsPerPage);
+    let currentPage = 1
+    const totalPages = Math.ceil(items.length / itemsPerPage)
 
     function showItems(page) {
-        const startIndex = (page - 1) * itemsPerPage;
-        const endIndex = startIndex + itemsPerPage;
-        const pageItems = items.slice(startIndex, endIndex);
+        const startIndex = (page - 1) * itemsPerPage
+        const endIndex = startIndex + itemsPerPage
+        const pageItems = items.slice(startIndex, endIndex)
 
-        itemsContainer.innerHTML = "";
+        itemsContainer.innerHTML = ""
 
         pageItems.forEach((item) => {
-            const li = document.createElement("li");
-            li.innerText = item.innerText;
-            itemsContainer.appendChild(item);
-        });
-        window.scrollTo({top: queryInputOffsetTop, behavior: 'smooth'});
+            const li = document.createElement('li')
+            li.innerText = item.innerText
+            itemsContainer.appendChild(item)
+        })
     }
 
-    function setupPagination() {
-        const pagination = document.querySelector(paginationContainer);
-        pagination.innerHTML = "";
-
+    function createPaginationLinks(paginationElement) {
+        const isBottomPagination = paginationElement.classList.contains('bottom')
+        paginationElement.innerHTML = ''
         if (totalPages > 1) {
             for (let i = 1; i <= totalPages; i++) {
-                const link = document.createElement("a");
-                link.href = "#";
-                link.innerText = i;
-
+                const link = document.createElement('a')
+                link.href = 'javascript:void(0)'
+                link.innerText = `${i}`
                 if (i === currentPage) {
-                    link.classList.add("active");
+                    link.classList.add('active')
                 }
+                link.addEventListener('click', (event) => {
+                    event.preventDefault()
+                    currentPage = i
 
-                link.addEventListener("click", (event) => {
-                    event.preventDefault();
-                    currentPage = i;
-                    showItems(currentPage);
+                    // Calculate distance from bottom of page before redraw (only for bottom pagination)
+                    const scrollBottomOffset = isBottomPagination
+                        ? document.documentElement.scrollHeight - window.scrollY
+                        : null
 
-                    const currentActive = pagination.querySelector(".active");
-                    currentActive.classList.remove("active");
-                    link.classList.add("active");
-                });
+                    showItems(currentPage)
 
-                pagination.appendChild(link);
+                    // Restore scroll position relative to bottom (only for bottom pagination)
+                    if (isBottomPagination) {
+                        window.scrollTo(0, document.documentElement.scrollHeight - scrollBottomOffset)
+                    }
+
+                    // Update active state on all pagination containers
+                    document.querySelectorAll(paginationContainer + ' a.active').forEach(a => a.classList.remove('active'))
+                    document.querySelectorAll(paginationContainer + ' a').forEach(a => {
+                        if (a.innerText === `${i}`) {
+                            a.classList.add('active')
+                        }
+                    })
+                })
+                paginationElement.appendChild(link)
             }
         }
     }
 
-    showItems(currentPage);
-    setupPagination();
+    function setupPagination() {
+        // Populate all pagination containers (top and bottom)
+        const paginationElements = document.querySelectorAll(paginationContainer)
+        paginationElements.forEach(el => createPaginationLinks(el))
+    }
+
+    showItems(currentPage)
+    setupPagination()
 }
\ No newline at end of file
diff --git a/assets/javascripts/plugins-search.js b/assets/javascripts/plugins-search.js
index e1309eb83f5..5a53e7ead44 100644
--- a/assets/javascripts/plugins-search.js
+++ b/assets/javascripts/plugins-search.js
@@ -1,31 +1,65 @@
-const queryInputFieldId = "query";
-const mobileQueryInputFieldId = "mobilequery";
-const allPluginsContainerDivClass = "allplugins";
-const pluginContainerDivClassName = "plugin";
-const allPluginsHeadingLabelClassName = "allpluginslabel";
-const searchResultsDivClassName = "searchresults";
-const searchResultsHeadingLabelClassName = "searchresultslabel";
-const searchResultsLabelSelector = "h3." + searchResultsHeadingLabelClassName;
-const gitHubStarsSelector = "div.githubstar";
-const noresultsDivClassName = "noresults";
-
-const allPlugins = [];
-const elementsClassNames = [allPluginsContainerDivClass, allPluginsHeadingLabelClassName];
-
-window.addEventListener("load", (event) => {
-    const elements = document.querySelectorAll("div." + allPluginsContainerDivClass + " ul > li.plugin");
+const queryInputFieldId = 'query'
+const mobileQueryInputFieldId = 'mobile-query'
+const allPluginsContainerDivClass = 'all-plugins'
+const pluginContainerDivClassName = 'plugin'
+const allPluginsHeadingLabelClassName = 'all-plugins-label'
+const searchResultsDivClassName = 'search-results'
+const searchResultsHeadingLabelClassName = 'search-results-label'
+const searchResultsLabelSelector = 'h3.' + searchResultsHeadingLabelClassName
+const gitHubStarsSelector = 'div.github-star'
+const noresultsDivClassName = 'no-results'
+const allPlugins = []
+const elementsClassNames = [allPluginsContainerDivClass, allPluginsHeadingLabelClassName]
+
+// Tab navigation
+document.addEventListener('DOMContentLoaded', () =>{
+    const tabs = document.querySelectorAll('.plugins-nav .nav-tab')
+    const tabContents = document.querySelectorAll('.tab-content')
+    tabs.forEach(tab => {
+        tab.addEventListener('click', e => {
+            e.preventDefault()
+
+            // Remove the active class from all tabs and contents
+            tabs.forEach(t => t.classList.remove('active'))
+            tabContents.forEach(c => c.classList.remove('active'))
+
+            // Add active class to clicked tab
+            tab.classList.add('active')
+
+            // Show corresponding content
+            const tabId = tab.getAttribute('data-tab')
+            const content = document.getElementById(tabId)
+            if (content) {
+                content.classList.add('active')
+            }
+
+            // Update URL hash
+            history.replaceState(null, null, '#' + tabId)
+        })
+    })
+
+    // Handle initial hash on page load
+    const hash = window.location.hash.substring(1)
+    if (hash) {
+        const tab = document.querySelector('.nav-tab[data-tab="' + hash + '"]')
+        if (tab) {
+            tab.click()
+        }
+    }
+})
+
+window.addEventListener('load', () => {
+    const elements = document.querySelectorAll(`div.${allPluginsContainerDivClass} ul > li.plugin`)
     for (let i = 0; i <= elements.length - 1; i++) {
-        const element = elements[i];
-        const pluginData = element.innerHTML;
-        const name = element.getElementsByClassName('name');
-        const desc = element.getElementsByClassName('desc')[0]?.textContent;
-        const owner = element.getElementsByClassName('owner');
-        const labels = element.getElementsByClassName('label');
-        const vcsUrl = element.querySelector("h3.name > a").href
-        const metaInfo = element.querySelector("p")?.outerHTML
-        const ghStar = element.querySelector(gitHubStarsSelector)?.outerHTML
-
-        const plugin = {
+        const pluginData = elements[i].innerHTML
+        const name = elements[i].getElementsByClassName('name')
+        const desc = elements[i].getElementsByClassName('desc')[0]?.textContent
+        const owner = elements[i].getElementsByClassName('owner')
+        const labels = elements[i].getElementsByClassName('label')
+        const vcsUrl = elements[i].querySelector('h3.name > a').href
+        const metaInfo = elements[i].querySelector("p")?.outerHTML
+        const ghStar = elements[i].querySelector(gitHubStarsSelector)?.outerHTML
+        allPlugins.push({
             pluginData: pluginData,
             name: name[0]?.textContent,
             desc: desc,
@@ -34,27 +68,42 @@ window.addEventListener("load", (event) => {
             vcsUrl: vcsUrl,
             metaInfo: metaInfo,
             ghStar: ghStar
-        };
-
-        allPlugins.push(plugin);
+        })
     }
 
     if (document.getElementById(queryInputFieldId)) {
-        const e = document.getElementById(queryInputFieldId);
-        e.oninput = onQueryChanged;
-        e.onpropertychange = e.oninput; // for IE8
+        const queryInput = document.getElementById(queryInputFieldId)
+        const searchBox = queryInput.closest('.search-box-inline')
+        const clearBtn = searchBox?.querySelector('.search-clear-btn')
+
+        queryInput.addEventListener('input', onQueryChanged)
+
+        // Update the has-value class on input
+        queryInput.addEventListener('input', () => {
+            if (searchBox) {
+                searchBox.classList.toggle('has-value', queryInput.value.length > 0)
+            }
+        })
+
+        // Clear button functionality
+        if (clearBtn) {
+            clearBtn.addEventListener('click', () => {
+                queryInput.value = ''
+                searchBox.classList.remove('has-value')
+                resetDefault()
+                queryInput.focus()
+            })
+        }
     }
     if (document.getElementById(mobileQueryInputFieldId)) {
-        const e = document.getElementById(mobileQueryInputFieldId);
-        e.oninput = onQueryChanged;
-        e.onpropertychange = e.oninput; // for IE8
+        document.getElementById(mobileQueryInputFieldId)
+            .addEventListener('input', onQueryChanged)
     }
 });
 
 function hideElementsToDisplaySearchResults() {
     for (let i = 0; i < elementsClassNames.length; i++) {
-        const className = elementsClassNames[i];
-        hideElementsByClassName(className);
+        hideElementsByClassName(elementsClassNames[i])
     }
 }
 
@@ -64,145 +113,102 @@ function resetDefault() {
     hideElementsByClassName(searchResultsHeadingLabelClassName)
     clearSearchResultsDiv()
     for (let i = 0; i < elementsClassNames.length; i++) {
-        const className = elementsClassNames[i];
-        showElementsByClassName(className);
+        showElementsByClassName(elementsClassNames[i])
     }
-    paginate(defaultPluginList, max, pluginsContainer, paginationContainerClass);
+    paginate(defaultPluginList, max, pluginsContainer, paginationContainerClass)
 }
 
 function hideElementsByClassName(className) {
-    const elements = document.getElementsByClassName(className);
+    const elements = document.getElementsByClassName(className)
     for (let i = 0; i < elements.length; i++) {
-        const element = elements[i];
-        element.classList.add("hidden");
+        elements[i].classList.add('hidden')
     }
 }
 
 function showElementsByClassName(className) {
-    const elements = document.getElementsByClassName(className);
+    const elements = document.getElementsByClassName(className)
     for (let i = 0; i < elements.length; i++) {
-        const element = elements[i];
-        element.classList.remove("hidden");
+        elements[i].classList.remove('hidden')
     }
 }
 
-function labelsAtPlugin(element) {
-
-    const labels = [];
-    for (let y = 0; y < element.length; y++) {
-        labels.push(element[y].textContent)
-    }
-    return labels;
-}
+const labelsAtPlugin = elements =>
+    [...elements].map(el => el.textContent);
 
 function clearSearchResultsDiv() {
-    searchResultsDiv.innerHTML = ""
+    searchResultsDiv.innerHTML = ''
 }
 
 function onQueryChanged() {
-    let query = queryValue()?.trim();
-    const matchingPlugins = [];
-    if (query === null || query === "") {
-        resetDefault();
-        return;
+    let query = queryValue()?.trim()
+    const matchingPlugins = []
+    if (query === null || query === '') {
+        resetDefault()
+        return
     } else if (query.length < 3) {
-        return;
+        return
     }
 
     if (query !== '') {
         for (let i = 0; i <= allPlugins.length - 1; i++) {
-            const plugin = allPlugins[i];
+            const plugin = allPlugins[i]
             if (doesPluginMatchesQuery(plugin, query)) {
-                matchingPlugins.push(plugin);
+                matchingPlugins.push(plugin)
             }
         }
     }
     if (searchResultsDiv) {
         if (matchingPlugins.length > 0) {
             if (searchResultsDiv.parentNode.getElementsByClassName(searchResultsLabelSelector).length === 0) {
-                const searchResultHeadingLabel = document.querySelector(searchResultsLabelSelector);
-                const querySpan = searchResultHeadingLabel.querySelector("span");
+                const searchResultHeadingLabel = document.querySelector(searchResultsLabelSelector)
+                const querySpan = searchResultHeadingLabel.querySelector('span')
                 querySpan.innerHTML = queryValue()
                 showElementsByClassName(searchResultsHeadingLabelClassName)
             }
-            hideElementsToDisplaySearchResults();
-            searchResultsDiv.innerHTML = renderPlugins(matchingPlugins);
+            hideElementsToDisplaySearchResults()
+            searchResultsDiv.innerHTML = renderPlugins(matchingPlugins)
             paginate(Array.from(searchResultsDiv.getElementsByClassName(pluginContainerDivClassName)), max, searchResultsDiv, paginationContainerClass)
             showElementsByClassName(searchResultsDivClassName)
             hideElementsByClassName(noresultsDivClassName)
         } else if (matchingPlugins.length === 0) {
-            clearSearchResultsDiv();
-            hideElementsToDisplaySearchResults();
+            clearSearchResultsDiv()
+            hideElementsToDisplaySearchResults()
             showElementsByClassName(noresultsDivClassName)
-            const pagination = document.querySelector(paginationContainerClass);
-            pagination.innerHTML = "";
+            const pagination = document.querySelector(paginationContainerClass)
+            pagination.innerHTML = ''
         }
     }
 }
 
 function doesTagsMatchesQuery(tags, query) {
-    for (let x = 0; x < tags.length; x++) {
-        const tag = tags[x];
-        if (tag.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
-            return true;
-        }
-    }
-    return false;
+    const q = query.toLowerCase()
+    return tags.some(tag => tag.toLowerCase().includes(q))
 }
 
 function doesTitleMatchesQuery(title, query) {
-    if (title !== undefined) {
-        title = title.toLowerCase();
-        query = query.toLowerCase();
-        if (title.includes(query)) {
-            return true;
-        }
-        if (query.includes(" ")) {
-            return query.split(" ").every(term => title.includes(term));
-        }
-    }
+    if (title == null) return false
+    const t = title.toLowerCase()
+    const q = query.toLowerCase()
+    return t.includes(q) || (q.includes(" ") && q.split(" ").every(term => t.includes(term)))
 }
 
 function doesOwnerMatchesQuery(owner, query) {
-    if (owner !== undefined) {
-        if (owner.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
-            return true;
-        }
-    }
+    return owner != null && owner.toLowerCase().includes(query.toLowerCase())
 }
 
 function doesPluginMatchesQuery(guide, query) {
-    return doesTitleMatchesQuery(guide.name, query) || doesOwnerMatchesQuery(guide.owner, query) || doesTagsMatchesQuery(guide.labels, query);
+    return doesTitleMatchesQuery(guide.name, query) || doesOwnerMatchesQuery(guide.owner, query) || doesTagsMatchesQuery(guide.labels, query)
 }
 
 function queryValue() {
-    let value = document.getElementById(queryInputFieldId).value;
-    value = value.trim();
-    if (value === '') {
-        if (document.getElementById(mobileQueryInputFieldId)) {
-            value = document.getElementById(mobileQueryInputFieldId).value;
-            value = value.trim();
-            return value;
-        }
-    }
-    return value;
+    const val = id => (document.getElementById(id)?.value ?? '').trim()
+    return val(queryInputFieldId) || val(mobileQueryInputFieldId)
 }
 
 function renderPlugins(plugins) {
-    let html = "";
-    html += "  <ul>";
-    for (let i = 0; i <= plugins.length - 1; i++) {
-        html += "    " + renderPluginAsHtmlLi(plugins[i]);
-    }
-    html += "  </ul>";
-
-    return html;
+    return `  <ul>${plugins.map(p => `    ${renderPluginAsHtmlLi(p)}`).join('')}  </ul>`
 }
 
 function renderPluginAsHtmlLi(plugin) {
-    let html = "<li class='plugin'>";
-    html += plugin.pluginData
-    html += "</li>"
-    return html;
-}
-
+    return `<li class="plugin">${plugin.pluginData}</li>`
+}
\ No newline at end of file
diff --git a/assets/javascripts/plugins.js b/assets/javascripts/plugins.js
index 1dad11b49c0..3a28ab580f1 100644
--- a/assets/javascripts/plugins.js
+++ b/assets/javascripts/plugins.js
@@ -1,18 +1,73 @@
-let pluginsDiv;
-let defaultPluginList;
-let pluginsContainer;
-let queryInputOffsetTop = document.getElementById("query")
+let pluginsDiv
+let defaultPluginList
+let pluginsContainer
+let queryInputOffsetTop = document.getElementById('query')
 let searchResultsDiv
 
-window.addEventListener("load", (event) => {
-    searchResultsDiv = document.querySelector("div.searchresults")
-    pluginsDiv = document.getElementsByClassName("allplugins");
-    pluginsContainer = document.querySelector("div.plugins");
-    if (queryInputOffsetTop  !== null){
-        queryInputOffsetTop = queryInputOffsetTop.offsetTop;
+window.addEventListener('load', () => {
+    searchResultsDiv = document.querySelector('div.search-results')
+    pluginsDiv = document.getElementsByClassName('all-plugins')
+    // Target the ul.plugin-list inside the plugins div, not the div itself
+    pluginsContainer = document.querySelector('div.plugins ul.plugin-list')
+    if (queryInputOffsetTop !== null) {
+        queryInputOffsetTop = queryInputOffsetTop.offsetTop
     }
-    if (pluginsDiv.length > 0 && pluginsDiv[0].display !== "none") {
-        defaultPluginList = Array.from(pluginsDiv[0].getElementsByClassName("plugin")).slice();
-        paginate(defaultPluginList, max, pluginsContainer, paginationContainerClass);
+    if (pluginsDiv.length > 0 && pluginsDiv[0].display !== 'none') {
+        defaultPluginList = Array.from(pluginsDiv[0].getElementsByClassName('plugin')).slice()
+        paginate(defaultPluginList, max, pluginsContainer, paginationContainerClass)
     }
 })
+
+// Copy Maven coordinates to clipboard
+document.addEventListener('click', async (e) => {
+    const btn = e.target.closest('.copy-btn')
+    if (!btn) return
+
+    const coords = btn.dataset.coords
+    if (!coords) return
+
+    const flash = () => {
+        const original = btn.textContent
+        btn.textContent = '✓'
+        btn.classList.add('copied')
+        setTimeout(() => {
+            btn.textContent = original
+            btn.classList.remove('copied')
+        }, 1500)
+    }
+
+    const fallbackCopy = (text) => {
+        const ta = document.createElement('textarea')
+        ta.value = text
+        document.body.appendChild(ta)
+        ta.select()
+        document.execCommand('copy')
+        ta.remove()
+    }
+
+    try {
+        await navigator.clipboard.writeText(coords)
+        flash()
+    } catch (err) {
+        console.error('Failed to copy:', err)
+        fallbackCopy(coords)
+        flash()
+    }
+})
+
+// Version dropdown click-to-toggle
+document.addEventListener('click', (e) => {
+    const dropdown = e.target.closest('.version-current')?.closest('.version-dropdown')
+    // Click outside any .version-current -> close all
+    if (!dropdown) {
+        document.querySelectorAll('.version-dropdown.open')
+            .forEach(d => d.classList.remove('open'))
+        return
+    }
+    e.stopPropagation()
+    // Close other open dropdowns
+    document.querySelectorAll('.version-dropdown.open').forEach(d => {
+        if (d !== dropdown) d.classList.remove('open')
+    })
+    dropdown.classList.toggle('open')
+})
diff --git a/assets/javascripts/search.js b/assets/javascripts/search.js
index 39dcabd29df..dae8d1d0f71 100644
--- a/assets/javascripts/search.js
+++ b/assets/javascripts/search.js
@@ -1,239 +1,227 @@
-var queryInputFieldId = "query";
-var mobileQueryInputFieldId = "mobilequery";
-var allguides = new Array();
-var guideClassName = "guide";
-var multiguideClassName = "multiguide";
-
-
-var elementsClassNames = new Array();
-elementsClassNames.push('training');
-elementsClassNames.push('latestguides');
-elementsClassNames.push('guidegroup');
-elementsClassNames.push('tagsbytopic');
-elementsClassNames.push('guidesuggestion');
-
-onload = function () {
-    var elements = document.getElementsByClassName(guideClassName);
-    for ( var i = 0; i < elements.length; i++ ) {
-        var element = elements[i];
-        var guide = { href: element.getAttribute('href'), title: element.text, tags: tagsAtGuide(element.parentNode)  }; /* */
-        allguides.push(guide);
-    }
-    elements = document.getElementsByClassName(multiguideClassName);
-    for ( var i = 0; i < elements.length; i++ ) {
-        var element = elements[i];
-        var guide = { title: titleAtMultiguide(element), versions: versionsAtMultiguide(element)  }; /* */
-        allguides.push(guide);
-    }
-
-    if ( document.getElementById(queryInputFieldId) ) {
-        var e = document.getElementById(queryInputFieldId);
-        e.oninput = onQueryChanged;
-        e.onpropertychange = e.oninput; // for IE8
-    }
-    if ( document.getElementById(mobileQueryInputFieldId) ) {
-        var e = document.getElementById(mobileQueryInputFieldId);
-        e.oninput = onQueryChanged;
-        e.onpropertychange = e.oninput; // for IE8
-    }
-};
+const allGuides = []
+const guideClassName = 'guide'
+const mobileQueryInputFieldId = 'mobile-query'
+const multiGuideClassName = 'multi-guide'
+const queryInputFieldId = 'query'
+
+
+const elementsClassNames = [
+    'training',
+    'latest-guides',
+    'guide-group',
+    'tags-by-topic',
+    'guides-suggestion',
+]
+
+window.addEventListener('load', () => {
+    let elements = document.getElementsByClassName(guideClassName)
+    for (let i = 0; i < elements.length; i++) {
+        allGuides.push({
+            href: elements[i].getAttribute('href'),
+            title: elements[i].text,
+            tags: tagsAtGuide(elements[i].parentNode)
+        })
+    }
+    elements = document.getElementsByClassName(multiGuideClassName)
+    for (let i = 0; i < elements.length; i++) {
+        allGuides.push({
+            title: titleAtMultiGuide(elements[i]),
+            versions: versionsAtMultiGuide(elements[i])
+        })
+    }
+    if (document.getElementById(queryInputFieldId)) {
+        document.getElementById(queryInputFieldId)
+            .addEventListener('input', onQueryChanged)
+    }
+    if (document.getElementById(mobileQueryInputFieldId)) {
+        document.getElementById(mobileQueryInputFieldId)
+            .addEventListener('input', onQueryChanged)
+    }
+})
 
 function hideElementsToDisplaySearchResults() {
-    for ( var i = 0; i < elementsClassNames.length; i++) {
-        var className = elementsClassNames[i];
-        hideElementsByClassName(className);
+    for (let i = 0; i < elementsClassNames.length; i++) {
+        hideElementsByClassName(elementsClassNames[i])
     }
 }
 
 function showElementsToDisplaySearchResults() {
-    for (var i = 0; i < elementsClassNames.length; i++) {
-        var className = elementsClassNames[i];
-        showElementsByClassName(className);
+    for (let i = 0; i < elementsClassNames.length; i++) {
+        showElementsByClassName(elementsClassNames[i])
     }
 }
 
 function hideElementsByClassName(className) {
-    var elements = document.getElementsByClassName(className);
-    for ( var i = 0; i < elements.length; i++ ) {
-        var element = elements[i];
-        element.style.display = "none";
+    const elements = document.getElementsByClassName(className)
+    for (let i = 0; i < elements.length; i++) {
+        elements[i].style.display = 'none'
     }
 }
 
 function showElementsByClassName(className) {
-    var elements = document.getElementsByClassName(className);
-    for ( var i = 0; i < elements.length; i++ ) {
-        var element = elements[i];
-        element.style.display = "block";
+    const elements = document.getElementsByClassName(className)
+    for (let i = 0; i < elements.length; i++) {
+        elements[i].style.display = 'block'
     }
 }
 
-function titleAtMultiguide(element) {
-    for (var y = 0; y < element.childNodes.length; y++) {
-        if (element.childNodes[y].className == "title") {
-            return element.childNodes[y].textContent;
+function titleAtMultiGuide(element) {
+    for (let y = 0; y < element.childNodes.length; y++) {
+        if (element.childNodes[y].className === 'title') {
+            return element.childNodes[y].textContent
         }
     }
-    return '';
+    return ''
 }
 
-function versionsAtMultiguide(element) {
-    var versions = [];
-
-    for (var y = 0; y < element.childNodes.length; y++) {
-        if (element.childNodes[y].className === "align-left") {
-            var versionDiv = element.childNodes[y];
-            var verEl;
-            var hrefEl;
-            var tagsArr = [];
-            for (var x = 0; x < versionDiv.childNodes.length; x++) {
-                if (versionDiv.childNodes[x].className === "grailsVersion") {
+function versionsAtMultiGuide(element) {
+    const versions = []
+    for (let y = 0; y < element.childNodes.length; y++) {
+        if (element.childNodes[y].className === 'align-left') {
+            const versionDiv = element.childNodes[y]
+            let verEl
+            let hrefEl
+            const tagsArr = []
+            for (let x = 0; x < versionDiv.childNodes.length; x++) {
+                if (versionDiv.childNodes[x].className === 'grails-version') {
                     verEl = versionDiv.childNodes[x].textContent
-                    hrefEl = versionDiv.childNodes[x].getAttribute('href');
+                    hrefEl = versionDiv.childNodes[x].getAttribute('href')
                 }
-                if (versionDiv.childNodes[x].className === "tag") {
-                    var tag = versionDiv.childNodes[x];
-                    tagsArr.push(tag.textContent);
+                if (versionDiv.childNodes[x].className === 'tag') {
+                    tagsArr.push(versionDiv.childNodes[x].textContent);
                 }
             }
-            versions.push({grailsVersion: verEl, href: hrefEl, tags: tagsArr})
+            versions.push({
+                grailsVersion: verEl,
+                href: hrefEl,
+                tags: tagsArr
+            })
         }
     }
     return versions
 }
 
 function tagsAtGuide(element) {
-    var tags = new Array();
-
-    for (var y = 0; y < element.childNodes.length; y++) {
-        if (element.childNodes[y].className == "tag") {
-            var tag = element.childNodes[y];
-            tags.push(tag.textContent);
+    const tags = []
+    for (let y = 0; y < element.childNodes.length; y++) {
+        if (element.childNodes[y].className === 'tag') {
+            tags.push(element.childNodes[y].textContent);
         }
     }
-    return tags;
+    return tags
 }
 
 function onQueryChanged() {
-    var query = queryValue();
-    const resultsDiv = document.getElementsByClassName("searchresults")
-    query = query.trim();
-    if ( query === '' ) {
-        showElementsToDisplaySearchResults();
-        resultsDiv[0].innerHTML = "";
-        return;
-    }
-
-    var matchingGuides = [];
-    if ( query !== '' ) {
-        for (var i = 0; i < allguides.length; i++) {
-            var guide = allguides[i];
-            if ( doesGuideMatchesQuery(guide, query) ) {
-                matchingGuides.push(guide);
-            }
+    const query = queryValue().trim()
+    const resultsDiv = document.getElementsByClassName('search-results')
+    if (query === '') {
+        showElementsToDisplaySearchResults()
+        resultsDiv[0].innerHTML = ''
+        return
+    }
+
+    const matchingGuides = []
+    for (let i = 0; i < allguides.length; i++) {
+        let guide = allguides[i]
+        if (doesGuideMatchQuery(guide, query) ) {
+            matchingGuides.push(guide)
         }
     }
-    if ( matchingGuides.length > 0 ) {
-        hideElementsToDisplaySearchResults();
-        var html = renderGuideGroup(matchingGuides, query);
-        resultsDiv[0].innerHTML = html;
-
+    if (matchingGuides.length > 0) {
+        hideElementsToDisplaySearchResults()
+        resultsDiv[0].innerHTML = renderGuideGroup(matchingGuides, query)
     } else {
-        resultsDiv[0].innerHTML = "<div class='guidegroup'><div class='guidegroupheader'><h2>No results found</h2></div></div>";
+        resultsDiv[0].innerHTML =
+            "<div class='guide-group'><div class='guide-group-header'><h2>No results found</h2></div></div>"
     }
 }
 
-function doesTagsMatchesQuery(tags, query) {
-    for ( var x = 0; x < tags.length; x++) {
-        var tag = tags[x];
-        if (tag.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
-            return true;
+function doesTagsMatchQuery(tags, query) {
+    for (let x = 0; x < tags.length; x++) {
+        if (tags[x].toLowerCase().indexOf(query.toLowerCase()) !== -1) {
+            return true
         }
     }
-    return false;
+    return false
 }
 
-function doesTitleMatchesQuery(title, query) {
+function doesTitleMatchQuery(title, query) {
     if (title.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
-        return true;
+        return true
     }
 }
 
-function doesGuideMatchesQuery(guide, query) {
-    if (doesTitleMatchesQuery(guide.title, query)) {
-        return true;
+function doesGuideMatchQuery(guide, query) {
+    if (doesTitleMatchQuery(guide.title, query)) {
+        return true
     }
     if (guide.tags === undefined || guide.tags === null) {
-        for ( var i = 0; i < guide.versions.length; i++) {
-            var version = guide.versions[i];
-            if (doesTagsMatchesQuery(version.tags,query)) {
-                return true;
+        for (let i = 0; i < guide.versions.length; i++) {
+            const version = guide.versions[i]
+            if (doesTagsMatchQuery(version.tags, query)) {
+                return true
             }
         }
     } else {
-        if (doesTagsMatchesQuery(guide.tags, query)) {
-            return true;
+        if (doesTagsMatchQuery(guide.tags, query)) {
+            return true
         }
     }
-
-    return false;
+    return false
 }
 
 function queryValue() {
-    var value = document.getElementById(queryInputFieldId).value;
-    value = value.trim();
-    if ( value === '' ) {
+    const value = document.getElementById(queryInputFieldId).value.trim()
+    if (value === '') {
         if (document.getElementById(mobileQueryInputFieldId)) {
-            value = document.getElementById(mobileQueryInputFieldId).value;
-            value = value.trim();
-            return value;
+            return document.getElementById(mobileQueryInputFieldId).value.trim()
         }
     }
-    return value;
+    return value
 }
 
 function renderGuideGroup(guides, query) {
-    var html = "";
-    html += "<div class='guidegroup'>";
-    html += "  <div class='guidegroupheader'>";
-    html += "    <h2>Guides Filtered by: " + queryValue() + "</h2>";
-    html += "  </div>";
-    html += "  <ul>";
-    for ( var i = 0; i < guides.length; i++ ) {
-        html += "    " + renderGuideAsHtmlLi(guides[i], query);
-    }
-    html += "  </ul>";
-    html += "</div>";
-    return html;
+    const items = guides.map(g => renderGuideAsHtmlLi(g, query)).join('');
+    return `
+    <div class="guide-group">
+      <div class="guide-group-header">
+        <h2>Guides Filtered by: ${queryValue()}</h2>
+      </div>
+      <ul>
+        ${items}
+      </ul>
+    </div>
+  `;
 }
 
 function renderGuideAsHtmlLi(guide, query) {
-    var html = "<li>";
-    if (guide.tags === undefined || guide.tags === null) {
-        html += "<div class=\"multiguide\">";
-        html += "<span class=\"title\">" + guide.title + "</span>";
-        var titleMatched = doesTitleMatchesQuery(guide.title, query);
-        for (var i = 0; i < guide.versions.length; i++) {
-            var version = guide.versions[i];
-            var tagsMatched = doesTagsMatchesQuery(version.tags, query);
-            if (titleMatched || tagsMatched) {
-                html += "<div class=\"align-left\">";
-                html += "<a class=\"grailsVersion\" href=\""+version.href+"\">"+version.grailsVersion+"</a>"
-                for (var x = 0; x < version.tags.length; x++) {
-                    var tag = version.tags[x];
-                    html += "<span style='display: none' class='tag'>" + tag + "</span>";
-                }
-                html += "</div>";
-            }
-        }
-        html += "</div>";
-    } else {
-        html += "<a class='" + guideClassName + "' href='" + guide.href + "'>" + guide.title + "</a>";
-        for (var i = 0; i < guide.tags.length; i++) {
-            var tag = guide.tags[i];
-            html += "<span style='display: none' class='tag'>" + tag + "</span>";
-        }
-    }
-    html += "</li>"
-    return html;
+    const hiddenTags = (tags = []) =>
+        tags.map(tag => `<span style="display: none" class="tag">${tag}</span>`).join('')
+
+    // Multi-guide (no guide.tags)
+    if (guide.tags == null) {
+        const titleMatched = doesTitleMatchQuery(guide.title, query)
+        const versionsHtml = guide.versions
+            .filter(v => titleMatched || doesTagsMatchQuery(v.tags, query))
+            .map(v => `
+              <div class="align-left">
+                <a class="grails-version" href="${v.href}">${v.grailsVersion}</a>
+                ${hiddenTags(v.tags)}
+              </div>`
+            )
+            .join('')
+
+        return `<li>
+                  <div class="multi-guide">
+                    <span class="title">${guide.title}</span>
+                    ${versionsHtml}
+                  </div>
+                </li>`;
+    }
+
+    // Single guide (has guide.tags)
+    return `<li>
+              <a class="${guideClassName}" href="${guide.href}">${guide.title}</a>
+              ${hiddenTags(guide.tags)}
+            </li>`;
 }
diff --git a/assets/stylesheets/foundation.css b/assets/stylesheets/foundation.css
index 42ca115742f..1012d917008 100644
--- a/assets/stylesheets/foundation.css
+++ b/assets/stylesheets/foundation.css
@@ -553,6 +553,6 @@ tbody tr td:first-child {
   min-width: 130px;
 }
 
-body.foundation h3.columnheader {
+body.foundation h3.column-header {
     margin-bottom: 0
 }
diff --git a/assets/stylesheets/paginate.css b/assets/stylesheets/paginate.css
index f5bc5dfff66..73ae0dea34a 100644
--- a/assets/stylesheets/paginate.css
+++ b/assets/stylesheets/paginate.css
@@ -1,51 +1,40 @@
 .pagination-container {
-    display: flex;
+    display: inline-flex;
     flex-wrap: wrap;
     justify-content: flex-start;
     align-items: center;
-    margin-top: 20px;
-}
-
-.pagination-container a {
-    color: black;
-    float: left;
-    padding: 8px 16px;
-    text-decoration: none;
-}
-
-.pagination-container {
-    display: inline-flex;
-    border-radius: 5px;
+    border-radius: 4px;
     overflow: hidden;
 }
 
 .pagination-container a {
-    color: black;
-    padding: 8px 16px;
+    color: #555;
+    padding: 4px 10px;
     text-decoration: none;
     border: 1px solid #ddd;
     border-right: none;
-    background-color: #f1f1f1;
-    transition: background-color 0.3s ease-in-out;
+    background-color: #f5f5f5;
+    font-size: 0.85em;
+    transition: background-color 0.2s ease-in-out;
 }
 
 .pagination-container a:first-child {
-    border-top-left-radius: 5px;
-    border-bottom-left-radius: 5px;
+    border-top-left-radius: 4px;
+    border-bottom-left-radius: 4px;
 }
 
 .pagination-container a:last-child {
     border-right: 1px solid #ddd;
-    border-top-right-radius: 5px;
-    border-bottom-right-radius: 5px;
+    border-top-right-radius: 4px;
+    border-bottom-right-radius: 4px;
 }
 
 .pagination-container a.active {
-    background-color: #4caf50;
+    background-color: #feb672;
     color: white;
-    border: none;
+    border-color: #feb672;
 }
 
 .pagination-container a:hover:not(.active) {
-    background-color: #ddd;
+    background-color: #e8e8e8;
 }
diff --git a/assets/stylesheets/plugin.css b/assets/stylesheets/plugin.css
index da7a8318a87..e95902e8ab2 100644
--- a/assets/stylesheets/plugin.css
+++ b/assets/stylesheets/plugin.css
@@ -1,25 +1,667 @@
-ul.plugin, ol.plugin {
+/* Plugin page layout */
+.plugins-page {
+    max-width: 100%;
+    margin: 0 auto;
+    padding: 20px;
+    box-sizing: border-box;
+}
+
+/* Override screen.css .content styles for plugins page */
+.content.plugins-page {
+    max-width: 1141px !important;
+    width: 95% !important;
+    overflow: hidden !important;
+}
+
+/* Tab navigation */
+.plugins-nav {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px;
+    margin-bottom: 20px;
+    border-bottom: 2px solid #eee;
+    padding-bottom: 0;
+}
+
+.plugins-nav .nav-tab {
+    padding: 10px 20px;
+    text-decoration: none;
+    color: #666;
+    font-weight: 500;
+    border-bottom: 2px solid transparent;
+    margin-bottom: -2px;
+    transition: color 0.2s, border-color 0.2s;
+}
+
+.plugins-nav .nav-tab:hover {
+    color: #333;
+}
+
+.plugins-nav .nav-tab.active {
+    color: #feb672;
+    border-bottom-color: #feb672;
+}
+
+/* Tab content */
+.tab-content {
+    display: none;
+    width: 100%;
+    max-width: 100%;
+    box-sizing: border-box;
+}
+
+.tab-content.active {
+    display: block;
+}
+
+/* Search and pagination row */
+.search-pagination-row {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+    gap: 15px;
+    width: 100%;
+    box-sizing: border-box;
+}
+
+.search-pagination-row.bottom-only {
+    margin-top: 20px;
+    margin-bottom: 0;
+    justify-content: center;
+}
+
+.search-box-inline {
+    flex: 0 1 300px;
+    min-width: 150px;
+    position: relative;
+}
+
+.search-box-inline input {
+    width: 100%;
+    padding: 8px 30px 8px 12px;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    font-size: 0.85em;
+    box-sizing: border-box;
+}
+
+.search-box-inline input:focus {
+    outline: none;
+    border-color: #feb672;
+}
+
+.search-clear-btn {
+    position: absolute;
+    right: 6px;
+    top: 50%;
+    transform: translateY(-50%);
+    background: none;
+    border: none;
+    color: #999;
+    font-size: 1.2em;
+    cursor: pointer;
+    padding: 0 4px;
+    line-height: 1;
+    display: none;
+}
+
+.search-clear-btn:hover {
+    color: #666;
+}
+
+.search-box-inline.has-value .search-clear-btn {
+    display: block;
+}
+
+.search-pagination-row .pagination-container {
+    flex-shrink: 0;
+}
+
+/* Plugins container */
+.plugins.allplugins {
+    display: block;
+    width: 100%;
+    max-width: 100%;
+    box-sizing: border-box;
+    clear: both;
+}
+
+/* Section titles */
+.section-title {
+    font-size: 1.3em;
+    color: #333;
+    margin-bottom: 20px;
+    padding-bottom: 10px;
+    border-bottom: 2px solid #feb672;
+}
+
+/* Links section */
+.links-section {
+    max-width: 600px;
+}
+
+.links-list {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+}
+
+.links-list li {
+    margin-bottom: 20px;
+    padding: 15px;
+    background: #f8f8f8;
+    border-radius: 8px;
+}
+
+.links-list li a {
+    font-size: 1.1em;
+    font-weight: 500;
+    color: #333;
+    text-decoration: none;
+}
+
+.links-list li a:hover {
+    color: #feb672;
+}
+
+.links-list li p {
+    margin: 8px 0 0;
+    color: #666;
+    font-size: 0.9em;
+}
+
+/* Plugin list container - responsive grid */
+.plugin-list {
     padding-left: 0;
+    margin: 0;
+    list-style: none;
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 20px;
+    width: 100%;
+    max-width: 100%;
+    box-sizing: border-box;
+}
+
+/* 1 column for small screens */
+@media screen and (max-width: 600px) {
+    .plugin-list {
+        grid-template-columns: 1fr;
+    }
 }
 
+/* Plugin card */
 li.plugin {
-    padding: 24px;
-    border-radius: 1px;
-    box-shadow: 0 8px 17px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-    -webkit-box-shadow: 0 8px 17px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
+    padding: 20px;
+    border-radius: 8px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    background: #fff;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    position: relative;
+}
+
+/* Deprecated plugin card */
+li.plugin.deprecated {
+    opacity: 0.7;
+    background: #fafafa;
+}
+
+li.plugin.deprecated:hover {
+    opacity: 0.9;
+}
+
+.deprecated-badge {
+    display: inline-block;
+    background: #e74c3c;
+    color: white;
+    font-size: 0.65em;
+    font-weight: 600;
+    padding: 3px 8px;
+    border-radius: 3px;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+    cursor: help;
+    flex-shrink: 0;
+}
+
+/* Plugin header row */
+li.plugin .plugin-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 10px;
+}
+
+/* Plugin name */
+li.plugin h3.name {
+    margin: 0;
+    font-size: 1.2em;
+    line-height: 1.3;
+    flex: 1;
+    min-width: 0;
+    word-wrap: break-word;
+}
+
+li.plugin h3.name a {
+    color: #333;
+    text-decoration: none;
+}
+
+li.plugin h3.name a:hover {
+    color: #feb672;
+}
+
+/* Description */
+li.plugin p.desc {
+    margin: 0;
+    font-size: 0.9em;
+    color: #666;
+    line-height: 1.5;
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+/* Version dropdown */
+.version-dropdown {
+    position: relative;
+    flex-shrink: 0;
+}
+
+.version-current {
+    display: flex;
+    flex-direction: column;
+    border-radius: 4px;
+    overflow: hidden;
+    font-size: 0.75em;
+    border: 1px solid #ddd;
+    cursor: pointer;
+    user-select: none;
+}
+
+.version-current:hover {
+    border-color: #ccc;
+}
+
+.version-current .version-row {
+    display: flex;
+    align-items: stretch;
+    white-space: nowrap;
+}
+
+.version-current .version {
+    background: #f0f0f0;
+    color: #555;
+    padding: 2px 8px;
+    font-weight: 600;
+    flex-grow: 1;
+}
+
+/* When version is the only element (no grails-compat), it should fill the row */
+.version-current .version:last-child {
+    border-radius: 0;
+}
+
+.version-current .grails-compat {
+    background: #f8f8f8;
+    color: #777;
+    padding: 2px 8px;
+    border-left: 1px solid #ddd;
 }
 
-div.twocolumns div.column {
+.version-current .dropdown-arrow {
+    background: #f0f0f0;
+    color: #555;
+    padding: 2px 8px;
+    border-left: 1px solid #ddd;
+    font-size: 1em;
+    line-height: 1;
+    display: flex;
+    align-items: center;
+}
+
+.version-current .version-date-row {
+    background: #fafafa;
+    color: #888;
+    padding: 2px 8px;
+    border-top: 1px solid #eee;
+    text-align: center;
+    font-size: 0.9em;
+}
+
+.version-dropdown.open .version-current .dropdown-arrow {
+    color: #333;
+}
+
+.version-list {
+    display: none;
+    position: absolute;
+    top: 100%;
+    right: 0;
+    margin-top: 4px;
+    background: #fff;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+    z-index: 100;
+    min-width: 280px;
+    max-height: 250px;
+    overflow-y: auto;
+}
+
+.version-dropdown.open .version-list {
+    display: block;
+}
+
+.version-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 12px;
+    padding: 8px 12px;
+    font-size: 0.85em;
+    border-bottom: 1px solid #f0f0f0;
+    white-space: nowrap;
+}
+
+.version-item:last-child {
+    border-bottom: none;
+}
+
+.version-item:hover {
+    background: #f8f8f8;
+}
+
+.version-item .ver {
+    font-weight: 600;
+    color: #555;
+    flex-shrink: 0;
+}
+
+.version-item .date {
+    color: #999;
+    font-size: 0.9em;
+    flex-shrink: 0;
+}
+
+.version-item .compat {
+    color: #777;
+    font-size: 0.9em;
+    text-align: right;
+    flex-shrink: 0;
+}
+
+/* Legacy pill style (fallback) */
+.plugin-meta {
+    display: flex;
+    flex-direction: column;
+    border-radius: 4px;
+    overflow: hidden;
+    font-size: 0.75em;
+    border: 1px solid #ddd;
+    flex-shrink: 0;
+}
+
+.plugin-meta .version-row {
+    display: flex;
+    align-items: stretch;
+    white-space: nowrap;
+}
+
+.plugin-meta .version {
+    background: #f0f0f0;
+    color: #555;
+    padding: 2px 8px;
+    font-weight: 600;
+    flex-grow: 1;
+}
+
+.plugin-meta .grails-compat {
+    background: #f8f8f8;
+    color: #777;
+    padding: 2px 8px;
+    border-left: 1px solid #ddd;
+}
+
+.plugin-meta .version-date-row {
+    background: #fafafa;
+    color: #888;
+    padding: 2px 8px;
+    border-top: 1px solid #eee;
+    text-align: center;
+    font-size: 0.9em;
+}
+
+/* Maven coordinates wrapper */
+.coords-wrapper {
+    display: flex;
+    align-items: stretch;
+    gap: 0;
+    border-radius: 4px;
+    overflow: hidden;
+    border: 1px solid #e0e0e0;
+    background: #f5f5f5;
+}
+
+.coords-wrapper a.maven-link {
+    flex: 1;
+    min-width: 0;
+    text-decoration: none;
+    display: flex;
+    align-items: center;
+}
+
+.coords-wrapper a.maven-link:hover code.coords {
+    background: #eaeaea;
+}
+
+/* Override screen.css code { margin: 0 !important; padding: 0 !important; } */
+li.plugin .coords-wrapper code.coords {
+    display: block;
+    width: 100%;
+    background: #f5f5f5;
+    padding: 12px 14px !important;
+    margin: 0 !important;
+    font-size: 0.8em;
+    color: #555;
+    border: none;
+    border-radius: 0;
+    transition: background 0.2s;
+    line-height: 1.5;
+    word-break: break-word;
+    overflow-wrap: break-word;
+}
+
+.coords-wrapper .copy-btn {
+    flex-shrink: 0;
+    width: 44px;
+    padding: 0 14px;
+    background: #e8e8e8;
+    border: none;
+    border-left: 1px solid #ddd;
+    cursor: pointer;
+    font-size: 1em;
+    transition: background 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.coords-wrapper .copy-btn:hover {
+    background: #ddd;
+}
+
+.coords-wrapper .copy-btn.copied {
+    background: #4caf50;
+    color: white;
+}
+
+/* Plugin card footer */
+.plugin-card-footer {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    flex-wrap: wrap;
+}
+
+.plugin-card-footer .footer-left {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap;
+}
+
+.plugin-card-footer .owner-pill {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 3px 10px;
+    background: #f0f4f8;
+    color: #5a7a9a;
+    border-radius: 12px;
+    font-size: 0.75em;
+    text-decoration: none;
+    transition: background 0.2s;
+}
+
+.plugin-card-footer .owner-pill .owner-icon {
+    font-size: 0.8em;
+    color: #5a7a9a;
+}
+
+.plugin-card-footer .owner-pill:hover {
+    background: #e0e8f0;
+    color: #4a6a8a;
+}
+
+.plugin-card-footer .owner-pill:hover .owner-icon {
+    color: #4a6a8a;
+}
+
+.plugin-card-footer .docs-link {
+    display: inline-flex;
+    align-items: center;
+    padding: 3px 8px;
+    background: #f0f0f0;
+    border-radius: 4px;
+    color: #555;
+    font-size: 0.75em;
+    text-decoration: none;
+    transition: background 0.2s;
+}
+
+.plugin-card-footer .docs-link:hover {
+    background: #e0e0e0;
+}
+
+.plugin-card-footer .license {
+    color: #888;
+    font-size: 0.7em;
+    padding: 2px 6px;
+    background: #f8f8f8;
+    border-radius: 3px;
+}
+
+/* GitHub stars - toned down, inline with tags */
+.github-stars {
+    display: inline-flex;
+    align-items: center;
+    color: #888;
+    font-size: 0.75em;
+}
+
+/* Tags row with GitHub stars */
+.plugin-tags-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    flex-wrap: wrap;
+    margin-top: auto;
+}
+
+/* Labels/tags */
+li.plugin ul.labels {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    padding: 0;
+    margin: 0;
+    list-style: none;
+}
+
+li.plugin ul.labels li.label {
+    padding: 0;
+    box-shadow: none;
+    background: none;
+}
+
+li.plugin ul.labels li.label a {
+    display: inline-block;
+    padding: 3px 8px;
+    background: #f0f4f8;
+    color: #5a7a9a;
+    border-radius: 3px;
+    font-size: 0.75em;
+    text-decoration: none;
+    transition: background 0.2s;
+}
+
+li.plugin ul.labels li.label a:hover {
+    background: #e0e8f0;
+}
+
+
+/* Two column layout adjustments */
+div.two-columns div.column {
     width: 44.326906%;
     padding: 15px;
 }
 
-article.quote, .guidegroup {
+article.quote, .guide-group {
     border-top: 0;
     padding-top: 0px;
 }
 
-.columnheader {
+.column-header {
     margin-top: 50px;
-    margin-bottom: 10px;
+    margin-bottom: 15px;
+    font-size: 1.3em;
+    color: #333;
+    border-bottom: 2px solid #feb672;
+    padding-bottom: 8px;
+}
+
+/* Responsive adjustments - matches main site breakpoint */
+@media screen and (max-width: 960px) {
+    li.plugin {
+        padding: 16px;
+    }
+
+    .plugins-nav {
+        gap: 2px;
+    }
+
+    .plugins-nav .nav-tab {
+        padding: 8px 12px;
+        font-size: 0.85em;
+    }
+
+    .search-pagination-row {
+        flex-direction: column;
+        align-items: flex-start;
+        gap: 10px;
+    }
+
+    .search-box-inline {
+        flex: none;
+        width: 100%;
+        min-width: auto;
+    }
 }
diff --git a/assets/stylesheets/screen.css b/assets/stylesheets/screen.css
index 6a4369c10dd..ebbbfd5e59c 100644
--- a/assets/stylesheets/screen.css
+++ b/assets/stylesheets/screen.css
@@ -75,22 +75,22 @@ body {
 	background-color: #F5F5F5;
 	margin: 0;
 	padding: 0;
-	font-family: 'Roboto',"Open Sans","Helvetica Neue", Helvetica, Arial,sans-serif;
+	font-family: Roboto, 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
 	font-size: 16px;
 	height: 100%;
 }
 
-body.documentation .headerbar,
+body.documentation .header-bar,
 .question h2,
-.columnheader a,
-.columnheader {
+.column-header a,
+.column-header {
 	color: #FFFFFF;
 	background-color: #C8C8C8;
 }
-body.guides .columnheader a {
+body.guides .column-header a {
 	background-color: transparent;
 }
-body.guides .columnheader a:hover {
+body.guides .column-header a:hover {
 	color: #FFFFFF;
 	text-decoration: underline;
 }
@@ -108,19 +108,19 @@ ul, ol {
 	padding-left: 20px;
 }
 
-.blogpost > ul, ol {
+.blog-post > ul, ol {
 	padding-left: 20px !important;
 }
 
-.blogpost > ul {
+.blog-post > ul {
 	list-style: disc !important;
 }
 
-.blogpost > ol, ul ol {
+.blog-post > ol, ul ol {
 	list-style: lower-roman !important;
 }
 
-.blogpost > ol {
+.blog-post > ol {
 	list-style: lower-roman !important;
 }
 
@@ -129,7 +129,7 @@ b, strong {
 }
 
 h4, h5, h6, b, strong {
-color: #000;
+	color: #000;
 }
 
 html, body {
@@ -139,11 +139,12 @@ html, body {
 body.home {
 	background-color: #ffffff;
 }
+
 a {
 	text-decoration: underline;
 }
 
-.columnheader a {
+.column-header a {
 	text-decoration: none;
 }
 
@@ -162,7 +163,7 @@ h6 {
 	padding: 0;
 }
 
-.tagcloud {
+.tag-cloud {
 	overflow: auto;
 	margin-bottom: 44px !important; /* 44px + 6px tag margin bottom */
 }
@@ -268,7 +269,7 @@ li {
 	margin-bottom: 10px;
 }
 
-.fullwidth,
+.full-width,
 .content {
 	margin: 0 auto;
 	z-index: 2;
@@ -281,22 +282,22 @@ li {
 }
 
 
-.listingblock .content {
+.listing-block .content {
 	width: auto;
 	max-width: 100%;
 }
 
-.fullwidth {
+.full-width {
 	width: 99%;
 }
 
-.fullwidth p,
+.full-width p,
 .content p {
 	font-size: 18px;
 	line-height: 24px;
 }
 
-.fullwidth article,
+.full-width article,
 .content article {
 	clear: both;
 	margin: 50px 0 50px;
@@ -307,7 +308,7 @@ li {
 }
 
 div.code,
-.fullwidth article pre,
+.full-width article pre,
 .content article pre {
 	background-color: #f5f5f5;
 	font-family: "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
@@ -326,7 +327,7 @@ code {
 
 .quote,
 .post,
-.usergroup,
+.user-group,
 .question,
 .event,
 .book {
@@ -339,38 +340,38 @@ code {
 
 .quote,
 .post,
-.usergroup,
+.user-group,
 .question,
 .event,
 .book,
-.transparent_post  {
+.transparent-post  {
 	clear: both;
 	overflow: auto;
 	margin: 0;
 	padding: 50px;
 }
 
-.twocolumns .column:first-child .transparent_post {
+.two-columns .column:first-child .transparent-post {
 	padding-left: 0;
 }
 
-.usergroup,
+.user-group,
 .question,
 .event,
 .book {
 	margin-bottom: 50px;
 }
 
-.column usergroup:last-child,
+.column user-group:last-child,
 .column .question:last-child,
 .column .event:last-child {
 	margin-bottom: 0;
 }
 
-.usergroup ul { padding-left: 0; margin-left: 0; }
-.usergroup li { list-style-type: none; }
+.user-group ul { padding-left: 0; margin-left: 0; }
+.user-group li { list-style-type: none; }
 
-.usergroup a { color: #7c7c7c;}
+.user-group a { color: #7c7c7c;}
 
 .event ul,
 .event li,
@@ -422,81 +423,81 @@ code {
 	margin-top: 5px;
 }
 
-.mainheader {
+.main-header {
 	padding-top: 17px;
 	padding-bottom: 0;
 	overflow: auto;
 	background-color: #ffffff !important;
 }
 
-body.guide .colist th,
-body.guide .colist td {
+body.guide .co-list th,
+body.guide .co-list td {
 	border: none;
 }
 
-body.guide .mainheader,
-body.grails3plugins .mainheader,
-body.gorm .mainheader {
+body.guide .main-header,
+body.grails3-plugins .main-header,
+body.gorm .main-header {
 	background-color: #f5f5f5 !important;
 }
 
-#showNavigationLink {
+#show-navigation-link {
 	margin-top: 12px;
 	margin-bottom: 20px;
 	display: block;
 }
 
-.secondarymenu ul {
+.secondary-menu ul {
 	margin: 0;
 	padding: 0;
 }
-.mainmenu {
+.main-menu {
 	clear: right;
 }
 
-#topmenus {
+#top-menus {
 	margin-top: 17px;
 	display: none;
 	background-color: gray;
 	overflow: auto;
 	padding: 10px;
 }
-#topmenus a {
+#top-menus a {
 	color: #ffffff;
 }
 
-.mainmenu,
-.secondarymenu,
-.mainmenu ul,
-.secondarymenu ul {
+.main-menu,
+.secondary-menu,
+.main-menu ul,
+.secondary-menu ul {
 	margin: 0;
 	padding: 0;
 }
-.secondarymenu,
-.mainmenu {
+.secondary-menu,
+.main-menu {
 	float: right;
 	width: 50%;
 	clear: none;
 }
 
 @media screen and (min-width: 961px) {
-	.secondarymenu,
-	.mainmenu {
+	.secondary-menu,
+	.main-menu {
 		float: none;
 		width: auto;
 		clear: both;
 	}
 }
 
-.mainmenu li,
-.secondarymenu li,
-.secondarymenu li a,
-.mainmenu li a {
+.main-menu li,
+.secondary-menu li,
+.secondary-menu li a,
+.main-menu li a {
 	text-align: left;
 }
 
-.mainmenu li,
-.secondarymenu li {
+.main-menu li,
+.secondary-menu li {
 	margin: 0;
 	padding: 0;
 	font-size: 16px;
@@ -504,51 +505,51 @@ body.gorm .mainheader {
 	float: left;
 }
 
-.secondarymenu li,
-.mainmenu li {
+.secondary-menu li,
+.main-menu li {
 	width: 100%;
 }
 
-.secondarymenu li,
-.mainmenu li {
+.secondary-menu li,
+.main-menu li {
 	list-style-type: none;
 	padding: 0;
 }
 
-.secondarymenu li a,
-.mainmenu li a {
+.secondary-menu li a,
+.main-menu li a {
 	display: block;
 	padding: 20px 5px;
 	text-decoration: none;
 	letter-spacing: 0.5px;
 	color: #7c7c7c;
 }
-.secondarymenu li.active a,
-.mainmenu li.active a {
+.secondary-menu li.active a,
+.main-menu li.active a {
 	text-decoration: underline;
 }
 
-.secondarymenu li a {
+.secondary-menu li a {
 	text-transform: uppercase;
 }
-.headerbar {
+.header-bar {
 	clear: both;
 	text-transform: uppercase;
 	padding-bottom: 42.5px;
 }
 
-.headerbar a {
+.header-bar a {
 	color: #ffffff;
 }
 
-.headerbar h1 {
+.header-bar h1 {
 	color: #ffffff;
 	float: left;
 	font-size: 24px;
 	font-family: 'Roboto-Medium', "Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
 }
 
-.headerbar h1 {
+.header-bar h1 {
 	margin-top: 42.5px;
 }
 
@@ -557,22 +558,22 @@ body.gorm .mainheader {
 }
 
 .training th,
-.calltoaction h2 {
+.call-to-action h2 {
 	background-color: gray;/*#feb672;*/
 }
 
-.get-started-row .calltoaction h2 {
+.get-started-row .call-to-action h2 {
     background-color: #255aa8;
 }
 
-.headerbar,
+.header-bar,
 #features {
 	background-color: #255AA8;
 }
 
 a,
 a:hover,
-.partnersnav li a {
+.partners-nav li a {
 	color: #255AA8;
 }
 
@@ -596,24 +597,24 @@ a:hover,
 	text-transform: uppercase;
 	font-family: 'Archia-Light', 'Roboto-Light', "Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
 }
-.chailicelogo {
+.chalice-logo {
 	display: none;
 }
-.downloadlatestversion {
+.download-latest-version {
 	margin-top: 15px !important;
 }
 
-.olderversions {
+.older-versions {
 	overflow: auto;
 	margin-bottom: 50px;
 }
 
-.olderversions select {
+.older-versions select {
 	width: 100%;
 	font-size: 20px;
 }
 
-.olderversions h4 {
+.older-versions h4 {
 	background-color: #ffffff;
 	font-size: 18px;
 	font-family: 'Roboto-Medium', sans-serif;
@@ -624,7 +625,7 @@ a:hover,
 }
 
 article.quote,
-.guidegroup {
+.guide-group {
 	border-top: 2px solid #feb672;
 	background-color: #ffffff;
 	padding-top: 12px;
@@ -634,57 +635,57 @@ article.quote,
 	margin-bottom: 50px;
 }
 
-.twocolumns .column article.quote:first-child {
+.two-columns .column article.quote:first-child {
 	margin-top: 50px;
 }
 
-.twocolumns .column .guidegroup:first-child {
+.two-columns .column .guide-group:first-child {
 	margin-top: 30px;
 }
 
-.guidegroup p {
+.guide-group p {
 	margin: 0;
 	padding: 10px 0;
 	background-color: #F5F5F5;
 }
 
-.guidegroupheader {
+.guide-group-header {
 	padding-left: 18px;
 	height: 118px;
 	overflow: hidden;
 	padding-top: 10px;
 }
 
-.guidegroupheader a {
+.guide-group-header a {
 	color: #7c7c7c;
 }
 
-.guidegroupheader img {
+.guide-group-header img {
 	float: left;
 	width: 93px;
 	height: 93px;
 	margin-right: 18px;
 }
 
-.guidegroup h2 {
+.guide-group h2 {
 	height: 144px;
 	text-transform: uppercase;
 	font-size: 18px;
 	padding-top: 40px;
 }
-.guidegroup ul {
+.guide-group ul {
 	padding: 0;
 	margin: 0;
 	background-color: #ffffff;
 }
 
-div.guidegroupbody, div.guidegroupbody p {
+div.guide-group-body, div.guide-group-body p {
 	padding: 10px;
 	margin: 0;
 	background-color: #ffffff;
 }
 
-.guidegroup li {
+.guide-group li {
 	overflow: auto;
 	list-style-type: none;
 	min-height: 60px;
@@ -693,60 +694,60 @@ div.guidegroupbody, div.guidegroupbody p {
 	padding: 38px 30px 0;
 }
 
-.guidegroup li.legend {
+.guide-group li.legend {
 	padding-top: 0;
 	border: 0;
 }
 
 
-.guidegroup li a {
+.guide-group li a {
 	display: block;
 	text-decoration: underline;
 	letter-spacing: 1px;
 }
-.guidegroup li span.title {
+.guide-group li span.title {
 	display: block;
 	border: 1px solid #ffffff;
 }
-.guidegroup li span.title,
-.guidegroup li a.guide {
+.guide-group li span.title,
+.guide-group li a.guide {
 	background-image: url(../images/caret.png);
 	background-repeat: no-repeat;
 	background-position: right center;
 	padding-right: 20px;
 }
-.guidegroup li a.quickcast {
+.guide-group li a.quick-cast {
 	background-image: url(../images/video.png);
 }
 
-.guidegroup li a {
-	font-family: 'Roboto-Light', "Open Sans","Helvetica Neue", Helvetica, Arial, sans-serif;
+.guide-group li a {
+	font-family: Roboto-Light, 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
 	color: #7c7c7c;
 }
-.guidegroup li a.grailsVersion {
+.guide-group li a.grails-version {
 	float: left;
 	padding-right: 20px;
 }
 
-.guidegroup li.legend a {
+.guide-group li.legend a {
 	background-image: none;
 	display: inline;
 	padding: 0;
 }
-.sponsoredby {
+.sponsored-by {
 	margin-top: 30px;
 }
-.sponsoredby h4 {
+.sponsored-by h4 {
 	text-transform: uppercase;
 	font-size: 14px;
 	margin: 0 0 35px 10px;
 }
 
-.sponsoredby img {
+.sponsored-by img {
 	width: 100%;
 }
 
-.sponsoredby, .search, .latestguides {
+.sponsored-by, .search, .latest-guides {
 	margin-bottom: 24px;
 }
 
@@ -768,8 +769,8 @@ div.search {
 	display: block;
 }
 
-.goldenratio,
-.twocolumns {
+.golden-ratio,
+.two-columns {
 	overflow: auto;
 }
 .column {
@@ -777,7 +778,7 @@ div.search {
 	overflow: hidden;
 }
 
-.columnheader {
+.column-header {
 	min-height: 35px;
 	padding-top: 12px;
 	padding-bottom: 6px;
@@ -791,58 +792,58 @@ div.search {
 	margin-top: 50px;
 	margin-bottom: 50px;
 }
-.transparent_post .columnheader,
-.question .columnheader,
-.post .columnheader {
+.transparent-post .column-header,
+.question .column-header,
+.post .column-header {
 	margin-top: 0;
 	margin-bottom: 20px;
 }
 
-body.documentation .columnheader,
-body.guides .columnheader {
+body.documentation .column-header,
+body.guides .column-header {
 	background-color: #255AA8;
 	color: #ffffff;
 }
 
-.latestguides ul {
+.latest-guides ul {
 	margin-bottom: 40px;
 	padding-left: 0;
 }
 
-body.grails3plugins .latestguides ul {
+body.grails3-plugins .latest-guides ul {
 	padding: 0 0 0 20px;
 }
 
-.latestguides li {
+.latest-guides li {
 	list-style-type: none;
 	margin-bottom: 30px;
 }
 
-.latestguides li b {
+.latest-guides li b {
 	text-transform: uppercase;
 	font-weight: normal;
 }
 
 li.plugin h3,
-.latestguides li b,
-.latestguides li a,
-.latestguides li span {
+.latest-guides li b,
+.latest-guides li a,
+.latest-guides li span {
 	display: block;
 	margin-bottom: 5px;
 }
-.latestguides li a,
-.latestguides li span {
+.latest-guides li a,
+.latest-guides li span {
 	font-size: 14px;
 }
-.tagsbytopic ul {
+.tags-by-topic ul {
 	margin: 40px 0 0 0;
 	padding: 0;
 	overflow: auto;
 }
-body.grails3plugins .tagsbytopic ul {
+body.grails3-plugins .tags-by-topic ul {
 	padding-left: 20px;
 }
-.tagsbytopic li {
+.tags-by-topic li {
 	list-style-type: none;
 	background-color: #F0C45A;
 	float: left;
@@ -854,62 +855,62 @@ body.grails3plugins .tagsbytopic ul {
 	vertical-align: middle;
 	padding: 7px 10px 3px;
 }
-.tagsbytopic li a {
+.tags-by-topic li a {
 	text-decoration: none;
 	color: #ffffff;
 	text-transform: uppercase;
 }
 
-.tagsbytopic li.tag1{ font-size: 12px; background-color: #dac589; }
-.tagsbytopic li.tag2{ font-size: 12px; background-color: #f0c45b; }
-.tagsbytopic li.tag3{ font-size: 12px; background-color: #f0c45b;}
-.tagsbytopic li.tag4{ font-size: 12px; background-color: #f2c458;}
-.tagsbytopic li.tag5{ font-size: 12px; background-color: #f2c458;}
-.tagsbytopic li.tag6{ font-size: 16px; background-color: #f2c458;}
-.tagsbytopic li.tag7{ font-size: 16px; background-color: #feb672;}
-.tagsbytopic li.tag8{ font-size: 16px; background-color: #feb672;}
-.tagsbytopic li.tag9{ font-size: 16px; background-color: #feb672;}
-.tagsbytopic li.tag10{ font-size: 16px; background-color: #feb672;}
-.tagsbytopic li.tag11{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag12{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag13{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag14{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag15{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag16{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag17{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag18{ font-size: 20px; background-color: #feb672;}
-.tagsbytopic li.tag19{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag20{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag21{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag22{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag23{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag24{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag25{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag26{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag27{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag28{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag29{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag30{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag31{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag32{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag33{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag34{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag35{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag36{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag37{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag38{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag39{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag40{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag41{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag42{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag43{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag44{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag45{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag46{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag47{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag48{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag49{ font-size: 24px; background-color: #feb672;}
-.tagsbytopic li.tag50{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag1{ font-size: 12px; background-color: #dac589; }
+.tags-by-topic li.tag2{ font-size: 12px; background-color: #f0c45b; }
+.tags-by-topic li.tag3{ font-size: 12px; background-color: #f0c45b;}
+.tags-by-topic li.tag4{ font-size: 12px; background-color: #f2c458;}
+.tags-by-topic li.tag5{ font-size: 12px; background-color: #f2c458;}
+.tags-by-topic li.tag6{ font-size: 16px; background-color: #f2c458;}
+.tags-by-topic li.tag7{ font-size: 16px; background-color: #feb672;}
+.tags-by-topic li.tag8{ font-size: 16px; background-color: #feb672;}
+.tags-by-topic li.tag9{ font-size: 16px; background-color: #feb672;}
+.tags-by-topic li.tag10{ font-size: 16px; background-color: #feb672;}
+.tags-by-topic li.tag11{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag12{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag13{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag14{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag15{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag16{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag17{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag18{ font-size: 20px; background-color: #feb672;}
+.tags-by-topic li.tag19{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag20{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag21{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag22{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag23{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag24{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag25{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag26{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag27{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag28{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag29{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag30{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag31{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag32{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag33{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag34{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag35{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag36{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag37{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag38{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag39{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag40{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag41{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag42{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag43{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag44{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag45{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag46{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag47{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag48{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag49{ font-size: 24px; background-color: #feb672;}
+.tags-by-topic li.tag50{ font-size: 24px; background-color: #feb672;}
 
 footer {
 	clear: both;
@@ -923,9 +924,34 @@ footer li {
 	line-height: 15px;
 }
 
+footer .content {
+	flex-direction: row;
+	justify-content: center;
+	align-items: flex-start;
+	gap: 60px;
+}
+
 .apache-grails {
-	text-align: center !important;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	text-align: center;
+}
+
+.apache-grails p {
+	font-size: 15px;
+	color: #666;
+	line-height: 1.6;
+	margin: 10px 0;
+	max-width: 480px;
+}
+
+.apache-grails img {
+	margin: 20px 0;
+	max-width: 300px;
+	height: auto;
 }
+
 .apache-grails span {
 	text-align: center;
 	display: block;
@@ -934,60 +960,95 @@ footer li {
 	line-height: 15px;
 }
 
-.socialmedianav ul {
+.social-media-nav {
+	text-align: center;
+	margin: 30px 0;
+}
+
+.social-media-nav ul {
 	display: flex;
 	justify-content: center;
-	padding-left: 1em;
+	padding-left: 0;
+	margin: 0;
 	flex-wrap: wrap;
+	gap: 10px;
 }
-@media screen and (min-width: 961px) {
-	.socialmedianav ul {
-		flex-wrap: nowrap;
-	}
-}
-.socialmedianav li {
-	margin-left: 10px;
-	margin-right: 10px;
+
+.social-media-nav li {
 	list-style-type: none;
 	width: 45px;
 	height: 45px;
+	margin: 0;
+}
+
+.social-media-nav li a {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 100%;
+	height: 100%;
+	transition: transform 0.2s;
+}
+
+.social-media-nav li a:hover {
+	transform: scale(1.1);
+}
+
+.social-media-nav li img {
+	width: 45px;
+	height: 45px;
 }
 
-.partnersnav ul {
+.partners-nav ul {
 	padding-left: 1em;
 }
-.partnersnav li {
+.partners-nav li {
 	list-style-type: none;
 	color: #7C7C7C;
 	margin-bottom: 20px;
 }
-.partnersnav li a {
+.partners-nav li a {
 	text-decoration: none;
 }
 
-.apache-grails,
-.partnersnav,
-.socialmedianav {
+.footer-bottom {
 	text-align: center;
-	margin-bottom: 30px;
+	padding: 20px;
+	background-color: #f5f5f5;
+	border-top: 1px solid #eee;
 }
 
-.grailslogo {
+.footer-bottom p {
+	margin: 5px 0;
+	font-size: 14px;
+	color: #666;
+}
+
+.footer-bottom a {
+	color: #5a7a9a;
+	text-decoration: none;
+}
+
+.footer-bottom a:hover {
+	text-decoration: underline;
+}
+
+.grails-logo {
 	text-align: center;
 	margin: 0 auto;
 	display: block;
 	max-width: 8rem;
 }
 
-body.home .grailslogo {
+body.home .grails-logo {
 	display: block;
 }
 
-.homebar {
+.home-bar {
 	padding-top: 50px;
 	background-color: #f8f7f7 !important;
 }
-.homebar h1 {
+.home-bar h1 {
 	text-align: center;
 	width: 52.58545136%; /* 680px / 1141px; */
 	margin: 0 auto;
@@ -1002,7 +1063,7 @@ article.quote h2 {
 	padding: 46px 40px;
 }
 
-.calltoaction h2 {
+.call-to-action h2 {
 	padding: 10px;
 	width: 90%;
 	max-width: 270px;
@@ -1017,7 +1078,7 @@ article.quote h2 {
 	font-family: 'Archia-Light', 'Roboto-Light', "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
 }
 
-.calltoaction {
+.call-to-action {
 	text-align: center;
 	overflow: auto;
 	padding-top: 30px;
@@ -1025,17 +1086,17 @@ article.quote h2 {
 }
 
 
-.calltoaction.position-reset {
+.call-to-action.position-reset {
 	padding-top: 0;
 	padding-bottom: 0;
 }
 
 
-.calltoaction h2 a {
+.call-to-action h2 a {
 	font-size: 16px;
 }
 
-.calltoaction h2 a {
+.call-to-action h2 a {
 	color: #ffffff;
 	text-decoration: none;
 	width: 100%;
@@ -1043,17 +1104,17 @@ article.quote h2 {
 	font-weight: normal;
 	letter-spacing: 1px;
 }
-.calltoaction .allversions {
+.call-to-action .all-versions {
 	margin-top: 15px;
 	display: block;
 	color: #7c7c7c;
 }
-.calltoaction b {
+.call-to-action b {
 	margin-bottom: 10px;
 	font-size: 14px;
 }
-.calltoaction h2 span.version,
-.calltoaction b {
+.call-to-action h2 span.version,
+.call-to-action b {
 	display: block;
 }
 
@@ -1083,7 +1144,7 @@ article.quote h2 {
 	padding: 0;
 }
 
-.threecolumns .column {
+.three-columns .column {
 	margin-top: 20px;
 	margin-bottom: 0;
 }
@@ -1095,204 +1156,200 @@ article.quote h2 {
 
 @media screen and (min-width: 961px) {
 
-	#topmenus a {
+	#top-menus a {
 		color: rgb(124, 124, 124);
 	}
 
-	.calltoaction h2 a {
+	.call-to-action h2 a {
 		font-size: 20px;
 	}
 	.get-started-row {
 		display: flex;
 	}
 
-	.mainheader {
+	.main-header {
 		padding-bottom: 50px;
 	}
 
-	#showNavigationLink {
+	#show-navigation-link {
 		display: none;
 	}
 
-	.homebar h1 {
+	.home-bar h1 {
 		font-size: 27px;
 	}
 
-	.grailslogo {
+	.grails-logo {
 		float: left;
 		display: block;
 	}
-	#topmenus {
+	#top-menus {
 		margin-top: 0;
 		display: block;
 		background-color: #ffffff;
 	}
-	body.guide #topmenus,
-	body.grails3plugins #topmenus,
-	body.gorm #topmenus {
+	body.guide #top-menus,
+	body.grails3-plugins #top-menus,
+	body.gorm #top-menus {
 		background-color: #F5F5F5;
 	}
 
-	.mainmenu li,
-	.secondarymenu li,
-	.secondarymenu li a,
-	.mainmenu li a {
+	.main-menu li,
+	.secondary-menu li,
+	.secondary-menu li a,
+	.main-menu li a {
 		text-align: center;
 	}
-	.mainmenu li,
-	.secondarymenu li {
+	.main-menu li,
+	.secondary-menu li {
 		text-align: right;
 		margin-left: 25px;
 	}
-	.mainmenu li { font-size: 16px; }
-	.secondarymenu li { font-size: 10px; }
+	.main-menu li { font-size: 16px; }
+	.secondary-menu li { font-size: 10px; }
 
-	.mainmenu li,
-	.secondarymenu li {
+	.main-menu li,
+	.secondary-menu li {
 		padding-bottom: 11px;
 		border-bottom: 3px solid #FFFFFF;
 	}
-	body.guide .mainmenu li,
-	body.guide .secondarymenu li,
-	body.grails3plugins .mainmenu li,
-	body.grails3plugins .secondarymenu li,
-	body.gorm .mainmenu li,
-	body.gorm .secondarymenu li {
+	body.guide .main-menu li,
+	body.guide .secondary-menu li,
+	body.grails3-plugins .main-menu li,
+	body.grails3-plugins .secondary-menu li,
+	body.gorm .main-menu li,
+	body.gorm .secondary-menu li {
 		border-bottom: 3px solid #f5f5f5;
 	}
 
-	body.home .mainmenu li,
-	body.home .secondarymenu li {
+	body.home .main-menu li,
+	body.home .secondary-menu li {
 		border-bottom: 3px solid #FFFFFF;
 	}
 
-	body.guide .mainmenu li.active,
-	body.guide .secondarymenu li.active,
-	body.grails3plugins .mainmenu li.active,
-	body.grails3plugins .secondarymenu li.active,
-	.mainmenu li.active,
-	.secondarymenu li.active {
+	body.guide .main-menu li.active,
+	body.guide .secondary-menu li.active,
+	body.grails3-plugins .main-menu li.active,
+	body.grails3-plugins .secondary-menu li.active,
+	.main-menu li.active,
+	.secondary-menu li.active {
 		border-bottom: 3px solid #feb672;
 	}
-	.secondarymenu li.active a,
-	.mainmenu li.active a {
+	.secondary-menu li.active a,
+	.main-menu li.active a {
 		text-decoration: none;
 	}
-	.secondarymenu,
-	.mainmenu {
+	.secondary-menu,
+	.main-menu {
 		float: right;
 	}
-	.secondarymenu li,
-	.mainmenu li {
+	.secondary-menu li,
+	.main-menu li {
 		float: left;
 		width: auto;
 	}
-	.secondarymenu li a,
-	.mainmenu li a {
+	.secondary-menu li a,
+	.main-menu li a {
 		padding: 0;
 	}
-	.secondarymenu {
+	.secondary-menu {
 		margin-bottom: 35px;
 	}
 
-	.mainmenu li a {
+	.main-menu li a {
 		text-transform: none;
 	}
 
-	.homebar {
+	.home-bar {
 		background-image: url(../images/banner.png);
 		background-repeat: no-repeat;
 		background-position: center top;
 	}
 
-	.chalicesbg {
+	.chalices-bg {
 		background-image: url(../images/chalices.svg);
 		background-repeat: no-repeat;
 		background-position: center center;
 	}
-	.chalicesbg a,
-	.chalicesbg a:visited,
-	.chalicesbg a:hover {
+	.chalices-bg a,
+	.chalices-bg a:visited,
+	.chalices-bg a:hover {
 		color: #FFFFFF;
 		text-decoration: none;
 
 	}
-	.chalicesbg a:hover {
+	.chalices-bg a:hover {
 		text-decoration: underline;
 	}
-	.headerbar {
+	.header-bar {
 		height: 200px;
 		padding-top: 0;
 	}
-	.headerbar h1 {
+	.header-bar h1 {
 		margin-top: 110px;
 	}
 
-	.twocolumns .column {
+	.two-columns .column {
 		float: right;
 		width: 47.32690622%; /* 547 / 1141px;*/
 	}
-	.goldenratio .column {
+	.golden-ratio .column {
 		float: right;
 		width: 38.2%;
 	}
-	.goldenratio .column:first-child {
+	.golden-ratio .column:first-child {
 		float: right;
 		width: 61.8%;
 	}
 
-	.fivecolumns,
-	.fourcolumns,
-	.threecolumns {
+	.five-columns,
+	.four-columns,
+	.three-columns {
 		clear: both;
 		overflow: auto;
 		margin: 0;
 		padding: 0;
 	}
 
-	.fivecolumns .column,
-	.fourcolumns .column,
-	.threecolumns .column {
+	.five-columns .column,
+	.four-columns .column,
+	.three-columns .column {
 		float: left;
 		overflow: auto;
 	}
 
-	.fivecolumns .column {
+	.five-columns .column {
 		width: 18%;
 	}
-	.fourcolumns .column {
+	.four-columns .column {
 		width: 23%;
 	}
-	.threecolumns .column {
+	.three-columns .column {
 		width: 31%;
 	}
 
-	.fivecolumns .column,
-	.fourcolumns .column {
+	.five-columns .column,
+	.four-columns .column {
 		margin-left: 1%;
 		margin-right: 1%;
 	}
 
-	.threecolumns .column:nth-child(3n+2) {
+	.three-columns .column:nth-child(3n+2) {
 		margin-left: 3%;
 		margin-right: 3%;
 	}
-	.threecolumns .column:nth-child(3n+1) {
+	.three-columns .column:nth-child(3n+1) {
 		clear: left;
 	}
-	.twocolumns .column.odd,
-	.twocolumns .column:first-child {
+	.two-columns .column.odd,
+	.two-columns .column:first-child {
 		margin-left: 4.382120947%; /* 30px / 1141px; */
 	}
 
 	footer {
 		overflow: auto;
 	}
-	footer .content {
-		display: flex;
-		justify-content: center;
-	}
-	.partnersnav li {
+	.partners-nav li {
 		text-align: right;
 	}
 	.event img {
@@ -1302,20 +1359,18 @@ article.quote h2 {
 		width: 120px;
 	}
 
-	.calltoactions {
+	.call-to-actions {
 		width: 56.09114812%;/* 640px / 1141px; */
 		margin: 0 auto;
 		overflow: auto;
 	}
 
-	.calltoaction {
+	.call-to-action {
 		width: 42.1875%; /* 270px / 640px */
 		float: left;
 		padding: 3.90625% 3.90625% 50px 3.90625%; /* 25px / 640px */
 		overflow: auto;
 	}
-	.apache-grails span { text-align: left; }
-	.apache-grails { text-align: left !important; }
 }
 
 /**************/
@@ -1342,7 +1397,7 @@ article.guide {
 	border-top-right-radius: 5px;
 	background-color: #ffffff !important;
 }
-article.guide .mauticform_wrapper {
+article.guide .mautic-form-wrapper {
 	margin: 0;
 }
 @media screen and (min-width: 961px) {
@@ -1372,16 +1427,16 @@ article.guide aside button.btn-large {
 	border: none;
 }
 
-article.guide aside .githublinks {
+article.guide aside .github-links {
 	margin: 5px 0;
 	overflow: auto;
 }
 
-ul.githublinks {
+ul.github-links {
 	padding-left: 0;
 }
 
-article.guide aside .githublinks li {
+article.guide aside .github-links li {
 	width: 46%;
 	margin-top: 0.5%;
 	margin-bottom: 0.5%;
@@ -1389,15 +1444,15 @@ article.guide aside .githublinks li {
 	list-style-type: none;
 	float: left;
 }
-article.guide aside .githublinks li:first-child {
+article.guide aside .github-links li:first-child {
 	margin-right: 4%;
 	margin-left: 0;
 }
-article.guide aside .githublinks li:last-child {
+article.guide aside .github-links li:last-child {
 	margin-left: 4%;
 	margin-right: 0;
 }
-article.guide aside .githublinks li button {
+article.guide aside .github-links li button {
 	width: 100%;
 }
 
@@ -1413,8 +1468,8 @@ article.guide aside .githublinks li button {
 article.guide th {
 	color: #ffffff;
 }
-article.guide #ocitraining td,
-article.guide td.tableblock {
+article.guide #oci-training td,
+article.guide td.table-block {
 	background-color: rgb(245, 245, 245);
 }
 article.guide td a {
@@ -1431,8 +1486,8 @@ article.guide h3,
 article.guide h4,
 article.guide h5,
 article.guide h6,
-article.guide #toctitle,
-article.guide .sidebarblock>.content>.title {
+article.guide #toc-title,
+article.guide .sidebar-block>.content>.title {
 	color: #7c7c7c;
 }
 
@@ -1474,33 +1529,33 @@ article.guide .tip td.content {
 }
 
 
-body.grails3plugins .thirdmenu ul {
+body.grails3-plugins .third-menu ul {
 	margin: 0;
 	padding: 0;
 }
-body.grails3plugins .thirdmenu li {
+body.grails3-plugins .third-menu li {
 	list-style-type: none;
 	border-bottom: 1px solid #eee;
 	border-left: 4px solid #ffffff !important;
 }
 
-body.grails3plugins .thirdmenu  li.active {
+body.grails3-plugins .third-menu  li.active {
 	border-left: 4px solid #feb672 !important;
 }
 
-body.grails3plugins .thirdmenu li a {
+body.grails3-plugins .third-menu li a {
 	color: #000000;
 	text-decoration: none;
 	padding: 20px;
 	display: block;
 }
-body.grails3plugins article {
+body.grails3-plugins article {
 	margin-top: 0;
 	padding-top: 0;
 	background-color: #ffffff;
 }
 
-.twocolumns .column .plugins:first-child {
+.two-columns .column .plugins:first-child {
 	margin-top: 20px;
 }
 .plugins-list {
@@ -1517,10 +1572,10 @@ body.grails3plugins article {
 	overflow: auto;
 }
 
-.plugins-list li .iconlinks {
+.plugins-list li .icon-links {
 	float: right;
 }
-.plugins-list li .iconlinks li {
+.plugins-list li .icon-links li {
 	list-style: none;
 	display: inline;
 	border: none;
@@ -1528,20 +1583,20 @@ body.grails3plugins article {
 	padding: 0;
 }
 
-body.grails3plugins .labels {
+body.grails3-plugins .labels {
 	overflow: auto;
 	margin: 20px 0;
 	padding: 0;
 	clear: both;
 }
-body.grails3plugins li.label {
+body.grails3-plugins li.label {
 	display: block;
 	float: left;
 	list-style-type: none;
 	margin: 5px;
 	padding: 0;
 }
-body.grails3plugins li.label a {
+body.grails3-plugins li.label a {
 	border: 1px solid rgb(211, 211, 211);
 	text-decoration: none;
 	padding: 10px;
@@ -1573,10 +1628,10 @@ body.grails3plugins li.label a {
 	text-decoration: none;
 }
 
-.align_left {
+.align-left {
 	float: left;
 }
-.align_right {
+.align-right {
 	float: right;
 }
 
@@ -1615,7 +1670,7 @@ body.grails3plugins li.label a {
 	cursor: pointer;
 }
 
-.githubstar {
+.github-star {
 	border-bottom-left-radius: 5px;
 	border-bottom-right-radius: 5px;
 	border-collapse: collapse;
@@ -1641,19 +1696,19 @@ body.grails3plugins li.label a {
 	border: 3px solid #DFDFDF;
 }
 
-.githubstar a {
+.github-star a {
 	color: #000000;
 	display: block;
 }
-.githubstar,
-.githubstar a {
+.github-star,
+.github-star a {
 	width: 120px;
 	height: 44px;
 
 }
 
-.githubstar span.star,
-.githubstar span.count {
+.github-star span.star,
+.github-star span.count {
 	text-align: center;
 	display: block;
 	float: left;
@@ -1661,7 +1716,7 @@ body.grails3plugins li.label a {
 	height: 28px;
 }
 
-.githubstar span.count {
+.github-star span.count {
 	width: 35px;
 	float: right;
 	border-left: 3px solid #DFDFDF;
@@ -1670,7 +1725,7 @@ body.grails3plugins li.label a {
 	margin-right: 1px;
 	height: 26px;
 }
-.githubstar span.star {
+.github-star span.star {
 	width: 60px;
 	padding-top: 12px;
 }
@@ -1684,19 +1739,19 @@ body.grails3plugins li.label a {
 		padding: 50px;
 	}
 }
-body.post .largegoldenratio {
+body.post .large-golden-ratio {
 	max-width: 100%;
 }
 @media screen and (min-width: 961px) {
-	.largegoldenratio {
+	.large-golden-ratio {
 		width: 56.45721134%;
 		margin-right: 5.34618756%;
 	}
-	.smallgoldenratio {
+	.small-golden-ratio {
 		width: 38.1966011%;
 	}
 }
-.blogpost h1 {
+.blog-post h1 {
 	font-size: 2em !important;
 	line-height: 1.5em !important;
 	margin-bottom: 0;
@@ -1705,32 +1760,32 @@ body.post .largegoldenratio {
 	font-family: 'Roboto-Medium', "Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
 	text-transform: uppercase;
 }
-.blogpost .date,
-.blogpost .author {
+.blog-post .date,
+.blog-post .author {
 	text-transform: uppercase;
 	font-family: 'Roboto-Medium', "Open Sans","Helvetica Neue",Helvetica,Arial, sans-serif;
 }
-.light h2, .blogpost  h2, .blogpost h2 {
+.light h2, .blog-post  h2, .blog-post h2 {
 	font-family: 'Roboto-Medium', "Open Sans","Helvetica Neue",Helvetica,Arial, sans-serif;
 	text-transform: uppercase;
 }
-.blogpost article {
+.blog-post article {
 	margin: 15px 0;
 }
-.blogpost img {
+.blog-post img {
 	width: 100%;
 }
 @media screen and (min-width: 961px) {
-	.blogcard h3 {
+	.blog-card h3 {
 		padding-top: 12.843%;
 		padding-bottom: 12.843%;
 	}
 }
-.blogcard h3, .blogcard h2, .blogcard .tagsbytopic {
+.blog-card h3, .blog-card h2, .blog-card .tags-by-topic {
 	padding-left: 5.137%;
 	padding-right: 5.137%;
 }
-.blogcard h3 {
+.blog-card h3 {
 	font-weight: normal;
 	font-family: 'Roboto-Medium', "Open Sans","Helvetica Neue",Helvetica,Arial, sans-serif;
 }
@@ -1741,11 +1796,11 @@ body.post .largegoldenratio {
 	color: #231F20;
 }
 
-.related-posts .blogcard:first-of-type {
+.related-posts .blog-card:first-of-type {
 	margin-top: 1rem !important;
 }
 @media screen and (min-width: 961px) {
-	.blogcard {
+	.blog-card {
 		width: 88.77245509%;
 		height: 0;
 		margin-left: 11.22754491%;
@@ -1753,28 +1808,28 @@ body.post .largegoldenratio {
 		margin-top: 5.613772455%;
 		margin-bottom: 5.613772455%;
 	}
-	.light .blogcard {
+	.light .blog-card {
 		background-color: #255AA8;
 		color: #FFFFFF;
 	}
-	.light .blogcard a:visited,
-	.light .blogcard a:hover,
-	.light .blogcard a {
+	.light .blog-card a:visited,
+	.light .blog-card a:hover,
+	.light .blog-card a {
 		color: #FFFFFF;
 		text-decoration: none;
 	}
-	.light .blogcard a:hover {
+	.light .blog-card a:hover {
 		text-decoration: underline;
 	}
 }
 @media screen and (max-width: 960px) {
-	.blogcard {
+	.blog-card {
 		background-image: none !important;
 		margin-bottom: 50px;
 	}
 }
 @media screen and (min-width: 961px) {
-	.container .smallgoldenratio .blogcard {
+	.container .small-golden-ratio .blog-card {
 		margin-left: 0;
 		margin-right: 11.22754491%;
 		margin-top: 9.585%;
@@ -1784,7 +1839,7 @@ body.post .largegoldenratio {
 .padded-t h2 {
 	display: none;
 }
-.padded-t .blogcard h2 {
+.padded-t .blog-card h2 {
 	display: block;
 }
 
@@ -1796,10 +1851,10 @@ body.post .largegoldenratio {
 		padding-top: 50px;
 		overflow: auto;
 	}
-	.blogcard {
+	.blog-card {
 		background-size: cover;
 	}
-	.container .smallgoldenratio .blogcard:last-child {
+	.container .small-golden-ratio .blog-card:last-child {
 		margin-bottom: 9.585%;
 	}
 }
@@ -1845,10 +1900,10 @@ li.plugin {
 	padding: 1px 10px 1px 15px;
 }
 
-li.plugin .iconlinks {
+li.plugin .icon-links {
 	float: right;
 }
-li.plugin .iconlinks li {
+li.plugin .icon-links li {
 	list-style: none;
 	display: inline;
 	border: none;
@@ -1885,12 +1940,12 @@ ul.labels li.label a {
 	text-transform: uppercase;
 }
 
-li.plugin.p{
+li.plugin.p {
 	font-size: 16px;
 	line-height: 20px;
 }
 
-.versionselector select {
+.version-selector select {
 	border-color: #ddd;
 	border-radius: 2px;
 }
\ No newline at end of file
diff --git a/assets/stylesheets/support-schedule.css b/assets/stylesheets/support-schedule.css
index f7eb0ab0417..d9d9c9c0f74 100644
--- a/assets/stylesheets/support-schedule.css
+++ b/assets/stylesheets/support-schedule.css
@@ -3,7 +3,7 @@ table.support-schedule {
     overflow-x: auto;
 }
 
-.twocolumns.support-schedule {
+.two-columns.support-schedule {
     background-color: #ffffff;
 }
 
diff --git a/assets/stylesheets/support.css b/assets/stylesheets/support.css
index d7ab8654fbd..3fcfe9a8aa5 100644
--- a/assets/stylesheets/support.css
+++ b/assets/stylesheets/support.css
@@ -235,7 +235,7 @@ table.support-schedule {
 	overflow-x: auto;
 }
 
-.twocolumns.support-schedule {
+.two-columns.support-schedule {
 	background-color: #ffffff;
 }
 
diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle
index 60f50c9432a..a76806a10d6 100644
--- a/buildSrc/build.gradle
+++ b/buildSrc/build.gradle
@@ -45,7 +45,7 @@ gradlePlugin {
     plugins {
         register('grails-website') {
             id = 'grails-website'
-            implementationClass = 'org.grails.gradle.GrailsWebsitePlugin'
+            implementationClass = 'website.gradle.GrailsWebsitePlugin'
             displayName = 'Grails Website Plugin'
             description = 'Creates tasks to generate the Grails Website'
         }
diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/buildSrc/src/main/groovy/org/grails/Content.groovy b/buildSrc/src/main/groovy/org/grails/Content.groovy
deleted file mode 100644
index c3f6cdc8843..00000000000
--- a/buildSrc/src/main/groovy/org/grails/Content.groovy
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.grails
-
-import edu.umd.cs.findbugs.annotations.Nullable
-
-interface Content {
-    @Nullable String getPath();
-    @Nullable String getTitle();
-    /**
-     *
-     * @return Body class
-     */
-    @Nullable String getBody();
-    @Nullable String getDate();
-    @Nullable String getDescription();
-    @Nullable List<String> getKeywords();
-    @Nullable String getRobots();
-}
diff --git a/buildSrc/src/main/groovy/org/grails/ContentAndMetadata.groovy b/buildSrc/src/main/groovy/org/grails/ContentAndMetadata.groovy
deleted file mode 100644
index 5bb001a5d02..00000000000
--- a/buildSrc/src/main/groovy/org/grails/ContentAndMetadata.groovy
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.grails
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class ContentAndMetadata {
-    String content
-    Map<String, String> metadata
-}
diff --git a/buildSrc/src/main/groovy/org/grails/HtmlMinutes.groovy b/buildSrc/src/main/groovy/org/grails/HtmlMinutes.groovy
deleted file mode 100644
index 6bd3c6f3ca0..00000000000
--- a/buildSrc/src/main/groovy/org/grails/HtmlMinutes.groovy
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.grails
-
-class HtmlMinutes {
-    String path
-    MinutesMetadata metadata
-    String html
-}
diff --git a/buildSrc/src/main/groovy/org/grails/HtmlPost.groovy b/buildSrc/src/main/groovy/org/grails/HtmlPost.groovy
deleted file mode 100644
index 98cf405546f..00000000000
--- a/buildSrc/src/main/groovy/org/grails/HtmlPost.groovy
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.grails
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class HtmlPost {
-
-    String path
-    PostMetadata metadata
-    String html
-    Set<String> tags
-}
diff --git a/buildSrc/src/main/groovy/org/grails/MinutesMetadata.groovy b/buildSrc/src/main/groovy/org/grails/MinutesMetadata.groovy
deleted file mode 100644
index d4bd49f2a1d..00000000000
--- a/buildSrc/src/main/groovy/org/grails/MinutesMetadata.groovy
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.grails
-
-import edu.umd.cs.findbugs.annotations.Nullable
-import groovy.transform.CompileStatic
-
-@CompileStatic
-interface MinutesMetadata {
-
-    String get(String name)
-
-    @Nullable
-    String getUrl()
-
-    @Nullable
-    String getTitle()
-
-    @Nullable
-    String getDate()
-
-    Map<String, String> toMap()
-
-}
diff --git a/buildSrc/src/main/groovy/org/grails/MinutesMetadataAdaptor.groovy b/buildSrc/src/main/groovy/org/grails/MinutesMetadataAdaptor.groovy
deleted file mode 100644
index 3bfd7ef28e0..00000000000
--- a/buildSrc/src/main/groovy/org/grails/MinutesMetadataAdaptor.groovy
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.grails
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class MinutesMetadataAdaptor implements MinutesMetadata {
-
-    Map<String, String> metadata
-    MinutesMetadataAdaptor(Map<String, String> metadata) {
-        this.metadata = metadata
-
-    }
-
-    @Override
-    String get(String name) {
-        return metadata[name]
-    }
-
-    @Override
-    String getUrl() {
-        get('url')
-    }
-
-    @Override
-    String getTitle() {
-        get('title')
-    }
-
-    @Override
-    String getDate() {
-        get('date')
-    }
-
-    @Override
-    Map<String, String> toMap() {
-        metadata
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/PageElement.groovy b/buildSrc/src/main/groovy/org/grails/PageElement.groovy
deleted file mode 100644
index 9f7ec8e2390..00000000000
--- a/buildSrc/src/main/groovy/org/grails/PageElement.groovy
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.grails
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-interface PageElement {
-
-    String renderAsHtml()
-}
diff --git a/buildSrc/src/main/groovy/org/grails/PostMetadata.groovy b/buildSrc/src/main/groovy/org/grails/PostMetadata.groovy
deleted file mode 100644
index c12f68a16d6..00000000000
--- a/buildSrc/src/main/groovy/org/grails/PostMetadata.groovy
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.grails
-
-import jakarta.annotation.Nullable
-
-interface PostMetadata {
-
-    String get(String name)
-
-    @Nullable
-    String getUrl()
-
-    @Nullable
-    String getTitle()
-
-    @Nullable
-    String getAuthor()
-
-    @Nullable
-    String getDate()
-
-    Map<String, String> toMap()
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/org/grails/PostMetadataAdapter.groovy b/buildSrc/src/main/groovy/org/grails/PostMetadataAdapter.groovy
deleted file mode 100644
index c9a50bcda99..00000000000
--- a/buildSrc/src/main/groovy/org/grails/PostMetadataAdapter.groovy
+++ /dev/null
@@ -1,43 +0,0 @@
-package org.grails
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class PostMetadataAdapter implements PostMetadata {
-
-    Map<String, String> metadata
-    PostMetadataAdapter(Map<String, String> metadata) {
-        this.metadata = metadata
-
-    }
-
-    @Override
-    String get(String name) {
-        return metadata[name]
-    }
-
-    @Override
-    String getUrl() {
-        get('url')
-    }
-
-    @Override
-    String getTitle() {
-        get('title')
-    }
-
-    @Override
-    String getAuthor() {
-        get('author')
-    }
-
-    @Override
-    String getDate() {
-        get('date')
-    }
-
-    @Override
-    Map<String, String> toMap() {
-        metadata
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/DocumentationPage.groovy b/buildSrc/src/main/groovy/org/grails/documentation/DocumentationPage.groovy
deleted file mode 100644
index e46b8f1e418..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/DocumentationPage.groovy
+++ /dev/null
@@ -1,167 +0,0 @@
-package org.grails.documentation
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.xml.MarkupBuilder
-import org.yaml.snakeyaml.Yaml
-
-@CompileStatic
-class DocumentationPage {
-
-    @CompileDynamic
-    private static String renderCategory(DocumentationCategory cat) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div(class: 'guidegroup') {
-            html.div(class: 'guidegroupheader') {
-                html.img(src: cat.image, alt: cat.title)
-                h2 cat.title
-            }
-            ul {
-                cat.items.each { item ->
-                    li {
-                        a(href: item.url, item.title)
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String renderDocumentation(String version) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div(class: "guidegroup") {
-            if (version) {
-                boolean isSnapshot = version.endsWith('-SNAPSHOT') || version.contains('snapshot')
-                div(class: "guidegroupheader") {
-                    img(src: "[%url]/images/documentation.svg", alt: "Grails Version (${version})")
-                    h2 "${isSnapshot ? 'Snapshot' : (version.contains('.M') ? 'Milestone' : (version.contains('.RC') ? 'Release Candidate': 'Latest'))} Version (${version}) Documentation"
-                }
-                ul {
-                    li {
-                        a(href: "https://grails.apache.org/docs/${version}/", 'User Guide')
-                    }
-                    li {
-                        a(href: "https://grails.apache.org/docs/${version}/api/", 'API Reference')
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-
-    @CompileDynamic
-    static String mainContent(File releases, File modules) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-
-        html.div(class: 'headerbar chalicesbg') {
-            html.div(class: 'content') {
-                h1 'Documentation'
-            }
-        }
-        SoftwareVersion preRelease = SiteMap.latestPreReleaseVersion(releases)
-        SoftwareVersion latest = SiteMap.latestVersion(releases)
-        Collection<DocumentationCategory> categories = categories(modules)
-        html.div(class: 'content') {
-            html.div(class: "twocolumns") {
-                html.div(class: "odd column"){
-                    if (preRelease > latest) {
-                        mkp.yieldUnescaped(renderDocumentation(preRelease.versionText))
-                    }
-
-                    mkp.yieldUnescaped(renderDocumentation('snapshot'))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'GORM - Data Access Toolkit' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Security' }))
-
-                }
-                html.div(class: "column") {
-                    mkp.yieldUnescaped(renderDocumentation(latest.versionText))
-
-                    html.div(class: 'olderversions') {
-                        h3(class: 'columnheader', style: 'margin-bottom: 10px;', 'Older Version')
-                        p 'Browse previous versions\' documentation since Grails 1.2.0'
-
-                        div(class: 'versionselector') {
-                            h4 'Single Page - User Guide'
-                            select(onchange: "window.location.href='https://grails.apache.org/docs/' + this.value + '/guide/single.html'") {
-                                option 'Select a version'
-                                mkp.yield('[%versions]')
-                            }
-                        }
-                        div(class: 'versionselector') {
-                            h4 'User Guide'
-                            select(onchange: "window.location.href='https://grails.apache.org/docs/' + this.value") {
-                                option 'Select a version'
-                                mkp.yield('[%versions]')
-                            }
-                        }
-                        div(class: 'versionselector') {
-                            h4 'API Reference'
-                            select(onchange: "window.location.href='https://grails.apache.org/docs/' + this.value + '/api'") {
-                                option 'Select a version'
-                                mkp.yield('[%versions]')
-                            }
-                        }
-                    }
-
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Upgrade' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Testing' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Views' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Async' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Database' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Redis' }))
-                }
-
-            }
-        }
-
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static Collection<DocumentationCategory> categories(File modules) {
-        Yaml yaml = new Yaml()
-        Map model = yaml.load(modules.newDataInputStream())
-        Map<String, DocumentationCategory> categories = [:]
-        model['modules'].collect { k, v ->
-            if (!categories.containsKey(v.category)) {
-                DocumentationCategory cat = new DocumentationCategory()
-                if (v.category) {
-                    cat.title = v.category
-                }
-                if (v.categoryImage) {
-                    cat.image = v.categoryImage
-                }
-                if (v.categoryDescription) {
-                    cat.description = v.categoryDescription
-                }
-                if (v.categoryUrl) {
-                    cat.url = v.categoryUrl
-                }
-                categories[v.category] = cat
-            }
-            DocumentationItem item = new DocumentationItem()
-            item.url = v.url
-            item.title = v.title
-            categories[v.category].items << item
-        }
-        categories.values()
-    }
-
-    static class DocumentationCategory {
-        String title
-        String image
-        String description
-        String url
-        List<DocumentationItem> items = []
-    }
-
-    static class DocumentationItem {
-        String title
-        String url
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/DownloadPage.groovy b/buildSrc/src/main/groovy/org/grails/documentation/DownloadPage.groovy
deleted file mode 100644
index 17c0d7b168a..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/DownloadPage.groovy
+++ /dev/null
@@ -1,240 +0,0 @@
-/*
- *  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
- *
- *    http://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 org.grails.documentation
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.xml.MarkupBuilder
-
-@CompileStatic
-class DownloadPage {
-
-    static String binaryUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
-        "https://www.apache.org/dyn/closer.lua/grails/${directory}/${version}/distribution/apache-${artifact}-${version}-bin.zip${ext}?action=download"
-    }
-
-    static String sourceUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
-        "https://www.apache.org/dyn/closer.lua/grails/${directory}/${version}/sources/apache-${artifact}-${version}-src.zip${ext}?action=download"
-    }
-
-    static String binaryVerificationUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
-        "https://downloads.apache.org/grails/${directory}/${version}/distribution/apache-${artifact}-${version}-bin.zip${ext}"
-    }
-
-    static String sourceVerificationUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
-        "https://downloads.apache.org/grails/${directory}/${version}/sources/apache-${artifact}-${version}-src.zip${ext}"
-    }
-
-    /**
-     * Does not handle pre-release versions as these are not displayed in the select box.
-     */
-    static String resolveOldDownloadUrl(String version) {
-        def parts = ((version.split(/\./)*.replaceAll(/\D.*/, '')*.toInteger()) + [0, 0, 0]).take(3)
-        def (major, minor, patch) = [parts[0], parts[1], parts[2]]
-        def tag = "v$version"
-        if (major < 7) {
-            def baseUrl = 'https://github.com/apache/grails-core/releases/download'
-            def artifactName = "grails-$version"
-            if (major == 6) {
-                baseUrl = 'https://github.com/apache/grails-forge/releases/download'
-                artifactName = "grails-cli-$version"
-            }
-            if (major == 1 && minor == 1) {
-                artifactName = "grails-bin-$version"
-            }
-            if (major <= 1 && patch == 0) {
-                tag = "v$major.$minor"
-            }
-            return "$baseUrl/$tag/${artifactName}.zip"
-        }
-        else {
-            return "https://www.apache.org/dyn/closer.lua/grails/core/$version/distribution/apache-grails-$version-bin.zip?action=download"
-        }
-    }
-
-    @CompileDynamic
-    static String renderDownload(String version) {
-        String redisVersion = '5.0.0'
-        String quartzVersion = '4.0.0'
-        String springSecurityVersion = '7.0.0'
-        String grailsGithubActionsVersion = '1.0.1'
-        String grailsGradlePublishVersion = '0.0.4'
-
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        boolean isSnapshot = version.endsWith('-SNAPSHOT') || version.contains('snapshot')
-        if (!isSnapshot) {
-            html.div(class: "guidegroup") {
-                if (version) {
-                    div(class: "guidegroupheader") {
-                        img(src: "[%url]/images/download.svg", alt: "Download Grails (${version})")
-                        h2 "${isSnapshot ? 'Snapshot' : (version.contains('-M') ? 'Milestone' : (version.contains('-RC') ? 'Release Candidate': 'Latest Stable'))} Version (${version}) Downloads"
-                    }
-                    ul {
-                        if (version.startsWith('7')) {
-                            li {
-                                a(href: sourceUrl(version), 'Source')
-                                a(href: sourceVerificationUrl(version, 'grails', '.sha512'), 'SHA512')
-                                a(href: sourceVerificationUrl(version, 'grails', '.asc'), 'ASC')
-                            }
-                            li {
-                                a(href: binaryUrl(version, 'grails'), 'Binary')
-                                a(href: binaryVerificationUrl(version, 'grails', '.sha512'), 'SHA512')
-                                a(href: binaryVerificationUrl(version, 'grails', '.asc'), 'ASC')
-                            }
-                            li {
-                                a(href: binaryUrl(version, 'grails-wrapper'), 'Binary Wrapper')
-                                a(href: binaryVerificationUrl(version, 'grails-wrapper', '.sha512'), 'SHA512')
-                                a(href: binaryVerificationUrl(version, 'grails-wrapper', '.asc'), 'ASC')
-                            }
-                            li {
-                                a(href: sourceUrl(springSecurityVersion, 'grails-spring-security', '', 'spring-security'), "Grails Spring Security ${springSecurityVersion} Plugin Source")
-                                a(href: sourceVerificationUrl(springSecurityVersion, 'grails-spring-security', '.sha512', 'spring-security'), 'SHA512')
-                                a(href: sourceVerificationUrl(springSecurityVersion, 'grails-spring-security', '.asc', 'spring-security'), 'ASC')
-                            }
-                            li {
-                                a(href: sourceUrl(redisVersion, 'grails-redis', '', 'redis'), "Grails Redis ${redisVersion} Plugin Source")
-                                a(href: sourceVerificationUrl(redisVersion, 'grails-redis', '.sha512', 'redis'), 'SHA512')
-                                a(href: sourceVerificationUrl(redisVersion, 'grails-redis', '.asc', 'redis'), 'ASC')
-                            }
-                            li {
-                                a(href: sourceUrl(quartzVersion, 'grails-quartz', '', 'quartz'), "Grails Quartz ${quartzVersion} Plugin Source")
-                                a(href: sourceVerificationUrl(quartzVersion, 'grails-quartz', '.sha512', 'quartz'), 'SHA512')
-                                a(href: sourceVerificationUrl(quartzVersion, 'grails-quartz', '.asc', 'quartz'), 'ASC')
-                            }
-                            li {
-                                a(href: sourceUrl(grailsGithubActionsVersion, 'grails-github-actions', '', 'actions'), "Grails GitHub Actions ${grailsGithubActionsVersion} Source")
-                                a(href: sourceVerificationUrl(grailsGithubActionsVersion, 'grails-github-actions', '.sha512', 'actions'), 'SHA512')
-                                a(href: sourceVerificationUrl(grailsGithubActionsVersion, 'grails-github-actions', '.asc', 'actions'), 'ASC')
-                            }
-                            li {
-                                a(href: sourceUrl(grailsGradlePublishVersion, 'grails-publish', '', 'grails-publish'), "Grails Publish Gradle Plugin ${grailsGradlePublishVersion} Source")
-                                a(href: sourceVerificationUrl(grailsGradlePublishVersion, 'grails-publish', '.sha512', 'grails-publish'), 'SHA512')
-                                a(href: sourceVerificationUrl(grailsGradlePublishVersion, 'grails-publish', '.asc', 'grails-publish'), 'ASC')
-                            }
-                        } else {
-                            li {
-                                a(href: "https://github.com/apache/grails-forge/releases/download/v${version}/grails-cli-${version}.zip", 'Binary')
-                            }
-                        }
-                        li {
-                            a(href: "https://github.com/apache/grails-core/releases/tag/v${version}", 'Grails Release Notes')
-                        }
-                        if (version.startsWith('7')) {
-                            li {
-                                a(href: "https://github.com/apache/grails-spring-security/releases/tag/v${springSecurityVersion}", "Grails Spring Security Plugin ${springSecurityVersion} Release Notes")
-                            }
-                            li {
-                                a(href: "https://github.com/apache/grails-redis/releases/tag/v${redisVersion}", "Grails Redis ${redisVersion} Plugin Release Notes")
-                            }
-                            li {
-                                a(href: "https://github.com/apache/grails-quartz/releases/tag/v${quartzVersion}", "Grails Quartz ${quartzVersion} Plugin Release Notes")
-                            }
-                            li {
-                                a(href: "https://github.com/apache/grails-github-actions/releases/tag/v${grailsGithubActionsVersion}", "Grails GitHub Actions ${grailsGithubActionsVersion} Release Notes")
-                            }
-                            li {
-                                a(href: "https://github.com/apache/grails-gradle-publish/releases/tag/v${grailsGradlePublishVersion}", "Grails Publish Gradle Plugin ${grailsGradlePublishVersion} Release Notes")
-                            }
-                        }
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-
-    @CompileDynamic
-    static String mainContent(File releases) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-
-        html.div(class: 'headerbar chalicesbg') {
-            html.div(class: 'content') {
-                h1 'Downloads'
-            }
-        }
-        SoftwareVersion preRelease = SiteMap.latestPreReleaseVersion(releases)
-        SoftwareVersion latest = SiteMap.latestVersion(releases)
-        html.div(class: 'content') {
-            html.div(class: "twocolumns") {
-                html.div(class: "odd column"){
-                    h3(class: 'columnheader', style: 'margin-bottom: 10px;', 'Source and Binary Releases')
-                    mkp.yieldUnescaped "We provide OpenPGP signatures ('.asc') files and checksums ('.sha512') for every release artifact. We recommend that you "
-                    a(href: 'https://www.apache.org/info/verification.html', 'verify')
-                    mkp.yieldUnescaped " the integrity of downloaded files by generating your own checksums and match them against ours, and checking signatures using the "
-                    a(href: 'https://www.apache.org/dyn/closer.lua/grails/KEYS?action=download', 'KEYS')
-                    mkp.yieldUnescaped " file which contains the Grails OpenPGP release keys."
-                    p ''
-                    if (preRelease > latest) {
-                        mkp.yieldUnescaped(renderDownload(preRelease.versionText))
-                    }
-
-                    mkp.yieldUnescaped(renderDownload('snapshot'))
-
-                    mkp.yieldUnescaped(renderDownload(latest.versionText))
-
-                    h3(class:'columnheader', 'Older Versions')
-                    p 'You can download previous versions as far back as Grails 0.1.'
-                    p 'NOTE: Versions prior to 7.0.0-M4 are not ASF releases. Links to those releases are provided here as a convenience.'
-                    div(class: 'versionselector') {
-                        select(class: 'form-control', onchange: 'window.location.href=this.value') {
-                            option(label: 'Select a version', disabled: 'disabled', selected: 'selected')
-                            SiteMap.stableVersions(releases)*.versionText.each {
-                                option(value: resolveOldDownloadUrl(it), it)
-                            }
-                        }
-                    }
-                }
-                html.div(class: "column") {
-
-                    h3(class: 'columnheader', style: 'margin-bottom: 10px;', 'Grails Application Forge')
-
-                    mkp.yieldUnescaped'The quickest way to get started with our application generator:'
-                    p {
-                        a(href: 'https://start.grails.org', 'Grails Application Forge')
-                    }
-
-
-                    h3(class: 'columnheader', style: 'margin-bottom: 10px;', 'Installing with SDKMAN!')
-
-                    p {
-                        a(href: 'https://sdkman.io/', 'SDKMAN! (The Software Development Kit Manager)')
-                    }
-
-                    p 'This tool makes installing the Grails framework on any Unix based platform (Mac OSX, Linux, Cygwin, Solaris, or FreeBSD) easy.'
-                    p 'Simply open a new terminal and enter:'
-                    div(class:'code', '$ curl -s https://get.sdkman.io | bash')
-                    p 'Follow the on-screen instructions to complete installation.'
-                    p 'Open a new terminal or type the command:'
-                    div(class:'code', '$ source "$HOME/.sdkman/bin/sdkman-init.sh"')
-                    p 'Then install the latest stable Grails version:'
-                    div(class:'code', '$ sdk install grails')
-                    p 'If prompted, make this your default version. After installation is complete it can be tested with:'
-                    div(class:'code', '$ grails --version')
-                    p "That's all there is to it!"
-                }
-            }
-        }
-
-        writer.toString()
-    }
-
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/GuideGroup.groovy b/buildSrc/src/main/groovy/org/grails/documentation/GuideGroup.groovy
deleted file mode 100644
index b0383827cac..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/GuideGroup.groovy
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.grails.documentation
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class GuideGroup implements GuideGroupHtml {
-    String title
-    String image
-    String description
-    String ulId
-    List<GuideGroupItem> items = []
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/GuideGroupHtml.groovy b/buildSrc/src/main/groovy/org/grails/documentation/GuideGroupHtml.groovy
deleted file mode 100644
index 15eb10d6cd3..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/GuideGroupHtml.groovy
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.grails.documentation
-
-import groovy.transform.CompileDynamic
-import groovy.xml.MarkupBuilder
-import org.grails.PageElement
-
-trait GuideGroupHtml implements PageElement {
-
-    abstract String getUlId()
-
-    abstract String getImage()
-
-    abstract String getTitle()
-
-    abstract  List<GuideGroupItem> getItems()
-
-    abstract String getDescription()
-
-    @CompileDynamic
-    @Override
-    String renderAsHtml() {
-        renderAsHtmlWithStyleAttr(null)
-    }
-
-    String renderAsHtmlWithStyleAttr(String styleAttr) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        final int imageMarginRight = 10
-        html.div(class: "guidegroup", style: "${styleAttr ?: ''}") {
-            div(class: "guidegroupheader") {
-                img src: image, alt: title
-                h2 {
-                    html.mkp.yieldUnescaped title
-                }
-            }
-            ul(id: getUlId() ?: '') {
-                if ( description ) {
-                    li(class: 'legend', description)
-                }
-                if ( items ) {
-                    for ( GuideGroupItem item : items ) {
-                        if ( item.href ) {
-                            li {
-                                if (item.image) {
-                                    img width: 70, class: 'align-left', style: "margin-right: ${imageMarginRight}px;", src: "${item.image}" as String, alt: item.title
-                                }
-                                a href: item.href, item.title
-                                if (item.legend) {
-                                    p(item.legend)
-                                }
-                            }
-                        } else {
-                            li(item.title)
-                        }
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/GuideGroupItem.groovy b/buildSrc/src/main/groovy/org/grails/documentation/GuideGroupItem.groovy
deleted file mode 100644
index e0dce3346fb..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/GuideGroupItem.groovy
+++ /dev/null
@@ -1,12 +0,0 @@
-package org.grails.documentation
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class GuideGroupItem {
-    String title
-    String href
-    String legend
-    String image
-    String category
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/ProfilesPage.groovy b/buildSrc/src/main/groovy/org/grails/documentation/ProfilesPage.groovy
deleted file mode 100644
index be366d30817..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/ProfilesPage.groovy
+++ /dev/null
@@ -1,104 +0,0 @@
-package org.grails.documentation;
-
-import groovy.transform.CompileDynamic;
-import groovy.transform.CompileStatic;
-import groovy.xml.MarkupBuilder
-import org.grails.utils.MarkdownUtils
-import org.yaml.snakeyaml.Yaml;
-
-@CompileStatic
-class ProfilesPage {
-
-    @CompileDynamic
-    static String mainContent(File profiles) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div( class:'headerbar chalicesbg'){
-            div( class:'content'){
-                h1 'Profiles'
-            }
-        }
-        Collection<ProfilesCategory> categories = categories(profiles)
-        html.div(class: 'content') {
-            div(class: 'twocolumns') {
-                div(class: 'odd column') {
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Profiles' }))
-                }
-                div(class: 'column') {
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Plugin Profiles' }))
-                    mkp.yieldUnescaped(renderCategory(categories.find { it.title == 'Third-Party Profiles' }))
-
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static Collection<ProfilesCategory> categories(File modules) {
-        Yaml yaml = new Yaml()
-        Map model = yaml.load(modules.newDataInputStream())
-        Map<String, ProfilesCategory> categories = [:]
-        model['profiles'].collect { k, v ->
-            if (!categories.containsKey(v.category)) {
-                ProfilesCategory cat = new ProfilesCategory()
-                if (v.category) {
-                    cat.title = v.category
-                }
-                if (v.image) {
-                    cat.image = v.image
-                }
-                if (v.description) {
-                    cat.description = v.description
-                }
-                categories[v.category] = cat
-            }
-            Profile item = new Profile()
-            item.url = v.url
-            item.title = v.title
-            categories[v.category].profiles << item
-        }
-        categories.values()
-    }
-
-    @CompileDynamic
-    private static String renderCategory(ProfilesCategory cat) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        if (cat) {
-            html.div(class: 'guidegroup') {
-                div(class: 'guidegroupheader') {
-                    html.img(src: cat.image, alt: cat.title)
-                    h2 cat.title
-                }
-                ul {
-                    if (cat.description) {
-                        li(class: 'legend') {
-                            mkp.yieldUnescaped MarkdownUtils.htmlFromMarkdown(cat.description)
-                                    .replaceAll('<p>', '')
-                                    .replaceAll('</p>', '')
-                        }
-                    }
-                    cat.profiles.each { item ->
-                        li {
-                            a(href: item.url, item.title)
-                        }
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    static class ProfilesCategory {
-        String title
-        String image
-        String description
-        List<Profile> profiles = []
-    }
-
-    static class Profile {
-        String title
-        String url
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/SiteMap.groovy b/buildSrc/src/main/groovy/org/grails/documentation/SiteMap.groovy
deleted file mode 100644
index 69359de69a9..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/SiteMap.groovy
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.grails.documentation
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import org.yaml.snakeyaml.Yaml
-
-@CompileStatic
-class SiteMap {
-
-    static List<SoftwareVersion> versions(File releases) {
-        Yaml yaml = new Yaml()
-        assert releases.exists()
-        Map model = yaml.load(releases.newDataInputStream())
-        model['releases'].collect { Map release ->
-            SoftwareVersion.build(release['version'] as String)
-        }.sort()
-    }
-
-    static SoftwareVersion latestVersion(File releases) {
-        List<SoftwareVersion> versions = stableVersions(releases)
-        versions ? versions.get(0) : null
-    }
-
-    static List<String> olderVersions(File releases) {
-        stableVersions(releases).reverse().drop(1).collect { it.versionText }
-    }
-
-    static List<String> versionsBeforeGrails6(File releases) {
-        stableVersions(releases)
-                .findAll { version -> version.major < 6 }
-                .reverse()
-                .drop(1)
-                .collect { it.versionText }
-    }
-
-    static List<String> versionsAfterGrails6(File releases) {
-        stableVersions(releases)
-                .findAll { version -> version.major >= 6 }
-                .reverse()
-                .drop(1)
-                .collect { it.versionText }
-    }
-
-    static List<SoftwareVersion> stableVersions(File releases) {
-        (versions(releases).findAll { SoftwareVersion softwareVersion ->
-            !softwareVersion.isSnapshot()
-        } as List<SoftwareVersion>).sort { a, b -> b <=> a }
-    }
-
-    @CompileDynamic
-    static List<SoftwareVersion> preReleaseVersions(File releases) {
-        versions(releases).findAll { SoftwareVersion softwareVersion ->
-            Snapshot snapshot = softwareVersion.snapshot
-            snapshot?.isMilestone() || snapshot?.isReleaseCandidate()
-        }.sort { a, b -> b <=> a }
-    }
-
-    static SoftwareVersion latestPreReleaseVersion(File releases) {
-        List<SoftwareVersion> versions = preReleaseVersions(releases)
-        versions ? versions.get(0) : null
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/documentation/Snapshot.groovy b/buildSrc/src/main/groovy/org/grails/documentation/Snapshot.groovy
deleted file mode 100644
index 4a4a8e9f3ea..00000000000
--- a/buildSrc/src/main/groovy/org/grails/documentation/Snapshot.groovy
+++ /dev/null
@@ -1,63 +0,0 @@
-package org.grails.documentation
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class Snapshot implements Comparable<Snapshot> {
-
-    private String text
-
-    int getMilestoneVersion() {
-        text.replace("M", "").toInteger()
-    }
-
-    int getReleaseCandidateVersion() {
-        text.replace("RC", "").toInteger()
-    }
-
-    boolean isBuildSnapshot() {
-        text.endsWith("SNAPSHOT")
-    }
-
-    boolean isReleaseCandidate() {
-        text.startsWith("RC")
-    }
-
-    boolean isMilestone() {
-        text.startsWith("M")
-    }
-
-    Snapshot(String text) {
-        this.text = text
-    }
-
-    @Override
-    int compareTo(Snapshot o) {
-
-        if (this.buildSnapshot && !o.buildSnapshot) {
-            return 1
-        } else if (!this.buildSnapshot && o.buildSnapshot) {
-            return -1
-        } else if (this.buildSnapshot && o.buildSnapshot) {
-            return 0
-        }
-
-        if (this.releaseCandidate && !o.releaseCandidate) {
-            return 1
-        } else if (!this.releaseCandidate && o.releaseCandidate) {
-            return -1
-        } else if (this.releaseCandidate && o.releaseCandidate) {
-            return this.releaseCandidateVersion <=> o.releaseCandidateVersion
-        }
-
-        if (this.milestone && !o.milestone) {
-            return 1
-        } else if (!this.milestone && o.milestone) {
-            return -1
-        } else if (this.milestone && o.milestone) {
-            return this.milestoneVersion <=> o.milestoneVersion
-        }
-
-        return 0
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/events/Event.groovy b/buildSrc/src/main/groovy/org/grails/events/Event.groovy
deleted file mode 100644
index 1a27e6684a1..00000000000
--- a/buildSrc/src/main/groovy/org/grails/events/Event.groovy
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.grails.events
-
-import edu.umd.cs.findbugs.annotations.Nullable
-import groovy.transform.CompileStatic
-import groovy.transform.ToString
-
-import java.time.LocalDate
-
-@ToString
-@CompileStatic
-class Event {
-    String name
-    String location
-    LocalDate date
-    List<String> speakers
-
-    @Nullable
-    String link
-}
diff --git a/buildSrc/src/main/groovy/org/grails/events/EventsPage.groovy b/buildSrc/src/main/groovy/org/grails/events/EventsPage.groovy
deleted file mode 100644
index c4f255ba444..00000000000
--- a/buildSrc/src/main/groovy/org/grails/events/EventsPage.groovy
+++ /dev/null
@@ -1,47 +0,0 @@
-package org.grails.events
-
-import groovy.transform.CompileDynamic
-
-@CompileDynamic
-class EventsPage {
-
-    @CompileDynamic
-    static String eventsTable(List<Event> events, boolean showLocation = false) {
-        String msg = """\
-<div class="training"><table>
-<colgroup>
-<col>
-<col>
-${showLocation ? '<col>': ''}
-<col>
-</colgroup>
-<thead>
-<tr><th>Event</th><th>Date(s)</th>${showLocation ? '<th>Location</th>': ''}<th>Speakers</th></tr>
-</thead>
-<tbody>
-"""
-        for (Event event : events) {
-            msg += tableRowForEvent(event, showLocation)
-        }
-        msg += '</tbody>'
-        msg += '</table></div>'
-        msg
-    }
-
-    static String tableRowForEvent(Event event, boolean showLocation) {
-        String msg = '<tr>'
-        if (event.link) {
-            msg += "<td><a href=\"${event.link}\">${event.name ?: ''}</a></td>"
-        } else {
-            msg += "<td>${event.name ?: ''}</td>"
-        }
-        msg += "<td>${event.date ?: ''}</td>"
-        if (showLocation) {
-            msg += "<td>${event.location ?: ''}</td>"
-        }
-        msg += "<td>${(event.speakers ? event.speakers.join(', ') : '')}</td>"
-        msg += "</tr>"
-        msg
-    }
-
-}
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/BlogTask.groovy b/buildSrc/src/main/groovy/org/grails/gradle/tasks/BlogTask.groovy
deleted file mode 100644
index e4c802e6aba..00000000000
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/BlogTask.groovy
+++ /dev/null
@@ -1,701 +0,0 @@
-/*
- *  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
- *
- *    http://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 org.grails.gradle.tasks
-
-import java.time.Instant
-import java.time.LocalDateTime
-import java.time.ZoneId
-import java.time.ZonedDateTime
-import java.util.stream.Collectors
-
-import javax.annotation.Nonnull
-import javax.inject.Inject
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.xml.MarkupBuilder
-
-import io.micronaut.rss.DefaultRssFeedRenderer
-import io.micronaut.rss.RssChannel
-import io.micronaut.rss.RssItem
-import org.gradle.api.Project
-import org.gradle.api.file.CopySpec
-import org.gradle.api.file.DirectoryProperty
-import org.gradle.api.file.RegularFileProperty
-import org.gradle.api.model.ObjectFactory
-import org.gradle.api.provider.ListProperty
-import org.gradle.api.provider.Property
-import org.gradle.api.tasks.CacheableTask
-import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.InputDirectory
-import org.gradle.api.tasks.InputFile
-import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.OutputDirectory
-import org.gradle.api.tasks.PathSensitive
-import org.gradle.api.tasks.PathSensitivity
-import org.gradle.api.tasks.TaskAction
-import org.gradle.api.tasks.TaskProvider
-
-import org.grails.utils.DateUtils
-import org.grails.HtmlPost
-import org.grails.MarkdownPost
-import org.grails.PostMetadata
-import org.grails.PostMetadataAdapter
-import org.grails.documentation.SiteMap
-import org.grails.gradle.GrailsWebsiteExtension
-import org.grails.utils.MarkdownUtils
-import org.grails.tags.Tag
-import org.grails.tags.TagCloud
-
-@CompileStatic
-@CacheableTask
-class BlogTask extends GrailsWebsiteTask {
-
-    @Internal
-    String description =
-            'Renders Markdown posts (posts/*.md) into HTML pages (dist/blog/*.html). ' +
-            'It generates tag pages. Generates RSS feed. ' +
-            'Posts with future dates are not generated.'
-
-    public static final String NAME = 'renderBlog'
-
-    private static final String RSS_FILE = 'rss.xml'
-    private static final String IMAGES = 'images'
-    private static final String HASHTAG_SPAN = '<span class="hashtag">#'
-    private static final String SPAN_CLOSE = "</span>"
-    private static final int MAX_RELATED_POSTS = 3
-    private static final String BLOG = 'blog'
-    private static final String TAG = 'tag'
-    private static final String INDEX = 'index.html'
-    private static List<String> ALLOWED_TAG_PREFIXES = new ArrayList<>()
-    private static final int MAX_TITLE_LENGTH = 45
-
-    static {
-        def characters = 'A'..'Z'
-        def digits = 0..9
-        def l = characters.stream()
-                .map({str -> "#${str}".toString()})
-                .collect(Collectors.toList())
-        l.addAll(characters.stream()
-                .map({str -> "#${str.toLowerCase()}".toString()})
-                .collect(Collectors.toList()))
-        l.addAll(digits.stream()
-                .map({digit -> "#${digit}".toString()})
-                .collect(Collectors.toList()))
-        ALLOWED_TAG_PREFIXES = l
-    }
-
-    private final ObjectFactory objects
-
-    @Inject
-    BlogTask(ObjectFactory objects) {
-        this.objects = objects
-    }
-
-    @InputFile
-    @PathSensitive(PathSensitivity.RELATIVE)
-    final RegularFileProperty document = objects.fileProperty()
-
-    @InputFile
-    @PathSensitive(PathSensitivity.RELATIVE)
-    final RegularFileProperty releases = objects.fileProperty()
-
-    @InputDirectory
-    @PathSensitive(PathSensitivity.RELATIVE)
-    final DirectoryProperty assetsDir = objects.directoryProperty()
-
-    @InputDirectory
-    @PathSensitive(PathSensitivity.RELATIVE)
-    final DirectoryProperty postsDir = objects.directoryProperty()
-
-    @Input
-    final Property<String> about = objects.property(String)
-
-    @Input
-    final ListProperty<String> keywords = objects.listProperty(String)
-
-    @Input
-    final Property<String> robots = objects.property(String)
-
-    @Input
-    final Property<String> title = objects.property(String)
-
-    @Input
-    final Property<String> url = objects.property(String)
-
-    @OutputDirectory
-    final DirectoryProperty outputDir = objects.directoryProperty()
-
-    static TaskProvider<BlogTask> register(
-            Project project,
-            GrailsWebsiteExtension siteExt,
-            String name = NAME
-    ) {
-        project.tasks.register(name, BlogTask) {
-            it.about.set(siteExt.description)
-            it.assetsDir.set(siteExt.assetsDir)
-            it.document.set(siteExt.template)
-            it.keywords.set(siteExt.keywords)
-            it.outputDir.set(siteExt.outputDir)
-            it.postsDir.set(siteExt.postsDir)
-            it.releases.set(siteExt.releases)
-            it.robots.set(siteExt.robots)
-            it.title.set(siteExt.title)
-            it.url.set(siteExt.url)
-        }
-    }
-
-    @TaskAction
-    void renderBlog() {
-        def meta = RenderSiteTask.siteMeta(
-                title.get(),
-                about.get(),
-                url.get(),
-                keywords.get(),
-                robots.get(),
-                SiteMap.latestVersion(releases.get().asFile).versionText,
-                SiteMap.olderVersions(releases.get().asFile)
-                        .reverse()
-                        .collect {"<option>$it</option>" }
-                        .join(' ')
-        )
-        renderPosts(
-                meta,
-                processPosts(
-                        meta,
-                        parsePosts(postsDir.get().asFile)
-                                .findAll {
-                                    !it.parsedDate.after(new Date())
-                                }
-                                .sort(false)
-                ),
-                outputDir.dir('dist/blog').get().asFile.tap { it.mkdirs() },
-                document.get().asFile.text
-        )
-        copyBackgroundImages()
-        copyBlogImages()
-    }
-
-    void copyBlogImages() {
-        project.copy { CopySpec copy ->
-            copy.from(postsDir)
-            copy.into(outputDir.dir('dist/blog'))
-            copy.include(CopyAssetsTask.IMAGE_EXTENSIONS)
-        }
-    }
-
-    void copyBackgroundImages() {
-        project.copy { CopySpec copy ->
-            copy.from(assetsDir.dir('bgimages'))
-            copy.into(outputDir.dir('dist/images'))
-            copy.include(CopyAssetsTask.IMAGE_EXTENSIONS)
-        }
-    }
-
-    static RssItem rssItemWithPage(
-            String title,
-            Date pubDate,
-            String link,
-            String guid,
-            String html,
-            String author = null
-    ) {
-        def htmlWithoutTitleAndDate = html
-        if (htmlWithoutTitleAndDate.contains('<span class="date">')) {
-            htmlWithoutTitleAndDate = htmlWithoutTitleAndDate
-                    .substring(htmlWithoutTitleAndDate.indexOf('<span class="date">'))
-            htmlWithoutTitleAndDate = htmlWithoutTitleAndDate
-                    .substring(htmlWithoutTitleAndDate.indexOf('</span>') + '</span>'.length())
-        }
-        def builder = RssItem.builder()
-                .title(title)
-                .pubDate(
-                        ZonedDateTime.of(
-                                Instant.ofEpochMilli(pubDate.time)
-                                    .atZone(ZoneId.systemDefault())
-                                    .toLocalDateTime(), ZoneId.of('GMT')
-                        )
-                )
-                .link(link)
-                .guid(guid)
-                .description(htmlWithoutTitleAndDate)
-        if (author) {
-            builder = builder.author(parseAuthorName(author))
-        }
-        builder.build()
-    }
-
-    static String parseAuthorName(String author) {
-        author.contains(' (') ? author.substring(0, author.indexOf(' (')) : author
-    }
-
-    @CompileDynamic
-    static String renderPostHtml(
-            HtmlPost htmlPost,
-            String templateText,
-            List<HtmlPost> posts
-    ) {
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        mb.div(class: 'headerbar chalicesbg') {
-            div(class: 'content') {
-                h1 {
-                    a(href: '[%url]/blog/index.html','Grails Blog')
-                }
-            }
-        }
-        mb.div(class: 'content container') {
-            div(class: 'light padded blogpost') {
-                mkp.yieldUnescaped(htmlPost.html)
-                h2(class: 'space-above') {
-                    span('You might also like ...')
-                }
-                div(class: 'threecolumns') {
-                    for (HtmlPost post : relatedPosts(htmlPost, posts)) {
-                        div(class: 'column') {
-                            mkp.yieldUnescaped(postCard(post))
-                        }
-                    }
-                }
-            }
-        }
-        String html = writer.toString()
-        Map<String, String> metadata = htmlPost.metadata.toMap()
-        html = RenderSiteTask.renderHtmlWithTemplateContent(html, metadata, templateText)
-        html = RenderSiteTask.highlightMenu(html, metadata, htmlPost.path)
-        metadata['body'] = metadata['body'] ? metadata['body'] : ''
-        if (metadata['body']) {
-            html = html.replace("<body>", "<body class='${metadata['body']}'>")
-        }
-        html
-    }
-
-    static List<HtmlPost> relatedPosts(HtmlPost htmlPost, List<HtmlPost> posts) {
-        List<HtmlPost> relatedPosts = []
-        for (String tag : htmlPost.tags) {
-            for (HtmlPost p : posts) {
-                if (p.tags.contains(tag) && p.path != htmlPost.path) {
-                    List<String> paths = relatedPosts*.path
-                    if (paths.contains(p.path)) {
-                        continue
-                    }
-                    relatedPosts.add(p)
-                    if (relatedPosts.size() > MAX_RELATED_POSTS) {
-                        break
-                    }
-                }
-            }
-            if (relatedPosts.size() > MAX_RELATED_POSTS) {
-                break
-            }
-        }
-        if (relatedPosts.size() < MAX_RELATED_POSTS) {
-            for (def p : posts) {
-                def paths = relatedPosts*.path
-                paths.add(htmlPost.path)
-                if (paths.contains(p.path)) {
-                    continue
-                }
-                relatedPosts.add(p)
-                if (relatedPosts.size() > MAX_RELATED_POSTS) {
-                    break
-                }
-            }
-        }
-        relatedPosts.subList(0, MAX_RELATED_POSTS).sort { a, b ->
-            DateUtils.parseDate(a.metadata.date).after(DateUtils.parseDate(b.metadata.date)) ? -1 : 1
-        }
-    }
-
-    static List<HtmlPost> processPosts(Map<String, String> globalMetadata, List<MarkdownPost> markdownPosts) {
-        markdownPosts.collect { MarkdownPost mdPost ->
-                Map<String, String> metadata = RenderSiteTask.processMetadata(globalMetadata + mdPost.metadata)
-            PostMetadata postMetadata = new PostMetadataAdapter(metadata)
-            String markdown = mdPost.content
-            if (metadata.containsKey('slides')) {
-                markdown = markdown + "\n\n[Slides](${metadata['slides']})\n\n"
-            }
-            if (metadata.containsKey('code')) {
-                markdown = markdown + "\n\n[Code](${metadata['code']})\n\n"
-            }
-            String html = MarkdownUtils.htmlFromMarkdown(markdown)
-            String iframe = RenderSiteTask.parseVideoIframe(metadata)
-            if (iframe) {
-                html = html + iframe
-            }
-            String contentHtml = wrapTags(metadata, html)
-            Set<String> postTags = parseTags(contentHtml)
-            new HtmlPost(metadata: postMetadata, html: contentHtml, path: mdPost.path, tags: postTags)
-        }
-    }
-
-    static void renderPosts(
-            Map<String, String> globalMetadata,
-            List<HtmlPost> listOfPosts,
-            File outputDir,
-            final String templateText
-    ) {
-        List<String> postCards = []
-        List<RssItem> rssItems = []
-        Map<String, List<String>> tagPosts = [:]
-        Map<String, Integer> tagsMap = [:]
-
-        for (HtmlPost htmlPost : listOfPosts) {
-            postCards << postCard(htmlPost)
-            def html = renderPostHtml(htmlPost, templateText, listOfPosts)
-            new File(outputDir, htmlPost.path).tap {
-                it.createNewFile()
-                it.text = html
-            }
-
-            def postTags = parseTags(html)
-            for (String postTag : postTags) {
-                tagsMap[postTag] = tagsMap.containsKey(postTag) ? (1 + tagsMap[postTag]) : 1
-                if (!tagPosts.containsKey(postTag)) {
-                    tagPosts[postTag] = []
-                }
-                tagPosts[postTag] << htmlPost.path
-            }
-            def postLink = postLink(htmlPost)
-            rssItems.add(
-                    rssItemWithPage(
-                            htmlPost.metadata.title,
-                            DateUtils.parseDate(htmlPost.metadata.date),
-                            postLink,
-                            htmlPost.path.replace('.html', ''),
-                            htmlPost.html,
-                            htmlPost.metadata.author
-                    )
-            )
-        }
-        def tags = tagsMap.collect {k, v ->
-            new Tag(title: k, ocurrence: v)
-        } as Set<Tag>
-        renderArchive(new File(outputDir, 'index.html'), postCards, globalMetadata, templateText, tags)
-        renderRss(globalMetadata, rssItems, new File(outputDir.parentFile, RSS_FILE))
-        renderTags(globalMetadata, outputDir, tagsMap.keySet(), listOfPosts, templateText)
-    }
-
-    static Set<String> parseTags(String html) {
-        def pageHtml = html
-        def tags = [] as Set<String>
-        for (; ;) {
-            if (!(pageHtml.contains(HASHTAG_SPAN) && pageHtml.contains(SPAN_CLOSE))) {
-                return tags
-            }
-            pageHtml = pageHtml.substring(pageHtml.indexOf(HASHTAG_SPAN) + HASHTAG_SPAN.length())
-            def tag = pageHtml.substring(0, pageHtml.indexOf(SPAN_CLOSE))
-            tags << tag
-            pageHtml = pageHtml.substring(pageHtml.indexOf(SPAN_CLOSE) + SPAN_CLOSE.length())
-        }
-    }
-
-    static void renderTags(
-            Map<String, String> metadata,
-            File outputDir,
-            Set<String> tags,
-            List<HtmlPost> posts,
-            String templateText
-    ) {
-        def tagFolder = new File(outputDir, TAG).tap {
-            it.mkdirs()
-        }
-
-        def resolvedMetadata = RenderSiteTask.processMetadata(metadata)
-
-        for (String tag : tags) {
-            def tagCards = [] as List<String>
-            def postsTagged = posts.findAll { it.tags.contains(tag) }
-            for (def post : postsTagged) {
-                tagCards << postCard(post)
-            }
-            def tagFile = new File(tagFolder, "${tag}.html")
-            resolvedMetadata['title'] = "${tag.toUpperCase()} | Blog | Grails Framework".toString()
-            renderCards(tagFile, tagCards, resolvedMetadata, templateText, renderTagTitle(tag))
-        }
-    }
-
-    static String postLink(HtmlPost post) {
-        "$post.metadata.url/$BLOG/$post.path"
-    }
-
-    @CompileDynamic
-    private static String postCard(HtmlPost htmlPost) {
-        def imageUrl = htmlPost.metadata['image'] ?
-                "$htmlPost.metadata.url/$IMAGES/${htmlPost.metadata['image']}".toString() :
-                null
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        mb.article(class: 'blogcard', style: imageUrl ? 'background-image: url(' + imageUrl + ')' : '') {
-            a(href: postLink(htmlPost)) {
-                h3 {
-                    mkp.yield(
-                            RenderSiteTask.formatDate(htmlPost.metadata.date)
-                    )
-                }
-                h2 {
-                    def title = RenderSiteTask.replaceLineWithMetadata(
-                            htmlPost.metadata.title,
-                            htmlPost.metadata.toMap()
-                    )
-                    if (title.length() > MAX_TITLE_LENGTH) {
-                        title = "${title.substring(0, MAX_TITLE_LENGTH)}..."
-                    }
-                    mkp.yield(title)
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static String renderTagTitle(String tag) {
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        mb.h1 {
-            span('Tag:')
-            b(tag)
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static String tagsCard(Map<String, String> sitemeta, Set<Tag> tags) {
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        mb.article(class: 'tagcloud blogcard desktop') {
-            h3('Post by Tag')
-            mkp.yieldUnescaped(
-                    TagCloud.tagCloud(
-                            "${sitemeta['url']}/${BLOG}/${TAG}",
-                            tags,
-                            false
-                    )
-            )
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static String rssCard(String url) {
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        def imageUrl = "$url/images/feedicon.svg"
-        mb.article(
-                class: 'blogcard desktop',
-                style: /background-image: url('/ + imageUrl + /')/
-        ) {
-            h3('Feeds')
-            h2 {
-                a(href: '[%url]/' + RSS_FILE, 'RSS Feed')
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static String subscribeCard() {
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        mb.article(class: 'blogcard desktop') {
-            mkp.yieldUnescaped('''
-<!--[if lte IE 8]>
-<script charset="utf-8" type="text/javascript" src="//js.hsforms.net/forms/v2-legacy.js"></script>
-<![endif]-->
-<script charset="utf-8" type="text/javascript" src="//js.hsforms.net/forms/v2.js"></script>
-<script>
-  hbspt.forms.create({
-\tportalId: "4547412",
-\tformId: "a675210e-7748-44bf-b603-3363d613ffb1"
-});
-</script>
-''')
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    private static void renderArchive(
-            File f,
-            List<String> postCards,
-            Map<String, String> sitemeta,
-            String templateText,
-            Set<Tag> tags
-    ) {
-        def cards = [] as List<String>
-        cards.addAll(postCards)
-//        cards.add(2, tagsCard(sitemeta, tags))
-//        cards.add(5, rssCard(sitemeta['url']))
-        //cards.add(8, subscribeCard())
-        def resolvedMetadata = RenderSiteTask.processMetadata(sitemeta)
-        // String html = EventsPage.mainContent(sitemeta['url']) +
-        //         cardsHtml(cards, resolvedMetadata)
-        def html = cardsHtml(cards, resolvedMetadata)
-        resolvedMetadata['title'] = 'Blog | Grails Framework'
-        html = RenderSiteTask.renderHtmlWithTemplateContent(html, resolvedMetadata, templateText)
-        html = RenderSiteTask.highlightMenu(html, resolvedMetadata, "/$BLOG/$INDEX")
-        f.createNewFile()
-        f.text = html
-    }
-
-    private static void renderCards(
-            File f,
-            List<String> cards,
-            Map<String, String> meta,
-            String templateText,
-            String title = null
-    ) {
-        def pageHtml = cardsHtml(cards, meta, title)
-        f.createNewFile()
-        f.text = RenderSiteTask.renderHtmlWithTemplateContent(pageHtml, meta, templateText)
-    }
-
-    @CompileDynamic
-    static String cardsHtml(
-            List<String> cards,
-            Map<String, String> meta,
-            String title = null
-    ) {
-        def writer = new StringWriter()
-        def mb = new MarkupBuilder(writer)
-        mb.div(class: 'headerbar chalicesbg') {
-            div(class: 'content') {
-                if (title) {
-                    mkp.yieldUnescaped(title)
-                } else {
-                    h1 {
-                        a(href: '[%url]/blog/index.html', 'Grails Blog')
-                    }
-                }
-            }
-        }
-        mb.div(class: 'clear content container') {
-//            if (title) {
-//                mkp.yieldUnescaped(title)
-//            } else {
-//
-//            }
-
-            div(class: 'light') {
-                div(class: 'padded', style: 'padding-top: 0;') {
-                    for (int i = 0; i < cards.size(); i++) {
-                        if (i == 0) {
-                            mkp.yieldUnescaped('<div class="threecolumns">')
-                        }
-                        div(class: 'column') {
-                            mkp.yieldUnescaped(cards[i])
-                        }
-                        if (i != 0 && ((i + 1 ) % 3 == 0)) {
-                            mkp.yieldUnescaped('</div>')
-                            if (i != (cards.size() - 1)) {
-                                mkp.yieldUnescaped('<div class="threecolumns">')
-                            }
-                        }
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    private static void renderRss(
-            Map<String, String> sitemeta,
-            List<RssItem> rssItems,
-            File outputFile
-    ) {
-        def builder = RssChannel.builder(
-                sitemeta['title'],
-                sitemeta['url'],
-                sitemeta['description']
-        )
-        builder.pubDate(
-                ZonedDateTime.of(
-                        LocalDateTime.now(),
-                        ZoneId.of('GMT')
-                )
-        )
-        builder.lastBuildDate(
-                ZonedDateTime.of(
-                        LocalDateTime.now(),
-                        ZoneId.of('GMT')
-                )
-        )
-        .docs('https://blogs.law.harvard.edu/tech/rss')
-        .generator('Micronaut RSS')
-        .managingEditor('delamos@objectcomputing.com')
-        .webMaster('delamos@objectcomputing.com')
-
-        for (RssItem item : rssItems) {
-            builder.item(item)
-        }
-        def writer = new FileWriter(outputFile)
-        new DefaultRssFeedRenderer().with {
-            render(writer, builder.build())
-        }
-        writer.close()
-    }
-
-    static boolean isTag(String word) {
-        ALLOWED_TAG_PREFIXES.any {word.startsWith(it) }
-    }
-
-    @Nonnull
-    static String wrapTags(Map<String, String> metadata, @Nonnull String html) {
-        html.split('\n')
-            .collect { line ->
-                if (line.startsWith('<p>') && line.endsWith('</p>')) {
-                    def lineWithoutParagraphs = line
-                            .replaceAll('<p>', '')
-                            .replaceAll('</p>', '')
-                    def words = lineWithoutParagraphs.split(' ')
-                    lineWithoutParagraphs = words.collect { word ->
-                        if (isTag(word)) {
-                            def tag = word
-                            if (word.contains('<')) {
-                                tag = word.substring(0, word.indexOf('<'))
-                            }
-                            return "<a href=\"${metadata['url']}/$BLOG/$TAG/${tag.replaceAll('#', '')}.html\"><span class=\"hashtag\">$tag</span></a>".toString()
-                        } else {
-                            return word
-                        }
-                    }.join(' ')
-                    return "<p>$lineWithoutParagraphs</p>".toString()
-                } else {
-                    return line
-                }
-            }.join('\n')
-    }
-
-    static List<MarkdownPost> parsePosts(File postsDir) {
-        List<MarkdownPost> listOfPosts = []
-        postsDir.eachFile { file ->
-            if (file.path.endsWith('.md') || file.path.endsWith('.markdown')) {
-                def contentAndMetadata = RenderSiteTask.parseFile(file)
-                listOfPosts.add(
-                        new MarkdownPost(
-                                filename: file.name,
-                                content: contentAndMetadata.content,
-                                metadata: contentAndMetadata.metadata
-                        )
-                )
-            }
-        }
-        listOfPosts
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/CopyAssetsTask.groovy b/buildSrc/src/main/groovy/org/grails/gradle/tasks/CopyAssetsTask.groovy
deleted file mode 100644
index acddefc2c9a..00000000000
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/CopyAssetsTask.groovy
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- *  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
- *
- *    http://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 org.grails.gradle.tasks
-
-import javax.inject.Inject
-
-import groovy.transform.CompileStatic
-
-import org.gradle.api.Project
-import org.gradle.api.file.CopySpec
-import org.gradle.api.file.DirectoryProperty
-import org.gradle.api.model.ObjectFactory
-import org.gradle.api.tasks.CacheableTask
-import org.gradle.api.tasks.InputDirectory
-import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.OutputDirectory
-import org.gradle.api.tasks.PathSensitive
-import org.gradle.api.tasks.PathSensitivity
-import org.gradle.api.tasks.TaskAction
-import org.gradle.api.tasks.TaskProvider
-
-import org.grails.gradle.GrailsWebsiteExtension
-
-@CompileStatic
-@CacheableTask
-class CopyAssetsTask extends GrailsWebsiteTask {
-
-    @Internal
-    String description = 'Copies css, js, fonts and images from the assets folder to the dist folder'
-
-    public static final String NAME = 'copyAssets'
-
-    public static final List<String> FONT_EXTENSIONS = ['*.eot', '*.ttf', '*.woff', '*.woff2']
-    public static final List<String> JAVASCRIPT_EXTENSIONS = ['*.js']
-    public static final List<String> CSS_EXTENSIONS = ['*.css']
-    public static final List<String> IMAGE_EXTENSIONS = ['*.ico', '*.png', '*.svg', '*.jpg', '*.jpeg', '*.gif']
-    public static final List<String> FILE_EXTENSIONS = ['*.jar', '*.md5', '*.pdf']
-
-    private final ObjectFactory objects
-
-    @Inject
-    CopyAssetsTask(ObjectFactory objects) {
-        this.objects = objects
-    }
-
-    @InputDirectory
-    @PathSensitive(PathSensitivity.RELATIVE)
-    final DirectoryProperty assetsDir = objects.directoryProperty()
-
-    @OutputDirectory
-    final DirectoryProperty outputDir = objects.directoryProperty()
-
-    @TaskAction
-    void copyAssets() {
-        copyImages()
-        copyCss()
-        copyJs()
-        copyFonts()
-        copyFiles()
-    }
-
-    static TaskProvider<CopyAssetsTask> register(Project project, GrailsWebsiteExtension siteExt) {
-        project.tasks.register(NAME, CopyAssetsTask) {
-            it.outputDir.set(siteExt.outputDir)
-            it.assetsDir.set(siteExt.assetsDir)
-        }
-    }
-
-    private File getDistDir() {
-        new File(outputDir.get().asFile, 'dist').tap { it.mkdirs() }
-    }
-
-    static List<String> recursiveIncludes(Iterable<String> extensions) {
-        extensions.collect {
-            ["$it", "**/$it"]
-        }.flatten() as List<String>
-    }
-
-    void copyImages() {
-        def srcDir = new File(assetsDir.get().asFile, 'images')
-        def destDir = new File(distDir, 'images').tap { it.mkdirs() }
-        project.copy { CopySpec copySpec ->
-            copySpec.from(srcDir)
-            copySpec.into(destDir)
-            copySpec.include(recursiveIncludes(IMAGE_EXTENSIONS))
-            copySpec.setIncludeEmptyDirs(false)
-        }
-    }
-
-    void copyCss() {
-        def cssSrcDir = new File(assetsDir.get().asFile, 'stylesheets')
-        def cssDstDir = new File(distDir, 'stylesheets').tap {it.mkdirs() }
-        project.copy { CopySpec copySpec ->
-            copySpec.from(cssSrcDir)
-            copySpec.into(cssDstDir)
-            copySpec.include(CSS_EXTENSIONS)
-        }
-    }
-
-    void copyFonts() {
-        def dstFontsDir = new File(distDir, 'fonts').tap { it.mkdirs() }
-        def srcFontsDir = new File(assetsDir.get().asFile, 'fonts')
-        project.copy { CopySpec copySpec ->
-            copySpec.from(srcFontsDir)
-            copySpec.into(dstFontsDir)
-            copySpec.include(FONT_EXTENSIONS)
-        }
-    }
-
-    void copyJs() {
-        def jsDstDir = new File(distDir, 'javascripts').tap { it.mkdirs() }
-        def jsSrcDir = new File(assetsDir.get().asFile, 'javascripts')
-        project.copy { CopySpec copySpec ->
-            copySpec.from(jsSrcDir)
-            copySpec.into(jsDstDir)
-            copySpec.include(JAVASCRIPT_EXTENSIONS)
-        }
-    }
-
-    void copyFiles() {
-        def filesDstDir = new File(distDir, 'files').tap { it.mkdirs() }
-        def filesSrcDir = new File(assetsDir.get().asFile, 'files')
-        project.copy { CopySpec copySpec ->
-            copySpec.from(filesSrcDir)
-            copySpec.into(filesDstDir)
-            copySpec.include(recursiveIncludes(FILE_EXTENSIONS))
-        }
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/PluginsTask.groovy b/buildSrc/src/main/groovy/org/grails/gradle/tasks/PluginsTask.groovy
deleted file mode 100644
index 2b2944e7bb7..00000000000
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/PluginsTask.groovy
+++ /dev/null
@@ -1,221 +0,0 @@
-/*
- *  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
- *
- *    http://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 org.grails.gradle.tasks
-
-import java.net.http.HttpClient
-import java.net.http.HttpRequest
-import java.net.http.HttpResponse
-import java.time.LocalDateTime
-import java.time.format.DateTimeFormatter
-import java.util.stream.Collectors
-
-import javax.inject.Inject
-
-import groovy.json.JsonSlurper
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.util.logging.Slf4j
-
-import org.gradle.api.Project
-import org.gradle.api.file.DirectoryProperty
-import org.gradle.api.file.RegularFileProperty
-import org.gradle.api.model.ObjectFactory
-import org.gradle.api.provider.ListProperty
-import org.gradle.api.provider.Property
-import org.gradle.api.tasks.CacheableTask
-import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.InputFile
-import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.OutputDirectory
-import org.gradle.api.tasks.PathSensitive
-import org.gradle.api.tasks.PathSensitivity
-import org.gradle.api.tasks.TaskAction
-import org.gradle.api.tasks.TaskProvider
-
-import org.grails.gradle.GrailsWebsiteExtension
-import org.grails.plugin.Owner
-import org.grails.plugin.Plugin
-import org.grails.plugin.PluginsPage
-
-@Slf4j
-@CompileStatic
-@CacheableTask
-class PluginsTask extends GrailsWebsiteTask {
-
-    @Internal
-    String description = 'Generates an HTML Page listing the Grails plugins'
-
-    public static final String NAME = 'genPlugins'
-
-    private static final String GRAILS_PLUGINS_JSON =
-            'https://raw.githubusercontent.com/grails/grails-plugins-metadata/main/grails-plugins.json'
-
-    private final ObjectFactory objects
-
-    @Inject
-    PluginsTask(ObjectFactory objects) {
-        this.objects = objects
-    }
-
-    @InputFile
-    @PathSensitive(PathSensitivity.RELATIVE)
-    final RegularFileProperty document = objects.fileProperty()
-
-    @Input
-    final ListProperty<String> keywords = objects.listProperty(String)
-
-    @Input
-    final Property<String> url = objects.property(String)
-
-    @OutputDirectory
-    final DirectoryProperty outputDir = objects.directoryProperty()
-
-    static TaskProvider<PluginsTask> register(
-            Project project,
-            GrailsWebsiteExtension siteExt,
-            String name = NAME
-    ) {
-        project.tasks.register(name, PluginsTask) {
-            it.document.set(siteExt.template)
-            it.outputDir.set(siteExt.outputDir)
-            it.url.set(siteExt.url)
-        }
-    }
-
-    @TaskAction
-    void renderPluginsPage() {
-        def template = document.get().asFile
-        def templateText = template.text
-        def metadata = RenderSiteTask.siteMeta(
-                'Grails Plugins', // TODO Make it configurable
-                'List of Plugins', // TODO Make it configurable
-                url.get(),
-                keywords.get(),
-                'all', // TODO Make it configurable,
-                '',
-                ''
-        )
-        metadata.put('JAVASCRIPT', '/javascripts/plugins-search.js')
-
-        def resolvedMetadata = RenderSiteTask.processMetadata(metadata)
-        def result = new JsonSlurper().parse(GRAILS_PLUGINS_JSON.toURL())
-        def plugins = pluginsFromJson(result)
-        renderHtml(plugins, templateText, resolvedMetadata, 'plugins.html')
-    }
-
-    void renderHtml(List<Plugin> plugins, String templateText, Map<String, String> metadata, String fileName) {
-        def siteUrl = url.get()
-        def distDir = new File(outputDir.get().asFile, 'dist').tap { it.mkdir() }
-        def pluginsDir = new File(distDir, 'plugins').tap { it.mkdir() }
-        def pluginsTagsDir = new File(pluginsDir, 'tags').tap { it.mkdir() }
-        def pluginsOwnersDir = new File(pluginsDir, 'owners').tap { it.mkdir() }
-        def pluginOutputFile = new File(distDir, fileName).tap { it.createNewFile() }
-        def html = PluginsPage.mainContent(siteUrl, plugins, 'Grails Plugins', null)
-        html = RenderSiteTask.renderHtmlWithTemplateContent(html, metadata, templateText)
-        html = RenderSiteTask.highlightMenu(html, metadata, '/plugins.html')
-        pluginOutputFile.text = html
-
-        def tags = plugins.stream().flatMap(p -> p.labels.stream()).distinct().collect(Collectors.toList())
-        for (def tag in tags) {
-            def tagsOutputFile = new File(pluginsTagsDir, "${tag}.html").tap { it.createNewFile() }
-            def htmlForTags = renderHtmlPagesForTags(siteUrl, plugins, tag)
-            tagsOutputFile.text = RenderSiteTask.renderHtmlWithTemplateContent(htmlForTags, metadata, templateText)
-        }
-
-        def owners = plugins.stream().map(p -> p.owner.name).distinct().collect(Collectors.toList())
-        for (def owner in owners) {
-            def ownersOutputFile = new File(pluginsOwnersDir, "${owner}.html").tap { it.createNewFile() }
-            def htmlForOwnersFile = renderHtmlPagesForOwners(siteUrl, plugins, owner)
-            ownersOutputFile.text = RenderSiteTask.renderHtmlWithTemplateContent(htmlForOwnersFile, metadata, templateText)
-        }
-    }
-
-    static String renderHtmlPagesForTags(String siteUrl, List<Plugin> plugins, String tag) {
-        def filteredPlugins = plugins.stream().filter(p -> p.labels.contains(tag)).collect(Collectors.toList())
-        PluginsPage.mainContent(siteUrl, plugins , "Plugins by tag #${tag}", filteredPlugins)
-    }
-
-    static String renderHtmlPagesForOwners(String siteUrl, List<Plugin> plugins, String owner) {
-        def filteredPlugins = plugins.stream().filter(p -> p.owner.name == owner).collect(Collectors.toList())
-        PluginsPage.mainContent(siteUrl, plugins , "Plugins by creator: #${owner}", filteredPlugins)
-    }
-
-    @CompileDynamic
-    List<Plugin> pluginsFromJson(Object json) {
-        List<Plugin> plugins = []
-        for (int i = 0; i < json.size(); i++) {
-            def owner = new Owner(name: json[i].bintrayPackage.owner)
-            def updatedDate = parseIsoStringToDate(json[i].bintrayPackage.updated)
-            plugins.add(
-                    new Plugin(
-                            name: json[i].bintrayPackage.name,
-                            updated: updatedDate,
-                            owner: owner,
-                            desc: json[i].bintrayPackage.desc,
-                            vcsUrl: json[i].bintrayPackage.vcsUrl,
-                            latestVersion: json[i].bintrayPackage.latestVersion,
-                            githubStars: githubStars(json[i].bintrayPackage.vcsUrl).orElse(null),
-                            labels: json[i].bintrayPackage.labels as List<String>
-                    )
-            )
-        }
-        Collections.unmodifiableList(plugins)
-    }
-
-    static LocalDateTime parseIsoStringToDate(String isoFormattedString) {
-        def f = DateTimeFormatter.ofPattern(/yyyy-MM-dd'T'HH:mm:ss.SSSXXX/)
-        LocalDateTime.parse(isoFormattedString, f)
-    }
-
-    @CompileDynamic
-    static Optional<Integer> githubStars(String vcsUrl) {
-        if (!vcsUrl) {
-            return Optional.empty()
-        }
-        if (!vcsUrl.contains('github.com')) {
-            return Optional.empty()
-        }
-        if (!System.getenv('GH_TOKEN')) {
-            return Optional.empty()
-        }
-        try {
-            log.debug('Fetching github stars of {}', vcsUrl)
-            def url = 'https://api.github.com/repos/' + vcsUrl.substring(vcsUrl.indexOf('github.com/') + 'github.com/'.length())
-            def request = HttpRequest.newBuilder()
-                    .uri(new URI(url))
-                    .header('X-GitHub-Api-Version', '2022-11-28')
-                    .header('Authorization', "Bearer ${System.getenv("GH_TOKEN")}")
-                    .GET()
-                    .build()
-            def response = HttpClient.newBuilder().build().send(
-                    request,
-                    HttpResponse.BodyHandlers.ofString()
-            )
-            if (response.statusCode() == 200) {
-                def json = response.body()
-                def stars = (new JsonSlurper().parseText(json).stargazers_count) as Integer
-                return (stars == 0 ? Optional.empty() : Optional.of(stars)) as Optional<Integer>
-            }
-        } catch (Exception e) {
-            log.error("Error fetching GitHub stars for $vcsUrl", e)
-            return Optional.empty()
-        }
-        return Optional.empty()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/Category.groovy b/buildSrc/src/main/groovy/org/grails/guides/Category.groovy
deleted file mode 100644
index fa6caaf89b2..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/Category.groovy
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class Category {
-    String name
-    String image
-
-    String getSlug() {
-        String slug = name.replaceAll(' ', '')
-        slug = slug.replaceAll('\\(', '')
-        slug = slug.replaceAll('\\+', '')
-        slug = slug.replaceAll('\\)', '')
-        URLEncoder.encode(slug, 'UTF-8').toLowerCase()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/GrailsVersionedGuide.groovy b/buildSrc/src/main/groovy/org/grails/guides/GrailsVersionedGuide.groovy
deleted file mode 100644
index cf3a6c83fab..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/GrailsVersionedGuide.groovy
+++ /dev/null
@@ -1,30 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class GrailsVersionedGuide implements Guide {
-    List<String> authors
-    String category
-    Map<GrailsMajorVersion, List<String>> grailsMayorVersionTags = [:]
-    String name
-    String title
-    String subtitle
-    Date publicationDate
-    String versionNumber
-    String githubSlug
-    String githubBranch
-
-    @Override
-    List<String> getTags() {
-        List<List<String>> tags = grailsMayorVersionTags.collect {
-            GrailsMajorVersion k, List<String> v ->
-                v
-        }
-        tags << grailsMayorVersionTags.collect {
-            GrailsMajorVersion k, List<String> v ->
-                k.toString().toLowerCase()
-        } as List<String>
-        tags.flatten() as List<String>
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/Guide.groovy b/buildSrc/src/main/groovy/org/grails/guides/Guide.groovy
deleted file mode 100644
index d7f99e24fc9..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/Guide.groovy
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-interface Guide {
-    String getVersionNumber()
-    List<String> getAuthors()
-    String getCategory()
-    String getName()
-    String getTitle()
-    String getSubtitle()
-    List<String> getTags()
-    Date getPublicationDate()
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/org/grails/guides/GuidesFetcher.groovy b/buildSrc/src/main/groovy/org/grails/guides/GuidesFetcher.groovy
deleted file mode 100644
index db7aea13cda..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/GuidesFetcher.groovy
+++ /dev/null
@@ -1,121 +0,0 @@
-package org.grails.guides
-
-import groovy.json.JsonSlurper
-import groovy.time.TimeCategory
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import java.text.DateFormat
-import java.text.SimpleDateFormat
-
-@CompileStatic
-class GuidesFetcher {
-
-    public static final String GUIDES_JSON = 'https://raw.githubusercontent.com/grails-guides/grails-guides-template/gh-pages/guides.json'
-
-    @CompileDynamic
-    static List<Guide> fetchGuides(boolean skipFuture = true) {
-        URL url = new URL(GUIDES_JSON)
-        def jsonArr = new JsonSlurper().parseText(url.text)
-        DateFormat dateFormat = new SimpleDateFormat('dd MMM yyyy',
-                Locale.US)
-
-        Map<String, Set<String>> githubSlugsAndBranches = [:]
-        jsonArr.each { it ->
-            if (githubSlugsAndBranches.containsKey(it.githubSlug)) {
-                Set<String> arr = githubSlugsAndBranches[it.githubSlug]
-                arr << it.githubBranch ?: 'master'
-                githubSlugsAndBranches[it.githubSlug] = arr
-            } else {
-                githubSlugsAndBranches[it.githubSlug] = [it.githubBranch ?: 'master'] as HashSet<String>
-            }
-        }
-        List<Guide> guides = []
-        for (String githubSlug : githubSlugsAndBranches.keySet()) {
-            if (githubSlugsAndBranches[githubSlug].size() == 1) {
-                def guideArr = jsonArr.find {
-                    it.githubSlug == githubSlug &&
-                            (!it.githubBranch || githubSlugsAndBranches[githubSlug].any { branch -> branch == it.githubBranch})
-                }
-                if (guideArr) {
-                    Guide guide = new SingleGuide(
-                            versionNumber: guideArr.grailsVersion,
-                            authors: guideArr.authors as List,
-                            category: guideArr.category,
-                            githubSlug: guideArr.githubSlug,
-                            githubBranch: guideArr.githubBranch,
-                            name: guideArr.name,
-                            title: guideArr.title,
-                            subtitle: guideArr.subtitle,
-                            tags: guideArr.tags as List
-                    )
-                    if (guideArr.publicationDate) {
-                        guide.publicationDate = dateFormat.parse(guideArr.publicationDate as String)
-                    }
-                    guides << guide
-                }
-            } else if (githubSlugsAndBranches[githubSlug].size() > 1) {
-                GrailsVersionedGuide guide
-                for (String githubBranch : githubSlugsAndBranches[githubSlug]) {
-                    def guideArr = jsonArr.find {
-                        it.githubSlug == githubSlug &&
-                                it.githubBranch == githubBranch
-                    }
-                    if (guideArr) {
-                        if (!guide) {
-                            guide = new GrailsVersionedGuide()
-                        }
-                        guide.versionNumber = guideArr.grailsVersion
-                        guide.authors = guideArr.authors as List
-                        guide.category = guideArr.category
-                        guide.githubSlug = guideArr.githubSlug
-                        guide.githubBranch = guideArr.githubBranch
-                        guide.name = guideArr.name
-                        guide.title = guideArr.title
-                        guide.subtitle = guideArr.subtitle
-                        if (githubBranch == 'grails3') {
-                            guide.grailsMayorVersionTags[GrailsMajorVersion.GRAILS_3] = guideArr.tags
-                        } else if (githubBranch == 'grails4' || githubBranch == 'master') {
-                            guide.grailsMayorVersionTags[GrailsMajorVersion.GRAILS_4] = guideArr.tags
-                        } else if (githubBranch == 'grails5') {
-                            guide.grailsMayorVersionTags[GrailsMajorVersion.GRAILS_5] = guideArr.tags
-                        } else if (githubBranch == 'grails6') {
-                            guide.grailsMayorVersionTags[GrailsMajorVersion.GRAILS_6] = guideArr.tags
-                        }
-                        if (guideArr.publicationDate) {
-                            guide.publicationDate = dateFormat.parse(guideArr.publicationDate as String)
-                        }
-                    }
-                }
-                if (guide) {
-                    guides << guide
-                }
-            }
-        }
-        if(skipFuture) {
-            guides = guides.findAll { it.publicationDate.before(tomorrow()) }
-        }
-        guides.sort { Guide a, Guide b ->
-            b.publicationDate <=> a.publicationDate
-        }
-    }
-
-    static ProgrammingLanguage programmingLanguage(String slug) {
-        if(slug.endsWith('groovy')) {
-            return ProgrammingLanguage.GROOVY
-        } else if(slug.endsWith('kotlin')) {
-            return ProgrammingLanguage.KOTLIN
-        }
-
-        return ProgrammingLanguage.JAVA
-    }
-
-
-    @CompileDynamic
-    static Date tomorrow() {
-        Date tomorrow = new Date()
-        use(TimeCategory) {
-            tomorrow = tomorrow += 1.day
-        }
-        tomorrow
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/MultiLanguageGuide.groovy b/buildSrc/src/main/groovy/org/grails/guides/MultiLanguageGuide.groovy
deleted file mode 100644
index 852594cc9a3..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/MultiLanguageGuide.groovy
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class MultiLanguageGuide implements Guide {
-    List<String> authors
-    String category
-    Map<ProgrammingLanguage, String> githubSlugs
-    Map<ProgrammingLanguage, List<String>> programmingLanguageTags
-    String name
-    String title
-    String subtitle
-    Date publicationDate
-    String versionNumber
-
-    @Override
-    List<String> getTags() {
-        List<List<String>> tags = programmingLanguageTags.collect { ProgrammingLanguage k, List<String> v -> v}
-        tags << programmingLanguageTags.collect {ProgrammingLanguage k, List<String> v -> k.toString().toLowerCase()} as List<String>
-        tags.flatten() as List<String>
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/ProgrammingLanguage.groovy b/buildSrc/src/main/groovy/org/grails/guides/ProgrammingLanguage.groovy
deleted file mode 100644
index 56466e36101..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/ProgrammingLanguage.groovy
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-enum ProgrammingLanguage {
-    JAVA(1),
-    KOTLIN(2),
-    GROOVY(3)
-
-    final int id
-    private ProgrammingLanguage(int id) { this.id = id }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/SingleGuide.groovy b/buildSrc/src/main/groovy/org/grails/guides/SingleGuide.groovy
deleted file mode 100644
index e56c832daa6..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/SingleGuide.groovy
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-import groovy.transform.ToString
-
-@ToString(includes = ['title'])
-@CompileStatic
-class SingleGuide implements Guide {
-    String versionNumber
-    List<String> authors
-    String category
-    String githubSlug
-    String githubBranch
-    String name
-    String title
-    String subtitle
-    List<String> tags
-    Date publicationDate
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/SingleLanguageGuide.groovy b/buildSrc/src/main/groovy/org/grails/guides/SingleLanguageGuide.groovy
deleted file mode 100644
index 47ff078bc28..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/SingleLanguageGuide.groovy
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-import groovy.transform.ToString
-
-@ToString(includes = ['title'])
-@CompileStatic
-class SingleLanguageGuide implements Guide {
-    String versionNumber
-    List<String> authors
-    String category
-    String githubSlug
-    String name
-    String title
-    String subtitle
-    List<String> tags
-    Date publicationDate
-    ProgrammingLanguage programmingLanguage
-}
diff --git a/buildSrc/src/main/groovy/org/grails/guides/TagUtils.groovy b/buildSrc/src/main/groovy/org/grails/guides/TagUtils.groovy
deleted file mode 100644
index 24f3a633076..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/TagUtils.groovy
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileStatic
-import org.grails.plugin.Plugin
-import org.grails.tags.Tag
-
-@CompileStatic
-class TagUtils {
-    static Set<Tag> populateTags(List<Guide> guides) {
-        Map<String, Integer> tagsMap = [:]
-        if ( guides ) {
-            for ( Guide guide : guides ) {
-                if (guide.tags) {
-                    for ( String tag : guide.tags ) {
-                        String k = tag.trim().toLowerCase()
-                        tagsMap[k] = tagsMap.containsKey(k) ? ( 1 + tagsMap[k] ) : 1
-                    }
-                }
-            }
-        }
-        populateWithTagsMap(tagsMap)
-    }
-
-    static Set<Tag> populateTagsByPlugins(List<Plugin> plugins) {
-        Map<String, Integer> tagsMap = [:]
-        if (plugins) {
-
-            for (Plugin plugin : plugins) {
-                if (plugin.labels) {
-                    for (String tag : plugin.labels) {
-                        String k = tag.trim().toLowerCase()
-                        tagsMap[k] = tagsMap.containsKey(k) ? (1 + tagsMap[k]) : 1
-                    }
-                }
-            }
-        }
-        populateWithTagsMap(tagsMap)
-    }
-
-    static Set<Tag> populateTagsByPluginsOwners(List<Plugin> plugins) {
-        Map<String, Integer> tagsMap = [:]
-        if (plugins) {
-            for (Plugin plugin : plugins) {
-                if (plugin.owner) {
-                    String tag = plugin.owner.name
-                    String k = tag.trim().toLowerCase()
-                    tagsMap[k] = tagsMap.containsKey(k) ? (1 + tagsMap[k]) : 1
-                }
-            }
-        }
-        populateWithTagsMap(tagsMap)
-    }
-
-    private static Set<Tag> populateWithTagsMap(Map<String, Integer> tagsMap) {
-        Set<Tag> tags = []
-        tagsMap.each { k, v ->
-            tags << new Tag(title: k, ocurrence: v)
-        }
-        tags
-    }
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/org/grails/guides/Training.groovy b/buildSrc/src/main/groovy/org/grails/guides/Training.groovy
deleted file mode 100644
index 429dffb1b99..00000000000
--- a/buildSrc/src/main/groovy/org/grails/guides/Training.groovy
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.grails.guides
-
-import groovy.transform.CompileDynamic
-import groovy.xml.MarkupBuilder
-
-class Training {
-    @CompileDynamic
-    static String training() {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div(class: 'training') {
-            h3(class: 'columnheader') {
-                a(href: 'https://objectcomputing.com/training/catalog/grails/', 'Grails Training')
-            }
-            mkp.yieldUnescaped'[%events]'
-        }
-        writer.toString()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/plugin/Owner.groovy b/buildSrc/src/main/groovy/org/grails/plugin/Owner.groovy
deleted file mode 100644
index 0ecfdcf2dee..00000000000
--- a/buildSrc/src/main/groovy/org/grails/plugin/Owner.groovy
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.grails.plugin
-
-import groovy.transform.EqualsAndHashCode
-
-@EqualsAndHashCode
-class Owner {
-    String name;
-    List<Plugin> plugins;
-}
diff --git a/buildSrc/src/main/groovy/org/grails/plugin/Plugin.groovy b/buildSrc/src/main/groovy/org/grails/plugin/Plugin.groovy
deleted file mode 100644
index dbff42cdc4a..00000000000
--- a/buildSrc/src/main/groovy/org/grails/plugin/Plugin.groovy
+++ /dev/null
@@ -1,34 +0,0 @@
-package org.grails.plugin
-
-import groovy.transform.CompileStatic
-import groovy.transform.EqualsAndHashCode
-import groovy.transform.Sortable
-import org.jetbrains.annotations.NotNull
-
-import java.time.LocalDateTime
-
-@EqualsAndHashCode
-@CompileStatic
-@Sortable(includes=['updated'])
-class Plugin implements Comparable{
-    String name
-    Owner owner
-    String desc
-    List<String> labels
-    String latestVersion
-    String vcsUrl
-    LocalDateTime updated
-    String license
-    Integer githubStars
-
-    static String getUpdated(Plugin plugin){
-        plugin.updated
-    }
-
-
-    @Override
-    int compareTo(@NotNull Object o) {
-        return 0
-    }
-
-}
diff --git a/buildSrc/src/main/groovy/org/grails/plugin/PluginsPage.groovy b/buildSrc/src/main/groovy/org/grails/plugin/PluginsPage.groovy
deleted file mode 100644
index 7e841c2aec6..00000000000
--- a/buildSrc/src/main/groovy/org/grails/plugin/PluginsPage.groovy
+++ /dev/null
@@ -1,328 +0,0 @@
-package org.grails.plugin
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.xml.MarkupBuilder
-import org.grails.guides.Category
-import org.grails.guides.TagUtils
-import org.grails.tags.Tag
-import org.grails.tags.TagCloud
-import java.time.format.DateTimeFormatter
-import java.util.stream.Collector
-import java.util.stream.Collectors
-
-@CompileStatic
-class PluginsPage {
-    private static final Comparator<Plugin> COMPARE_BY_NAME = new Comparator<Plugin>() {
-        @Override
-        int compare(Plugin o1, Plugin o2) {
-            o1.name.toLowerCase() <=> o2.name.toLowerCase()
-        }
-    }
-
-    private static final Comparator<Plugin> COMPARE_BY_GITHUB_STARS = new Comparator<Plugin>() {
-        @Override
-        int compare(Plugin o1, Plugin o2) {
-            o2.githubStars <=> o1.githubStars
-        }
-    }
-    private static final Comparator<Plugin> COMPARE_BY_UPDATED = new Comparator<Plugin>() {
-        @Override
-        int compare(Plugin o1, Plugin o2) {
-            o2.updated <=> o1.updated
-        }
-    }
-    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("MMM d, YYYY");
-
-    @CompileDynamic
-    static String mainContent(String siteUrl,
-                              List<Plugin> plugins,
-                              String title,
-                              List<Plugin> filteredPlugins) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div(class: 'headerbar chalicesbg') {
-            div(class: 'content') {
-                h1 'Grails Plugins'
-            }
-        }
-        html.div(class: 'content') {
-            if (title !== 'Grails Plugins') {
-                html.div(class: "breadcrumbs") {
-                    a(href: siteUrl + "/plugins.html", "All Grails Plugins")
-                    span(' » ')
-                    span(title)
-                }
-            }
-            div(class: 'twocolumns') {
-                div(class: 'column') {
-                    mkp.yieldUnescaped searchBox(null, null)
-                    mkp.yieldUnescaped latestPlugins(siteUrl, plugins)
-                    mkp.yieldUnescaped topRatedPlugins(siteUrl, plugins)
-                    mkp.yieldUnescaped pluginsByTag(siteUrl, plugins)
-                    mkp.yieldUnescaped pluginsByOwner(siteUrl, plugins)
-                    mkp.yieldUnescaped linksMenu(siteUrl)
-                }
-                div(class: 'column') {
-                    if (filteredPlugins != null) {
-                        mkp.yieldUnescaped(renderPlugins(siteUrl, filteredPlugins, title))
-                    } else {
-                        mkp.yieldUnescaped(renderPlugins(siteUrl, plugins, title))
-                    }
-                }
-            }
-
-        }
-        writer.toString()
-    }
-
-
-    @CompileDynamic
-    static String renderTwoColumnsPlugins(String siteUrl, List<Plugin> plugins) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div {
-            h3(class: "columnheader", "Grails Plugins")
-            plugins.sort(COMPARE_BY_NAME)
-            int index = 0
-            int page = 2
-            do {
-                List<Plugin> l = (plugins.size() > (index + page)) ?
-                        plugins.subList(index, index + page) :
-                        plugins.subList(index, plugins.size())
-                l = l.reverse()
-                if (l.size() == 1) {
-                    mkp.yieldUnescaped renderSinglePlugin(siteUrl, l[0])
-                } else {
-                    div(class: 'twocolumns') {
-                        div(class: 'column') {
-                            if (l.size() >= 1) {
-                                mkp.yieldUnescaped renderPlugins(siteUrl, [l[0]])
-                            }
-                        }
-                        div(class: 'column') {
-                            if (l.size() >= 2) {
-                                mkp.yieldUnescaped renderPlugins(siteUrl, [l[1]])
-                            }
-                        }
-                    }
-                }
-                index += page
-            } while (plugins.size() > index)
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String linksMenu(String siteUrl) {
-        List<Map<String, String>> links = [
-                [url: "https://grails.apache.org/blog/2021-04-07-publish-grails-plugin-to-maven-central.html", title: "Publishing Guide"],
-                [url: "https://github.com/apache/grails-plugins-metadata", title: "Portal on Github"],
-        ]
-
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div {
-            mkp.yieldUnescaped createHeader('Useful Link')
-            ul(class: 'guidegroup') {
-                for (Map<String, String> link : links) {
-                    li {
-                        a(href: link.url, link.title)
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String renderPlugins(String siteUrl, List<Plugin> plugins, String title) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        if (title != 'Grails Plugins') {
-            html.h3(class: "columnheader allpluginslabel", "Plugins")
-        } else {
-            html.h3(class: "columnheader allpluginslabel", "All Grails Plugins (" +plugins.size() +")")
-        }
-        html.h3(class: "columnheader searchresultslabel hidden", "Plugins Filtered by: ") {
-            html.span(class: "query-label")
-        }
-        html.div(class: 'plugins allplugins') {
-            ul {
-                for (plugin in plugins) {
-                    mkp.yieldUnescaped renderSinglePlugin(siteUrl, plugin)
-                }
-            }
-        }
-        html.div(class: "guidegroup noresults hidden") {
-            div(class: "guidegroupheader") {
-                h2("No results found!")
-            }
-        }
-        html.div(class: 'searchresults hidden') {
-            mkp.yieldUnescaped('')
-        }
-        html.div(class: 'pagination-container')
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String pluginsByOwner(String siteUrl, List<Plugin> plugins) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div {
-            mkp.yieldUnescaped createHeader('Plugins By Owner')
-            Set<Tag> tags = TagUtils.populateTagsByPluginsOwners(plugins)
-            mkp.yieldUnescaped TagCloud.tagCloud(siteUrl + "/plugins/owners", tags, false)
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String topRatedPlugins(String siteUrl, List<Plugin> plugins) {
-        List<Plugin> pluginsWithStars = plugins.stream()
-                .filter(p -> p.githubStars != null)
-                .sorted(COMPARE_BY_GITHUB_STARS)
-                .collect(Collectors.toList())
-        List<Plugin> topRatedPlugins = []
-        for (Plugin plugin : pluginsWithStars) {
-            if (topRatedPlugins.stream().noneMatch(p -> p.vcsUrl == plugin.vcsUrl)) {
-                topRatedPlugins.add(plugin)
-            }
-        }
-        topRatedPlugins = topRatedPlugins.take(5)
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div {
-            mkp.yieldUnescaped createHeader('Top Rated Plugins')
-            div {
-                ul(class: 'plugin') {
-                    for (Plugin plugin : topRatedPlugins) {
-                        mkp.yieldUnescaped renderSinglePlugin(siteUrl, plugin)
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String latestPlugins(String siteUrl, List<Plugin> plugins) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div {
-            mkp.yieldUnescaped createHeader('Latest Plugins')
-            mkp.yieldUnescaped fetchLatestPlugins(siteUrl, plugins)
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String pluginsByTag(String siteUrl, List<Plugin> plugins) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div {
-            mkp.yieldUnescaped createHeader('Plugins By Tag')
-            Set<Tag> tags = TagUtils.populateTagsByPlugins(plugins)
-            mkp.yieldUnescaped TagCloud.tagCloud(siteUrl + "/plugins/tags", tags, false)
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String renderSinglePlugin(String siteUrl, Plugin plugin) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder mb = new MarkupBuilder(writer)
-
-        mb.li(class: 'plugin') {
-
-            if (plugin.vcsUrl) {
-                h3(class: 'name') {
-                    a(href: plugin.vcsUrl, plugin.name)
-                }
-            } else {
-                h3(class: 'name') {
-                    a(plugin.name)
-                }
-            }
-            if (plugin.desc) {
-                p(class: 'desc') { mkp.yield(plugin.desc) }
-            }
-
-            if (plugin.latestVersion) {
-                span plugin.latestVersion
-            }
-            mkp.yield(" published ")
-            mkp.yield(FORMATTER.format(plugin.updated))
-            if (plugin.owner) {
-                a(href: "[%url]/plugins/owners/${plugin.owner.name}.html") {
-                    mkp.yield("by " + plugin.owner.name)
-                }
-            }
-
-            if (plugin.labels) {
-                ul(class: 'labels') {
-                    for (String label : plugin.labels) {
-                        li(class: 'label') {
-                            a(href: "[%url]/plugins/tags/${label}.html") {
-                                mkp.yield(label)
-                            }
-                        }
-                    }
-                }
-            }
-            if (plugin.githubStars) {
-                div(class: 'githubstar') {
-                    span(class: 'star') {
-                        img(src: "${siteUrl}/images/small_githubstar.svg", height: 20)
-                    }
-                    span(class: 'count', plugin.githubStars)
-                }
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String createHeader(String section) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.h3(class: 'columnheader', section)
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String fetchLatestPlugins(String siteUrl, List<Plugin> plugins) {
-        final List<Plugin> sortedPlugins = plugins.stream()
-                .sorted(COMPARE_BY_UPDATED)
-                .collect(Collectors.toList())
-        final List<Plugin> topFive = sortedPlugins.take(5)
-        renderLatestPlugins(siteUrl, topFive)
-    }
-
-    @CompileDynamic
-    static String renderLatestPlugins(String siteUrl, List<Plugin> topFive) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.ul(class: 'plugin') {
-            for (plugin in topFive) {
-                mkp.yieldUnescaped renderSinglePlugin(siteUrl, plugin)
-            }
-        }
-        writer.toString()
-    }
-
-    @CompileDynamic
-    static String searchBox(Tag tag, Category category) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        if (!(tag || category)) {
-            html.div(class: 'searchbox', style: 'margin-top: 50px !important;') {
-                div(class: 'search', style: 'margin-bottom: 0px !important;') {
-                    input(type: 'text', id: 'query', placeholder: 'SEARCH')
-                }
-            }
-        }
-        writer.toString()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/questions/Question.groovy b/buildSrc/src/main/groovy/org/grails/questions/Question.groovy
deleted file mode 100644
index 5ba385d9d05..00000000000
--- a/buildSrc/src/main/groovy/org/grails/questions/Question.groovy
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.grails.questions
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class Question implements QuestionHtml {
-    String title
-    String answer
-    String slug
-}
diff --git a/buildSrc/src/main/groovy/org/grails/questions/QuestionHtml.groovy b/buildSrc/src/main/groovy/org/grails/questions/QuestionHtml.groovy
deleted file mode 100644
index 3feca8132e7..00000000000
--- a/buildSrc/src/main/groovy/org/grails/questions/QuestionHtml.groovy
+++ /dev/null
@@ -1,32 +0,0 @@
-package org.grails.questions
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.xml.MarkupBuilder
-import org.grails.PageElement
-import org.grails.utils.MarkdownUtils
-
-@CompileStatic
-trait QuestionHtml implements PageElement {
-
-    abstract String getTitle()
-    abstract String getAnswer()
-    abstract String getSlug()
-
-    @CompileDynamic
-    @Override
-    String renderAsHtml() {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div(class: 'question', id: slug) {
-            h2 {
-                html.mkp.yieldUnescaped title
-            }
-            String text = MarkdownUtils.htmlFromMarkdown(answer)
-            text = text.replaceAll("\\\\n","<br/>")
-            html.mkp.yieldUnescaped(text)
-        }
-        writer.toString()
-    }
-
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/org/grails/questions/QuestionsPage.groovy b/buildSrc/src/main/groovy/org/grails/questions/QuestionsPage.groovy
deleted file mode 100644
index 57668de17e6..00000000000
--- a/buildSrc/src/main/groovy/org/grails/questions/QuestionsPage.groovy
+++ /dev/null
@@ -1,36 +0,0 @@
-package org.grails.questions
-
-import groovy.xml.MarkupBuilder
-import org.grails.utils.MarkdownUtils
-import org.yaml.snakeyaml.Yaml
-
-class QuestionsPage {
-
-    static String mainContent(File questions) {
-        def writer = new StringWriter()
-        def html = new MarkupBuilder(writer)
-        def yaml = new Yaml()
-        def model = yaml.load(questions.newDataInputStream())
-        def questionList = model['questions'].collect {
-            new Question(it as Map)
-        }
-        html.div(class: 'headerbar chalicesbg') {
-            html.div(class: 'content') {
-                h1('Questions')
-            }
-        }
-        html.div(class: 'content') {
-            article(id: 'questions') {
-                for (Question question : questionList) {
-                    div(class: 'question', id: question.slug) {
-                        h2(class: 'columnheader', question.title)
-                        mkp.yieldUnescaped(
-                                MarkdownUtils.htmlFromMarkdown(question.answer)
-                        )
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/tags/Tag.groovy b/buildSrc/src/main/groovy/org/grails/tags/Tag.groovy
deleted file mode 100644
index 2143ad58f3a..00000000000
--- a/buildSrc/src/main/groovy/org/grails/tags/Tag.groovy
+++ /dev/null
@@ -1,30 +0,0 @@
-package org.grails.tags
-
-import groovy.transform.Canonical
-import groovy.transform.CompileStatic
-import groovy.transform.Sortable
-
-@Canonical
-@Sortable(includes = ['title'])
-@CompileStatic
-class Tag {
-    String title
-    int ocurrence
-
-    void setTitle(String title) {
-        this.title = title?.trim()
-    }
-
-    String getSlug() {
-        if ( !title ) {
-            return null
-        }
-        String str = title
-        str = str.toLowerCase()
-        for ( String regex : [' ', '\\[', ']', '\''] ) {
-            str = str.replaceAll(regex, '')
-        }
-        str = str.trim()
-        URLEncoder.encode(str, 'UTF-8')
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/tags/TagCloud.groovy b/buildSrc/src/main/groovy/org/grails/tags/TagCloud.groovy
deleted file mode 100644
index 35200e2de5a..00000000000
--- a/buildSrc/src/main/groovy/org/grails/tags/TagCloud.groovy
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.grails.tags
-
-import groovy.transform.CompileDynamic
-import groovy.transform.CompileStatic
-import groovy.xml.MarkupBuilder
-
-@CompileStatic
-class TagCloud {
-
-    @CompileDynamic
-    static String tagCloud(String url, Set<Tag> tags, boolean includeHeader = true) {
-        StringWriter writer = new StringWriter()
-        MarkupBuilder html = new MarkupBuilder(writer)
-        html.div(class: 'tagsbytopic') {
-            if (includeHeader) {
-                h3 class: 'columnheader', 'Guides by Tag'
-            }
-            ul(class: 'tagcloud') {
-                tags.sort { Tag a, Tag b -> a.slug <=> b.slug }.each { Tag t ->
-                    li(class: "tag${t.ocurrence}") {
-                        a href: "${url}/${t.slug.toLowerCase()}.html", t.title
-                    }
-                }
-            }
-        }
-        writer.toString()
-    }
-}
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/GrailsWebsiteExtension.groovy b/buildSrc/src/main/groovy/website/gradle/GrailsWebsiteExtension.groovy
similarity index 95%
rename from buildSrc/src/main/groovy/org/grails/gradle/GrailsWebsiteExtension.groovy
rename to buildSrc/src/main/groovy/website/gradle/GrailsWebsiteExtension.groovy
index c99987636b5..2ae07cecaff 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/GrailsWebsiteExtension.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/GrailsWebsiteExtension.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle
+package website.gradle
 
 import javax.inject.Inject
 
@@ -35,6 +35,7 @@ class GrailsWebsiteExtension {
 
     public static final String NAME = 'grailsWebsite'
 
+    final RegularFileProperty htaccess
     final RegularFileProperty modules
     final RegularFileProperty profiles
     final RegularFileProperty questions
@@ -57,6 +58,9 @@ class GrailsWebsiteExtension {
     @Inject
     GrailsWebsiteExtension(ObjectFactory objects, ProjectLayout layout, ProviderFactory providers) {
         // File properties with conventions
+        htaccess = objects.fileProperty()
+        htaccess.convention(layout.buildDirectory.file('dist/.htaccess'))
+
         modules = objects.fileProperty()
         modules.convention(layout.projectDirectory.file('conf/modules.yml'))
 
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/GrailsWebsitePlugin.groovy b/buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy
similarity index 64%
rename from buildSrc/src/main/groovy/org/grails/gradle/GrailsWebsitePlugin.groovy
rename to buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy
index 76929e1bcb5..94d3439d5f2 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/GrailsWebsitePlugin.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy
@@ -16,32 +16,31 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle
+package website.gradle
 
 import groovy.transform.CompileStatic
 
 import org.gradle.api.Plugin
 import org.gradle.api.Project
 
-import org.grails.gradle.tasks.BlogTask
-import org.grails.gradle.tasks.BuildGuidesTask
-import org.grails.gradle.tasks.CopyAssetsTask
-import org.grails.gradle.tasks.DocumentationTask
-import org.grails.gradle.tasks.DownloadTask
-import org.grails.gradle.tasks.GuidesTask
-import org.grails.gradle.tasks.HtaccessTask
-import org.grails.gradle.tasks.MinutesTask
-import org.grails.gradle.tasks.PluginsTask
-import org.grails.gradle.tasks.ProfilesTask
-import org.grails.gradle.tasks.QuestionsTask
-import org.grails.gradle.tasks.RenderSiteTask
-import org.grails.gradle.tasks.SitemapTask
+import website.gradle.tasks.BlogTask
+
+import website.gradle.tasks.AssetsTask
+import website.gradle.tasks.DocumentationTask
+import website.gradle.tasks.DownloadTask
+import website.gradle.tasks.GrailsWebsiteTask
+import website.gradle.tasks.GuidesTask
+import website.gradle.tasks.HtaccessTask
+import website.gradle.tasks.MinutesTask
+import website.gradle.tasks.PluginsTask
+import website.gradle.tasks.ProfilesTask
+import website.gradle.tasks.QuestionsTask
+import website.gradle.tasks.RenderSiteTask
+import website.gradle.tasks.SitemapTask
 
 @CompileStatic
 class GrailsWebsitePlugin implements Plugin<Project> {
 
-    public static final String TASK_RENDER_SITE = 'renderSite'
-
     @Override
     void apply(Project project) {
         project.pluginManager.apply('base')
@@ -51,8 +50,8 @@ class GrailsWebsitePlugin implements Plugin<Project> {
                 GrailsWebsiteExtension
         )
 
+        AssetsTask.register(project, siteExt)
         BlogTask.register(project, siteExt)
-        CopyAssetsTask.register(project, siteExt)
         DocumentationTask.register(project, siteExt)
         DownloadTask.register(project, siteExt)
         GuidesTask.register(project, siteExt)
@@ -63,36 +62,47 @@ class GrailsWebsitePlugin implements Plugin<Project> {
         QuestionsTask.register(project, siteExt)
 
         SitemapTask.register(project, siteExt).configure {
+
             // SitemapTask must run after all tasks that generate files in dist/
+
             it.dependsOn(BlogTask.NAME)
             it.dependsOn(MinutesTask.NAME)
             it.dependsOn(PluginsTask.NAME)
             it.dependsOn(HtaccessTask.NAME)
         }
 
-        BuildGuidesTask.register(project, siteExt).configure {
-            it.dependsOn(CopyAssetsTask.NAME)
-            it.dependsOn(GuidesTask.NAME)
-            it.finalizedBy(SitemapTask.NAME)
+        RenderSiteTask.register(project, siteExt).configure {
 
-        }
+            // The Grails Guides are not part of this site, they are published to https://guides.grails.org
+            // and hosted at gh-pages in https://github.com/grails/grails-guides-template
+            // The buildGuides task can be used to generate the guide site.
 
-        RenderSiteTask.register(project, siteExt).configure {
-            it.dependsOn(CopyAssetsTask.NAME)
+            it.dependsOn(AssetsTask.NAME)
             it.dependsOn(DocumentationTask.NAME)
             it.dependsOn(DownloadTask.NAME)
-            it.dependsOn(GuidesTask.NAME)
             it.dependsOn(ProfilesTask.NAME)
             it.dependsOn(QuestionsTask.NAME)
+
             it.finalizedBy(BlogTask.NAME)
-            it.finalizedBy(PluginsTask.NAME)
             it.finalizedBy(MinutesTask.NAME)
-            it.finalizedBy(SitemapTask.NAME)
             it.finalizedBy(HtaccessTask.NAME)
+            it.finalizedBy(PluginsTask.NAME)
+            it.finalizedBy(SitemapTask.NAME)
+        }
+
+        project.tasks.register('buildGuides') {
+            // Task for only generating the Grails Guides which are published to https://guides.grails.org
+            // and hosted at gh-pages in https://github.com/grails/grails-guides-template
+            it.description = 'Build guides website - generates guides pages, copies assets and generates a sitemap'
+            it.group = GrailsWebsiteTask.GROUP
+            it.dependsOn(AssetsTask.NAME)
+            it.dependsOn(GuidesTask.NAME)
+            it.finalizedBy(SitemapTask.NAME)
+
         }
 
         project.tasks.named('build') {
-            it.dependsOn(TASK_RENDER_SITE)
+            it.dependsOn(RenderSiteTask.NAME)
         }
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/QuestionsTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/AssetsTask.groovy
similarity index 53%
copy from buildSrc/src/main/groovy/org/grails/gradle/tasks/QuestionsTask.groovy
copy to buildSrc/src/main/groovy/website/gradle/tasks/AssetsTask.groovy
index 18c4be1fb8e..96f6d886aed 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/QuestionsTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/AssetsTask.groovy
@@ -16,18 +16,18 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
 import groovy.transform.CompileStatic
 
 import org.gradle.api.Project
+import org.gradle.api.file.CopySpec
 import org.gradle.api.file.DirectoryProperty
-import org.gradle.api.file.RegularFileProperty
 import org.gradle.api.model.ObjectFactory
 import org.gradle.api.tasks.CacheableTask
-import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.InputDirectory
 import org.gradle.api.tasks.Internal
 import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.PathSensitive
@@ -35,53 +35,57 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.gradle.GrailsWebsiteExtension
-import org.grails.questions.QuestionsPage
+import website.gradle.GrailsWebsiteExtension
 
 @CompileStatic
 @CacheableTask
-class QuestionsTask extends GrailsWebsiteTask {
+class AssetsTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates FAQ HTML - build/temp/faq.html'
+    final String description =
+            'Copies css, js, fonts and images from the assets folder to the dist folder'
 
-    public static final String NAME = 'genFaq'
+    public static final String NAME = 'copyAssets'
 
-    private static final String PAGE_NAME_QUESTIONS = 'faq.html'
+    // dir name -> list of file name glob filters
+    static final Map<String,List<String>> assetTypes = [
+            'files': ['*.jar', '*.md5', '*.pdf'],
+            'fonts': ['*.eot', '*.ttf', '*.woff', '*.woff2'],
+            'images': ['*.gif', '*.ico', '*.jpeg', '*.jpg', '*.png', '*.svg'],
+            'javascripts': ['*.js'],
+            'stylesheets': ['*.css'],
+    ]
 
     private final ObjectFactory objects
 
     @Inject
-    QuestionsTask(ObjectFactory objects) {
+    AssetsTask(ObjectFactory objects) {
         this.objects = objects
     }
 
-    @InputFile
+    @InputDirectory
     @PathSensitive(PathSensitivity.RELATIVE)
-    final RegularFileProperty questions = objects.fileProperty()
+    final DirectoryProperty assetsDir = objects.directoryProperty()
 
     @OutputDirectory
     final DirectoryProperty outputDir = objects.directoryProperty()
 
-    static TaskProvider<QuestionsTask> register(
-            Project project,
-            GrailsWebsiteExtension siteExt,
-            String name = NAME
-    ) {
-        project.tasks.register(name, QuestionsTask) {
+    static TaskProvider<AssetsTask> register(Project project, GrailsWebsiteExtension siteExt) {
+        project.tasks.register(NAME, AssetsTask) {
             it.outputDir.set(siteExt.outputDir)
-            it.questions.set(siteExt.questions)
+            it.assetsDir.set(siteExt.assetsDir)
         }
     }
 
     @TaskAction
-    void renderQuestionsPage() {
-        def buildDir = outputDir.get().asFile
-        def temp = new File(buildDir, 'temp').tap { it.mkdir() }
-
-        def output = new File(temp, PAGE_NAME_QUESTIONS)
-        output.createNewFile()
-        output.text = "title: FAQ | Apache Grails&reg;\nbody: faq\n---\n" +
-                QuestionsPage.mainContent(questions.get().asFile)
+    void copyAssets() {
+        assetTypes.each { dirName, extFilters ->
+            project.copy { CopySpec cs ->
+                cs.from(assetsDir.dir(dirName))
+                cs.into(outputDir.dir("dist/$dirName"))
+                cs.include(extFilters.collect { '**/' + it })
+                cs.includeEmptyDirs = false
+            }
+        }
     }
 }
diff --git a/buildSrc/src/main/groovy/website/gradle/tasks/BlogTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/BlogTask.groovy
new file mode 100644
index 00000000000..64b6843565f
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/BlogTask.groovy
@@ -0,0 +1,523 @@
+/*
+ *  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
+ *
+ *    http://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.gradle.tasks
+
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.util.regex.Pattern
+
+import javax.annotation.Nonnull
+import javax.inject.Inject
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+import groovy.xml.MarkupBuilder
+
+import io.micronaut.rss.DefaultRssFeedRenderer
+import io.micronaut.rss.RssChannel
+import io.micronaut.rss.RssItem
+import org.gradle.api.Project
+import org.gradle.api.file.CopySpec
+import org.gradle.api.file.Directory
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.api.tasks.TaskProvider
+
+import website.model.HtmlPost
+import website.model.MarkdownPost
+import website.model.PostMetadataAdapter
+import website.model.documentation.SiteMap
+import website.gradle.GrailsWebsiteExtension
+import website.utils.DateUtils
+import website.utils.MarkdownUtils
+
+@CompileStatic
+@CacheableTask
+class BlogTask extends GrailsWebsiteTask {
+
+    @Internal
+    final String description =
+            'Renders Markdown posts (posts/*.md) into HTML pages (dist/blog/*.html). ' +
+            'It generates tag pages. Generates RSS feed. ' +
+            'Posts with future dates are not generated.'
+
+    public static final String NAME = 'renderBlog'
+
+    private static final String BLOG = 'blog'
+    private static final String IMAGES = 'images'
+    private static final String INDEX = 'index.html'
+    private static final String RSS_FILE = 'rss.xml'
+    private static final String TAG = 'tag'
+
+    private static final Pattern HASHTAG_SPAN = ~/<span class="hashtag">#(.*?)<\/span>/
+
+    private static List<String> ALLOWED_TAG_PREFIXES =
+            (('A'..'Z') + ('a'..'z') + ('0'..'9')).collect { '#' + it }
+
+    private static final int MAX_RELATED_POSTS = 3
+    private static final int MAX_TITLE_LENGTH = 45
+
+    private final ObjectFactory objects
+
+    @Inject
+    BlogTask(ObjectFactory objects) {
+        this.objects = objects
+    }
+
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    final RegularFileProperty document = objects.fileProperty()
+
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    final RegularFileProperty releases = objects.fileProperty()
+
+    @InputDirectory
+    @PathSensitive(PathSensitivity.RELATIVE)
+    final DirectoryProperty assetsDir = objects.directoryProperty()
+
+    @InputDirectory
+    @PathSensitive(PathSensitivity.RELATIVE)
+    final DirectoryProperty postsDir = objects.directoryProperty()
+
+    @Input
+    final Property<String> about = objects.property(String)
+
+    @Input
+    final ListProperty<String> keywords = objects.listProperty(String)
+
+    @Input
+    final Property<String> robots = objects.property(String)
+
+    @Input
+    final Property<String> title = objects.property(String)
+
+    @Input
+    final Property<String> url = objects.property(String)
+
+    @OutputDirectory
+    final DirectoryProperty outputDir = objects.directoryProperty()
+
+    static TaskProvider<BlogTask> register(Project project, GrailsWebsiteExtension siteExt) {
+        project.tasks.register(NAME, BlogTask) {
+            it.about.set(siteExt.description)
+            it.assetsDir.set(siteExt.assetsDir)
+            it.document.set(siteExt.template)
+            it.keywords.set(siteExt.keywords)
+            it.outputDir.set(siteExt.outputDir)
+            it.postsDir.set(siteExt.postsDir)
+            it.releases.set(siteExt.releases)
+            it.robots.set(siteExt.robots)
+            it.title.set(siteExt.title)
+            it.url.set(siteExt.url)
+        }
+    }
+
+    @TaskAction
+    void renderBlog() {
+        def releasesDir = releases.get().asFile
+        def today = new Date()
+        def versionsOptions = SiteMap.olderVersions(releasesDir)
+                .reverse()
+                .collect { "<option>$it</option>" }
+                .join(' ')
+        def meta = RenderSiteTask.siteMeta(
+                title.get(),
+                about.get(),
+                url.get(),
+                keywords.get(),
+                robots.get(),
+                SiteMap.latestVersion(releasesDir).versionText,
+                versionsOptions
+        )
+        def posts = parsePosts(postsDir.get().asFile)
+                .findAll { !it.parsedDate.after(today) }
+                .sort(false)
+        def processed = processPosts(meta, posts)
+        def blogOut = outputDir.dir('dist/blog').get().asFile.tap { it.mkdirs() }
+        def templateText = document.get().asFile.text
+
+        renderPosts(meta, processed, blogOut, templateText)
+
+        copyImages(assetsDir.dir('bgimages'), outputDir.dir('dist/images'))
+        copyImages(postsDir, outputDir.dir('dist/blog'))
+    }
+
+    void copyImages(Provider<Directory> srcDir, Provider<Directory> dstDir) {
+        project.copy { CopySpec copy ->
+            copy.from(srcDir)
+            copy.into(dstDir)
+            copy.include(AssetsTask.assetTypes.images)
+        }
+    }
+
+    static RssItem rssItemWithPage(
+            String title,
+            Date pubDate,
+            String link,
+            String guid,
+            String html,
+            String author = null
+    ) {
+        def description = html.contains('<span class="date">')
+                ? html.substring(html.indexOf('<span class="date">'))
+                        .with { it.substring(it.indexOf('</span>') + '</span>'.length()) }
+                : html
+        def zdt = Instant.ofEpochMilli(pubDate.time)
+                .atZone(ZoneId.systemDefault())
+                .withZoneSameInstant(ZoneId.of('GMT'))
+        def builder = RssItem.builder()
+                .title(title)
+                .pubDate(zdt)
+                .link(link)
+                .guid(guid)
+                .description(description)
+        if (author) {
+            builder.author(parseAuthorName(author))
+        }
+        builder.build()
+    }
+
+    static String parseAuthorName(String author) {
+        author.split(/\s*\(/, 2)[0].trim()
+    }
+
+    @CompileDynamic
+    static String renderPostHtml(HtmlPost htmlPost, String templateText, List<HtmlPost> posts) {
+        def writer = new StringWriter()
+        new MarkupBuilder(writer).with {
+            div(class: 'header-bar chalices-bg') {
+                div(class: 'content') {
+                    h1 {
+                        a(href: '[%url]/blog/index.html', 'Grails Blog')
+                    }
+                }
+            }
+            div(class: 'content container') {
+                div(class: 'light padded blog-post') {
+                    mkp.yieldUnescaped(htmlPost.html)
+                    h2(class: 'space-above') {
+                        span('You might also like ...')
+                    }
+                    div(class: 'three-columns') {
+                        relatedPosts(htmlPost, posts).each { post ->
+                            div(class: 'column') {
+                                mkp.yieldUnescaped(BlogTask.postCard(post))
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        def metadata = htmlPost.metadata.toMap()
+        def html = RenderSiteTask.renderHtmlWithTemplateContent(writer.toString(), metadata, templateText)
+        html = RenderSiteTask.highlightMenu(html, metadata, htmlPost.path)
+
+        def bodyClass = metadata.body
+        bodyClass ? html.replace('<body>', "<body class='${bodyClass}'>") : html
+    }
+
+    static List<HtmlPost> relatedPosts(HtmlPost htmlPost, List<HtmlPost> posts) {
+        def byPath = new LinkedHashMap<String, HtmlPost>()  // preserves insertion order
+
+        // 1) Prefer posts that share tags (in tag iteration order)
+        htmlPost.tags.each { tag ->
+            posts.findAll { it.path != htmlPost.path && it.tags.contains(tag) }
+                    .each { p ->
+                        if (byPath.size() < MAX_RELATED_POSTS) byPath.putIfAbsent(p.path, p)
+                    }
+        }
+
+        // 2) Fill remaining slots with other posts (original order)
+        if (byPath.size() < MAX_RELATED_POSTS) {
+            posts.each { post ->
+                if (post.path != htmlPost.path && byPath.size() < MAX_RELATED_POSTS) {
+                    byPath.putIfAbsent(post.path, post)
+                }
+            }
+        }
+
+        // 3) Return newest first (and cap)
+        byPath.values()
+                .toList()
+                .sort { DateUtils.parseDate(it.metadata.date) }
+                .reverse()
+                .take(MAX_RELATED_POSTS)
+    }
+
+    static List<HtmlPost> processPosts(Map<String, String> globalMetadata, List<MarkdownPost> markdownPosts) {
+        markdownPosts.collect { post ->
+            def metadata = RenderSiteTask.processMetadata(globalMetadata + post.metadata)
+            def postMetadata = new PostMetadataAdapter(metadata)
+            def markdown = post.content
+            if (metadata.slides) markdown += "\n\n[Slides](${metadata.slides})\n\n"
+            if (metadata.code)   markdown += "\n\n[Code](${metadata.code})\n\n"
+            def html = MarkdownUtils.htmlFromMarkdown(markdown)
+            RenderSiteTask.parseVideoIframe(metadata)?.with { iframe ->
+                html += iframe
+            }
+            def contentHtml = wrapTags(metadata, html)
+            new HtmlPost(
+                    metadata: postMetadata,
+                    html: contentHtml,
+                    path: post.path,
+                    tags: parseTags(contentHtml)
+            )
+        }
+    }
+
+    static void renderPosts(
+            Map<String, String> globalMetadata,
+            List<HtmlPost> listOfPosts,
+            File outputDir,
+            String templateText
+    ) {
+        List<String> postCards = []
+        List<RssItem> rssItems  = []
+        Map<String, Integer> tagsCount = [:].withDefault { 0 }
+        Map<String, List<String>> tagPosts = [:].withDefault { [] }
+
+        listOfPosts.each { post ->
+            postCards << postCard(post)
+            def html = renderPostHtml(post, templateText, listOfPosts)
+            new File(outputDir, post.path).tap {
+                it.parentFile?.mkdirs()
+                it.setText(text, 'UTF-8')
+            }
+            parseTags(html).each { tag ->
+                tagsCount[tag] = tagsCount[tag] + 1
+                tagPosts[tag] << post.path
+            }
+            def link = postLink(post)
+            rssItems << rssItemWithPage(
+                    post.metadata.title,
+                    DateUtils.parseDate(post.metadata.date),
+                    link,
+                    post.path.replace('.html', ''),
+                    post.html,
+                    post.metadata.author
+            )
+        }
+        renderArchive(new File(outputDir, INDEX), postCards, globalMetadata, templateText)
+        renderRss(globalMetadata, rssItems, new File(outputDir.parentFile, RSS_FILE))
+        renderTags(globalMetadata, outputDir, tagsCount.keySet(), listOfPosts, templateText)
+    }
+
+    static Set<String> parseTags(String html) {
+        def tags = [] as Set<String>
+        def m = (html =~ HASHTAG_SPAN)
+        while (m.find()) {
+            tags.add(m.group(1))
+        }
+        tags
+    }
+
+    static void renderTags(
+            Map<String, String> metadata,
+            File outputDir,
+            Set<String> tags,
+            List<HtmlPost> posts,
+            String templateText
+    ) {
+        def tagFolder = new File(outputDir, TAG).tap { mkdirs() }
+        def meta = RenderSiteTask.processMetadata(metadata)
+        tags.each { tag ->
+            def tagCards = posts
+                    .findAll { it.tags.contains(tag) }
+                    .collect { postCard(it) }
+            renderCards(
+                    new File(tagFolder, "${tag}.html"),
+                    tagCards,
+                    new LinkedHashMap(meta).tap {
+                        it.title = tag.toUpperCase() + ' | Blog | Grails Framework'
+                    },
+                    templateText,
+                    renderTagTitle(tag)
+            )
+        }
+    }
+
+    static String postLink(HtmlPost post) {
+        "$post.metadata.url/$BLOG/$post.path"
+    }
+
+    @CompileDynamic
+    static String postCard(HtmlPost htmlPost) {
+        def meta = htmlPost.metadata
+        def imageUrl = meta['image'] ? "$meta.url/$IMAGES/${meta['image']}" : null
+        def style = imageUrl ? "background-image: url('$imageUrl')" : null
+        def title = RenderSiteTask.replaceLineWithMetadata(meta.title, meta.toMap())
+        title = title.size() > MAX_TITLE_LENGTH ? "${title.take(MAX_TITLE_LENGTH)}..." : title
+        def writer = new StringWriter()
+        new MarkupBuilder(writer).with {
+            omitNullAttributes = true
+            article(class: 'blog-card', style: style) {
+                a(href: postLink(htmlPost)) {
+                    h3 { mkp.yield(RenderSiteTask.formatDate(meta.date)) }
+                    h2 { mkp.yield(title) }
+                }
+            }
+        }
+        writer.toString()
+    }
+
+    @CompileDynamic
+    private static String renderTagTitle(String tag) {
+        def writer = new StringWriter()
+        new MarkupBuilder(writer).with {
+            h1 {
+                span('Tag:')
+                b(tag)
+            }
+        }
+        writer.toString()
+    }
+
+    @CompileDynamic
+    private static void renderArchive(
+            File f,
+            List<String> postCards,
+            Map<String, String> siteMeta,
+            String templateText
+    ) {
+        def meta = RenderSiteTask.processMetadata(siteMeta).tap {
+            it.title = 'Blog | Grails Framework'
+        }
+        def html = cardsHtml(postCards)
+        html = RenderSiteTask.renderHtmlWithTemplateContent(html, meta, templateText)
+        html = RenderSiteTask.highlightMenu(html, meta, "/$BLOG/$INDEX")
+        f.setText(html, 'UTF-8')
+    }
+
+    private static void renderCards(
+            File f,
+            List<String> cards,
+            Map<String, String> meta,
+            String templateText,
+            String title = null
+    ) {
+        f.setText(
+                RenderSiteTask.renderHtmlWithTemplateContent(
+                        cardsHtml(cards, title),
+                        meta,
+                        templateText
+                ),
+                'UTF-8'
+        )
+    }
+
+    @CompileDynamic
+    static String cardsHtml(List<String> cards, String title = null) {
+        def writer = new StringWriter()
+        new MarkupBuilder(writer).tap {
+            div(class: 'header-bar chalices-bg') {
+                div(class: 'content') {
+                    if (title) {
+                        mkp.yieldUnescaped(title)
+                    } else {
+                        h1 { a(href: '[%url]/blog/index.html', 'Grails Blog') }
+                    }
+                }
+            }
+            div(class: 'clear content container') {
+                div(class: 'light') {
+                    div(class: 'padded', style: 'padding-top: 0') {
+                        cards.collate(3).each { row ->
+                            div(class: 'three-columns') {
+                                row.each { card ->
+                                    div(class: 'column') { mkp.yieldUnescaped(card) }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        writer.toString()
+    }
+
+    private static void renderRss(
+            Map<String, String> sitemeta,
+            List<RssItem> rssItems,
+            File outputFile
+    ) {
+        def nowGmt = ZonedDateTime.now(ZoneId.of('GMT'))
+        def channel = RssChannel.builder(sitemeta.title, sitemeta.url, sitemeta.description)
+                .pubDate(nowGmt)
+                .lastBuildDate(nowGmt)
+                .docs('https://blogs.law.harvard.edu/tech/rss')
+                .generator('Micronaut RSS')
+                .managingEditor('private@grails.apache.org')
+                .webMaster('private@grails.apache.org')
+                .tap { b -> rssItems.each { b.item(it) } }
+                .build()
+        outputFile.withWriter { writer ->
+            new DefaultRssFeedRenderer().render(writer, channel)
+        }
+    }
+
+    static boolean isTag(String word) {
+        ALLOWED_TAG_PREFIXES.any {word.startsWith(it) }
+    }
+
+    @Nonnull
+    static String wrapTags(Map<String, String> metadata, @Nonnull String html) {
+        def base = "$metadata.url/$BLOG/$TAG"
+        html.readLines().collect { line ->
+            if (!(line.startsWith('<p>') && line.endsWith('</p>'))) return line
+
+            def inner = line.replaceFirst(/^<p>/, '').replaceFirst(/<\/p>$/, '')
+            def replaced = inner.split(' ').collect { word ->
+                if (!isTag(word)) return word
+                def tag = word.tokenize('<')[0] // "#foo" from "#foo</span>" etc
+                def slug = tag.replace('#', '')
+                """<a href="$base/${slug}.html"><span class="hashtag">$tag</span></a>"""
+            }.join(' ')
+            "<p>$replaced</p>"
+        }.join('\n')
+    }
+
+    static List<MarkdownPost> parsePosts(File postsDir) {
+        postsDir.listFiles()
+                .findAll { it.name ==~ /.*\.(md|markdown)$/ }
+                .collect { file ->
+                    def cm = RenderSiteTask.parseFile(file)
+                    new MarkdownPost(
+                            filename: file.name,
+                            content: cm.content,
+                            metadata: cm.metadata
+                    )
+                }
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/DocumentationTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/DocumentationTask.groovy
similarity index 88%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/DocumentationTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/DocumentationTask.groovy
index c1e58520d75..a5bb653b3c0 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/DocumentationTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/DocumentationTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
@@ -37,15 +37,16 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.documentation.DocumentationPage
-import org.grails.gradle.GrailsWebsiteExtension
+import website.model.documentation.DocumentationPage
+import website.gradle.GrailsWebsiteExtension
 
 @CompileStatic
 @CacheableTask
 class DocumentationTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates the Documentation HTML Page -> build/temp/documentation.html'
+    final String description =
+            'Generates the Documentation HTML Page -> build/temp/documentation.html'
 
     public static final String NAME = 'genDocsPage'
 
@@ -82,15 +83,17 @@ class DocumentationTask extends GrailsWebsiteTask {
     @TaskAction
     void renderDocsPage() {
         def buildDir = outputDir.get().asFile
-        def tempDir = new File(buildDir, 'temp').tap {mkdir() }
-        def outputFile = new File(tempDir, 'documentation.html').tap { createNewFile() }
-        outputFile.text =
+        def tempDir = new File(buildDir, 'temp').tap {mkdirs() }
+        def outputFile = new File(tempDir, 'documentation.html')
+        outputFile.setText(
                 'title: Documentation | Grails Framework\n' +
                 'body: docs\n' +
                 '---\n' +
                 DocumentationPage.mainContent(
                         releases.get().asFile,
                         modules.get().asFile
-                )
+                ),
+                'UTF-8'
+        )
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/DownloadTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/DownloadTask.groovy
similarity index 86%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/DownloadTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/DownloadTask.groovy
index d8d5d5d7cd6..d0c2791bca1 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/DownloadTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/DownloadTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
@@ -37,15 +37,15 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.documentation.DownloadPage
-import org.grails.gradle.GrailsWebsiteExtension
+import website.model.documentation.DownloadPage
+import website.gradle.GrailsWebsiteExtension
 
 @CompileStatic
 @CacheableTask
 class DownloadTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates download HTML page -> build/temp/download.html'
+    final String description = 'Generates download HTML page -> build/temp/download.html'
 
     public static final String NAME = 'genDownloads'
 
@@ -80,12 +80,14 @@ class DownloadTask extends GrailsWebsiteTask {
 
     @TaskAction
     void renderDocsPage() {
-        def tempDir = new File(outputDir.get().asFile, 'temp').tap { it.mkdir() }
-        def outputFile = new File(tempDir, 'download.html').tap { it.createNewFile() }
-        outputFile.text =
+        def tempDir = new File(outputDir.get().asFile, 'temp').tap { it.mkdirs() }
+        def outputFile = new File(tempDir, 'download.html')
+        outputFile.setText(
                 'title: Downloads | Apache Grails&reg;\n' +
                 'body: docs\n' +
                 '---\n' +
-                DownloadPage.mainContent(releases.get().asFile)
+                DownloadPage.mainContent(releases.get().asFile),
+                'UTF-8'
+        )
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/GrailsWebsiteTask.groovy
similarity index 89%
copy from buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
copy to buildSrc/src/main/groovy/website/gradle/tasks/GrailsWebsiteTask.groovy
index 6c7fa74fa36..49dbdf0e3c5 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/GrailsWebsiteTask.groovy
@@ -16,11 +16,13 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import org.gradle.api.DefaultTask
 
 abstract class GrailsWebsiteTask extends DefaultTask {
 
-    String group = 'grails website'
+    public static String GROUP = 'grails website'
+
+    String group = GROUP
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GuidesTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/GuidesTask.groovy
similarity index 79%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/GuidesTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/GuidesTask.groovy
index fc87d95ea72..49fe3eb1b48 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GuidesTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/GuidesTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
@@ -38,19 +38,20 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.Page
-import org.grails.documentation.SiteMap
-import org.grails.gradle.GrailsWebsiteExtension
-import org.grails.guides.GuidesFetcher
-import org.grails.guides.GuidesPage
-import org.grails.guides.TagUtils
+import website.model.Page
+import website.model.documentation.SiteMap
+import website.gradle.GrailsWebsiteExtension
+import website.model.guides.GuidesFetcher
+import website.model.guides.GuidesPage
+import website.model.guides.TagUtils
 
 @CompileStatic
 @CacheableTask
 class GuidesTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates guides home, tags and categories HTML pages - build/temp/index.html'
+    final String description =
+            'Generates guides home, tags and categories HTML pages - build/temp/index.html'
 
     public static final String NAME = 'genGuides'
 
@@ -109,9 +110,8 @@ class GuidesTask extends GrailsWebsiteTask {
     @TaskAction
     void renderGuides() {
         def tempDir = new File(outputDir.get().asFile, 'temp').tap { it.mkdirs() }
-        def classLoader = getClass().classLoader
+        generateGuidesPages(tempDir, url.get())
 
-        generateGuidesPages(classLoader, tempDir, url.get())
         def template = document.get().asFile
         def templateText = template.text
         def distDir = new File(outputDir.get().asFile, 'dist').tap { it.mkdirs() }
@@ -171,29 +171,34 @@ class GuidesTask extends GrailsWebsiteTask {
         )
     }
 
-    static void generateGuidesPages(ClassLoader classLoader, File pages, String url) {
+    static void generateGuidesPages(File pages, String url) {
         def guides = GuidesFetcher.fetchGuides()
         def tags = TagUtils.populateTags(guides)
-        def pageOutput = new File(pages, PAGE_NAME_GUIDES)
-        pageOutput.createNewFile()
-        pageOutput.text = "title: Guides | Grails Framework\nbody: guides\nJAVASCRIPT: ${url}/javascripts/search.js\n---\n" +
-                GuidesPage.mainContent(classLoader, guides, tags)
+        new File(pages, PAGE_NAME_GUIDES).setText(
+                "title: Guides | Grails Framework\nbody: guides\nJAVASCRIPT: $url/javascripts/search.js\n---\n" +
+                        GuidesPage.mainContent(guides, tags),
+                'UTF-8'
 
-        def tagsDir = new File(pages, 'tags').tap { it.mkdir() }
+        )
+
+        def tagsDir = new File(pages, 'tags').tap { it.mkdirs() }
         for (def tag : tags) {
             def slug = "${tag.slug.toLowerCase()}.html"
-            pageOutput = new File(tagsDir, slug)
-            pageOutput.createNewFile()
-            pageOutput.text = "---\ntitle: Guides with tag: ${tag} | Grails Framework\nbody: guides\n---\n" +
-                    GuidesPage.mainContent(classLoader, guides, tags, null, tag)
+            new File(tagsDir, slug).setText(
+                    "---\ntitle: Guides with tag: $tag.title | Grails Framework\nbody: guides\n---\n" +
+                            GuidesPage.mainContent(guides, tags, null, tag),
+                    'UTF-8'
+
+            )
         }
-        def categoriesDir = new File(pages, 'categories').tap { it.mkdir() }
+        def categoriesDir = new File(pages, 'categories').tap { it.mkdirs() }
         for (def category : GuidesPage.categories.values()) {
             def slug = "${category.slug.toLowerCase()}.html"
-            pageOutput = new File(categoriesDir, slug)
-            pageOutput.createNewFile()
-            pageOutput.text = "---\ntitle: Guides at category ${category.name} | Grails Framework\nbody: guides\n---\n" +
-                    GuidesPage.mainContent(classLoader, guides, tags, category, null)
+            new File(categoriesDir, slug).setText(
+                    "---\ntitle: Guides at category $category.name | Grails Framework\nbody: guides\n---\n" +
+                            GuidesPage.mainContent(guides, tags, category, null),
+                    'UTF-8'
+            )
         }
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/HtaccessTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/HtaccessTask.groovy
similarity index 72%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/HtaccessTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/HtaccessTask.groovy
index e3320b6438b..383da218e1b 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/HtaccessTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/HtaccessTask.groovy
@@ -16,34 +16,29 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
 import groovy.transform.CompileStatic
 
 import org.gradle.api.Project
-import org.gradle.api.file.DirectoryProperty
 import org.gradle.api.file.RegularFileProperty
 import org.gradle.api.model.ObjectFactory
 import org.gradle.api.tasks.CacheableTask
-import org.gradle.api.tasks.InputDirectory
 import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.OutputFile
-import org.gradle.api.tasks.PathSensitive
-import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.gradle.GrailsWebsiteExtension
+import website.gradle.GrailsWebsiteExtension
 
 @CompileStatic
 @CacheableTask
 class HtaccessTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates the .htaccess file'
+    final String description = 'Generates the .htaccess file'
 
     public static final String NAME = 'genHtaccess'
 
@@ -54,7 +49,7 @@ class HtaccessTask extends GrailsWebsiteTask {
             'https://www.gstatic.com/recaptcha/',
             'https://hcaptcha.com',
             'https://*.hcaptcha.com'
-        ]
+    ]
 
     private static String HT_ACCESS_CONTENT =
             '# Custom 404 error page\n' +
@@ -71,11 +66,8 @@ class HtaccessTask extends GrailsWebsiteTask {
         this.objects = objects
     }
 
-    @Internal
-    final DirectoryProperty outputDir = objects.directoryProperty()
-
     @OutputFile
-    final RegularFileProperty htaccessFile = objects.fileProperty()
+    final RegularFileProperty htaccess = objects.fileProperty()
 
     static TaskProvider<HtaccessTask> register(
             Project project,
@@ -83,19 +75,12 @@ class HtaccessTask extends GrailsWebsiteTask {
             String name = NAME
     ) {
         project.tasks.register(name, HtaccessTask) {
-            it.outputDir.set(siteExt.outputDir)
-            it.htaccessFile.convention(
-                    project.layout.buildDirectory.file('dist/.htaccess')
-            )
+            it.htaccess.set(siteExt.htaccess)
         }
     }
 
     @TaskAction
     void generateHtaccess() {
-        def outputDir = new File(outputDir.get().asFile, 'dist').tap {mkdirs() }
-        def htaccess = new File(outputDir, '.htaccess').tap {
-            text = HT_ACCESS_CONTENT
-        }
-        logger.lifecycle('Generated .htaccess file at: {}', htaccess.absolutePath)
+        htaccess.get().asFile.text = HT_ACCESS_CONTENT
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/MinutesTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/MinutesTask.groovy
similarity index 91%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/MinutesTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/MinutesTask.groovy
index ef0652f0c1f..d401c5f564c 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/MinutesTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/MinutesTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import java.time.LocalDateTime
 import java.time.ZoneId
@@ -49,20 +49,20 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.utils.DateUtils
-import org.grails.HtmlMinutes
-import org.grails.MarkdownMinutes
-import org.grails.MinutesMetadataAdaptor
-import org.grails.documentation.SiteMap
-import org.grails.gradle.GrailsWebsiteExtension
-import org.grails.utils.MarkdownUtils
+import website.utils.DateUtils
+import website.model.HtmlMinutes
+import website.model.MarkdownMinutes
+import website.model.MinutesMetadataAdaptor
+import website.model.documentation.SiteMap
+import website.gradle.GrailsWebsiteExtension
+import website.utils.MarkdownUtils
 
 @CompileStatic
 @CacheableTask
 class MinutesTask extends GrailsWebsiteTask {
 
     @Internal
-    String description =
+    final String description =
             'Renders Markdown minutes (minutes/*.md) into HTML pages (dist/foundation/minutes/*.html). ' +
             'It generates tag pages. Generates RSS feed. Minutes with future dates are not generated.'
 
@@ -155,14 +155,10 @@ class MinutesTask extends GrailsWebsiteTask {
                 outputDir.dir('foundation/minutes').get().asFile.tap { it.mkdirs() },
                 document.get().asFile.text
         )
-        copyBackgroundImages()
-    }
-
-    void copyBackgroundImages() {
         project.copy { CopySpec copy ->
             copy.from(assetsDir.dir('bgimages'))
             copy.into(outputDir.dir('dist/images'))
-            copy.include(CopyAssetsTask.IMAGE_EXTENSIONS)
+            copy.include(AssetsTask.assetTypes.images)
         }
     }
 
@@ -179,7 +175,7 @@ class MinutesTask extends GrailsWebsiteTask {
 
         def writer = new StringWriter()
         new MarkupBuilder(writer).with {
-            div(class: 'headerbar chalicesbg') {
+            div(class: 'header-bar chalices-bg') {
                 div(class: 'content') {
                     h1 {
                         a(href: '[%url]/foundation/index.html', 'Foundation')
@@ -188,13 +184,13 @@ class MinutesTask extends GrailsWebsiteTask {
             }
 
             article(class: 'content container') {
-                section(class: 'largegoldenratio align-left foundation-description') {
+                section(class: 'large-golden-ratio align-left foundation-description') {
                     div {
                         mkp.yieldUnescaped(htmlMinutes.html)
                     }
                 }
 
-                section(class: 'smallgoldenratio align-left foundation-boards') {
+                section(class: 'small-golden-ratio align-left foundation-boards') {
                     div(class: 'meeting-archive-list') {
                         a(href: '[%url]/foundation/minutes/index.html', style: 'text-decoration: none', title: 'Meeting Minutes Archive') {
                             h2 { mkp.yield("Meeting Minutes Archive") }
@@ -221,10 +217,8 @@ class MinutesTask extends GrailsWebsiteTask {
         def metadata = htmlMinutes.metadata.toMap()
         html = RenderSiteTask.renderHtmlWithTemplateContent(html, metadata, templateText)
         html = RenderSiteTask.highlightMenu(html, metadata, htmlMinutes.path)
-        metadata['body'] = metadata['body'] ? metadata['body'] : 'foundation minutes'
-        if (metadata['body']) {
-            html = html.replace('<body>', "<body class='${metadata['body']}'>")
-        }
+        metadata['bodyClassAttr'] = metadata['bodyClassAttr'] ?: 'foundation minutes'
+        html = html.replace('<body>', "<body class='${metadata['bodyClassAttr']}'>")
         html
     }
 
@@ -269,8 +263,7 @@ class MinutesTask extends GrailsWebsiteTask {
         for (def htmlMinutes : listOfMinutes) {
             minuteCards.add(minutesCard(htmlMinutes))
             new File(outputDir, htmlMinutes.path).tap {
-                it.createNewFile()
-                it.text = renderMinutesHtml(htmlMinutes, templateText, listOfMinutes)
+                it.setText(renderMinutesHtml(htmlMinutes, templateText, listOfMinutes), 'UTF-8')
             }
             rssItems.add(
                     BlogTask.rssItemWithPage(
@@ -303,7 +296,7 @@ class MinutesTask extends GrailsWebsiteTask {
     private static String minutesCard(HtmlMinutes htmlMinutes) {
         def writer = new StringWriter()
         new MarkupBuilder(writer).with {
-            article(class: 'blogcard', style: "margin-bottom: 0; background-image: url($htmlMinutes.metadata.url/$IMAGES/$MINUTES_BG)") {
+            article(class: 'blog-card', style: "margin-bottom: 0; background-image: url($htmlMinutes.metadata.url/$IMAGES/$MINUTES_BG)") {
                 a(href: minutesLink(htmlMinutes)) {
                     h3 {
                         mkp.yield(RenderSiteTask.formatDate(htmlMinutes.metadata.date))
@@ -333,17 +326,14 @@ class MinutesTask extends GrailsWebsiteTask {
         }
         html = RenderSiteTask.renderHtmlWithTemplateContent(html, resolvedMetadata, templateText)
         html = RenderSiteTask.highlightMenu(html, resolvedMetadata, "/$MINUTES/$INDEX")
-        f.with {
-            createNewFile()
-            text = html
-        }
+        f.setText(html, 'UTF-8')
     }
 
     @CompileDynamic
     static String cardsHtml(List<String> cards, String title = null) {
         def writer = new StringWriter()
         new MarkupBuilder(writer).with {
-            div(class: 'headerbar chalicesbg') {
+            div(class: 'header-bar chalices-bg') {
                 div(class: 'content') {
                     if (title) {
                         mkp.yieldUnescaped(title)
@@ -355,12 +345,12 @@ class MinutesTask extends GrailsWebsiteTask {
                 }
             }
             div(class: 'clear content container') {
-                h3(class: 'columnheader', 'Meeting Minutes Archive')
+                h3(class: 'column-header', 'Meeting Minutes Archive')
                 div(class: 'light') {
                     div(class: 'padded', style: 'padding-top: 0;') {
                         for (int i = 0; i < cards.size(); i++) {
                             if (i == 0) {
-                                mkp.yieldUnescaped('<div class="threecolumns">')
+                                mkp.yieldUnescaped('<div class="three-columns">')
                             }
                             div(class: 'column') {
                                 mkp.yieldUnescaped(cards[i])
@@ -369,7 +359,7 @@ class MinutesTask extends GrailsWebsiteTask {
                             if (i != 0 && ((i + 1) % 3 == 0)) {
                                 mkp.yieldUnescaped('</div>')
                                 if (i != (cards.size() - 1)) {
-                                    mkp.yieldUnescaped('<div class="threecolumns">')
+                                    mkp.yieldUnescaped('<div class="three-columns">')
                                 }
                             }
                         }
diff --git a/buildSrc/src/main/groovy/website/gradle/tasks/PluginsTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/PluginsTask.groovy
new file mode 100644
index 00000000000..96b0cf19984
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/PluginsTask.groovy
@@ -0,0 +1,266 @@
+/*
+ *  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
+ *
+ *    http://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.gradle.tasks
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+import javax.inject.Inject
+
+import groovy.json.JsonSlurper
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import jakarta.annotation.Nullable
+
+import org.gradle.api.Project
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.api.tasks.TaskProvider
+import website.gradle.GrailsWebsiteExtension
+import website.model.plugin.Owner
+import website.model.plugin.Plugin
+import website.model.plugin.PluginVersion
+import website.model.plugin.PluginsPage
+
+@Slf4j
+@CompileStatic
+@CacheableTask
+class PluginsTask extends GrailsWebsiteTask {
+
+    @Internal
+    final String description = 'Generates an HTML Page listing the Grails plugins'
+
+    public static final String NAME = 'genPlugins'
+
+    private static final String GRAILS_PLUGINS_JSON =
+            'https://raw.githubusercontent.com/grails/grails-plugins-metadata/main/grails-plugins-index.json'
+
+    private final ObjectFactory objects
+
+    @Inject
+    PluginsTask(ObjectFactory objects) {
+        this.objects = objects
+    }
+
+    @InputFile
+    @PathSensitive(PathSensitivity.RELATIVE)
+    final RegularFileProperty document = objects.fileProperty()
+
+    @Input
+    final ListProperty<String> keywords = objects.listProperty(String)
+
+    @Input
+    final Property<String> url = objects.property(String)
+
+    @OutputDirectory
+    final DirectoryProperty outputDir = objects.directoryProperty()
+
+    static TaskProvider<PluginsTask> register(
+            Project project,
+            GrailsWebsiteExtension siteExt,
+            String name = NAME
+    ) {
+        project.tasks.register(name, PluginsTask) {
+            it.document.set(siteExt.template)
+            it.outputDir.set(siteExt.outputDir)
+            it.url.set(siteExt.url)
+        }
+    }
+
+    @TaskAction
+    void renderPluginsPage() {
+        def metadata = RenderSiteTask.siteMeta(
+                'Grails Plugins', // TODO Make it configurable
+                'List of Plugins', // TODO Make it configurable
+                url.get(),
+                keywords.get(),
+                'all', // TODO Make it configurable,
+                '',
+                ''
+        )
+        metadata.put('JAVASCRIPT', '[%url]/javascripts/plugins-search.js')
+
+        def resolvedMetadata = RenderSiteTask.processMetadata(metadata)
+        def result = new JsonSlurper().parse(GRAILS_PLUGINS_JSON.toURL()) as List<Object>
+        def plugins = pluginsFromJson(result)
+        renderHtml(plugins, document.get().asFile.text, resolvedMetadata, 'plugins.html')
+    }
+
+    void renderHtml(List<Plugin> plugins, String templateText, Map<String, String> metadata, String fileName) {
+        def siteUrl = url.get()
+
+        def distDir          = new File(outputDir.get().asFile, 'dist').tap { mkdirs() }
+        def pluginsDir       = new File(distDir, 'plugins').tap { mkdirs() }
+        def pluginsTagsDir   = new File(pluginsDir, 'tags').tap { mkdirs() }
+        def pluginsOwnersDir = new File(pluginsDir, 'owners').tap { mkdirs() }
+
+        def wrap = { String html -> RenderSiteTask.renderHtmlWithTemplateContent(html, metadata, templateText) }
+
+        // main plugins page
+        new File(distDir, fileName).setText(
+                RenderSiteTask.highlightMenu(
+                        wrap(PluginsPage.mainContent(siteUrl, plugins, 'Grails Plugins', null)),
+                        metadata,
+                        '/plugins.html'
+                ),
+                'UTF-8'
+        )
+
+        // tag pages
+        (plugins ?: [])
+                .collectMany { it.labels ?: ([] as List<String>) }
+                .unique()
+                .each { tag ->
+                    new File(pluginsTagsDir, "${tag}.html").setText(
+                            wrap(renderHtmlPagesForTags(siteUrl, plugins, tag)),
+                            'UTF-8'
+                    )
+                }
+
+        // owner pages
+        (plugins ?: [])
+                .collect { it.owner?.name }
+                .findAll { it }
+                .unique()
+                .each { owner ->
+                    new File(pluginsOwnersDir, "${owner}.html").setText(
+                            wrap(renderHtmlPagesForOwners(siteUrl, plugins, owner)),
+                            'UTF-8'
+                    )
+                }
+    }
+
+    static String renderHtmlPagesForTags(String siteUrl, List<Plugin> plugins, String tag) {
+        def filteredPlugins = (plugins ?: []).findAll { (it.labels ?: []).contains(tag) }
+        PluginsPage.mainContent(siteUrl, plugins, "Plugins by tag #$tag", filteredPlugins)
+    }
+
+    static String renderHtmlPagesForOwners(String siteUrl, List<Plugin> plugins, String owner) {
+        def filteredPlugins = (plugins ?: []).findAll { it.owner?.name == owner }
+        PluginsPage.mainContent(siteUrl, plugins, "Plugins by creator: #$owner", filteredPlugins)
+    }
+
+    /**
+     * Converts the JSON array from grails-plugins-index.json into a list of {@link Plugin} objects.
+     * Extracts the latest version and update date from the first entry in each plugin's versions array.
+     *
+     * @param json the parsed JSON array from the plugins index
+     * @return list of Plugin objects
+     */
+    @CompileDynamic
+    static List<Plugin> pluginsFromJson(List json) {
+        (json ?: []).collect { item ->
+            def versionsJson = item['versions'] as List
+            def latestVersionEntry = versionsJson?.first()
+
+            // Parse all versions
+            def versions = (versionsJson ?: []).collect { v ->
+                new PluginVersion(
+                    version: v['version'],
+                    date: v['date'] ? parseIsoStringToDate(v['date'] as String) : null,
+                    grailsVersion: v['grailsVersion']
+                )
+            } as List<PluginVersion>
+
+            new Plugin(
+                    name: item['name'],
+                    desc: item['desc'],
+                    coords: item['coords'],
+                    vcsUrl: item['vcs'],
+                    docsUrl: item['docs'],
+                    mavenRepo: item['maven-repo'],
+                    owner: new Owner(name: item['owner']),
+                    latestVersion: latestVersionEntry?.version,
+                    grailsVersion: latestVersionEntry?.grailsVersion,
+                    updated: latestVersionEntry?.date ? parseIsoStringToDate(latestVersionEntry.date as String) : null,
+                    githubStars: githubStars(item['vcs'] as String),
+                    labels: (item['labels'] ?: []) as List<String>,
+                    license: (item['licenses'] as List)?.first(),
+                    deprecated: item['deprecated'] as String,
+                    versions: versions
+            )
+        }
+    }
+
+    /**
+     * Parses an ISO 8601 formatted date string into a {@link LocalDateTime}.
+     *
+     * @param isoFormattedString the date string (e.g., "2025-12-19T15:35:33+0000")
+     * @return parsed LocalDateTime
+     */
+    static LocalDateTime parseIsoStringToDate(String isoFormattedString) {
+        def f = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ")
+        LocalDateTime.parse(isoFormattedString, f)
+    }
+
+    /**
+     * Fetches the GitHub star count for a repository using the GitHub API.
+     * Requires the GH_TOKEN environment variable to be set.
+     *
+     * @param vcsUrl the GitHub repository URL
+     * @return the number of stars, or {@code null} if unavailable or not a GitHub repo
+     */
+    @Nullable
+    @CompileDynamic
+    static Integer githubStars(String vcsUrl) {
+        def token = System.getenv('GH_TOKEN')
+        if (!vcsUrl || !vcsUrl.contains('github.com') || !token) {
+            return null
+        }
+        try {
+            log.info('Fetching github stars of {}', vcsUrl)
+            def repoPath = vcsUrl.replaceFirst(/^.*github\.com\//, '')
+            def url = "https://api.github.com/repos/$repoPath"
+            def request = HttpRequest.newBuilder()
+                    .uri(new URI(url))
+                    .header('X-GitHub-Api-Version', '2022-11-28')
+                    .header('Authorization', "Bearer $token")
+                    .GET()
+                    .build()
+            def response = HttpClient.newHttpClient()
+                    .send(request, HttpResponse.BodyHandlers.ofString())
+
+            if (response.statusCode() != 200) {
+                return null
+            }
+            def stars = new JsonSlurper()
+                    .parseText(response.body())['stargazers_count'] as Integer
+            return stars ?: null // 0 → null
+        } catch (Exception e) {
+            log.error("Error fetching GitHub stars for $vcsUrl", e)
+            return null
+        }
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/ProfilesTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/ProfilesTask.groovy
similarity index 79%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/ProfilesTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/ProfilesTask.groovy
index 60d1bc01f97..92a1102a1de 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/ProfilesTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/ProfilesTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
@@ -35,15 +35,15 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.documentation.ProfilesPage
-import org.grails.gradle.GrailsWebsiteExtension
+import website.model.documentation.ProfilesPage
+import website.gradle.GrailsWebsiteExtension
 
 @CompileStatic
 @CacheableTask
 class ProfilesTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates the Profiles HTML Page -> build/temp/profiles.html'
+    final String description = 'Generates the Profiles HTML Page -> build/temp/profiles.html'
 
     public static final String NAME = 'genProfilesPage'
 
@@ -69,14 +69,15 @@ class ProfilesTask extends GrailsWebsiteTask {
     }
 
     @TaskAction
-    void renderDocsPage() {
-        def buildDir = outputDir.get().asFile
-        def tempDir = new File(buildDir, 'temp').tap { mkdir() }
-        def output = new File(tempDir, 'profiles').tap { createNewFile() }
-        output.text =
+    void renderProfilesPage() {
+        def tempDir = new File(outputDir.get().asFile, 'temp').tap { mkdirs() }
+        def output = new File(tempDir, 'profiles.html')
+        output.setText(
                 'title: Profiles | Grails Framework\n' +
-                'body: docs\n' +
-                '---\n' +
-                ProfilesPage.mainContent(profiles.get().asFile)
+                        'body: docs\n' +
+                        '---\n' +
+                        ProfilesPage.mainContent(profiles.get().asFile),
+                'UTF-8'
+        )
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/QuestionsTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/QuestionsTask.groovy
similarity index 84%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/QuestionsTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/QuestionsTask.groovy
index 18c4be1fb8e..8ac574545ac 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/QuestionsTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/QuestionsTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.inject.Inject
 
@@ -35,15 +35,15 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.gradle.GrailsWebsiteExtension
-import org.grails.questions.QuestionsPage
+import website.gradle.GrailsWebsiteExtension
+import website.model.questions.QuestionsPage
 
 @CompileStatic
 @CacheableTask
 class QuestionsTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates FAQ HTML - build/temp/faq.html'
+    final String description = 'Generates FAQ HTML - build/temp/faq.html'
 
     public static final String NAME = 'genFaq'
 
@@ -77,11 +77,11 @@ class QuestionsTask extends GrailsWebsiteTask {
     @TaskAction
     void renderQuestionsPage() {
         def buildDir = outputDir.get().asFile
-        def temp = new File(buildDir, 'temp').tap { it.mkdir() }
-
+        def temp = new File(buildDir, 'temp').tap { it.mkdirs() }
         def output = new File(temp, PAGE_NAME_QUESTIONS)
-        output.createNewFile()
-        output.text = "title: FAQ | Apache Grails&reg;\nbody: faq\n---\n" +
-                QuestionsPage.mainContent(questions.get().asFile)
+        output.setText(
+                "title: FAQ | Apache Grails&reg;\nbody: faq\n---\n" + QuestionsPage.mainContent(questions.get().asFile),
+                'UTF-8'
+        )
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/RenderSiteTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/RenderSiteTask.groovy
similarity index 94%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/RenderSiteTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/RenderSiteTask.groovy
index c9ff5bd01db..7000abe4534 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/RenderSiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/RenderSiteTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import javax.annotation.Nonnull
 import javax.inject.Inject
@@ -44,11 +44,11 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.ContentAndMetadata
-import org.grails.utils.DateUtils
-import org.grails.Page
-import org.grails.documentation.SiteMap
-import org.grails.gradle.GrailsWebsiteExtension
+import website.model.ContentAndMetadata
+import website.utils.DateUtils
+import website.model.Page
+import website.model.documentation.SiteMap
+import website.gradle.GrailsWebsiteExtension
 
 import static groovy.io.FileType.FILES
 
@@ -57,7 +57,7 @@ import static groovy.io.FileType.FILES
 class RenderSiteTask extends GrailsWebsiteTask {
 
     @Internal
-    String description =
+    final String description =
             'Build Grails website - generates pages with HTML entries in pages and build/temp, ' +
             'renders blog and RSS feed, copies assets and generates a sitemap'
 
@@ -133,7 +133,7 @@ class RenderSiteTask extends GrailsWebsiteTask {
                 .reverse()
                 .collect { "<option>${it}</option>" }
                 .join(' ')
-        def m = siteMeta(
+        def m = website.gradle.tasks.RenderSiteTask.siteMeta(
                 title.get(),
                 about.get(),
                 url.get(),
@@ -175,31 +175,30 @@ class RenderSiteTask extends GrailsWebsiteTask {
 
     static void renderPages(
             Map<String,
-            String> sitemeta,
+            String> siteMeta,
             List<Page> listOfPages,
             File outputDir,
             String templateText
     ) {
-        for (Page page : listOfPages) {
+        for (def page : listOfPages) {
             def resolvedMetadata = processMetadata(
-                    sitemeta + page.metadata
+                    siteMeta + page.metadata
             )
             def html = renderHtmlWithTemplateContent(
                     page.content,
                     resolvedMetadata,
                     templateText
             )
-            html = highlightMenu(html, sitemeta, page.path)
-            if (page.body) {
-                html = html.replace('<body>', "<body class='${page.body}'>")
+            html = highlightMenu(html, siteMeta, page.path)
+            if (page.bodyClassAttr) {
+                html = html.replace('<body>', "<body class='${page.bodyClassAttr}'>")
             }
             saveHtmlToPath(outputDir, html, page.path)
         }
     }
 
     static void saveHtmlToPath(File outputDir, String html, String filepath) {
-        def pageOutput = new File(outputDir.absolutePath)
-        pageOutput.mkdir()
+        def pageOutput = new File(outputDir.absolutePath).tap { it.mkdirs() }
         def paths = filepath.split('/')
         for (String path : paths) {
             if (path.endsWith('.html')) {
@@ -207,16 +206,14 @@ class RenderSiteTask extends GrailsWebsiteTask {
             } else if (path.trim().isEmpty()) {
                 // continue
             } else {
-                pageOutput = new File(pageOutput, path)
-                pageOutput.mkdir()
+                pageOutput = new File(pageOutput, path).tap { it.mkdirs() }
             }
         }
-        pageOutput.createNewFile()
-        pageOutput.text = html
+        pageOutput.setText(html, 'UTF-8')
     }
 
-    static Map<String, String> processMetadata(Map<String, String> sitemeta) {
-        def resolvedMetadata = sitemeta
+    static Map<String, String> processMetadata(Map<String, String> siteMeta) {
+        def resolvedMetadata = siteMeta
         if (resolvedMetadata.containsKey('CSS')) {
             resolvedMetadata.put(
                     'CSS',
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/SitemapTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/SitemapTask.groovy
similarity index 92%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/SitemapTask.groovy
rename to buildSrc/src/main/groovy/website/gradle/tasks/SitemapTask.groovy
index cc855329cd4..5cb8d978b7a 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/SitemapTask.groovy
+++ b/buildSrc/src/main/groovy/website/gradle/tasks/SitemapTask.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.gradle.tasks
 
 import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
@@ -39,7 +39,7 @@ import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
 import org.gradle.api.tasks.TaskProvider
 
-import org.grails.gradle.GrailsWebsiteExtension
+import website.gradle.GrailsWebsiteExtension
 
 import static groovy.io.FileType.FILES
 
@@ -48,7 +48,7 @@ import static groovy.io.FileType.FILES
 class SitemapTask extends GrailsWebsiteTask {
 
     @Internal
-    String description = 'Generates build/dist/sitemap.xml with every page in the site'
+    final String description = 'Generates build/dist/sitemap.xml with every page in the site'
 
     public static final String NAME = 'genSitemap'
 
@@ -100,8 +100,8 @@ class SitemapTask extends GrailsWebsiteTask {
     @CompileDynamic
     static String sitemapContent(List<String> urls) {
         def writer = new StringWriter()
-        def html = new MarkupBuilder(writer)
-        html.urlset(xmlns: 'https://www.sitemaps.org/schemas/sitemap/0.9') {
+        def xml = new MarkupBuilder(writer)
+        xml.urlset(xmlns: 'https://www.sitemaps.org/schemas/sitemap/0.9') {
             for (def urlStr : urls) {
                 url {
                     loc(urlStr)
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy b/buildSrc/src/main/groovy/website/model/Content.groovy
similarity index 67%
copy from buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
copy to buildSrc/src/main/groovy/website/model/Content.groovy
index 6c7fa74fa36..a5866f82914 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/model/Content.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,11 +16,18 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.model
 
-import org.gradle.api.DefaultTask
+import jakarta.annotation.Nullable
 
-abstract class GrailsWebsiteTask extends DefaultTask {
+interface Content {
+
+    @Nullable String getPath();
+    @Nullable String getTitle();
+    @Nullable String getBodyClassAttr();
+    @Nullable String getDate();
+    @Nullable String getDescription();
+    @Nullable List<String> getKeywords();
+    @Nullable String getRobots();
 
-    String group = 'grails website'
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy b/buildSrc/src/main/groovy/website/model/ContentAndMetadata.groovy
similarity index 79%
copy from buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
copy to buildSrc/src/main/groovy/website/model/ContentAndMetadata.groovy
index 6c7fa74fa36..9c958a1d0f4 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/model/ContentAndMetadata.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,11 +16,15 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
 
-import org.gradle.api.DefaultTask
+package website.model
 
-abstract class GrailsWebsiteTask extends DefaultTask {
+import groovy.transform.CompileStatic
 
-    String group = 'grails website'
+@CompileStatic
+class ContentAndMetadata {
+
+    String content
+
+    Map<String, String> metadata
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy b/buildSrc/src/main/groovy/website/model/HtmlMinutes.groovy
similarity index 79%
copy from buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
copy to buildSrc/src/main/groovy/website/model/HtmlMinutes.groovy
index 6c7fa74fa36..efa8c871186 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/model/HtmlMinutes.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,11 +16,12 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.model
 
-import org.gradle.api.DefaultTask
+class HtmlMinutes {
 
-abstract class GrailsWebsiteTask extends DefaultTask {
+    String html
+    String path
 
-    String group = 'grails website'
+    MinutesMetadata metadata
 }
diff --git a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy b/buildSrc/src/main/groovy/website/model/HtmlPost.groovy
similarity index 78%
copy from buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
copy to buildSrc/src/main/groovy/website/model/HtmlPost.groovy
index 455b609980e..f481bbc4a4c 100644
--- a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
+++ b/buildSrc/src/main/groovy/website/model/HtmlPost.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,15 +16,17 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model
 
 import groovy.transform.CompileStatic
 
 @CompileStatic
-class MarkdownPost extends Page {
+class HtmlPost {
 
-    @Override
-    String getPath() {
-        return filename.replace('.md', '.html').replace('.markdown', '.html')
-    }
+    String html
+    String path
+
+    PostMetadata metadata
+
+    Set<String> tags
 }
diff --git a/buildSrc/src/main/groovy/org/grails/MarkdownMinutes.groovy b/buildSrc/src/main/groovy/website/model/MarkdownMinutes.groovy
similarity index 97%
copy from buildSrc/src/main/groovy/org/grails/MarkdownMinutes.groovy
copy to buildSrc/src/main/groovy/website/model/MarkdownMinutes.groovy
index e1254250eb3..090a2b8198a 100644
--- a/buildSrc/src/main/groovy/org/grails/MarkdownMinutes.groovy
+++ b/buildSrc/src/main/groovy/website/model/MarkdownMinutes.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model
 
 import groovy.transform.CompileStatic
 
diff --git a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy b/buildSrc/src/main/groovy/website/model/MarkdownPost.groovy
similarity index 90%
copy from buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
copy to buildSrc/src/main/groovy/website/model/MarkdownPost.groovy
index 455b609980e..29ba2830d3c 100644
--- a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
+++ b/buildSrc/src/main/groovy/website/model/MarkdownPost.groovy
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model
 
 import groovy.transform.CompileStatic
 
@@ -25,6 +25,6 @@ class MarkdownPost extends Page {
 
     @Override
     String getPath() {
-        return filename.replace('.md', '.html').replace('.markdown', '.html')
+        filename.replace('.md', '.html').replace('.markdown', '.html')
     }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy b/buildSrc/src/main/groovy/website/model/MinutesMetadata.groovy
similarity index 72%
copy from buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
copy to buildSrc/src/main/groovy/website/model/MinutesMetadata.groovy
index 455b609980e..3e006f960d6 100644
--- a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
+++ b/buildSrc/src/main/groovy/website/model/MinutesMetadata.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,15 +16,25 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model
 
+import edu.umd.cs.findbugs.annotations.Nullable
 import groovy.transform.CompileStatic
 
 @CompileStatic
-class MarkdownPost extends Page {
+interface MinutesMetadata {
+
+    String get(String name)
+
+    @Nullable
+    String getUrl()
+
+    @Nullable
+    String getTitle()
+
+    @Nullable
+    String getDate()
+
+    Map<String, String> toMap()
 
-    @Override
-    String getPath() {
-        return filename.replace('.md', '.html').replace('.markdown', '.html')
-    }
 }
diff --git a/buildSrc/src/main/groovy/org/grails/guides/GrailsMajorVersion.groovy b/buildSrc/src/main/groovy/website/model/MinutesMetadataAdaptor.groovy
similarity index 59%
copy from buildSrc/src/main/groovy/org/grails/guides/GrailsMajorVersion.groovy
copy to buildSrc/src/main/groovy/website/model/MinutesMetadataAdaptor.groovy
index 75ac7247430..4969b23a447 100644
--- a/buildSrc/src/main/groovy/org/grails/guides/GrailsMajorVersion.groovy
+++ b/buildSrc/src/main/groovy/website/model/MinutesMetadataAdaptor.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,27 +16,42 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.guides
+package website.model
 
 import groovy.transform.CompileStatic
 
 @CompileStatic
-enum GrailsMajorVersion {
+class MinutesMetadataAdaptor implements MinutesMetadata {
 
-    GRAILS_3(3),
-    GRAILS_4(4),
-    GRAILS_5(5),
-    GRAILS_6(6),
-    GRAILS_7(7)
+    Map<String, String> metadata
 
-    final int id
+    MinutesMetadataAdaptor(Map<String, String> metadata) {
+        this.metadata = metadata
 
-    private GrailsMajorVersion(int id) {
-        this.id = id
     }
 
     @Override
-    String toString() {
-        this.name()
+    String get(String name) {
+        return metadata[name]
     }
-}
\ No newline at end of file
+
+    @Override
+    String getUrl() {
+        get('url')
+    }
+
+    @Override
+    String getTitle() {
+        get('title')
+    }
+
+    @Override
+    String getDate() {
+        get('date')
+    }
+
+    @Override
+    Map<String, String> toMap() {
+        metadata
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/grails/Page.groovy b/buildSrc/src/main/groovy/website/model/Page.groovy
similarity index 93%
rename from buildSrc/src/main/groovy/org/grails/Page.groovy
rename to buildSrc/src/main/groovy/website/model/Page.groovy
index 83068c289f2..bff0ecf4bd4 100644
--- a/buildSrc/src/main/groovy/org/grails/Page.groovy
+++ b/buildSrc/src/main/groovy/website/model/Page.groovy
@@ -16,17 +16,18 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model
 
 import groovy.transform.CompileStatic
 
-import org.grails.utils.DateUtils
+import website.utils.DateUtils
 
 @CompileStatic
 class Page implements Content, Comparable<Page> {
 
     String filename
     String content
+
     Map<String, String> metadata
 
     @Override
@@ -40,8 +41,8 @@ class Page implements Content, Comparable<Page> {
     }
 
     @Override
-    String getBody() {
-        metadata?.get('body')
+    String getBodyClassAttr() {
+        metadata?.get('bodyClassAttr')
     }
 
     @Override
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy b/buildSrc/src/main/groovy/website/model/PageElement.groovy
similarity index 79%
copy from buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
copy to buildSrc/src/main/groovy/website/model/PageElement.groovy
index 6c7fa74fa36..539da9d6fa9 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/model/PageElement.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,11 +16,12 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.model
 
-import org.gradle.api.DefaultTask
+import groovy.transform.CompileStatic
 
-abstract class GrailsWebsiteTask extends DefaultTask {
+@CompileStatic
+interface PageElement {
 
-    String group = 'grails website'
+    String renderAsHtml()
 }
diff --git a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy b/buildSrc/src/main/groovy/website/model/PostMetadata.groovy
similarity index 69%
rename from buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
rename to buildSrc/src/main/groovy/website/model/PostMetadata.groovy
index 6c7fa74fa36..8c99daff896 100644
--- a/buildSrc/src/main/groovy/org/grails/gradle/tasks/GrailsWebsiteTask.groovy
+++ b/buildSrc/src/main/groovy/website/model/PostMetadata.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,11 +16,25 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.gradle.tasks
+package website.model
 
-import org.gradle.api.DefaultTask
+import jakarta.annotation.Nullable
 
-abstract class GrailsWebsiteTask extends DefaultTask {
+interface PostMetadata {
 
-    String group = 'grails website'
-}
+    String get(String name)
+
+    @Nullable
+    String getUrl()
+
+    @Nullable
+    String getTitle()
+
+    @Nullable
+    String getAuthor()
+
+    @Nullable
+    String getDate()
+
+    Map<String, String> toMap()
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/org/grails/guides/GrailsMajorVersion.groovy b/buildSrc/src/main/groovy/website/model/PostMetadataAdapter.groovy
similarity index 57%
copy from buildSrc/src/main/groovy/org/grails/guides/GrailsMajorVersion.groovy
copy to buildSrc/src/main/groovy/website/model/PostMetadataAdapter.groovy
index 75ac7247430..ce7a5035314 100644
--- a/buildSrc/src/main/groovy/org/grails/guides/GrailsMajorVersion.groovy
+++ b/buildSrc/src/main/groovy/website/model/PostMetadataAdapter.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,27 +16,47 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.guides
+package website.model
 
 import groovy.transform.CompileStatic
 
 @CompileStatic
-enum GrailsMajorVersion {
+class PostMetadataAdapter implements PostMetadata {
 
-    GRAILS_3(3),
-    GRAILS_4(4),
-    GRAILS_5(5),
-    GRAILS_6(6),
-    GRAILS_7(7)
+    Map<String, String> metadata
 
-    final int id
+    PostMetadataAdapter(Map<String, String> metadata) {
+        this.metadata = metadata
 
-    private GrailsMajorVersion(int id) {
-        this.id = id
     }
 
     @Override
-    String toString() {
-        this.name()
+    String get(String name) {
+        return metadata[name]
     }
-}
\ No newline at end of file
+
+    @Override
+    String getUrl() {
+        get('url')
+    }
+
+    @Override
+    String getTitle() {
+        get('title')
+    }
+
+    @Override
+    String getAuthor() {
+        get('author')
+    }
+
+    @Override
+    String getDate() {
+        get('date')
+    }
+
+    @Override
+    Map<String, String> toMap() {
+        metadata
+    }
+}
diff --git a/buildSrc/src/main/groovy/website/model/documentation/DocumentationPage.groovy b/buildSrc/src/main/groovy/website/model/documentation/DocumentationPage.groovy
new file mode 100644
index 00000000000..33fa011bfca
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/model/documentation/DocumentationPage.groovy
@@ -0,0 +1,254 @@
+/*
+ *  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.documentation
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+import org.yaml.snakeyaml.Yaml
+
+import static website.utils.RenderUtils.renderHtml
+
+@CompileStatic
+class DocumentationPage {
+
+    /**
+     * Renders a single documentation category as an HTML guide group.
+     * Displays the category's icon, title, and a list of linked items.
+     *
+     * @param category the documentation category to render
+     * @return HTML string representing the category section
+     */
+    @CompileDynamic
+    private static String renderCategory(DocumentationCategory category) {
+        renderHtml {
+            div(class: 'guide-group') {
+                div(class: 'guide-group-header') {
+                    img(src: category.image, alt: category.title)
+                    h2(category.title)
+                }
+                ul {
+                    category.items.each { item ->
+                        li {
+                            a(href: item.url, item.title)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Renders the documentation links section for a specific Grails version.
+     * Includes links to the User Guide and API Reference.
+     *
+     * @param version the Grails version string (e.g., "6.2.0", "snapshot")
+     * @return HTML string with documentation links for the specified version
+     */
+    @CompileDynamic
+    static String renderDocumentation(String version) {
+        renderHtml {
+            div(class: 'guide-group') {
+                if (version) {
+                    div(class: 'guide-group-header') {
+                        img(
+                                src: '[%url]/images/documentation.svg',
+                                alt: "Grails Version ($version)"
+                        )
+                        h2(
+                                DocumentationPage.resolveDocumentationName(version)
+                        )
+                    }
+                    ul {
+                        li {
+                            a(
+                                    href: "https://grails.apache.org/docs/$version/",
+                                    'User Guide'
+                            )
+                        }
+                        li {
+                            a(
+                                    href: "https://grails.apache.org/docs/$version/api/",
+                                    'API Reference'
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Resolves a human-readable documentation title based on the version string.
+     * Determines whether the version is a Snapshot, Milestone, Release Candidate, or Latest release.
+     *
+     * @param version the Grails version string
+     * @return formatted documentation title (e.g., "Latest Version (6.2.0) Documentation")
+     */
+    static String resolveDocumentationName(String version) {
+        def v = version.toLowerCase()
+        def label =
+                v.endsWith('-snapshot') || v.contains('snapshot') ? 'Snapshot' :
+                        version.contains('.M')                    ? 'Milestone' :
+                        version.contains('.RC')                   ? 'Release Candidate' :
+                                                                    'Latest'
+
+        "$label Version ($version) Documentation"
+    }
+
+    /**
+     * Generates the main documentation page HTML content.
+     * Displays pre-release, snapshot, and latest version documentation links,
+     * along with categorized documentation for GORM, Security, and other modules.
+     * Also includes version selectors for browsing older documentation.
+     *
+     * @param releases the YAML file containing Grails release information
+     * @param modules the YAML file containing module/category definitions
+     * @return complete HTML string for the documentation page
+     */
+    @CompileDynamic
+    static String mainContent(File releases, File modules) {
+        renderHtml {
+            div(class: 'header-bar chalices-bg') {
+                div(class: 'content') {
+                    h1('Documentation')
+                }
+            }
+            def preRelease = SiteMap.latestPreReleaseVersion(releases)
+            def latest = SiteMap.latestVersion(releases)
+            def categories = DocumentationPage.categories(modules)
+            div(class: 'content') {
+                div(class: 'two-columns') {
+                    div(class: 'odd column'){
+                        if (preRelease > latest) {
+                            mkp.yieldUnescaped(
+                                    DocumentationPage.renderDocumentation(preRelease.versionText)
+                            )
+                        }
+                        mkp.yieldUnescaped(
+                                DocumentationPage.renderDocumentation('snapshot')
+                        )
+                        mkp.yieldUnescaped(
+                                DocumentationPage.renderCategory(categories.find {
+                                    it.title == 'GORM - Data Access Toolkit'
+                                })
+                        )
+                        mkp.yieldUnescaped(
+                                DocumentationPage.renderCategory(categories.find {
+                                    it.title == 'Security'
+                                })
+                        )
+
+                    }
+                    div(class: 'column') {
+                        mkp.yieldUnescaped(
+                                DocumentationPage.renderDocumentation(latest.versionText)
+                        )
+                        div(class: 'older-versions') {
+                            h3(
+                                    class: 'column-header',
+                                    style: 'margin-bottom: 10px',
+                                    'Older Version'
+                            )
+                            p('Browse previous versions\' documentation since Grails 1.2.0')
+                            div(class: 'version-selector') {
+                                h4('Single Page - User Guide')
+                                select(onchange: "window.location.href='https://grails.apache.org/docs/' + this.value + '/guide/single.html'") {
+                                    option('Select a version')
+                                    mkp.yield('[%versions]')
+                                }
+                            }
+                            div(class: 'version-selector') {
+                                h4('User Guide')
+                                select(onchange: "window.location.href='https://grails.apache.org/docs/' + this.value") {
+                                    option('Select a version')
+                                    mkp.yield('[%versions]')
+                                }
+                            }
+                            div(class: 'version-selector') {
+                                h4('API Reference')
+                                select(onchange: "window.location.href='https://grails.apache.org/docs/' + this.value + '/api'") {
+                                    option('Select a version')
+                                    mkp.yield('[%versions]')
+                                }
+                            }
+                        }
+                        ['Upgrade', 'Testing', 'Views', 'Async', 'Database', 'Redis'].each { title ->
+                            mkp.yieldUnescaped(
+                                    DocumentationPage.renderCategory(categories.find {
+                                        it.title == title
+                                    })
+                            )
+                        }
+                    }
+
+                }
+            }
+        }
+    }
+
+    /**
+     * Parses the modules YAML file and groups documentation items by category.
+     * Each category contains metadata (title, image, description, URL) and a list of items.
+     *
+     * @param modules the YAML file containing module definitions
+     * @return collection of {@link DocumentationCategory} objects with their items populated
+     */
+    @CompileDynamic
+    private static Collection<DocumentationCategory> categories(File modules) {
+        def model = modules.newInputStream().withCloseable { is ->
+            new Yaml().load(is) as Map
+        }
+        Map<String, DocumentationCategory> byCategory = [:].withDefault { key ->
+            new DocumentationCategory(title: key)
+        }
+        (model.modules as Map).each { k, v ->
+            def cat = byCategory[v['category']]
+            cat.with {
+                image = v['categoryImage'] ?: null
+                description = v['categoryDescription'] ?: null
+                url = v['categoryUrl'] ?: null
+            }
+            cat.items << new DocumentationItem(url: v['url'], title: v['title'])
+        }
+        byCategory.values()
+    }
+
+    /**
+     * Represents a documentation category containing related documentation items.
+     * Categories group items like "GORM", "Security", "Testing", etc.
+     */
+    @CompileStatic
+    static class DocumentationCategory {
+        String description
+        String image
+        String title
+        String url
+        List<DocumentationItem> items = []
+    }
+
+    /**
+     * Represents a single documentation item within a category.
+     * Contains a title and URL linking to the documentation resource.
+     */
+    @CompileStatic
+    static class DocumentationItem {
+        String title
+        String url
+    }
+}
diff --git a/buildSrc/src/main/groovy/website/model/documentation/DownloadPage.groovy b/buildSrc/src/main/groovy/website/model/documentation/DownloadPage.groovy
new file mode 100644
index 00000000000..6f13d25d41a
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/model/documentation/DownloadPage.groovy
@@ -0,0 +1,286 @@
+/*
+ *  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
+ *
+ *    http://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.documentation
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import static website.utils.RenderUtils.renderHtml
+
+@CompileStatic
+class DownloadPage {
+
+    static String binaryUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
+        "https://www.apache.org/dyn/closer.lua/grails/$directory/$version/distribution/apache-$artifact-$version-bin.zip$ext?action=download"
+    }
+
+    static String sourceUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
+        "https://www.apache.org/dyn/closer.lua/grails/$directory/$version/sources/apache-$artifact-$version-src.zip$ext?action=download"
+    }
+
+    static String binaryVerificationUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
+        "https://downloads.apache.org/grails/$directory/$version/distribution/apache-$artifact-$version-bin.zip$ext"
+    }
+
+    static String sourceVerificationUrl(String version, String artifact = 'grails', String ext = '', String directory = 'core') {
+        "https://downloads.apache.org/grails/$directory/$version/sources/apache-$artifact-$version-src.zip$ext"
+    }
+
+    /**
+     * Does not handle pre-release versions as these are not displayed in the select box.
+     */
+    static String resolveOldDownloadUrl(String version) {
+        def parts = ((version.split(/\./)*.replaceAll(/\D.*/, '')*.toInteger()) + [0, 0, 0]).take(3)
+        def (major, minor, patch) = [parts[0], parts[1], parts[2]]
+        def tag = "v$version"
+        if (major < 7) {
+            def baseUrl = 'https://github.com/apache/grails-core/releases/download'
+            def artifactName = "grails-$version"
+            if (major == 6) {
+                baseUrl = 'https://github.com/apache/grails-forge/releases/download'
+                artifactName = "grails-cli-$version"
+            }
+            if (major == 1 && minor == 1) {
+                artifactName = "grails-bin-$version"
+            }
+            if (major <= 1 && patch == 0) {
+                tag = "v$major.$minor"
+            }
+            return "$baseUrl/$tag/${artifactName}.zip"
+        }
+        else {
+            return "https://www.apache.org/dyn/closer.lua/grails/core/$version/distribution/apache-grails-$version-bin.zip?action=download"
+        }
+    }
+
+    @CompileDynamic
+    static String renderDownload(String version) {
+
+        if (version.toLowerCase().contains('snapshot')) {
+            return ''
+        }
+
+        def redisVersion = '5.0.0'
+        def quartzVersion = '4.0.0'
+        def springSecurityVersion = '7.0.0'
+        def grailsGithubActionsVersion = '1.0.1'
+        def grailsGradlePublishVersion = '0.0.4'
+
+        renderHtml {
+            div(class: 'guide-group') {
+                if (version) {
+                    div(class: 'guide-group-header') {
+                        img(src: '[%url]/images/download.svg', alt: "Download Grails ($version)")
+                        h2(
+                                DocumentationPage.resolveDocumentationName(version)
+                        )
+                    }
+                    ul {
+                        if (version.startsWith('7')) {
+                            li {
+                                a(href: DownloadPage.sourceUrl(version), 'Source')
+                                a(href: sourceVerificationUrl(version, 'grails', '.sha512'), 'SHA512')
+                                a(href: sourceVerificationUrl(version, 'grails', '.asc'), 'ASC')
+                            }
+                            li {
+                                a(href: binaryUrl(version, 'grails'), 'Binary')
+                                a(href: binaryVerificationUrl(version, 'grails', '.sha512'), 'SHA512')
+                                a(href: binaryVerificationUrl(version, 'grails', '.asc'), 'ASC')
+                            }
+                            li {
+                                a(href: binaryUrl(version, 'grails-wrapper'), 'Binary Wrapper')
+                                a(href: binaryVerificationUrl(version, 'grails-wrapper', '.sha512'), 'SHA512')
+                                a(href: binaryVerificationUrl(version, 'grails-wrapper', '.asc'), 'ASC')
+                            }
+                            li {
+                                a(href: sourceUrl(springSecurityVersion, 'grails-spring-security', '', 'spring-security'), "Grails Spring Security $springSecurityVersion Plugin Source")
+                                a(href: sourceVerificationUrl(springSecurityVersion, 'grails-spring-security', '.sha512', 'spring-security'), 'SHA512')
+                                a(href: sourceVerificationUrl(springSecurityVersion, 'grails-spring-security', '.asc', 'spring-security'), 'ASC')
+                            }
+                            li {
+                                a(href: sourceUrl(redisVersion, 'grails-redis', '', 'redis'), "Grails Redis $redisVersion Plugin Source")
+                                a(href: sourceVerificationUrl(redisVersion, 'grails-redis', '.sha512', 'redis'), 'SHA512')
+                                a(href: sourceVerificationUrl(redisVersion, 'grails-redis', '.asc', 'redis'), 'ASC')
+                            }
+                            li {
+                                a(href: sourceUrl(quartzVersion, 'grails-quartz', '', 'quartz'), "Grails Quartz $quartzVersion Plugin Source")
+                                a(href: sourceVerificationUrl(quartzVersion, 'grails-quartz', '.sha512', 'quartz'), 'SHA512')
+                                a(href: sourceVerificationUrl(quartzVersion, 'grails-quartz', '.asc', 'quartz'), 'ASC')
+                            }
+                            li {
+                                a(href: sourceUrl(grailsGithubActionsVersion, 'grails-github-actions', '', 'actions'), "Grails GitHub Actions $grailsGithubActionsVersion Source")
+                                a(href: sourceVerificationUrl(grailsGithubActionsVersion, 'grails-github-actions', '.sha512', 'actions'), 'SHA512')
+                                a(href: sourceVerificationUrl(grailsGithubActionsVersion, 'grails-github-actions', '.asc', 'actions'), 'ASC')
+                            }
+                            li {
+                                a(href: sourceUrl(grailsGradlePublishVersion, 'grails-publish', '', 'grails-publish'), "Grails Publish Gradle Plugin $grailsGradlePublishVersion Source")
+                                a(href: sourceVerificationUrl(grailsGradlePublishVersion, 'grails-publish', '.sha512', 'grails-publish'), 'SHA512')
+                                a(href: sourceVerificationUrl(grailsGradlePublishVersion, 'grails-publish', '.asc', 'grails-publish'), 'ASC')
+                            }
+                        } else {
+                            li {
+                                a(
+                                        href: "https://github.com/apache/grails-forge/releases/download/v$version/grails-cli-${version}.zip",
+                                        'Binary'
+                                )
+                            }
+                        }
+                        li {
+                            a(
+                                    href: "https://github.com/apache/grails-core/releases/tag/v$version",
+                                    'Grails Release Notes')
+                        }
+                        if (version.startsWith('7')) {
+                            li {
+                                a(
+                                        href: "https://github.com/apache/grails-spring-security/releases/tag/v$springSecurityVersion",
+                                        "Grails Spring Security Plugin $springSecurityVersion Release Notes")
+                            }
+                            li {
+                                a(
+                                        href: "https://github.com/apache/grails-redis/releases/tag/v$redisVersion",
+                                        "Grails Redis $redisVersion Plugin Release Notes")
+                            }
+                            li {
+                                a(
+                                        href: "https://github.com/apache/grails-quartz/releases/tag/v$quartzVersion",
+                                        "Grails Quartz $quartzVersion Plugin Release Notes"
+                                )
+                            }
+                            li {
+                                a(
+                                        href: "https://github.com/apache/grails-github-actions/releases/tag/v$grailsGithubActionsVersion",
+                                        "Grails GitHub Actions $grailsGithubActionsVersion Release Notes")
+                            }
+                            li {
+                                a(
+                                        href: "https://github.com/apache/grails-gradle-publish/releases/tag/v$grailsGradlePublishVersion",
+                                        "Grails Publish Gradle Plugin $grailsGradlePublishVersion Release Notes"
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+
+    @CompileDynamic
+    static String mainContent(File releases) {
+        def preRelease = SiteMap.latestPreReleaseVersion(releases)
+        def latest = SiteMap.latestVersion(releases)
+        renderHtml {
+            div(class: 'header-bar chalices-bg') {
+                div(class: 'content') {
+                    h1('Downloads')
+                }
+            }
+            div(class: 'content') {
+                div(class: 'two-columns') {
+                    div(class: 'odd column') {
+                        h3(
+                                class: 'column-header', style: 'margin-bottom: 10px',
+                                'Source and Binary Releases'
+                        )
+                        p {
+                            mkp.yieldUnescaped(
+                                    'We provide OpenPGP signatures (\'.asc\') files and checksums (\'.sha512\') for ' +
+                                    'every release artifact. We recommend that you '
+                            )
+                            a(href: 'https://www.apache.org/info/verification.html', 'verify')
+                            mkp.yieldUnescaped(
+                                    ' the integrity of downloaded files by generating your own checksums and match ' +
+                                    'them against ours, and checking signatures using the '
+                            )
+                            a(href: 'https://www.apache.org/dyn/closer.lua/grails/KEYS?action=download', 'KEYS')
+                            mkp.yieldUnescaped(' file which contains the Grails OpenPGP release keys.')
+                        }
+                        if (preRelease > latest) {
+                            mkp.yieldUnescaped(DownloadPage.renderDownload(preRelease.versionText))
+                        }
+
+                        mkp.yieldUnescaped(DownloadPage.renderDownload('snapshot'))
+                        mkp.yieldUnescaped(DownloadPage.renderDownload(latest.versionText))
+
+                        h3(
+                                class: 'column-header',
+                                'Older Versions'
+                        )
+                        p('You can download previous versions as far back as Grails 0.1.')
+                        p(
+                                'NOTE: Versions prior to 7.0.0-M4 are not ASF releases. Links to those releases are ' +
+                                'provided here as a convenience.'
+                        )
+                        div(class: 'version-selector') {
+                            select(class: 'form-control', onchange: 'window.location.href = this.value') {
+                                option(label: 'Select a version', disabled: 'disabled', selected: 'selected')
+                                SiteMap.stableVersions(releases)*.versionText.each {
+                                    option(value: DownloadPage.resolveOldDownloadUrl(it), it)
+                                }
+                            }
+                        }
+                    }
+                    div(class: 'column') {
+
+                        h3(
+                                class: 'column-header',
+                                style: 'margin-bottom: 10px',
+                                'Grails Application Forge'
+                        )
+                        p('The quickest way to get started with our application generator:')
+                        p {
+                            a(href: 'https://start.grails.org', 'Grails Application Forge')
+                        }
+
+                        h3(
+                                class: 'column-header',
+                                style: 'margin-bottom: 10px',
+                                'Installing with SDKMAN!'
+                        )
+                        p {
+                            a(
+                                    href: 'https://sdkman.io/',
+                                    'SDKMAN! (The Software Development Kit Manager)'
+                            )
+                        }
+
+                        p(
+                                'This tool makes installing the Grails framework on any Unix based platform ' +
+                                '(Mac OSX, Linux, Cygwin, Solaris, or FreeBSD) easy.'
+                        )
+                        p('Simply open a new terminal and enter:')
+                        div(class: 'code', '$ curl -s https://get.sdkman.io | bash')
+                        p('Follow the on-screen instructions to complete installation.')
+                        p('Open a new terminal or type the command:')
+                        div(class: 'code', '$ source "$HOME/.sdkman/bin/sdkman-init.sh"')
+                        p('Then install the latest stable Grails version:')
+                        div(class: 'code', '$ sdk install grails')
+                        p(
+                                'If prompted, make this your default version. After installation is complete ' +
+                                'it can be tested with:'
+                        )
+                        div(class: 'code', '$ grails --version')
+                        p('That\'s all there is to it!')
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy b/buildSrc/src/main/groovy/website/model/documentation/GuideGroup.groovy
similarity index 77%
copy from buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
copy to buildSrc/src/main/groovy/website/model/documentation/GuideGroup.groovy
index 455b609980e..0d49bfe9196 100644
--- a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
+++ b/buildSrc/src/main/groovy/website/model/documentation/GuideGroup.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,15 +16,17 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model.documentation
 
 import groovy.transform.CompileStatic
 
 @CompileStatic
-class MarkdownPost extends Page {
+class GuideGroup implements GuideGroupHtml {
 
-    @Override
-    String getPath() {
-        return filename.replace('.md', '.html').replace('.markdown', '.html')
-    }
+    String description
+    String image
+    String title
+    String ulId
+
+    List<GuideGroupItem> items = []
 }
diff --git a/buildSrc/src/main/groovy/website/model/documentation/GuideGroupHtml.groovy b/buildSrc/src/main/groovy/website/model/documentation/GuideGroupHtml.groovy
new file mode 100644
index 00000000000..c6e1fd06933
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/model/documentation/GuideGroupHtml.groovy
@@ -0,0 +1,84 @@
+/*
+ *  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.documentation
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import website.model.PageElement
+
+import static website.utils.RenderUtils.renderHtml
+
+@CompileStatic
+trait GuideGroupHtml implements PageElement {
+
+    abstract String getDescription()
+    abstract String getImage()
+    abstract String getUlId()
+    abstract String getTitle()
+
+    abstract List<GuideGroupItem> getItems()
+
+    @Override
+    String renderAsHtml() {
+        renderAsHtmlWithStyleAttr(null)
+    }
+
+    @CompileDynamic
+    String renderAsHtmlWithStyleAttr(String styleAttr) {
+        renderHtml {
+            omitNullAttributes = true
+            div(class: 'guide-group', style: styleAttr) {
+                div(class: 'guide-group-header') {
+                    img(src: image, alt: title)
+                    h2 {
+                        mkp.yieldUnescaped(title)
+                    }
+                }
+                ul(id: ulId) {
+                    if (description) {
+                        li(class: 'legend', description)
+                    }
+                    if (items) {
+                        for (def item : items) {
+                            if (item.href) {
+                                li {
+                                    if (item.image) {
+                                        img(
+                                                class: 'align-left',
+                                                style: 'with: 70px; margin-right: 10px',
+                                                src: item.image,
+                                                alt: item.title
+                                        )
+                                    }
+                                    a(href: item.href, item.title)
+                                    if (item.legend) {
+                                        p(item.legend)
+                                    }
+                                }
+                            } else {
+                                li(item.title)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy b/buildSrc/src/main/groovy/website/model/documentation/GuideGroupItem.groovy
similarity index 78%
copy from buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
copy to buildSrc/src/main/groovy/website/model/documentation/GuideGroupItem.groovy
index 455b609980e..cb7c693a1ae 100644
--- a/buildSrc/src/main/groovy/org/grails/MarkdownPost.groovy
+++ b/buildSrc/src/main/groovy/website/model/documentation/GuideGroupItem.groovy
@@ -7,7 +7,7 @@
  *  "License"); you may not use this file except in compliance
  *  with the License.  You may obtain a copy of the License at
  *
- *    http://www.apache.org/licenses/LICENSE-2.0
+ *    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
@@ -16,15 +16,16 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails
+package website.model.documentation
 
 import groovy.transform.CompileStatic
 
 @CompileStatic
-class MarkdownPost extends Page {
+class GuideGroupItem {
 
-    @Override
-    String getPath() {
-        return filename.replace('.md', '.html').replace('.markdown', '.html')
-    }
+    String category
+    String href
+    String image
+    String legend
+    String title
 }
diff --git a/buildSrc/src/main/groovy/website/model/documentation/ProfilesPage.groovy b/buildSrc/src/main/groovy/website/model/documentation/ProfilesPage.groovy
new file mode 100644
index 00000000000..f013ebf4f0f
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/model/documentation/ProfilesPage.groovy
@@ -0,0 +1,138 @@
+/*
+ *  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.documentation
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.yaml.snakeyaml.Yaml
+import website.utils.MarkdownUtils
+
+import static website.utils.RenderUtils.renderHtml
+
+@CompileStatic
+class ProfilesPage {
+
+    @CompileDynamic
+    static String mainContent(File profiles) {
+        renderHtml {
+            div(class: 'header-bar chalices-bg') {
+                div(class: 'content') {
+                    h1('Profiles')
+                }
+            }
+            def categories = ProfilesPage.categories(profiles)
+            div(class: 'content') {
+                div(class: 'two-columns') {
+                    div(class: 'odd column') {
+                        mkp.yieldUnescaped(ProfilesPage.renderCategory(categories.find {
+                            it.title == 'Profiles'
+                        }))
+                    }
+                    div(class: 'column') {
+                        mkp.yieldUnescaped(ProfilesPage.renderCategory(categories.find {
+                            it.title == 'Plugin Profiles'
+                        }))
+                        mkp.yieldUnescaped(ProfilesPage.renderCategory(categories.find {
+                            it.title == 'Third-Party Profiles'
+                        }))
+
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Reads a YAML "profiles" model file and groups the contained profiles into categories.
+     * <p>
+     * The YAML file is expected to contain a top-level {@code profiles} map. Each entry under
+     * {@code profiles} represents one profile definition with at least {@code url} and {@code title},
+     * and may also define category metadata such as {@code category}, {@code image}, and
+     * {@code description}.
+     * </p>
+     * <p>
+     * This method iterates over all profile definitions, creates (or reuses) a {@link ProfilesCategory}
+     * for each distinct {@code category} value, copies category metadata (title/image/description)
+     * from the YAML entry, and adds a {@link Profile} (url/title) to the category’s {@code profiles}
+     * collection.
+     * </p>
+     *
+     * @param modules the YAML file containing the profiles model (must be readable)
+     * @return a collection of {@link ProfilesCategory} instances, each containing the profiles that
+     *         belong to that category
+     */
+    private static Collection<ProfilesCategory> categories(File modules) {
+        def model = modules.newInputStream().withCloseable { is ->
+            new Yaml().load(is) as Map
+        }
+        Map<String, ProfilesCategory> byCategory = [:].withDefault { key ->
+            new ProfilesCategory(title: key)
+        }
+        (model.profiles as Map).each { k, v ->
+            def key = (v['category'] ?: 'Uncategorized') as String
+            def cat = byCategory[key]
+            cat.with {
+                image = v['image'] ?: null
+                description = v['description'] ?: null
+            }
+            cat.profiles << new Profile(url: v['url'], title: v['title'])
+        }
+        byCategory.values()
+    }
+
+    @CompileDynamic
+    private static String renderCategory(ProfilesCategory cat) {
+        if (!cat) return ''
+        renderHtml {
+            div(class: 'guide-group') {
+                div(class: 'guide-group-header') {
+                    img(src: cat.image, alt: cat.title)
+                    h2(cat.title)
+                }
+                ul {
+                    if (cat.description) {
+                        def legend = MarkdownUtils.htmlFromMarkdown(cat.description)
+                                .replaceAll('<p>', '')
+                                .replaceAll('</p>', '')
+
+                        li(class: 'legend') {
+                            mkp.yieldUnescaped(legend)
+                        }
+                    }
+                    cat.profiles.each { item ->
+                        li { a(href: item.url, item.title) }
+                    }
+                }
+            }
+        }
+    }
+
+    static class ProfilesCategory {
+        String title
+        String image
+        String description
+        List<Profile> profiles = []
+    }
+
+    static class Profile {
+        String title
+        String url
+    }
+}
diff --git a/buildSrc/src/main/groovy/website/model/documentation/SiteMap.groovy b/buildSrc/src/main/groovy/website/model/documentation/SiteMap.groovy
new file mode 100644
index 00000000000..f29727071ed
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/model/documentation/SiteMap.groovy
@@ -0,0 +1,61 @@
+/*
+ *  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.documentation
+
+import groovy.transform.CompileStatic
+
+import org.yaml.snakeyaml.Yaml
+
+@CompileStatic
+class SiteMap {
+
+    static List<SoftwareVersion> versions(File releases) {
+        assert releases.exists()
+        def model = releases.newInputStream().withCloseable {
+            new Yaml().load(it) as Map
+        }
+        (model.releases as List<Map>)
+                .collect { SoftwareVersion.build(it.version as String) }
+                .toSorted()
+    }
+
+    static SoftwareVersion latestVersion(File releases) {
+        stableVersions(releases)?.get(0)
+    }
+
+    static List<String> olderVersions(File releases) {
+        stableVersions(releases).tail()*.versionText
+    }
+
+    static List<SoftwareVersion> stableVersions(File releases) {
+        versions(releases)
+                .findAll { !it.isSnapshot() }
+                .toSorted { a, b -> b <=> a }
+    }
+
+    static List<SoftwareVersion> preReleaseVersions(File releases) {
+        versions(releases)
+                .findAll {it.getSnapshot()?.isMilestone() || it.getSnapshot()?.isReleaseCandidate() }
+                .toSorted { a, b -> b <=> a }
+    }
+
+    static SoftwareVersion latestPreReleaseVersion(File releases) {
+        preReleaseVersions(releases)?.get(0)
+    }
+}
diff --git a/buildSrc/src/main/groovy/website/model/documentation/Snapshot.groovy b/buildSrc/src/main/groovy/website/model/documentation/Snapshot.groovy
new file mode 100644
index 00000000000..99d5914b8df
--- /dev/null
+++ b/buildSrc/src/main/groovy/website/model/documentation/Snapshot.groovy
@@ -0,0 +1,56 @@
+/*
+ *  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.documentation
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class Snapshot implements Comparable<Snapshot> {
+
+    final String text
+
+    Snapshot(String text) {
+        this.text = text
+    }
... 3083 lines suppressed ...