Merge pull request #416 from sanjana2505006/feature/historical-contributors Add Historical Contributors section
diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..0180378 --- /dev/null +++ b/DESIGN.md
@@ -0,0 +1,215 @@ +<!-- +SPDX-License-Identifier: Apache-2.0 + +Licensed 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. +--> + +# Apache Grails Website Design System + +This document defines the reusable visual system for the guides catalogue. Implementations must place these tokens and components in `assets/stylesheets/screen.css` rather than introduce one-off styles. Catalogue tokens are scoped to the emitted `.guides-page` wrapper because the shared document template does not apply page metadata to the `<body>` element. + +## 1. Atmosphere & Identity + +The Apache Grails website is direct, technical, and welcoming. White and off-white reading surfaces keep dense documentation calm, Grails blue establishes structure, and the warm orange accent carries the framework's identity without overpowering the content. The signature is the chalice-led blue header paired with orange-topped white content cards. + +## 2. Color + +### Palette + +| Role | Token | Value | Usage | +| --- | --- | --- | --- | +| Brand | `--guides-color-brand` | `#255AA8` | Section headers, version chips, category title links, focus outlines, Kapa modal title | +| Brand hover | `--guides-color-brand-hover` | `#1d4684` | Interactive brand surfaces on hover and focus | +| Brand soft | `--guides-color-brand-soft` | `rgba(37, 90, 168, 0.08)` | Featured catalogue atmosphere | +| Accent | `--guides-color-accent` | `#feb672` | Card top borders, hidden launcher text accent, catalogue REST icon, identity details | +| Launcher dark | Kapa widget only | `#3F4346` | Ask AI launcher / project chrome background (not a page surface) | +| Legacy tag gold | Existing `.tag-cloud` scale | `#F0C45A` | Existing occurrence-scaled tag navigation | +| Accent wash | `--guides-color-accent-wash` | `rgba(254, 182, 114, 0.2)` | Featured catalogue highlight | +| Surface | `--guides-color-surface` | `#ffffff` | Cards, inputs, panels | +| Page | `--guides-color-page` | `#F5F5F5` | Page ground and quiet dividers | +| Text | `--guides-color-text` | `#666666` | Body and metadata (AA on white/off-white) | +| Strong text | `--guides-color-text-strong` | `#4f4f4f` | Guide titles and high-emphasis labels | +| Tag chip text | `--guides-color-tag-text` | `#3d2914` | Dark ink on gold/orange tag chips | +| On brand | `--guides-color-on-brand` | `#ffffff` | Text on blue surfaces | +| Quiet border | `--guides-color-border` | `#d8d8d8` | Nonessential dividers and card boundaries | +| Control border | `--guides-color-control-border` | `#767676` | Input and control boundaries (3:1+ on white/off-white) | + +### Rules + +- Grails blue establishes hierarchy; orange is an accent, not a second background system. +- White cards sit on the off-white page ground. +- New solid catalogue colors used for text, backgrounds, borders, or controls must be declared in this table and as scoped CSS custom properties before use. Derived alpha shadows may reuse brand RGB values when their complete shadow value is documented in the depth table. +- Purple gradients and unrelated accent hues do not belong in this design system. + +## 3. Typography + +### Font stack + +| Family | Usage | +| --- | --- | +| Roboto, Roboto Light, Roboto Medium | Catalogue UI, guide titles, metadata, navigation | +| Archia Light, Archia Thin, Archia Medium | Marketing display text elsewhere on the site | +| Inter | Long-form rendered guide articles only | +| JetBrains Mono | Code inside rendered guide articles only | + +### Scale + +| Level | Size | Weight | Line height | Usage | +| --- | --- | --- | --- | --- | +| Page title | `24px` | Medium | `1.2` | Blue header bar | +| Section | `18px` | Medium | `28px` | `.column-header` labels | +| Card title | `16px` | Medium | `1.35` | Guide and category cards | +| Body | `16px` | Regular | `1.5` | Catalogue introductions and controls | +| Metadata | `14px` | Regular | `1.4` | Date and category | +| Guide version chip | `12px` | Light | `1.2` | Version links inside guide cards | +| Version navigation chip | `16px` | Regular | `14px` | Legacy version-cloud navigation | + +### Heading hierarchy (guides catalogue) + +| Context | Rank | Notes | +| --- | --- | --- | +| Featured / version / catalogue / discovery section titles | `h2` | One section label each | +| Category grid card titles | `h3` | Nested under catalogue `h2` | +| Standalone category or tag page title | `h2` | Page-level heading when not nested | +| Latest Guides section | `h3` | Nested under discovery `h2` | +| Latest guide card titles | `h4` | Nested under Latest Guides `h3` | +| Featured / version guide card titles | `h3` | Nested under their section `h2` | +| Search result / no-result titles | `h3` | Nested under Find Guides `h2` | + +Large headings may use balanced wrapping. Body copy should use natural wrapping and remain readable at 200 percent zoom. + +## 4. Spacing & Layout + +### Spacing tokens + +| Token | Value | Usage | +| --- | --- | --- | +| `--guides-space-2` | `8px` | Chip gaps and tight relationships | +| `--guides-space-3` | `12px` | Compact list rows | +| `--guides-space-4` | `16px` | Default card and input padding | +| `--guides-space-5` | `24px` | Grid gaps and section rhythm | +| `--guides-space-6` | `32px` | Major section separation | +| Legacy section step | `50px` | Existing `.column-header` and page spacing | + +### Grid + +- Maximum content width is `1141px`, inherited from `.content`. +- The existing desktop breakpoint is `961px`; the catalogue adds a content-driven tablet breakpoint at `768px`. +- Catalogue and discovery layouts are one column below `768px`. +- Category cards use CSS multi-column masonry (`column-count` 1 / 2 / 3 at mobile / tablet / desktop) with `break-inside: avoid` so cards keep natural height instead of stretched equal-height grid rows. +- Browser mechanics such as `column-count`, percentages, and intrinsic sizing remain raw CSS rather than design tokens. + +## 5. Components + +### Featured catalogue + +- **Structure:** contained guides header followed by a labelled `section`, blue section heading, lightly layered blue-orange atmosphere, introduction, guide-card grid. +- **Variants:** current featured major on the index; selected major on a version page. +- **Spacing:** section gap 6, card gap 3 or 4, card padding 4. +- **States:** links expose default, hover, active, visited, and visible focus behavior. +- **Accessibility:** the heading labels the section; source order places the promoted catalogue before discovery controls. +- **Motion:** inherited color transitions only; no decorative movement. + +### Guide card + +- **Structure:** title, metadata, text action inside a list item. +- **Variants:** featured and version cards use `h3.guides-card-title`; latest cards use `h4.guides-card-title` under the Latest Guides `h3`. +- **Spacing:** padding 4; title-to-meta and meta-to-action use space 2. +- **States:** focus within receives a clear brand outline; links retain underline feedback. +- **Accessibility:** titles remain text, metadata remains legible, and the action stays keyboard reachable. +- **Layout:** cards flow through the responsive guide-card grid. + +### Category grid and category card + +- **Structure:** labelled catalogue section (`h2`), multi-column masonry container, image-and-title card header (`h3` when linked in the grid), guide list. +- **Variants:** full catalogue and selected-major-only catalogue; standalone category pages keep an `h2` title. +- **Spacing:** column gap 5; compact list rows use spaces 3 and 4; cards stack with space 5 between blocks in a column. +- **States:** category heading links use `--guides-color-brand` / hover variant (AA on white). +- **Accessibility:** category images are decorative because the adjacent heading supplies the name. +- **Icons:** homepage blue surfaces keep shared `restapis.svg` (white); catalogue cards use `restapis-guides.svg` (orange `#feb672` on white). +- **Layout:** one column on mobile; two columns from `768px`; three columns from `961px`; cards never stretch to equal row height. + +### Discovery + +- **Structure:** labelled section containing search/latest guides and version/tag navigation. +- **Variants:** index with search and latest cards; version page with navigation only. +- **Spacing:** section gap 6 and column gap 5 or 6. +- **States:** search uses an explicit label, native input behavior, and visible `:focus` / `:focus-visible` outline. +- **Accessibility:** a separate visually hidden `#guides-search-status` node uses `role="status"`, `aria-live="polite"`, and `aria-atomic="true"`; JS sets `""` on reset, `"N guide(s) found"` on matches, and `"No results found"` otherwise. Visible search result headings are `h3` under Find Guides. +- **Layout:** one column below desktop; two columns from `961px` only when both columns contain content. + +### Version chip + +- **Structure:** direct `.align-left.guides-version-chip` child of `.multi-guide`, containing the link and hidden search tags. +- **Guide variant:** `12px` inherited Roboto Light text, `4px` radius, and compact `4px 10px` padding. +- **Version-cloud navigation:** legacy `16px` inherited Roboto Regular text, `3px` radius, and `7px 12px 5px` padding. +- **States:** both use blue default, darker blue hover, and the catalogue's visible keyboard outline. +- **Accessibility:** search indexing uses `classList.contains('align-left')` so extra chip classes remain compatible. + +### Mobile navigation + +- **Structure:** `#show-navigation-link` is a visible `button` that toggles `#top-menus.is-open`. +- **States:** closed (`Show Navigation`, `aria-expanded="false"`) and open (`Hide Navigation`, `aria-expanded="true"`). +- **Color:** open mobile panel background is `#666666` with white links (AA); desktop panel stays white. +- **Behavior:** `toggleNavigation()` toggles the `is-open` class only (no inline `display`); desktop `@media (min-width: 961px)` keeps `#top-menus` visible after mobile open/close then resize. Legacy `show()` remains for other callers. + +### Kapa Ask AI launcher + +- **Structure:** 44x44 control with 12px bottom/right offsets; accessible DOM text `"Ask AI"` with `0` font-size so the control stays icon-only visually. +- **Colors:** hidden launcher text may use accent `#feb672`; modal title uses brand blue `#255AA8` on white; launcher background uses documented dark `#3F4346`. +- **Attributes:** documented legacy `data-button-*` plus current `data-launcher-button-*` height/width/background/bottom/right/image/padding/border-radius; `data-button-*` aliases (`text-color`, `text-font-size`, `bg-color`, `position-bottom/right`) are used for compatibility. +- **Layout:** catalogue pages reserve bottom safe space; below `961px`, `footer` gets a `68px` right exclusion zone so sentences are not covered. + +## 6. Motion & Interaction + +| Type | Duration | Easing | Usage | +| --- | --- | --- | --- | +| Color feedback | `200ms` | ease in-out | Links and interactive surfaces | +| Button press | `100ms` | ease | Existing one-pixel pressed translation | +| Card shadow | `300ms` | ease | Existing reusable content cards | + +- Motion communicates interaction only. +- New animation may use only `transform`, `opacity`, or color changes. +- `prefers-reduced-motion: reduce` sets `scroll-behavior: auto`, disables link/button transitions, and removes button press `transform` (including the navigation toggle). +- Every interactive element needs a visible focus state; hover cannot be the only cue. Search focus uses both `:focus` and `:focus-visible`. + +## 7. Depth & Surface + +The catalogue uses a restrained mixed strategy inherited from the site: white surfaces, an orange top rule for identity, and a very soft card shadow. Deeper shadows, glass effects, and heavy borders are not part of this surface. + +| Token | Value | Usage | +| --- | --- | --- | +| `--guides-radius` | `4px` | Cards, chips, inputs | +| `--guides-shadow-card` | Blue-tinted soft elevation plus one-pixel grounding shadow | Guide and category cards | +| Section shadow | `0 8px 18px rgba(37, 90, 168, 0.14)` | Section title elevation derived from brand blue | +| Legacy version-navigation radius | `3px` | Existing version-cloud chips | +| Accent rule | `2px solid var(--guides-color-accent)` | Guide and category card tops | + +## 8. Accessibility Constraints & Accepted Debt + +### Constraints + +- Target WCAG 2.2 AA with at least 4.5:1 contrast for normal text and 3:1 for large text and non-text controls. +- Keyboard users must reach every guide, category, version, and search control with a visible focus indicator. +- Users at 200 percent zoom and users on 375px-wide screens must receive a single readable column without horizontal scrolling of primary content. +- Reduced-motion users receive no non-essential transitions. +- The dense catalogue must lead with the promoted version and clear section labels to reduce memory and wayfinding load. +- Nested headings must not share rank with their parent section label. + +### Accepted debt + +| Item | Location | Why accepted | Owner / Exit | +| --- | --- | --- | --- | +| Legacy float layout | Non-guides `.two-columns` pages | Outside the Grails 8 catalogue scope | Replace when those pages are redesigned | +| Occurrence-scaled tag cloud sizes | `.tag1` through `.tag50` | Size scale retained; chip text forced to `--guides-color-tag-text` for AA | Revisit with a dedicated taxonomy UX change | +| Separate article typography | `body.guide` | Rendered guide reading system is intentionally distinct | Keep unless long-form documentation is redesigned |
diff --git a/assets/images/confs/stljug.jpg b/assets/images/confs/stljug.jpg new file mode 100644 index 0000000..e48359a --- /dev/null +++ b/assets/images/confs/stljug.jpg Binary files differ
diff --git a/assets/images/restapis-guides.svg b/assets/images/restapis-guides.svg new file mode 100644 index 0000000..86a5cc0 --- /dev/null +++ b/assets/images/restapis-guides.svg
@@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="95px" height="95px" viewBox="0 0 95 95" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Catalogue variant: Grails orange on white cards (homepage keeps white restapis.svg). --> + <title>restapis</title> + <desc>Grails REST APIs icon for guides catalogue cards</desc> + <defs></defs> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="restapis" transform="translate(2.000000, 1.000000)"> + <circle id="Oval" stroke="#feb672" stroke-width="2.8347" cx="45.848" cy="46.128" r="45.374"></circle> + <circle id="Oval" stroke="#feb672" stroke-width="2.8347" cx="45.374" cy="46.374" r="45.374"></circle> + <g id="Group" transform="translate(17.000000, 9.000000)" font-size="50" font-family="Courier-Bold, Courier" fill="#feb672" font-weight="bold"> + <g id="Group-2"> + <text id="{"> + <tspan x="0.497558594" y="48">{</tspan> + </text> + <text id="{-copy" transform="translate(41.500000, 44.000000) rotate(180.000000) translate(-41.500000, -44.000000) "> + <tspan x="26.4975586" y="62">{</tspan> + </text> + </g> + </g> + </g> + </g> +</svg>
diff --git a/assets/images/soren-glasius-2026-lifetime-contributor.png b/assets/images/soren-glasius-2026-lifetime-contributor.png new file mode 100644 index 0000000..246babe --- /dev/null +++ b/assets/images/soren-glasius-2026-lifetime-contributor.png Binary files differ
diff --git a/assets/javascripts/navigation.js b/assets/javascripts/navigation.js index 4770d15..89c29e8 100644 --- a/assets/javascripts/navigation.js +++ b/assets/javascripts/navigation.js
@@ -1,4 +1,22 @@ function show(showId, hideId) { document.getElementById(showId).style.display = 'block' document.getElementById(hideId).style.display = 'none' -} \ No newline at end of file +} + +function toggleNavigation() { + const menus = document.getElementById('top-menus') + const toggle = document.getElementById('show-navigation-link') + if (!menus || !toggle) { + return + } + const isOpen = menus.classList.contains('is-open') + if (isOpen) { + menus.classList.remove('is-open') + toggle.textContent = 'Show Navigation' + toggle.setAttribute('aria-expanded', 'false') + } else { + menus.classList.add('is-open') + toggle.textContent = 'Hide Navigation' + toggle.setAttribute('aria-expanded', 'true') + } +}
diff --git a/assets/javascripts/search.js b/assets/javascripts/search.js index 8b670e4..f34696e 100644 --- a/assets/javascripts/search.js +++ b/assets/javascripts/search.js
@@ -3,6 +3,7 @@ const mobileQueryInputFieldId = 'mobile-query' const multiGuideClassName = 'multi-guide' const queryInputFieldId = 'query' +const searchStatusId = 'guides-search-status' const elementsClassNames = [ @@ -12,6 +13,7 @@ 'versions-by-grails', 'tags-by-topic', 'guides-suggestion', + 'guides-catalogue', ] window.addEventListener('load', () => { @@ -62,13 +64,31 @@ function showElementsByClassName(className) { const elements = document.getElementsByClassName(className) for (let i = 0; i < elements.length; i++) { - elements[i].style.display = 'block' + elements[i].style.display = '' + } +} + +function hasClass(node, className) { + if (!node || node.nodeType !== 1) { + return false + } + if (node.classList) { + return node.classList.contains(className) + } + const value = node.className || '' + return (' ' + value + ' ').indexOf(' ' + className + ' ') !== -1 +} + +function updateSearchStatus(message) { + const status = document.getElementById(searchStatusId) + if (status) { + status.textContent = message } } function titleAtMultiGuide(element) { for (let y = 0; y < element.childNodes.length; y++) { - if (element.childNodes[y].className === 'title') { + if (hasClass(element.childNodes[y], 'title')) { return element.childNodes[y].textContent } } @@ -78,17 +98,17 @@ function versionsAtMultiGuide(element) { const versions = [] for (let y = 0; y < element.childNodes.length; y++) { - if (element.childNodes[y].className === 'align-left') { + if (hasClass(element.childNodes[y], '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') { + if (hasClass(versionDiv.childNodes[x], 'grails-version')) { verEl = versionDiv.childNodes[x].textContent hrefEl = versionDiv.childNodes[x].getAttribute('href') } - if (versionDiv.childNodes[x].className === 'tag') { + if (hasClass(versionDiv.childNodes[x], 'tag')) { tagsArr.push(versionDiv.childNodes[x].textContent); } } @@ -105,7 +125,7 @@ function tagsAtGuide(element) { const tags = [] for (let y = 0; y < element.childNodes.length; y++) { - if (element.childNodes[y].className === 'tag') { + if (hasClass(element.childNodes[y], 'tag')) { tags.push(element.childNodes[y].textContent); } } @@ -115,12 +135,20 @@ function onQueryChanged() { const query = queryValue().trim() const resultsDiv = document.getElementsByClassName('search-results') + if (!resultsDiv.length) { + return + } if (query === '') { showElementsToDisplaySearchResults() resultsDiv[0].innerHTML = '' + updateSearchStatus('') return } + // Hide catalogue and other browse surfaces for every non-empty query + // before branching into matches vs no-results. + hideElementsToDisplaySearchResults() + const matchingGuides = [] for (let i = 0; i < allGuides.length; i++) { let guide = allGuides[i] @@ -129,11 +157,13 @@ } } if (matchingGuides.length > 0) { - hideElementsToDisplaySearchResults() resultsDiv[0].innerHTML = renderGuideGroup(matchingGuides, query) + const count = matchingGuides.length + updateSearchStatus(count === 1 ? '1 guide found' : count + ' guides found') } else { resultsDiv[0].innerHTML = - "<div class='guide-group'><div class='guide-group-header'><h2>No results found</h2></div></div>" + "<div class='guide-group'><div class='guide-group-header'><h3>No results found</h3></div></div>" + updateSearchStatus('No results found') } } @@ -186,7 +216,7 @@ return ` <div class="guide-group"> <div class="guide-group-header"> - <h2>Guides Filtered by: ${queryValue()}</h2> + <h3>Guides Filtered by: ${queryValue()}</h3> </div> <ul> ${items} @@ -197,7 +227,7 @@ function renderGuideAsHtmlLi(guide, query) { const hiddenTags = (tags = []) => - tags.map(tag => `<span style="display: none" class="tag">${tag}</span>`).join('') + tags.map(tag => `<span class="tag">${tag}</span>`).join('') // Multi-guide (no guide.tags) if (guide.tags == null) { @@ -205,7 +235,7 @@ const versionsHtml = guide.versions .filter(v => titleMatched || doesTagsMatchQuery(v.tags, query)) .map(v => ` - <div class="align-left"> + <div class="align-left guides-version-chip"> <a class="grails-version" href="${v.href}">${v.grailsVersion}</a> ${hiddenTags(v.tags)} </div>`
diff --git a/assets/stylesheets/screen.css b/assets/stylesheets/screen.css index 9c75c39..5890c8d 100644 --- a/assets/stylesheets/screen.css +++ b/assets/stylesheets/screen.css
@@ -524,10 +524,41 @@ background-color: #f5f5f5 !important; } -#show-navigation-link { +#show-navigation-link, +button.navigation-toggle { margin-top: 12px; margin-bottom: 20px; display: block; + width: 100%; + box-sizing: border-box; + background: transparent; + border: 1px solid #255AA8; + border-radius: 4px; + color: #255AA8; + font-family: Roboto, 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.3; + padding: 12px 16px; + cursor: pointer; + text-decoration: none; + text-align: center; +} + +#show-navigation-link:hover, +#show-navigation-link:focus-visible, +button.navigation-toggle:hover, +button.navigation-toggle:focus-visible { + background-color: #255AA8; + color: #ffffff; + outline: 2px solid #255AA8; + outline-offset: 2px; +} + +@media screen and (min-width: 961px) { + #show-navigation-link, + button.navigation-toggle { + display: none; + } } .secondary-menu ul { @@ -541,10 +572,17 @@ #top-menus { margin-top: 17px; display: none; - background-color: gray; + /* #666666 with white link text ~5.74:1 (AA); gray/#808080 failed open-nav contrast */ + background-color: #666666; overflow: auto; padding: 10px; } +/* Class-based open state only applies below the desktop breakpoint. + Desktop media rule remains authoritative so a mobile close cannot + leave an inline display:none that suppresses nav after resize. */ +#top-menus.is-open { + display: block; +} #top-menus a { color: #ffffff; } @@ -750,7 +788,8 @@ margin-right: 18px; } -.guide-group h2 { +.guide-group h2, +.guide-group h3 { height: 144px; text-transform: uppercase; font-size: 18px; @@ -1294,7 +1333,8 @@ float: left; display: block; } - #top-menus { + #top-menus, + #top-menus.is-open { margin-top: 0; display: block; background-color: #ffffff; @@ -1788,19 +1828,7 @@ border-top-left-radius: 5px; border-top-right-radius: 5px; display: block; - /* Fallback (could use .jpg/.png alternatively) */ background-color: #F4F4F4; - /* SVG fallback for IE 9 (could be data URI, or could use filter) */ - background-image: url('githubstar.png'); - /* Safari 4, Chrome 1-9, iOS 3.2-4.3, Android 2.1-3.0 */ - background-image: -webkit-gradient(linear, left top, right top, from(#F4F4F4), to(#F9F9F9)); - /* Safari 5.1, iOS 5.0-6.1, Chrome 10-25, Android 4.0-4.3 */ - background-image: -webkit-linear-gradient(left, #F4F4F4, #F9F9F9); - /* Firefox 3.6 - 15 */ - background-image: -moz-linear-gradient(left, #F4F4F4, #F9F9F9); - /* Opera 11.1 - 12 */ - background-image: -o-linear-gradient(left, #F4F4F4, #F9F9F9); - /* Opera 15+, Chrome 25+, IE 10+, Firefox 16+, Safari 6.1+, iOS 7+, Android 4.4+ */ background-image: linear-gradient(to right, #F4F4F4, #F9F9F9); text-align: right; margin-top: 20px; @@ -2042,7 +2070,6 @@ background-color: #FFFFFF; color: #7c7c7c; display: block; - background-color: white-smoke; } .breadcrumbs { @@ -2304,8 +2331,12 @@ * that joins flush to the code body. */ body.guide .listingblock > .title { display: inline-block; + max-width: 100%; + box-sizing: border-box; margin: 0 !important; padding: 6px 12px; + white-space: normal; + overflow-wrap: anywhere; background-color: #1f2328; color: #f6f8fa; font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; @@ -2602,3 +2633,454 @@ } } +/* ========================================================================== + Guides catalogue (.guides-page) - index + version pages + ---------------------------------------------------------------------- + Tokens use existing brand values and stay scoped so non-guides pages keep + their legacy layout. Featured catalogue leads; discovery follows. + ========================================================================== */ + +.guides-page { + --guides-color-brand: #255AA8; + --guides-color-brand-hover: #1d4684; + --guides-color-brand-soft: rgba(37, 90, 168, 0.08); + --guides-color-accent: #feb672; + --guides-color-accent-wash: rgba(254, 182, 114, 0.2); + --guides-color-surface: #ffffff; + --guides-color-page: #F5F5F5; + /* #666666 on white ~5.74:1; meets WCAG AA for normal text */ + --guides-color-text: #666666; + --guides-color-text-strong: #4f4f4f; + --guides-color-on-brand: #ffffff; + /* Dark ink on pale gold/orange tag chips for AA contrast */ + --guides-color-tag-text: #3d2914; + --guides-color-border: #d8d8d8; + --guides-color-control-border: #767676; + --guides-content-max: 1141px; + --guides-shadow-card: 0 8px 24px rgba(37, 90, 168, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05); + --guides-space-2: 8px; + --guides-space-3: 12px; + --guides-space-4: 16px; + --guides-space-5: 24px; + --guides-space-6: 32px; + --guides-radius: 4px; + --guides-card-min: 280px; + /* Keep catalogue content clear of the fixed Kapa launcher */ + --guides-kapa-safe: 68px; + padding-bottom: calc(var(--guides-space-6) + var(--guides-kapa-safe)); +} + +.guides-page .tag { + display: none; +} + +@media screen and (max-width: 960px) { + .guides-header { + min-height: 92px; + padding: 0; + overflow: hidden; + } + + .guides-header .content { + display: flex; + align-items: center; + min-height: 92px; + } + + .guides-header h1 { + float: none; + margin: 0; + } +} + +.guides-page .guides-section-title { + margin-top: var(--guides-space-6); + margin-bottom: var(--guides-space-5); + color: var(--guides-color-on-brand); + background-color: var(--guides-color-brand); + border-bottom: 2px solid var(--guides-color-accent); + border-radius: var(--guides-radius); + box-shadow: 0 8px 18px rgba(37, 90, 168, 0.14); +} + +.guides-page .guides-featured .guides-section-title, +.guides-page .guides-version-catalogue .guides-section-title { + margin: 0 calc(var(--guides-space-5) * -1) var(--guides-space-5); + border-radius: var(--guides-radius) var(--guides-radius) 0 0; +} + +.guides-page .guides-featured-intro { + margin: calc(var(--guides-space-5) * -1) 0 var(--guides-space-5) 0; + padding: 0 var(--guides-space-4); + font-size: 16px; + line-height: 1.5; + color: var(--guides-color-text); +} + +.guides-page .guides-featured { + margin-top: var(--guides-space-5); + padding: 0 var(--guides-space-5) var(--guides-space-5); + background: + radial-gradient(circle at 92% 0, var(--guides-color-accent-wash), transparent 34%), + linear-gradient(180deg, var(--guides-color-brand-soft), var(--guides-color-surface) 42%); + border: 1px solid var(--guides-color-border); + border-radius: var(--guides-radius); + box-sizing: border-box; +} + +.guides-page .guides-card-list { + list-style: none; + margin: 0 0 var(--guides-space-5) 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: var(--guides-space-3); +} + +.guides-page .guides-card { + list-style: none; + margin: 0; + padding: var(--guides-space-4); + background-color: var(--guides-color-surface); + border-top: 2px solid var(--guides-color-accent); + border-radius: 0 0 var(--guides-radius) var(--guides-radius); + box-shadow: var(--guides-shadow-card); +} + +.guides-page .guides-card-title { + display: block; + margin-bottom: var(--guides-space-2); + font-family: 'Roboto-Medium', Roboto, 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 16px; + font-weight: normal; + text-transform: none; + letter-spacing: 0.2px; + color: var(--guides-color-text-strong); + line-height: 1.35; +} + +.guides-page .guides-card-meta { + display: block; + margin-bottom: var(--guides-space-2); + font-size: 14px; + line-height: 1.4; + color: var(--guides-color-text); +} + +.guides-page .guides-card-action { + display: inline-block; + font-size: 14px; + font-family: 'Roboto-Medium', Roboto, sans-serif; + text-decoration: none; + color: var(--guides-color-brand); +} + +.guides-page .guides-card-action:hover, +.guides-page .guides-card-action:focus { + text-decoration: underline; + color: var(--guides-color-brand-hover); +} + +.guides-page .guides-catalogue { + margin-bottom: var(--guides-space-6); +} + +.guides-page .guides-category-grid { + /* Multi-column masonry: cards keep natural height (no stretched interiors) */ + column-count: 1; + column-gap: var(--guides-space-5); + margin: 0 0 var(--guides-space-5) 0; +} + +.guides-page .guides-category-grid .guide-group, +.guides-page .guides-category-grid .guides-category-card { + display: inline-block; + width: 100%; + margin: 0 0 var(--guides-space-5) 0; + height: auto; + box-sizing: border-box; + border-radius: var(--guides-radius); + box-shadow: var(--guides-shadow-card); + break-inside: avoid; + page-break-inside: avoid; + -webkit-column-break-inside: avoid; +} + +.guides-page .guides-category-grid .guide-group-header { + height: auto; + min-height: 0; + padding: var(--guides-space-4); + display: flex; + align-items: center; + gap: var(--guides-space-3); +} + +.guides-page .guides-category-grid .guide-group-header img { + float: none; + flex: 0 0 64px; + width: 64px; + height: 64px; + margin-right: 0; +} + +.guides-page .guides-category-grid .guide-group h2, +.guides-page .guides-category-grid .guide-group h3 { + height: auto; + min-height: 0; + padding-top: 0; + margin: 0; + font-size: 16px; + line-height: 1.3; +} + +/* Category title links: brand blue meets AA at 16px on white (~4.6:1+) */ +.guides-page .guides-category-grid .guide-group-header a { + color: var(--guides-color-brand); + text-decoration: none; +} + +.guides-page .guides-category-grid .guide-group-header a:hover, +.guides-page .guides-category-grid .guide-group-header a:focus { + color: var(--guides-color-brand-hover); + text-decoration: underline; +} + +.guides-page .guides-category-grid .guide-group li, +.guides-page .guides-category-list .guides-item { + min-height: 0; + padding: var(--guides-space-3) var(--guides-space-4); + border-top: 2px solid var(--guides-color-page); + font-size: 16px; + line-height: 1.35; +} + +.guides-page .guides-category-list { + padding: 0; + margin: 0; + list-style: none; +} + +.guides-page .multi-guide { + display: flex; + flex-wrap: wrap; + gap: var(--guides-space-2); +} + +.guides-page .multi-guide .title { + flex-basis: 100%; +} + +.guides-page .guides-version-chip { + float: none; +} + +.guides-page .guides-version-chip .grails-version, +.guides-page .guide-group li a.grails-version { + float: none; + display: inline-block; + padding: 4px 10px; + margin: 0; + font-size: 12px; + line-height: 1.2; + letter-spacing: 0.4px; + text-transform: uppercase; + text-decoration: none; + color: var(--guides-color-on-brand); + background-color: var(--guides-color-brand); + border-radius: var(--guides-radius); + background-image: none; +} + +.guides-page .guides-version-chip .grails-version:hover, +.guides-page .guides-version-chip .grails-version:focus, +.guides-page .guide-group li a.grails-version:hover, +.guides-page .guide-group li a.grails-version:focus { + background-color: var(--guides-color-brand-hover); + color: var(--guides-color-on-brand); + text-decoration: none; +} + +.guides-page .guides-discovery { + margin-top: var(--guides-space-5); + margin-bottom: var(--guides-space-6); +} + +.guides-page .guides-discovery-layout { + display: grid; + grid-template-columns: 1fr; + gap: var(--guides-space-5); + align-items: start; +} + +.guides-page .guides-discovery-layout-single { + display: block; +} + +.guides-page .guides-discovery .tag-cloud { + max-height: 360px; + overflow-y: auto; + padding-right: var(--guides-space-2); + scrollbar-gutter: stable; +} + +.guides-page .guides-search { + margin-top: 0; + margin-bottom: var(--guides-space-4); +} + +.guides-page .guides-search-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.guides-page .guides-search .search { + margin-top: 0; + margin-bottom: 0; +} + +.guides-page .guides-search .search input { + box-sizing: border-box; + width: 100%; + max-width: 100%; + height: 48px; + padding: var(--guides-space-3) var(--guides-space-4); + border: 1px solid var(--guides-color-control-border); + border-radius: var(--guides-radius); + font-size: 16px; + color: var(--guides-color-text-strong); + background-color: var(--guides-color-surface); +} + +/* Visible focus for mouse and keyboard (not only :focus-visible) */ +.guides-page .guides-search .search input:focus, +.guides-page .guides-search .search input:focus-visible { + outline: 2px solid var(--guides-color-brand); + outline-offset: 2px; + border-color: var(--guides-color-brand); +} + +.guides-page .search-results .guide-group-header h3 { + color: var(--guides-color-text-strong); +} + +.guides-page .guides-visually-hidden, +.guides-page .guides-search-status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.guides-page .guides-latest .column-header, +.guides-page .guides-discovery .column-header { + margin-top: var(--guides-space-5); +} + +.guides-page a:focus, +.guides-page a:focus-visible, +.guides-page button:focus, +.guides-page button:focus-visible, +.guides-page input:focus, +.guides-page input:focus-visible { + outline: 2px solid var(--guides-color-brand); + outline-offset: 2px; +} + +/* Keep footer sentences clear of the fixed 44px Kapa launcher on narrow screens */ +@media screen and (max-width: 960px) { + footer { + padding-right: 68px; + box-sizing: border-box; + } +} + +.guides-page .guides-card:focus-within { + box-shadow: 0 0 0 2px var(--guides-color-brand); +} + +.guides-page .guide-group li a { + color: var(--guides-color-text); +} + +.guides-page .tags-by-topic li a { + color: var(--guides-color-tag-text); +} + +/* Kapa Ask AI: keep launcher clear of content on narrow viewports */ +#kapa-widget-container, +.kapa-widget-container { + z-index: 40; +} + +@media screen and (min-width: 768px) { + .guides-page .guides-card-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--guides-space-4); + } + + .guides-page .guides-category-grid { + column-count: 2; + column-gap: var(--guides-space-5); + } +} + +@media screen and (min-width: 961px) { + .guides-page .guides-discovery-layout { + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: var(--guides-space-6); + } + + .guides-page .guides-card-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .guides-page .guides-featured > .guides-card-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .guides-page .guides-category-grid { + column-count: 3; + column-gap: var(--guides-space-5); + } +} + +@media (prefers-reduced-motion: reduce) { + html, + body { + scroll-behavior: auto; + } + + a, + button, + #show-navigation-link, + button.navigation-toggle { + transition: none; + } + + button:active, + #show-navigation-link:active, + button.navigation-toggle:active { + transform: none; + } + + .guides-page, + .guides-page a, + .guides-page button, + .guides-page input, + .guides-page .guides-card { + transition: none; + } +} +
diff --git a/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy b/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy index 085ca59..6909f4c 100644 --- a/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy +++ b/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
@@ -32,7 +32,19 @@ @CompileStatic class GuidesPage { - public static final Integer NUMBER_OF_LATEST_GUIDES = 8 + /** + * Cap for the "Latest Guides" sidebar on the index. Sized high enough that + * every current Grails 8 guide still appears there when they are the newest + * publications - the old value of 8 silently dropped half of the modern set. + */ + public static final Integer NUMBER_OF_LATEST_GUIDES = 20 + + /** + * Major version featured at the top of the guides index category grid and + * used as the default "modern" cut when sorting multi-version guide rows. + */ + public static final String FEATURED_VERSION = '8' + public static final String GUIDES_URL = 'https://grails.apache.org/guides' /** @@ -45,8 +57,8 @@ private static final Pattern VERSION_TAG_PATTERN = ~/^grails\d+$/ /** - * The category-image map. Categories listed here are rendered both on the - * guides index page AND as standalone category pages under + * Display order for the category grid. Categories listed here are rendered + * both on the guides index page AND as standalone category pages under * {@code /guides/categories/<slug>.html}. Categories that exist in * {@code conf/guides.yml} but are NOT listed here are reachable only via * tags / search / Latest Guides. @@ -80,43 +92,71 @@ spa: new Category(name: 'Frontend SPA', image: 'react.svg'), testing: new Category(name: 'Grails Testing', image: 'testing.svg'), weblayer: new Category(name: 'Web Layer', image: 'views.svg'), + restapis: new Category(name: 'Grails REST APIs', image: 'restapis-guides.svg'), ] - - + + /** Ordered category tracks for the responsive catalogue grid. */ + static final List<String> CATEGORY_GRID_ORDER = [ + 'apprentice', 'advanced', + 'weblayer', 'restapis', + 'devops', 'gorm', + 'testing', 'security', + 'spa', 'async', + 'cloud', + ].asImmutable() + + @CompileDynamic - static String renderGuide(Guide guide) { + static String renderGuide(Guide guide, String versionFilter = null) { + // Pre-resolve anything MarkupBuilder would otherwise treat as a tag name. + final boolean multi = guide instanceof GrailsVersionedGuide + final GrailsVersionedGuide multiGuide = multi ? (GrailsVersionedGuide) guide : null + final List<Integer> versionKeys = multi ? orderedVersionKeys(multiGuide) : Collections.emptyList() + final Integer filteredMajor = (multi && versionFilter?.isInteger()) ? versionFilter.toInteger() : null + final List<String> filteredTags = (multi && filteredMajor != null) + ? (multiGuide.grailsMayorVersionTags[filteredMajor] ?: []) as List<String> + : Collections.emptyList() as List<String> + renderHtml { - li { + li(class: 'guides-item') { if (guide instanceof SingleGuide) { + String version = versionFilter ?: guide.versionNumber a( class: (guide.tags.contains('quick-cast') ? 'quick-cast guide' : 'guide'), - href: "$GUIDES_URL/${guide.name}/${guide.versionNumber}/guide/index.html", guide.title + href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html", guide.title ) guide.tags.each { - span( - style: 'display: none', - class: 'tag', it - ) + span(class: 'tag', it) } - } else if (guide instanceof GrailsVersionedGuide) { - def multiGuide = (GrailsVersionedGuide) guide - div(class: (guide.tags.contains('quick-cast') ? 'quick-cast multi-guide' : 'multi-guide')) { - span(class: 'title', guide.title) - for (def grailsVersion : multiGuide.grailsMayorVersionTags.keySet()) { - def tagList = multiGuide.grailsMayorVersionTags[grailsVersion] as Set<String> - div(class: 'align-left') { - a( - class: 'grails-version', - href: "$GUIDES_URL/${multiGuide.name}/${grailsVersion}/guide/index.html" - ) { - mkp.yield("grails$grailsVersion") - } - tagList.each { - span( - style: 'display: none', - class: 'tag', - it - ) + } else if (multi) { + // When a version filter is active (e.g. /versions/8.html), collapse + // multi-version rows to a single link for that major so the page + // reads as a complete modern catalogue rather than a version picker. + if (versionFilter) { + a( + class: (filteredTags.contains('quick-cast') ? 'quick-cast guide' : 'guide'), + href: "$GUIDES_URL/${multiGuide.name}/${versionFilter}/guide/index.html", + multiGuide.title + ) + filteredTags.each { + span(class: 'tag', it) + } + } else { + div(class: (guide.tags.contains('quick-cast') ? 'quick-cast multi-guide' : 'multi-guide')) { + span(class: 'title', guide.title) + // Newest majors first so Grails 8 is the first chip readers see. + for (def grailsVersion : versionKeys) { + def tagList = multiGuide.grailsMayorVersionTags[grailsVersion] as Set<String> + div(class: 'align-left guides-version-chip') { + a( + class: 'grails-version', + href: "$GUIDES_URL/${multiGuide.name}/${grailsVersion}/guide/index.html" + ) { + mkp.yield("grails$grailsVersion") + } + tagList.each { + span(class: 'tag', it) + } } } } @@ -135,14 +175,14 @@ String version = null ) { renderHtml { - div(class: 'header-bar chalices-bg') { + div(class: 'header-bar chalices-bg guides-header') { div(class: 'content') { if (tag || category || version) { h1 { a(href: '[%url]/index.html', 'Guides') if (tag) { mkp.yield(" → #$tag.title") - } else if(category) { + } else if (category) { mkp.yield(" → $category.name") } else if (version) { mkp.yield(" → Grails $version") @@ -153,102 +193,99 @@ } } } - div(class: 'content') { + div(class: 'content guides-page') { omitEmptyAttributes = true omitNullAttributes = true - div(class: 'two-columns') { - div(class: 'column') { - mkp.yieldUnescaped(rightColumn(tag, category, version, guides)) - } - div(class: 'column') { - // leftColumn only renders the clouds (and thus uses the - // version list) on the index and version pages; skip the - // scan/sort of availableVersions for tag/category pages. - def versions = (tag || category) ? [] : GuidesPage.availableVersions(guides) - mkp.yieldUnescaped(leftColumn(tag, category, tags, versions, version)) - if (tag) { - mkp.yieldUnescaped(guideGroupByTag(tag, guides)) - } else if (category) { - mkp.yieldUnescaped( - guideGroupByCategory( - category, - guides.findAll { it.category == category.name }, - false - ) - ) - } else if (!version) { - // Version pages render their list in the left column - // (see rightColumn); this column only holds the clouds. - div(class: 'search-results') { - mkp.yieldUnescaped('') + if (tag || category) { + div(class: 'two-columns') { + div(class: 'column') { + mkp.yieldUnescaped(rightColumn(tag, category, version, guides)) + } + div(class: 'column') { + if (tag) { + mkp.yieldUnescaped(guideGroupByTag(tag, guides)) + } else { + mkp.yieldUnescaped( + guideGroupByCategory( + category, + guides.findAll { it.category == category.name }, + false + ) + ) } } } - } - // The two-column grid pairs a "primary" category on the left with - // a complementary one on the right. Reading order goes top-down by - // pair, so the first pair (apprentice / advanced) is the most - // prominent. Tags - rendered in the right-hand sidebar above - - // are the primary navigation; this grid is a small curated set - // of high-level tracks for skimming by axis. - div(class: 'two-columns') { - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.apprentice, guides, true, 'margin-top: 0')) + } else { + // Index and version pages: promote the modern catalogue first, + // then keep discovery close enough to use before the long grid. + String featuredVersion = version ?: FEATURED_VERSION + List<Guide> featured = guidesForVersionList(featuredVersion, guides) + if (version) { + mkp.yieldUnescaped(guidesForVersion(version, guides)) + if (featured) { + mkp.yieldUnescaped(categoryGrid(guides, featuredVersion, true)) } - } - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.advanced, guides, true, 'margin-top: 0')) + mkp.yieldUnescaped(discoverySection(tag, category, tags, guides, version)) + } else { + if (featured) { + mkp.yieldUnescaped(featuredVersionSection(featuredVersion, featured, false)) + } + mkp.yieldUnescaped(discoverySection(tag, category, tags, guides, version)) + if (featured) { + mkp.yieldUnescaped(categoryGrid(guides, featuredVersion, false)) + } else { + // No featured-version guides yet - fall back to the full + // unfiltered category grid so the page is never empty. + mkp.yieldUnescaped(categoryGrid(guides, null, false)) } } } - div(class: 'two-columns') { - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.weblayer, guides, true, 'margin-top: 0')) + } + } + } + + /** + * Search, latest guides, and version/tag clouds. Rendered after the featured + * catalogue so Grails 8 content leads the page. + */ + @CompileDynamic + static String discoverySection( + Tag tag, + Category category, + Set<Tag> tags, + List<Guide> guides, + String version = null + ) { + if (tag || category) { + return '' + } + List<String> versions = GuidesPage.availableVersions(guides) + renderHtml { + section(class: 'guides-discovery', 'aria-labelledby': 'guides-discovery-heading') { + h2(id: 'guides-discovery-heading', class: 'column-header guides-section-title', 'Find Guides') + div(class: version ? 'guides-discovery-layout guides-discovery-layout-single' : 'guides-discovery-layout') { + if (!version) { + div(class: 'guides-discovery-primary') { + mkp.yieldUnescaped(searchBox(tag, category, version)) + div(class: 'search-results') { + mkp.yieldUnescaped('') + } + // Dedicated live region for screen readers (not the visible results box). + div( + id: 'guides-search-status', + class: 'guides-search-status guides-visually-hidden', + role: 'status', + 'aria-live': 'polite', + 'aria-atomic': 'true' + ) { + mkp.yieldUnescaped('') + } + mkp.yieldUnescaped(GuidesPage.latestGuides(guides)) } } - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.devops, guides, true, 'margin-top: 0')) - } - } - } - div(class: 'two-columns') { - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.gorm, guides, true, 'margin-top: 0')) - } - } - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.testing, guides, true, 'margin-top: 0')) - } - } - } - div(class: 'two-columns') { - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.security, guides, true, 'margin-top: 0')) - } - } - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.spa, guides, true, 'margin-top: 0')) - } - } - } - div(class: 'two-columns') { - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.async, guides, true, 'margin-top: 0')) - } - } - div(class: 'column') { - if (!(tag || category || version)) { - mkp.yieldUnescaped(guideGroupByCategory(categories.cloud, guides, true, 'margin-top: 0')) - } + div(class: 'guides-discovery-secondary') { + mkp.yieldUnescaped(GuidesPage.versionCloud(versions)) + mkp.yieldUnescaped(GuidesPage.tagCloud(tags)) } } } @@ -289,21 +326,26 @@ @CompileDynamic static String latestGuides(List<Guide> guides) { renderHtml { - div(class: 'latest-guides') { + div(class: 'latest-guides guides-latest') { h3(class: 'column-header', 'Latest Guides') - ul { + ul(class: 'guides-card-list') { guides.findAll { it.publicationDate } .sort { a, b -> b.publicationDate <=> a.publicationDate } .take(NUMBER_OF_LATEST_GUIDES) .each { guide -> - li { - b(guide.title) - span { + li(class: 'guides-card') { + // Nested under the Latest Guides h3 section heading. + h4(class: 'guides-card-title', guide.title) + span(class: 'guides-card-meta') { mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate)) mkp.yield(' - ') mkp.yield(guide.category) } - a(href: "$GUIDES_URL/${guide.name}/${guide.versionNumber}/guide/index.html", 'Read More') + a( + class: 'guides-card-action', + href: "$GUIDES_URL/${guide.name}/${guide.versionNumber}/guide/index.html", + 'Read More' + ) } } } @@ -349,9 +391,16 @@ static String searchBox(Tag tag, Category category, String version = null) { if (!(tag || category || version)) { renderHtml { - div(class: 'searchbox', style: 'margin-top: 50px !important') { - div(class: 'search', style: 'margin-bottom: 0px !important') { - input(type: 'text', id: 'query', placeholder: 'SEARCH') + div(class: 'searchbox guides-search') { + div(class: 'search') { + label(class: 'guides-search-label', 'for': 'query', 'Search guides') + input( + type: 'search', + id: 'query', + name: 'query', + placeholder: 'SEARCH', + 'aria-label': 'Search guides' + ) } } } @@ -365,27 +414,132 @@ Category category, List<Guide> guides, boolean linkToCategory = true, - String cssStyle = '' + String cssStyle = '', + String versionFilter = null, + boolean onlyMatchingVersion = false ) { + List<Guide> inCategory = guides.findAll { it.category == category.name } + if (versionFilter && onlyMatchingVersion) { + inCategory = inCategory.findAll { guideHasVersion(it, versionFilter) } + } + if (!inCategory) { + return '' + } + inCategory = sortGuidesForDisplay(inCategory, versionFilter ?: FEATURED_VERSION) + // cssStyle retained for call-site compatibility; presentation lives in CSS. renderHtml { - div(class: 'guide-group', style: cssStyle) { + div(class: 'guide-group guides-category-card') { div(class: 'guide-group-header') { img( src: "[%url]/images/$category.image" as String, - alt: category.name + alt: '' ) - if (linkToCategory) { + if (linkToCategory) { + // Grid cards nest under the catalogue section h2. a(href: "$GUIDES_URL/categories/${category.slug}.html") { - h2(category.name) + h3(category.name) } } else { + // Standalone category pages: this is the page-level heading. h2(category.name) } } - ul { - guides - .findAll { it.category == category.name } - .each { mkp.yieldUnescaped(GuidesPage.renderGuide(it)) } + ul(class: 'guides-category-list') { + inCategory.each { + mkp.yieldUnescaped(GuidesPage.renderGuide(it, onlyMatchingVersion ? versionFilter : null)) + } + } + } + } + } + + /** + * Responsive category grid used on the index and on version pages. When + * {@code onlyMatchingVersion} is true, each category only lists guides that + * ship the given major (so /versions/8.html surfaces the full Grails 8 set + * organized by track). When false, every guide stays visible but guides for + * {@code preferVersion} sort to the top of each category. + */ + @CompileDynamic + static String categoryGrid(List<Guide> guides, String preferVersion, boolean onlyMatchingVersion) { + String heading = onlyMatchingVersion && preferVersion + ? "Grails $preferVersion by Category" + : 'Browse by Category' + StringBuilder cards = new StringBuilder() + for (String key : CATEGORY_GRID_ORDER) { + Category cat = categories[key] + if (!cat) { + continue + } + String card = guideGroupByCategory( + cat, guides, true, '', preferVersion, onlyMatchingVersion) + if (card) { + cards << card + } + } + if (!cards.length()) { + return '' + } + renderHtml { + section( + class: 'guides-catalogue', + 'aria-labelledby': 'guides-catalogue-heading' + ) { + h2( + id: 'guides-catalogue-heading', + class: 'column-header guides-section-title', + heading + ) + div(class: 'guides-category-grid') { + mkp.yieldUnescaped(cards.toString()) + } + } + } + } + + /** + * Full-width "Grails N Guides" block listing every guide for that major, + * newest first, with no take() cap. On the index this is the featured modern + * catalogue leading the page before discovery content. + */ + @CompileDynamic + static String featuredVersionSection(String version, List<Guide> featured, boolean onVersionPage) { + // On the version page the version list is rendered by guidesForVersion. + if (onVersionPage) { + return '' + } + renderHtml { + section( + class: 'latest-guides featured-version-guides guides-featured', + 'aria-labelledby': 'guides-featured-heading' + ) { + h2( + id: 'guides-featured-heading', + class: 'column-header guides-section-title', + "Grails $version Guides" + ) + p(class: 'guides-featured-intro') { + mkp.yield("Every guide published for Grails $version. ") + a(href: "$GUIDES_URL/versions/${version}.html", "Browse by version →") + } + ul(class: 'guides-card-list') { + featured.each { guide -> + li(class: 'guides-card') { + h3(class: 'guides-card-title', guide.title) + span(class: 'guides-card-meta') { + if (guide.publicationDate) { + mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate)) + mkp.yield(' - ') + } + mkp.yield(guide.category) + } + a( + class: 'guides-card-action', + href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html", + 'Read More' + ) + } + } } } } @@ -394,12 +548,12 @@ @CompileDynamic static String guideGroupByTag(Tag tag, List<Guide> guides) { renderHtml { - div(class: 'guide-group') { + div(class: 'guide-group guides-category-card') { div(class: 'guide-group-header') { - img(src: '[%url]/images/documentation.svg', alt: 'Guides') + img(src: '[%url]/images/documentation.svg', alt: '') h2("Guides filtered by #$tag.title") } - ul { + ul(class: 'guides-category-list') { guides .findAll { Guide guide -> guide.tags.contains(tag.title) } .each { mkp.yieldUnescaped(GuidesPage.renderGuide(it)) } @@ -435,62 +589,117 @@ /** * Renders every guide published for {@code version} using the same visual - * treatment as the {@link #latestGuides} list on the index (title, date + - * category, "Read More"), but with no {@code take()} cap - the version - * filter is meant to surface the full set, newest first. Each "Read More" - * link targets the matching version variant of the guide. This list lives - * in the left column with the version + tag clouds beside it on the right, - * mirroring the index layout. + * treatment as the featured index list (title, date + category, "Read More"), + * but with no {@code take()} cap - the version filter is meant to surface the + * full set, newest first. Each "Read More" link targets the matching version + * variant of the guide. */ @CompileDynamic static String guidesForVersion(String version, List<Guide> guides) { + List<Guide> matched = guidesForVersionList(version, guides) renderHtml { - div(class: 'latest-guides') { - h3(class: 'column-header', "Guides for Grails $version") - ul { - guides - .findAll { Guide guide -> GuidesPage.guideHasVersion(guide, version) } - .sort { Guide a, Guide b -> - Date da = a.publicationDate - Date db = b.publicationDate - if (da == db) { - return 0 + section( + class: 'latest-guides guides-featured guides-version-catalogue', + 'aria-labelledby': 'guides-version-heading' + ) { + h2( + id: 'guides-version-heading', + class: 'column-header guides-section-title', + "Guides for Grails $version" + ) + if (matched) { + p(class: 'guides-featured-intro') { + mkp.yield("${matched.size()} guide${matched.size() == 1 ? '' : 's'} for Grails $version") + } + } + ul(class: 'guides-card-list') { + matched.each { guide -> + li(class: 'guides-card') { + h3(class: 'guides-card-title', guide.title) + span(class: 'guides-card-meta') { + if (guide.publicationDate) { + mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate)) + mkp.yield(' - ') } - if (da == null) { - return 1 - } - if (db == null) { - return -1 - } - db <=> da + mkp.yield(guide.category) } - .each { guide -> - li { - b(guide.title) - span { - if (guide.publicationDate) { - mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate)) - mkp.yield(' - ') - } - mkp.yield(guide.category) - } - a( - href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html", - 'Read More' - ) - } - } + a( + class: 'guides-card-action', + href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html", + 'Read More' + ) + } + } } } } } /** + * Every guide published for {@code version}, newest first, no cap. Shared by + * the version-page sidebar, the featured index section, and tests. + */ + static List<Guide> guidesForVersionList(String version, List<Guide> guides) { + sortGuidesByPublicationDate( + guides.findAll { Guide guide -> guideHasVersion(guide, version) } + ) + } + + /** + * Major-version keys for a multi-version guide, newest first, so the + * rendered chip row leads with Grails 8 rather than the YAML encounter order. + */ + static List<Integer> orderedVersionKeys(GrailsVersionedGuide guide) { + List<Integer> keys = new ArrayList<Integer>(guide.grailsMayorVersionTags.keySet()) + keys.sort { Integer a, Integer b -> b <=> a } + keys + } + + /** + * Sort guides so those that ship {@code preferVersion} come first (newest + * among themselves), then the rest by publication date descending. Used by + * the index category grid to keep modern work at the top of each track + * without hiding the legacy corpus. + */ + static List<Guide> sortGuidesForDisplay(List<Guide> guides, String preferVersion) { + List<Guide> preferred = [] + List<Guide> rest = [] + for (Guide guide : guides) { + if (preferVersion && guideHasVersion(guide, preferVersion)) { + preferred << guide + } else { + rest << guide + } + } + sortGuidesByPublicationDate(preferred) + sortGuidesByPublicationDate(rest) + } + + static List<Guide> sortGuidesByPublicationDate(List<Guide> guides) { + List<Guide> sorted = new ArrayList<Guide>(guides) + sorted.sort { Guide a, Guide b -> + Date da = a.publicationDate + Date db = b.publicationDate + if (da == db) { + return (a.title ?: '') <=> (b.title ?: '') + } + if (da == null) { + return 1 + } + if (db == null) { + return -1 + } + int byDate = db <=> da + byDate != 0 ? byDate : (a.title ?: '') <=> (b.title ?: '') + } + sorted + } + + /** * The distinct Grails major versions across all guides, newest first. * A {@link SingleGuide} contributes its single {@code versionNumber}; a * {@link GrailsVersionedGuide} contributes every key in * {@code grailsMayorVersionTags}. Versions are guaranteed numeric major - * versions in {@code conf/guides.yml}, so they sort numerically descending. + * versions in conf/guides.yml, so they sort numerically descending. */ static List<String> availableVersions(List<Guide> guides) { Set<String> versions = [] as Set<String>
diff --git a/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy b/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy new file mode 100644 index 0000000..a646faf --- /dev/null +++ b/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy
@@ -0,0 +1,375 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package website.model.guides + +import spock.lang.Specification + +class GuidesPageSpec extends Specification { + + def 'orderedVersionKeys returns majors newest first'() { + given: + def guide = new GrailsVersionedGuide( + name: 'multi', + title: 'Multi', + category: 'Advanced Grails', + grailsMayorVersionTags: [ + 3: ['old'], + 4: ['mid'], + 6: ['recent'], + 8: ['modern'], + ] + ) + + expect: + GuidesPage.orderedVersionKeys(guide) == [8, 6, 4, 3] + } + + def 'renderGuide emits multi-version chips newest first'() { + given: + def guide = new GrailsVersionedGuide( + name: 'multi', + title: 'Multi Guide', + category: 'Advanced Grails', + grailsMayorVersionTags: [ + 3: ['a'], + 8: ['b'], + 6: ['c'], + ] + ) + + when: + String html = GuidesPage.renderGuide(guide) + + then: + html.indexOf('grails8') < html.indexOf('grails6') + html.indexOf('grails6') < html.indexOf('grails3') + html.contains('/multi/8/guide/index.html') + html.contains('/multi/6/guide/index.html') + html.contains('/multi/3/guide/index.html') + html.contains('multi-guide') + html.contains("class='align-left guides-version-chip'") + html.contains("class='grails-version'") + } + + def 'renderGuide with version filter collapses multi-version row to one link'() { + given: + def guide = new GrailsVersionedGuide( + name: 'multi', + title: 'Multi Guide', + category: 'Advanced Grails', + grailsMayorVersionTags: [ + 4: ['legacy'], + 8: ['modern'], + ] + ) + + when: + String html = GuidesPage.renderGuide(guide, '8') + + then: + html.contains('/multi/8/guide/index.html') + html.contains('Multi Guide') + !html.contains('grails4') + !html.contains('/multi/4/guide/index.html') + } + + def 'guidesForVersionList returns every matching guide uncapped newest first'() { + given: + List<Guide> guides = (1..12).collect { int i -> + new SingleGuide( + name: "guide-$i", + title: "Guide $i", + category: 'Web Layer', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', "2026-05-${String.format('%02d', i)}"), + tags: ['t'], + authors: ['A'], + ) + } + guides << new SingleGuide( + name: 'old', + title: 'Old Guide', + category: 'Web Layer', + versionNumber: '4', + publicationDate: Date.parse('yyyy-MM-dd', '2017-01-01'), + tags: ['t'], + authors: ['A'], + ) + + when: + List<Guide> v8 = GuidesPage.guidesForVersionList('8', guides) + + then: + v8.size() == 12 + v8*.name == (12..1).collect { "guide-$it" } + v8.every { it.name != 'old' } + } + + def 'guidesForVersion HTML includes every guide and a count'() { + given: + List<Guide> guides = [ + new SingleGuide( + name: 'a', + title: 'Alpha', + category: 'GORM', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-06-01'), + tags: [], + authors: [], + ), + new SingleGuide( + name: 'b', + title: 'Beta', + category: 'Web Layer', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-05-01'), + tags: [], + authors: [], + ), + ] + + when: + String html = GuidesPage.guidesForVersion('8', guides) + + then: + html.contains('2 guides for Grails 8') + html.contains('/a/8/guide/index.html') + html.contains('/b/8/guide/index.html') + html.contains('Alpha') + html.contains('Beta') + } + + def 'sortGuidesForDisplay puts preferred version first'() { + given: + def v8 = new SingleGuide( + name: 'modern', + title: 'Modern', + category: 'Web Layer', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-05-01'), + tags: [], + authors: [], + ) + def v4 = new SingleGuide( + name: 'legacy', + title: 'Legacy', + category: 'Web Layer', + versionNumber: '4', + publicationDate: Date.parse('yyyy-MM-dd', '2018-01-01'), + tags: [], + authors: [], + ) + def multi = new GrailsVersionedGuide( + name: 'multi', + title: 'Multi', + category: 'Web Layer', + publicationDate: Date.parse('yyyy-MM-dd', '2020-01-01'), + grailsMayorVersionTags: [4: ['x'], 6: ['y']], + ) + + when: + List<Guide> sorted = GuidesPage.sortGuidesForDisplay([v4, multi, v8], '8') + + then: 'preferred major first; remaining guides keep newest-first date order' + sorted*.name == ['modern', 'multi', 'legacy'] + } + + def 'index mainContent features every Grails 8 guide and version page lists them all'() { + given: + List<Guide> guides = [ + new SingleGuide( + name: 'g8-a', + title: 'G8 A', + category: 'Web Layer', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-06-01'), + tags: ['htmx'], + authors: ['A'], + ), + new SingleGuide( + name: 'g8-b', + title: 'G8 B', + category: 'GORM', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-05-15'), + tags: ['gorm'], + authors: ['A'], + ), + new SingleGuide( + name: 'legacy', + title: 'Legacy Guide', + category: 'Web Layer', + versionNumber: '4', + publicationDate: Date.parse('yyyy-MM-dd', '2017-01-01'), + tags: ['old'], + authors: ['A'], + ), + ] + Set tags = [] as Set + + when: + String index = GuidesPage.mainContent(guides, tags) + String versionPage = GuidesPage.mainContent(guides, tags, null, null, '8') + + then: 'index leads with the full Grails 8 catalogue' + index.contains('Grails 8 Guides') + index.contains('/g8-a/8/guide/index.html') + index.contains('/g8-b/8/guide/index.html') + index.contains('Legacy Guide') + + and: 'featured catalogue precedes discovery search/clouds' + index.indexOf('featured-version-guides') < index.indexOf('guides-discovery') + index.indexOf('Grails 8 Guides') < index.indexOf("id='query'") + index.indexOf('guides-discovery') < index.indexOf('guides-category-grid') + index.contains('Browse by Category') + index.contains("class='guides-category-grid'") + !index.contains('style=') + + and: 'search exposes a dedicated polite live status region' + index.contains("id='guides-search-status'") + index.contains("class='guides-search-status guides-visually-hidden'") + index.contains("role='status'") + index.contains("aria-live='polite'") + index.contains("aria-atomic='true'") + index.indexOf("id='query'") < index.indexOf("id='guides-search-status'") + index.indexOf("class='search-results'") < index.indexOf("id='guides-search-status'") + + and: 'heading ranks nest without equal-rank peers' + index.contains("id='guides-catalogue-heading'") + index.contains('>Web Layer</h3>') + !index.contains('>Web Layer</h2>') + index.contains("<h3 class='column-header'>Latest Guides</h3>") + index.contains("<h4 class='guides-card-title'>") + index.contains("id='guides-featured-heading'") + index.contains("<h3 class='guides-card-title'>") + + and: 'version page lists every G8 guide uncapped with a count' + versionPage.contains('2 guides for Grails 8') + versionPage.contains('/g8-a/8/guide/index.html') + versionPage.contains('/g8-b/8/guide/index.html') + !versionPage.contains('/legacy/4/guide/index.html') + versionPage.contains('G8 A') + versionPage.contains('G8 B') + + and: 'version page promotes catalogue before discovery and uses category grid' + versionPage.indexOf('guides-version-catalogue') < versionPage.indexOf('guides-discovery') + versionPage.indexOf('guides-category-grid') < versionPage.indexOf('guides-discovery') + versionPage.contains('Grails 8 by Category') + versionPage.contains("class='guides-category-grid'") + versionPage.contains('guides-discovery-layout-single') + !versionPage.contains('guides-discovery-primary') + !versionPage.contains('style=') + } + + def 'categoryGrid emits responsive grid markup with catalogue heading'() { + given: + List<Guide> guides = [ + new SingleGuide( + name: 'g8-a', + title: 'G8 A', + category: 'Web Layer', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-06-01'), + tags: [], + authors: [], + ), + ] + + when: + String indexGrid = GuidesPage.categoryGrid(guides, '8', false) + String versionGrid = GuidesPage.categoryGrid(guides, '8', true) + String standalone = GuidesPage.guideGroupByCategory( + GuidesPage.categories.weblayer, guides, false) + + then: + indexGrid.contains("class='guides-category-grid'") + indexGrid.contains('Browse by Category') + indexGrid.contains('guides-catalogue') + indexGrid.contains('guide-group') + !indexGrid.contains('two-columns') + versionGrid.contains('Grails 8 by Category') + versionGrid.contains("class='guides-category-grid'") + !versionGrid.contains('style=') + + and: 'grid cards use h3 under the catalogue h2; standalone category keeps h2' + indexGrid.contains("id='guides-catalogue-heading'") + indexGrid.contains('Browse by Category</h2>') + indexGrid.contains('>Web Layer</h3>') + !indexGrid.contains('>Web Layer</h2>') + standalone.contains('>Web Layer</h2>') + !standalone.contains('>Web Layer</h3>') + } + + def 'latestGuides nests card titles as h4 under the Latest Guides h3'() { + given: + List<Guide> guides = [ + new SingleGuide( + name: 'g8-a', + title: 'G8 A', + category: 'Web Layer', + versionNumber: '8', + publicationDate: Date.parse('yyyy-MM-dd', '2026-06-01'), + tags: [], + authors: [], + ), + ] + + when: + String html = GuidesPage.latestGuides(guides) + + then: + html.contains("<h3 class='column-header'>Latest Guides</h3>") + html.contains("<h4 class='guides-card-title'>G8 A</h4>") + !html.contains("<h3 class='guides-card-title'>") + } + + def 'real conf/guides.yml exposes every version-8 entry on the version page'() { + given: + File yml = locateGuidesYml() + + expect: + yml != null + + when: + List<Guide> guides = GuidesFetcher.fetchGuides(yml, false) + List<String> v8Names = GuidesPage.guidesForVersionList('8', guides)*.name + String html = GuidesPage.guidesForVersion('8', guides) + String grid = GuidesPage.categoryGrid(guides, '8', true) + + then: + v8Names.size() >= 13 + v8Names.every { String name -> html.contains("/${name}/8/guide/index.html") } + html.contains("${v8Names.size()} guides for Grails 8") + // Categorized companion must not silently drop a modern guide whose + // category was missing from the hardcoded grid (regression: Grails REST APIs). + v8Names.every { String name -> grid.contains("/${name}/8/guide/index.html") } + grid.contains('Grails REST APIs') + grid.contains('restapis-guides.svg') + grid.contains('>Grails REST APIs</h3>') + !grid.contains('>Grails REST APIs</h2>') + } + + private static File locateGuidesYml() { + [ + new File('conf/guides.yml'), + new File('../conf/guides.yml'), + new File('../../conf/guides.yml'), + ].find { it.exists() } + } +}
diff --git a/conf/guides.yml b/conf/guides.yml index 902a3ee..94d905d 100644 --- a/conf/guides.yml +++ b/conf/guides.yml
@@ -1810,7 +1810,7 @@ title: 'Grails Database Migration' subtitle: 'In this guide we will learn how to use the Grails Database Migration Plugin' authors: - - 'Nirav Assar, Sergio del Amo' + - 'Nirav Assar, Sergio del Amo, Sanjana' category: 'Advanced Grails' publicationDate: '2017-08-07' versions: @@ -1924,7 +1924,59 @@ summary: title: Summary helpWithGrails: - title: Do you need help with Grails? + title: Do you need help with Grails? + '8': + sourcePath: guides/grails-database-migration/v8 + publicationDate: '2026-06-26' + tags: + - 'liquibase' + - 'database-migration' + - 'gorm' + - 'postgresql' + - 'schema-evolution' + - 'baselining' + - 'rollback' + - 'persistence' + - 'devops' + - 'versioning' + - 'spock' + - 'testcontainers' + - 'integration-testing' + sampleRef: + repo: 'grails-guides/grails-database-migration' + branch: 'grails8' + toc: + introduction: + title: Introduction + requirements: What you will need + gettingStarted: + title: Getting Started + pluginSetup: + title: Plugin setup + baseline: + title: Baseline the schema + makeColumnNullable: + title: Make a column nullable + addAttributes: + title: Add address columns + redesignTables: + title: Redesign tables and migrate data + releaseRunbook: + title: Release runbook + designedRollback: + title: Designed rollback + legacyOnboarding: + title: Existing database onboarding + changelogDiscipline: + title: Changelog discipline + testing: + title: Testing migrations + runningTheApp: + title: Running the application + summary: + title: Summary + helpWithGrails: + title: Do you need help with Grails? - name: 'grails-docker-bootbuildimage' title: 'Containerise a Grails 8 App with Spring Boot bootBuildImage' @@ -2190,10 +2242,11 @@ title: Help with Grails - name: 'grails-email' - title: 'Send Email and Spock Spring' - subtitle: 'Learn how to send emails with AWS SES and SendGrid from a Grails app and leverage Spock Spring integration to verify interaction.' + title: 'Sending Email from a Grails 8 Application (and Testing It)' + subtitle: 'Send mail over SMTP or through SendGrid / AWS SES, externalize configuration, and verify send interactions with Spock Spring integration tests, without live email.' authors: - 'Sergio del Amo' + - 'Sanjana' category: 'Grails Testing' publicationDate: '2018-06-04' versions: @@ -2257,6 +2310,46 @@ test: Test helpWithGrails: title: Do you need help with Grails? + '8': + sourcePath: guides/grails-email/v8 + publicationDate: '2026-07-01' + tags: + - 'email' + - 'smtp' + - 'sendgrid' + - 'ses' + - 'aws' + - 'spock-spring' + - 'integration-testing' + - 'mail' + sampleRef: + repo: 'grails-guides/grails-email' + branch: 'grails8' + toc: + introduction: + title: Introduction + requirements: + title: What you will need + gettingStarted: + title: Getting Started + emailContract: + title: Email contract + smtpMail: + title: SMTP with the Mail plugin + sendGridService: + title: SendGrid + awsSesService: + title: AWS SES + mailConfiguration: + title: Wire the provider + controller: + title: Controller + testing: + title: Testing without live email + summary: + title: Summary + helpWithGrails: + title: Do you need help with Grails? - name: 'grails-events' title: 'Grails Events' @@ -2823,6 +2916,51 @@ helpWithGrails: title: Help with Grails + - name: 'grails-http-client' + title: 'Calling REST APIs with Spring HTTP Services in Grails 8' + subtitle: 'Add a declarative @HttpExchange client to a Grails 8 REST API, combine GORM data with external API results, and test with Spock.' + authors: + - 'Sanjana' + category: 'Grails REST APIs' + publicationDate: '2026-06-26' + versions: + '8': + sourcePath: guides/grails-http-client/v8 + publicationDate: '2026-06-26' + tags: + - 'rest-api' + - 'http-client' + - 'http-exchange' + - 'spring-boot' + - 'gorm' + - 'postgresql' + - 'spock' + - 'testcontainers' + - 'integration-testing' + sampleRef: + repo: 'grails-guides/grails-http-client' + branch: 'grails8' + toc: + introduction: + title: Introduction + requirements: What you will need + gettingStarted: + title: Getting Started + httpClientSetup: + title: HTTP client setup + domainAndControllers: + title: Domain and local REST API + searchService: + title: Search service and controller + testing: + title: Testing + runningTheApp: + title: Running the application + summary: + title: Summary + helpWithGrails: + title: Do you need help with Grails? + - name: 'grails-htmx' title: 'HTMX with Grails 8' subtitle: 'Build a small task tracker with server-rendered Grails 8 GSP and HTMX-driven inline editing, live search, optimistic delete, and toggle - no SPA, no JSON.'
diff --git a/conf/releases.yml b/conf/releases.yml index 6cfaef5..3b19156 100644 --- a/conf/releases.yml +++ b/conf/releases.yml
@@ -31,23 +31,13 @@ # link. Per the migration plan this stays as GitHub for now. # displayName Human-readable label rendered on the downloads page. '7': - - artifactId: grails-spring-security - version: '7.0.2' - mirrorDirectory: spring-security - releaseNotesRepo: apache/grails-spring-security - displayName: Grails Spring Security Plugin - - artifactId: grails-redis - version: '5.0.1' - mirrorDirectory: redis - releaseNotesRepo: apache/grails-redis - displayName: Grails Redis Plugin - artifactId: grails-quartz version: '4.0.1' mirrorDirectory: quartz releaseNotesRepo: apache/grails-quartz displayName: Grails Quartz Plugin - artifactId: grails-github-actions - version: '1.0.2' + version: '1.0.3' mirrorDirectory: actions releaseNotesRepo: apache/grails-github-actions displayName: Grails GitHub Actions @@ -57,13 +47,8 @@ releaseNotesRepo: apache/grails-gradle-publish displayName: Grails Publish Gradle Plugin '8': - - artifactId: grails-spring-security - version: '8.0.0-M1' - mirrorDirectory: spring-security - releaseNotesRepo: apache/grails-spring-security - displayName: Grails Spring Security Plugin - artifactId: grails-publish - version: '1.0.0-M1' + version: '1.0.0-M2' mirrorDirectory: grails-publish releaseNotesRepo: apache/grails-gradle-publish displayName: Grails Publish Gradle Plugin @@ -438,3 +423,30 @@ - version: 7.1.2 publicationDate: Jun 22, 2026 + - version: 7.0.13 + publicationDate: Jul 04, 2026 + + - version: 7.1.3 + publicationDate: Jul 04, 2026 + + - version: 7.2.0 + publicationDate: Jul 04, 2026 + + - version: 8.0.0-M2 + publicationDate: Jul 05, 2026 + + - version: 7.0.14 + publicationDate: Jul 14, 2026 + + - version: 7.1.4 + publicationDate: Jul 14, 2026 + + - version: 7.2.1 + publicationDate: Jul 14, 2026 + + - version: 8.0.0-M3 + publicationDate: Jul 14, 2026 + + - version: 8.0.0-M4 + publicationDate: Jul 24, 2026 +
diff --git a/guides/grails-database-migration/v8/guide/addAttributes.adoc b/guides/grails-database-migration/v8/guide/addAttributes.adoc new file mode 100644 index 0000000..347d7ee --- /dev/null +++ b/guides/grails-database-migration/v8/guide/addAttributes.adoc
@@ -0,0 +1,16 @@ +Add address columns directly on `person` before splitting them into a separate table: + +[source,groovy] +.grails-app/migrations/add-address-fields-to-person.groovy +---- +include::../snippets/grails-app/migrations/add-address-fields-to-person.groovy[] +---- + +Include the file in `changelog.groovy` and apply: + +[source,bash] +---- +./grailsw dbm-update +---- + +This intermediate step mirrors a real-world scenario where address data lives on `person` before normalization.
diff --git a/guides/grails-database-migration/v8/guide/baseline.adoc b/guides/grails-database-migration/v8/guide/baseline.adoc new file mode 100644 index 0000000..b22f18a --- /dev/null +++ b/guides/grails-database-migration/v8/guide/baseline.adoc
@@ -0,0 +1,37 @@ +Start with a `Person` domain: + +[source,groovy] +.grails-app/domain/example/Person.groovy +---- +include::../snippets/grails-app/domain/example/Person-initial.groovy[] +---- + +Generate the initial changelog from GORM domains: + +[source,bash] +---- +./grailsw dbm-generate-gorm-changelog changelog.groovy +---- + +Move the generated changeset into its own file and reference it from the main changelog: + +[source,groovy] +.grails-app/migrations/create-person-table.groovy +---- +include::../snippets/grails-app/migrations/create-person-table.groovy[] +---- + +[source,groovy] +.grails-app/migrations/changelog.groovy +---- +include::../snippets/grails-app/migrations/changelog-baseline.groovy[] +---- + +Apply the migration: + +[source,bash] +---- +./grailsw dbm-update +---- + +Liquibase creates `DATABASECHANGELOG`, `DATABASECHANGELOGLOCK`, and the `person` table.
diff --git a/guides/grails-database-migration/v8/guide/changelogDiscipline.adoc b/guides/grails-database-migration/v8/guide/changelogDiscipline.adoc new file mode 100644 index 0000000..d2817de --- /dev/null +++ b/guides/grails-database-migration/v8/guide/changelogDiscipline.adoc
@@ -0,0 +1,8 @@ +Keep changelog history append-only and immutable. + +* Include migration files in release order from `changelog.groovy` +* Prefer one logical change per file, with a unique `author` + `id` +* Never edit a changeset that has already been applied in any environment - add a corrective changeset instead +* `dbm-clear-checksums` recalculates checksums after an emergency edit; it is not a substitute for append-only discipline + +Destructive redesigns should also declare `preConditions(onFail: 'HALT')` so the wrong ordering or schema drift fails loudly instead of damaging data.
diff --git a/guides/grails-database-migration/v8/guide/designedRollback.adoc b/guides/grails-database-migration/v8/guide/designedRollback.adoc new file mode 100644 index 0000000..333463e --- /dev/null +++ b/guides/grails-database-migration/v8/guide/designedRollback.adoc
@@ -0,0 +1,31 @@ +Treat rollback as a designed capability, not an afterthought. + +Simple, auto-reversible changesets (such as `dropNotNullConstraint`) can use: + +[source,bash] +---- +./grailsw dbm-rollback-count 1 +---- + +For multi-step redesigns, write explicit `rollback {}` blocks (as in `create-address-table.groovy`) so Liquibase can re-add dropped columns and restore data from `address`. + +Tag a known-good release before a risky update: + +[source,bash] +---- +./grailsw dbm-tag before-address-redesign +---- + +Preview the rollback SQL to that tag: + +[source,bash] +---- +./grailsw dbm-rollback-sql before-address-redesign +---- + +Execute the rollback when you need to: + +[source,bash] +---- +./grailsw dbm-rollback before-address-redesign +----
diff --git a/guides/grails-database-migration/v8/guide/gettingStarted.adoc b/guides/grails-database-migration/v8/guide/gettingStarted.adoc new file mode 100644 index 0000000..46f1702 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/gettingStarted.adoc
@@ -0,0 +1,17 @@ +Clone the repository and verify tests pass: + +[source,bash] +---- +git clone -b grails8 https://github.com/grails-guides/grails-database-migration.git +cd grails-database-migration/initial +./gradlew test +---- + +The `initial/` project is a Grails 8 REST API starter with the database-migration plugin and a `Person` domain - but no Liquibase changelogs yet. Unit tests cover `Person` constraints; `updateOnStart` stays `false` in test config until you generate the baseline changelog. + +To skip ahead, `cd ../complete` and run the same commands - that tree contains the finished migrations, domains, and tests. + +[[cloneAndRun]] +== Verify CI + +Both `initial` and `complete` must pass in CI (see `.github/workflows/grails8.yml`).
diff --git a/guides/grails-database-migration/v8/guide/helpWithGrails.adoc b/guides/grails-database-migration/v8/guide/helpWithGrails.adoc new file mode 100644 index 0000000..e062f61 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/helpWithGrails.adoc
@@ -0,0 +1 @@ +include::{commondir}/common-helpWithGrails.adoc[]
diff --git a/guides/grails-database-migration/v8/guide/introduction.adoc b/guides/grails-database-migration/v8/guide/introduction.adoc new file mode 100644 index 0000000..e49c67b --- /dev/null +++ b/guides/grails-database-migration/v8/guide/introduction.adoc
@@ -0,0 +1,5 @@ +Learn how to evolve a PostgreSQL schema safely across releases with the Grails Database Migration plugin and Liquibase changelogs: baseline from GORM domains, make columns nullable, add columns, redesign tables, and migrate existing data with custom SQL - without relying on Hibernate `dbCreate` auto-DDL. + +This is the production follow-on to the https://grails.apache.org/guides/grails-data-access/[Data Access with GORM] guide: *"Now that I've modeled my data, how do I evolve the schema safely across releases?"* + +This guide follows the standard Grails guides layout: work in the `initial/` project and compare your progress with `complete/`.
diff --git a/guides/grails-database-migration/v8/guide/legacyOnboarding.adoc b/guides/grails-database-migration/v8/guide/legacyOnboarding.adoc new file mode 100644 index 0000000..a8913bf --- /dev/null +++ b/guides/grails-database-migration/v8/guide/legacyOnboarding.adoc
@@ -0,0 +1,17 @@ +Greenfield apps can baseline from GORM domains with `dbm-generate-gorm-changelog`. Existing production databases usually need a different path. + +Snapshot the live schema: + +[source,bash] +---- +./grailsw dbm-generate-changelog existing-schema.groovy +---- + +Mark those changesets as already applied without running DDL again: + +[source,bash] +---- +./grailsw dbm-changelog-sync +---- + +After that, new releases only append forward migrations. Use `dbm-generate-gorm-changelog` when the database should match domains from scratch; use `dbm-generate-changelog` + `dbm-changelog-sync` when the schema already exists and you must not recreate it.
diff --git a/guides/grails-database-migration/v8/guide/makeColumnNullable.adoc b/guides/grails-database-migration/v8/guide/makeColumnNullable.adoc new file mode 100644 index 0000000..e76b98b --- /dev/null +++ b/guides/grails-database-migration/v8/guide/makeColumnNullable.adoc
@@ -0,0 +1,34 @@ +Make `age` optional on the `Person` domain, then diff the change into a new migration: + +[source,groovy] +.grails-app/domain/example/Person.groovy +---- +include::../snippets/grails-app/domain/example/Person-after-nullable.groovy[] +---- + +Generate a migration from the domain diff: + +[source,bash] +---- +./grailsw dbm-gorm-diff change-age-constraint-to-nullable.groovy --add +---- + +[source,groovy] +.grails-app/migrations/change-age-constraint-to-nullable.groovy +---- +include::../snippets/grails-app/migrations/change-age-constraint-to-nullable.groovy[] +---- + +Include the new file in `changelog.groovy` and apply: + +[source,bash] +---- +./grailsw dbm-update +---- + +This changeset is reversible. Roll back the last applied changeset with: + +[source,bash] +---- +./grailsw dbm-rollback-count 1 +----
diff --git a/guides/grails-database-migration/v8/guide/pluginSetup.adoc b/guides/grails-database-migration/v8/guide/pluginSetup.adoc new file mode 100644 index 0000000..b6413e6 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/pluginSetup.adoc
@@ -0,0 +1,19 @@ +Grails 8 uses `org.apache.grails:grails-data-hibernate5-dbmigration` from the Grails BOM - not the legacy `org.grails.plugins:database-migration` coordinate. + +Add the dependency in `build.gradle`: + +[source,groovy] +.build.gradle +---- +include::../snippets/build.gradle[] +---- + +Disable Hibernate auto-DDL and apply migrations on startup in `grails-app/conf/application.yml`: + +[source,yaml] +.grails-app/conf/application.yml +---- +include::../snippets/grails-app/conf/application-databasemigration.yml[] +---- + +`dbCreate: none` ensures schema changes flow through Liquibase changelogs only. `updateOnStart: true` is convenient for local and single-instance apps; for production with multiple instances, apply migrations once (for example with `./grailsw dbm-update`) before rolling out the new app version.
diff --git a/guides/grails-database-migration/v8/guide/redesignTables.adoc b/guides/grails-database-migration/v8/guide/redesignTables.adoc new file mode 100644 index 0000000..dcf95ff --- /dev/null +++ b/guides/grails-database-migration/v8/guide/redesignTables.adoc
@@ -0,0 +1,38 @@ +Introduce an `Address` domain and migrate existing column data with custom SQL. Address ownership is mandatory (`person` is required), matching `person_id NOT NULL` in the database: + +[source,groovy] +.grails-app/domain/example/Address.groovy +---- +include::../snippets/grails-app/domain/example/Address.groovy[] +---- + +This redesign is an expand-migrate-contract sequence: create `address`, copy data, then drop the denormalized `person` columns. Guard the destructive steps with `preConditions`, and supply explicit `rollback {}` blocks so `dbm-rollback` can restore columns and data: + +[source,groovy] +.grails-app/migrations/create-address-table.groovy +---- +include::../snippets/grails-app/migrations/create-address-table.groovy[] +---- + +Include the file in `changelog.groovy` and apply: + +[source,bash] +---- +./grailsw dbm-update +---- + +If `./grailsw` is unavailable, use `./gradlew runCommand "-Pargs=dbm-update"`. + +Expose persisted data through a read-only REST endpoint: + +[source,groovy] +.grails-app/controllers/example/UrlMappings.groovy +---- +include::../snippets/grails-app/controllers/example/UrlMappings.groovy[] +---- + +[source,groovy] +.grails-app/controllers/example/PersonController.groovy +---- +include::../snippets/grails-app/controllers/example/PersonController.groovy[] +----
diff --git a/guides/grails-database-migration/v8/guide/releaseRunbook.adoc b/guides/grails-database-migration/v8/guide/releaseRunbook.adoc new file mode 100644 index 0000000..2f987a1 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/releaseRunbook.adoc
@@ -0,0 +1,33 @@ +Before you apply pending changesets, inspect and preview them. + +Check what Liquibase thinks is pending: + +[source,bash] +---- +./grailsw dbm-status +---- + +Generate the SQL that would run, without applying it: + +[source,bash] +---- +./grailsw dbm-update-sql pending.sql +---- + +Review `pending.sql`, then apply: + +[source,bash] +---- +./grailsw dbm-update +---- + +`updateOnStart: true` is fine for local development and single-instance deployments. In production, prefer running `dbm-update` once as a release step before you roll out multiple application instances, so every node boots against an already-migrated schema. + +If `./grailsw` is unavailable, the Gradle form works for CI and automation: + +[source,bash] +---- +./gradlew runCommand "-Pargs=dbm-status" +./gradlew runCommand "-Pargs=dbm-update-sql pending.sql" +./gradlew runCommand "-Pargs=dbm-update" +----
diff --git a/guides/grails-database-migration/v8/guide/requirements.adoc b/guides/grails-database-migration/v8/guide/requirements.adoc new file mode 100644 index 0000000..b2dd4c9 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/requirements.adoc
@@ -0,0 +1,5 @@ +* Approximately 45 minutes +* JDK 21 (Apache Grails 8 requires Java 21) +* A https://www.grails.org/[Grails] installation or the bundled Gradle / Grails wrappers in `initial/` and `complete/` +* **Docker** - required for integration tests (Testcontainers PostgreSQL) +* **PostgreSQL 16** on `localhost:5432` - required for `./gradlew bootRun` in both `initial/` and `complete/` (default database `devDb`; see `grails-app/conf/application.yml`)
diff --git a/guides/grails-database-migration/v8/guide/runningTheApp.adoc b/guides/grails-database-migration/v8/guide/runningTheApp.adoc new file mode 100644 index 0000000..d3924c4 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/runningTheApp.adoc
@@ -0,0 +1,14 @@ +[source,bash] +---- +cd complete +./gradlew bootRun +---- + +Example endpoint: + +[source,bash] +---- +curl -s http://localhost:8080/api/persons +---- + +Migrations apply on startup via `updateOnStart: true`.
diff --git a/guides/grails-database-migration/v8/guide/summary.adoc b/guides/grails-database-migration/v8/guide/summary.adoc new file mode 100644 index 0000000..8885ddb --- /dev/null +++ b/guides/grails-database-migration/v8/guide/summary.adoc
@@ -0,0 +1,9 @@ +You evolved a PostgreSQL schema safely with Liquibase and the Grails Database Migration plugin: + +* Baseline changelog from GORM domains with `dbCreate: none` +* Nullable constraint changes via `dbm-gorm-diff` +* Column additions and table redesign with data migration SQL, preconditions, and designed rollbacks +* Release workflow: `dbm-status` → `dbm-update-sql` → `dbm-update` +* Spock unit and integration tests, including a migration-path proof + +Next steps: integrate migrations into CI/CD, or pair with the https://grails.apache.org/guides/grails-docker-bootbuildimage/[Docker bootBuildImage guide] for containerized releases.
diff --git a/guides/grails-database-migration/v8/guide/testing.adoc b/guides/grails-database-migration/v8/guide/testing.adoc new file mode 100644 index 0000000..bd9c047 --- /dev/null +++ b/guides/grails-database-migration/v8/guide/testing.adoc
@@ -0,0 +1,44 @@ +[[unitTests]] +== Unit tests + +Domain constraints after schema evolution: + +[source,groovy] +.src/test/groovy/example/PersonSpec.groovy +---- +include::../snippets/src/test/groovy/example/PersonSpec.groovy[] +---- + +[source,groovy] +.src/test/groovy/example/AddressSpec.groovy +---- +include::../snippets/src/test/groovy/example/AddressSpec.groovy[] +---- + +[[integrationTests]] +== Integration tests + +Assert Liquibase tracking tables, final schema shape, GORM persistence, and a migration-path proof that legacy `person` address columns are copied into `address` before those columns are dropped: + +[source,groovy] +.src/integration-test/groovy/example/DatabaseMigrationIntegrationSpec.groovy +---- +include::../snippets/src/integration-test/groovy/example/DatabaseMigrationIntegrationSpec.groovy[] +---- + +The migration-path test builds the pre-redesign schema on an isolated Testcontainers database, seeds a legacy row, then runs the same expand-migrate-contract SQL as `create-address-table.groovy`. + +Run unit tests: + +[source,bash] +---- +cd complete +./gradlew test +---- + +Run integration tests (requires Docker): + +[source,bash] +---- +./gradlew integrationTest +----
diff --git a/guides/grails-database-migration/v8/snippets/build.gradle b/guides/grails-database-migration/v8/snippets/build.gradle new file mode 100644 index 0000000..fde4390 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/build.gradle
@@ -0,0 +1 @@ + implementation "org.apache.grails:grails-data-hibernate5-dbmigration"
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/conf/application-databasemigration.yml b/guides/grails-database-migration/v8/snippets/grails-app/conf/application-databasemigration.yml new file mode 100644 index 0000000..12a9b1e --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/conf/application-databasemigration.yml
@@ -0,0 +1,10 @@ +environments: + development: + dataSource: + dbCreate: none + url: jdbc:postgresql://localhost:5432/devDb?tcpKeepAlive=true +grails: + plugin: + databasemigration: + updateOnStart: true + updateOnStartFileName: changelog.groovy
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/controllers/example/PersonController.groovy b/guides/grails-database-migration/v8/snippets/grails-app/controllers/example/PersonController.groovy new file mode 100644 index 0000000..cd37cda --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/controllers/example/PersonController.groovy
@@ -0,0 +1,12 @@ +package example + +class PersonController { + + static responseFormats = ['json'] + static allowedMethods = [index: 'GET'] + + def index(Integer max) { + params.max = Math.min(max ?: 10, 100) + respond Person.list(params), model: [personCount: Person.count()] + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-database-migration/v8/snippets/grails-app/controllers/example/UrlMappings.groovy new file mode 100644 index 0000000..fa36038 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
@@ -0,0 +1,17 @@ +package example + +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?"{ + constraints { + } + } + + "/api/persons"(controller: 'person', action: 'index', method: 'GET') + + "/"(controller: 'application', action: 'index') + "500"(view: '/error') + "404"(view: '/notFound') + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Address.groovy b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Address.groovy new file mode 100644 index 0000000..09d53cb --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Address.groovy
@@ -0,0 +1,25 @@ +package example + +import grails.persistence.Entity + +@Entity +class Address { + + Person person + String streetName + String city + String zipCode + + static belongsTo = [person: Person] + + static mapping = { + id generator: 'identity' + } + + static constraints = { + person nullable: false + streetName nullable: true + city nullable: true + zipCode nullable: true + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person-after-nullable.groovy b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person-after-nullable.groovy new file mode 100644 index 0000000..50a6604 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person-after-nullable.groovy
@@ -0,0 +1,15 @@ +package example + +import grails.persistence.Entity + +@Entity +class Person { + + String name + Integer age + + static constraints = { + name nullable: false + age nullable: true + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person-initial.groovy b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person-initial.groovy new file mode 100644 index 0000000..aff31e7 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person-initial.groovy
@@ -0,0 +1,15 @@ +package example + +import grails.persistence.Entity + +@Entity +class Person { + + String name + Integer age + + static constraints = { + name nullable: false + age nullable: false + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person.groovy b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person.groovy new file mode 100644 index 0000000..a289f6c --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/domain/example/Person.groovy
@@ -0,0 +1,21 @@ +package example + +import grails.persistence.Entity + +@Entity +class Person { + + String name + Integer age + + static hasMany = [addresses: Address] + + static mapping = { + id generator: 'identity' + } + + static constraints = { + name nullable: false + age nullable: true + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/add-address-fields-to-person.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/add-address-fields-to-person.groovy new file mode 100644 index 0000000..1a4320a --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/add-address-fields-to-person.groovy
@@ -0,0 +1,20 @@ +databaseChangeLog = { + + changeSet(author: 'guide (generated)', id: 'add-address-city-1') { + addColumn(tableName: 'person') { + column(name: 'city', type: 'VARCHAR(255)') + } + } + + changeSet(author: 'guide (generated)', id: 'add-address-street-1') { + addColumn(tableName: 'person') { + column(name: 'street_name', type: 'VARCHAR(255)') + } + } + + changeSet(author: 'guide (generated)', id: 'add-address-zip-1') { + addColumn(tableName: 'person') { + column(name: 'zip_code', type: 'VARCHAR(255)') + } + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/change-age-constraint-to-nullable.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/change-age-constraint-to-nullable.groovy new file mode 100644 index 0000000..7145fa4 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/change-age-constraint-to-nullable.groovy
@@ -0,0 +1,6 @@ +databaseChangeLog = { + + changeSet(author: 'guide (generated)', id: 'change-age-nullable-1') { + dropNotNullConstraint(columnDataType: 'integer', columnName: 'age', tableName: 'person') + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog-baseline.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog-baseline.groovy new file mode 100644 index 0000000..7bdf80a --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog-baseline.groovy
@@ -0,0 +1,4 @@ +databaseChangeLog = { + + include file: 'create-person-table.groovy' +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog-through-address-fields.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog-through-address-fields.groovy new file mode 100644 index 0000000..dd8d9d7 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog-through-address-fields.groovy
@@ -0,0 +1,6 @@ +databaseChangeLog = { + + include file: 'create-person-table.groovy' + include file: 'change-age-constraint-to-nullable.groovy' + include file: 'add-address-fields-to-person.groovy' +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog.groovy new file mode 100644 index 0000000..8c443be --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/changelog.groovy
@@ -0,0 +1,7 @@ +databaseChangeLog = { + + include file: 'create-person-table.groovy' + include file: 'change-age-constraint-to-nullable.groovy' + include file: 'add-address-fields-to-person.groovy' + include file: 'create-address-table.groovy' +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/create-address-table.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/create-address-table.groovy new file mode 100644 index 0000000..270ba1e --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/create-address-table.groovy
@@ -0,0 +1,96 @@ +databaseChangeLog = { + + changeSet(author: 'guide (generated)', id: 'create-address-table-1') { + preConditions(onFail: 'HALT') { + tableExists(tableName: 'person') + not { + tableExists(tableName: 'address') + } + } + createTable(tableName: 'address') { + column(autoIncrement: true, name: 'id', type: 'BIGINT') { + constraints(nullable: false, primaryKey: true, primaryKeyName: 'addressPK') + } + column(name: 'version', type: 'BIGINT') { + constraints(nullable: false) + } + column(name: 'person_id', type: 'BIGINT') { + constraints(nullable: false) + } + column(name: 'street_name', type: 'VARCHAR(255)') + column(name: 'city', type: 'VARCHAR(255)') + column(name: 'zip_code', type: 'VARCHAR(255)') + } + } + + changeSet(author: 'guide (generated)', id: 'create-address-fk-1') { + preConditions(onFail: 'HALT') { + tableExists(tableName: 'address') + tableExists(tableName: 'person') + } + addForeignKeyConstraint( + baseColumnNames: 'person_id', + baseTableName: 'address', + constraintName: 'fk_address_person', + referencedColumnNames: 'id', + referencedTableName: 'person' + ) + } + + changeSet(author: 'guide (generated)', id: 'migrate-address-data-1') { + preConditions(onFail: 'HALT') { + tableExists(tableName: 'address') + columnExists(tableName: 'person', columnName: 'street_name') + columnExists(tableName: 'person', columnName: 'city') + columnExists(tableName: 'person', columnName: 'zip_code') + } + sql('''insert into address (version, person_id, street_name, city, zip_code) + select 0, id, street_name, city, zip_code from person + where street_name is not null or city is not null or zip_code is not null''') + rollback { + sql('delete from address') + } + } + + changeSet(author: 'guide (generated)', id: 'drop-person-city-1') { + preConditions(onFail: 'HALT') { + tableExists(tableName: 'address') + columnExists(tableName: 'person', columnName: 'city') + } + dropColumn(columnName: 'city', tableName: 'person') + rollback { + addColumn(tableName: 'person') { + column(name: 'city', type: 'VARCHAR(255)') + } + sql('''update person p set city = a.city from address a where a.person_id = p.id''') + } + } + + changeSet(author: 'guide (generated)', id: 'drop-person-street-1') { + preConditions(onFail: 'HALT') { + tableExists(tableName: 'address') + columnExists(tableName: 'person', columnName: 'street_name') + } + dropColumn(columnName: 'street_name', tableName: 'person') + rollback { + addColumn(tableName: 'person') { + column(name: 'street_name', type: 'VARCHAR(255)') + } + sql('''update person p set street_name = a.street_name from address a where a.person_id = p.id''') + } + } + + changeSet(author: 'guide (generated)', id: 'drop-person-zip-1') { + preConditions(onFail: 'HALT') { + tableExists(tableName: 'address') + columnExists(tableName: 'person', columnName: 'zip_code') + } + dropColumn(columnName: 'zip_code', tableName: 'person') + rollback { + addColumn(tableName: 'person') { + column(name: 'zip_code', type: 'VARCHAR(255)') + } + sql('''update person p set zip_code = a.zip_code from address a where a.person_id = p.id''') + } + } +}
diff --git a/guides/grails-database-migration/v8/snippets/grails-app/migrations/create-person-table.groovy b/guides/grails-database-migration/v8/snippets/grails-app/migrations/create-person-table.groovy new file mode 100644 index 0000000..43380a5 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/grails-app/migrations/create-person-table.groovy
@@ -0,0 +1,19 @@ +databaseChangeLog = { + + changeSet(author: 'guide (generated)', id: 'create-person-table-1') { + createTable(tableName: 'person') { + column(autoIncrement: true, name: 'id', type: 'BIGINT') { + constraints(nullable: false, primaryKey: true, primaryKeyName: 'personPK') + } + column(name: 'version', type: 'BIGINT') { + constraints(nullable: false) + } + column(name: 'age', type: 'INTEGER') { + constraints(nullable: false) + } + column(name: 'name', type: 'VARCHAR(255)') { + constraints(nullable: false) + } + } + } +}
diff --git a/guides/grails-database-migration/v8/snippets/src/integration-test/groovy/example/DatabaseMigrationIntegrationSpec.groovy b/guides/grails-database-migration/v8/snippets/src/integration-test/groovy/example/DatabaseMigrationIntegrationSpec.groovy new file mode 100644 index 0000000..573255f --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/src/integration-test/groovy/example/DatabaseMigrationIntegrationSpec.groovy
@@ -0,0 +1,195 @@ +package example + +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration +import groovy.sql.Sql +import org.springframework.beans.factory.annotation.Autowired +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName +import spock.lang.Shared +import spock.lang.Specification + +import javax.sql.DataSource +import java.sql.Connection +import java.sql.DriverManager + +@Integration +class DatabaseMigrationIntegrationSpec extends Specification { + + @Shared + static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse('postgres:16-alpine')) + .withDatabaseName('migrate_path') + .withUsername('test') + .withPassword('test') + + @Autowired + DataSource dataSource + + def setupSpec() { + postgres.start() + } + + def cleanupSpec() { + if (postgres.running) { + postgres.stop() + } + } + + void 'Liquibase tracking tables exist on a clean database'() { + expect: + tableExists('databasechangelog') + tableExists('databasechangeloglock') + } + + void 'person table exists with expected columns and no address columns'() { + expect: + tableExists('person') + columnNames('person').containsAll(['id', 'version', 'name', 'age']) + !columnNames('person').contains('street_name') + !columnNames('person').contains('city') + !columnNames('person').contains('zip_code') + } + + void 'address table exists with person_id foreign key'() { + expect: + tableExists('address') + columnNames('address').containsAll(['id', 'version', 'person_id', 'street_name', 'city', 'zip_code']) + foreignKeyExists('address', 'person_id', 'person', 'id') + } + + @Rollback + void 'GORM can save Person and Address after migrations apply'() { + when: + def person = Person.withTransaction { + def p = new Person(name: 'Test Person', age: 30).save(flush: true, failOnError: true) + new Address( + person: p, + streetName: 'Main St', + city: 'Austin', + zipCode: '78701' + ).save(flush: true, failOnError: true) + p + } + + then: + person.id + Address.countByPerson(person) == 1 + Address.findByPerson(person).city == 'Austin' + } + + void 'legacy person address columns migrate into address table'() { + given: 'an isolated DB at the add-address-fields stage' + Connection conn = DriverManager.getConnection( + postgres.jdbcUrl, + postgres.username, + postgres.password + ) + Sql sql = new Sql(conn) + applyThroughAddressFields(sql) + + when: 'a legacy person row still has denormalized address columns' + sql.executeInsert('''insert into person (version, name, age, street_name, city, zip_code) + values (0, 'Legacy Person', 42, 'Congress Ave', 'Austin', '78701')''') + + and: 'the redesign SQL from create-address-table.groovy runs' + applyAddressRedesign(sql) + + then: 'address row matches the legacy values and person address columns are gone' + def address = sql.firstRow('select street_name, city, zip_code from address') + address.street_name == 'Congress Ave' + address.city == 'Austin' + address.zip_code == '78701' + !columnNames(conn, 'person').contains('street_name') + !columnNames(conn, 'person').contains('city') + !columnNames(conn, 'person').contains('zip_code') + + cleanup: + sql?.close() + conn?.close() + } + + private static void applyThroughAddressFields(Sql sql) { + sql.execute(''' + create table person ( + id bigserial primary key, + version bigint not null, + name varchar(255) not null, + age integer, + city varchar(255), + street_name varchar(255), + zip_code varchar(255) + ) + ''') + } + + private static void applyAddressRedesign(Sql sql) { + sql.execute(''' + create table address ( + id bigserial primary key, + version bigint not null, + person_id bigint not null references person(id), + street_name varchar(255), + city varchar(255), + zip_code varchar(255) + ) + ''') + sql.execute(''' + insert into address (version, person_id, street_name, city, zip_code) + select 0, id, street_name, city, zip_code from person + where street_name is not null or city is not null or zip_code is not null + ''') + sql.execute('alter table person drop column city') + sql.execute('alter table person drop column street_name') + sql.execute('alter table person drop column zip_code') + } + + private boolean tableExists(String table) { + def conn = dataSource.connection + try { + return tableExists(conn, table) + } finally { + conn.close() + } + } + + private static boolean tableExists(Connection conn, String table) { + def rs = conn.metaData.getTables(null, 'public', table, ['TABLE'] as String[]) + return rs.next() + } + + private Set<String> columnNames(String table) { + def conn = dataSource.connection + try { + return columnNames(conn, table) + } finally { + conn.close() + } + } + + private static Set<String> columnNames(Connection conn, String table) { + def rs = conn.metaData.getColumns(null, 'public', table, null) + def names = [] as Set + while (rs.next()) { + names << rs.getString('COLUMN_NAME').toLowerCase() + } + return names + } + + private boolean foreignKeyExists(String fkTable, String fkColumn, String pkTable, String pkColumn) { + def conn = dataSource.connection + try { + def rs = conn.metaData.getImportedKeys(null, 'public', fkTable) + while (rs.next()) { + if (rs.getString('FKTABLE_NAME').equalsIgnoreCase(fkTable) && + rs.getString('FKCOLUMN_NAME').equalsIgnoreCase(fkColumn) && + rs.getString('PKTABLE_NAME').equalsIgnoreCase(pkTable) && + rs.getString('PKCOLUMN_NAME').equalsIgnoreCase(pkColumn)) { + return true + } + } + return false + } finally { + conn.close() + } + } +}
diff --git a/guides/grails-database-migration/v8/snippets/src/test/groovy/example/AddressSpec.groovy b/guides/grails-database-migration/v8/snippets/src/test/groovy/example/AddressSpec.groovy new file mode 100644 index 0000000..c8b7580 --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/src/test/groovy/example/AddressSpec.groovy
@@ -0,0 +1,28 @@ +package example + +import grails.testing.gorm.DomainUnitTest +import spock.lang.Specification + +class AddressSpec extends Specification implements DomainUnitTest<Address> { + + void 'person is required'() { + when: + def address = new Address() + + then: + !address.validate() + address.errors['person'].code == 'nullable' + } + + void 'address fields are optional when person is set'() { + given: + mockDomain(Person) + + when: + def person = new Person(name: 'Ada', age: 36).save(flush: true) + def address = new Address(person: person) + + then: + address.validate() + } +}
diff --git a/guides/grails-database-migration/v8/snippets/src/test/groovy/example/PersonSpec.groovy b/guides/grails-database-migration/v8/snippets/src/test/groovy/example/PersonSpec.groovy new file mode 100644 index 0000000..7c3e56e --- /dev/null +++ b/guides/grails-database-migration/v8/snippets/src/test/groovy/example/PersonSpec.groovy
@@ -0,0 +1,24 @@ +package example + +import grails.testing.gorm.DomainUnitTest +import spock.lang.Specification + +class PersonSpec extends Specification implements DomainUnitTest<Person> { + + void 'name is required'() { + when: + def person = new Person() + + then: + !person.validate() + person.errors['name'] + } + + void 'age is optional'() { + when: + def person = new Person(name: 'Ada') + + then: + person.validate() + } +}
diff --git a/guides/grails-email/v8/guide/awsSesService.adoc b/guides/grails-email/v8/guide/awsSesService.adoc new file mode 100644 index 0000000..5579235 --- /dev/null +++ b/guides/grails-email/v8/guide/awsSesService.adoc
@@ -0,0 +1,9 @@ +AWS SES is the third provider option: + +[source,groovy] +.src/main/groovy/example/AwsSesEmailService.groovy +---- +include::../snippets/src/main/groovy/example/AwsSesEmailService.groovy[] +---- + +Set `EMAIL_PROVIDER=ses` and configure `AWS_REGION` and `AWS_SOURCE` plus standard AWS credentials for the SDK.
diff --git a/guides/grails-email/v8/guide/controller.adoc b/guides/grails-email/v8/guide/controller.adoc new file mode 100644 index 0000000..6bd28c9 --- /dev/null +++ b/guides/grails-email/v8/guide/controller.adoc
@@ -0,0 +1,39 @@ +Accept JSON payloads with a command object that implements `Email` and validates required fields: + +[source,groovy] +.grails-app/controllers/example/EmailCmd.groovy +---- +include::../snippets/grails-app/controllers/example/EmailCmd.groovy[] +---- + +The controller delegates to `EmailService`: + +[source,groovy] +.grails-app/controllers/example/MailController.groovy +---- +include::../snippets/grails-app/controllers/example/MailController.groovy[] +---- + +When the command object fails validation, `respond` renders the errors through the JSON views error template. Grails resolves that template by convention, first at `grails-app/views/<controllerName>/_errors.gson` and then at `grails-app/views/errors/_errors.gson`. A `view:` argument is ignored on this path, so the template has to live at one of those two locations. Update the generated template to report the request path and an absolute self link: + +[source,groovy] +.grails-app/views/errors/_errors.gson +---- +include::../snippets/grails-app/views/errors/_errors.gson[] +---- + +The template the application generator creates uses `g.link(resource: request.uri)`, which treats the URI as a resource name and reports `/send` instead of `/mail/send`. This version reports `request.uri` directly as `path`, and passes the same URI to `g.link` as `uri:` with `absolute: true` to build the self link. The context path is stripped before linking because `g.link` adds it back for absolute links, which would otherwise repeat it when the application runs under a servlet context path. + +[IMPORTANT] +==== +This endpoint is demonstration-only. It accepts arbitrary recipients and message content with no authentication, so a deployed copy can become an outbound mail relay through the configured provider. Before any public or production exposure, add authentication, authorization, recipient controls, and abuse/rate-limit protection. +==== + +Example request: + +[source,bash] +---- +curl -X POST http://localhost:8080/mail/send \ + -H 'Content-Type: application/json' \ + -d '{"recipient":"user@example.com","subject":"Hello","textBody":"Hi there"}' +----
diff --git a/guides/grails-email/v8/guide/emailContract.adoc b/guides/grails-email/v8/guide/emailContract.adoc new file mode 100644 index 0000000..f210773 --- /dev/null +++ b/guides/grails-email/v8/guide/emailContract.adoc
@@ -0,0 +1,25 @@ +Define a small `Email` contract and a single `EmailService` boundary that every provider implements: + +[source,groovy] +.src/main/groovy/example/Email.groovy +---- +include::../snippets/src/main/groovy/example/Email.groovy[] +---- + +[source,groovy] +.src/main/groovy/example/EmailService.groovy +---- +include::../snippets/src/main/groovy/example/EmailService.groovy[] +---- + +Controllers and tests depend on `EmailService`, not on SMTP, SendGrid, or SES directly. Swap providers by changing Spring wiring and configuration. + +Add the mail dependencies in `build.gradle`: + +[source,groovy] +.build.gradle +---- +include::../snippets/build.gradle[] +---- + +Grails 8 uses `org.grails.plugins:grails-mail` 5.x from the Grails plugin repository, compatible with Apache Grails 8.
diff --git a/guides/grails-email/v8/guide/gettingStarted.adoc b/guides/grails-email/v8/guide/gettingStarted.adoc new file mode 100644 index 0000000..4598fc8 --- /dev/null +++ b/guides/grails-email/v8/guide/gettingStarted.adoc
@@ -0,0 +1,14 @@ +Clone the repository and verify tests pass: + +[source,bash] +---- +git clone -b grails8 https://github.com/grails-guides/grails-email.git +cd grails-email/initial +./gradlew test +---- + +The `initial/` project is a Grails 8 REST API starter with no mail wiring yet. + +To skip ahead, `cd ../complete` and run the same commands; that tree contains the finished mail services, controller, and integration spec. + +Both `initial` and `complete` must pass in CI (see `.github/workflows/grails8.yml`).
diff --git a/guides/grails-email/v8/guide/helpWithGrails.adoc b/guides/grails-email/v8/guide/helpWithGrails.adoc new file mode 100644 index 0000000..e062f61 --- /dev/null +++ b/guides/grails-email/v8/guide/helpWithGrails.adoc
@@ -0,0 +1 @@ +include::{commondir}/common-helpWithGrails.adoc[]
diff --git a/guides/grails-email/v8/guide/introduction.adoc b/guides/grails-email/v8/guide/introduction.adoc new file mode 100644 index 0000000..144e596 --- /dev/null +++ b/guides/grails-email/v8/guide/introduction.adoc
@@ -0,0 +1,5 @@ +Learn how to send mail from a Grails 8 application over SMTP (Grails Mail plugin) and through a provider (SendGrid / AWS SES), externalize configuration in `application.yml`, and verify send interactions with Spock Spring integration tests, without dispatching real email in CI. + +This guide parallels the HTTP client guide in shape: an external integration behind a mockable service boundary, with tests that assert the contract. + +This guide follows the standard Grails guides layout: work in the `initial/` project and compare your progress with `complete/`.
diff --git a/guides/grails-email/v8/guide/mailConfiguration.adoc b/guides/grails-email/v8/guide/mailConfiguration.adoc new file mode 100644 index 0000000..913ee4a --- /dev/null +++ b/guides/grails-email/v8/guide/mailConfiguration.adoc
@@ -0,0 +1,9 @@ +Wire the active provider in `grails-app/conf/spring/resources.groovy`: + +[source,groovy] +.grails-app/conf/spring/resources.groovy +---- +include::../snippets/grails-app/conf/spring/resources.groovy[] +---- + +The switch reads `email.provider` from configuration. Provider wiring is skipped in the `test` environment so integration tests can register a mock `EmailService` bean.
diff --git a/guides/grails-email/v8/guide/requirements.adoc b/guides/grails-email/v8/guide/requirements.adoc new file mode 100644 index 0000000..ca2ab78 --- /dev/null +++ b/guides/grails-email/v8/guide/requirements.adoc
@@ -0,0 +1,4 @@ +* Approximately 45 minutes +* JDK 21 (Apache Grails 8 requires Java 21) +* A https://www.grails.org/[Grails] installation or the bundled Gradle wrapper in `initial/` and `complete/` +* Optional: local SMTP catcher on port **1025** for manual `bootRun` verification (for example https://github.com/mailhog/MailHog[MailHog])
diff --git a/guides/grails-email/v8/guide/sendGridService.adoc b/guides/grails-email/v8/guide/sendGridService.adoc new file mode 100644 index 0000000..58c9b14 --- /dev/null +++ b/guides/grails-email/v8/guide/sendGridService.adoc
@@ -0,0 +1,9 @@ +SendGrid implements the same `EmailService` contract: + +[source,groovy] +.src/main/groovy/example/SendGridEmailService.groovy +---- +include::../snippets/src/main/groovy/example/SendGridEmailService.groovy[] +---- + +Set `EMAIL_PROVIDER=sendgrid` and provide `SENDGRID_APIKEY` and `SENDGRID_FROM_EMAIL` (see the `sendgrid` block in `application.yml`).
diff --git a/guides/grails-email/v8/guide/smtpMail.adoc b/guides/grails-email/v8/guide/smtpMail.adoc new file mode 100644 index 0000000..0b5dcfe --- /dev/null +++ b/guides/grails-email/v8/guide/smtpMail.adoc
@@ -0,0 +1,17 @@ +The default provider delegates to the Grails Mail plugin: + +[source,groovy] +.grails-app/services/example/SmtpEmailService.groovy +---- +include::../snippets/grails-app/services/example/SmtpEmailService.groovy[] +---- + +Configure SMTP settings in `application.yml`: + +[source,yaml] +.grails-app/conf/application.yml +---- +include::../snippets/grails-app/conf/application.yml[] +---- + +For local development, point `SMTP_HOST` and `SMTP_PORT` at a mail catcher on `localhost:1025`.
diff --git a/guides/grails-email/v8/guide/summary.adoc b/guides/grails-email/v8/guide/summary.adoc new file mode 100644 index 0000000..f6f2cd1 --- /dev/null +++ b/guides/grails-email/v8/guide/summary.adoc
@@ -0,0 +1,8 @@ +You added outbound email to a Grails 8 application: + +* An `EmailService` boundary with SMTP, SendGrid, and AWS SES implementations +* Externalized provider and mail settings in `application.yml` +* A REST controller with command-object validation +* Spock Spring integration tests that mock the sender, with no live email in CI + +Next steps: add HTML templates, queue outbound mail asynchronously, or wire email into domain events.
diff --git a/guides/grails-email/v8/guide/testing.adoc b/guides/grails-email/v8/guide/testing.adoc new file mode 100644 index 0000000..19d0be8 --- /dev/null +++ b/guides/grails-email/v8/guide/testing.adoc
@@ -0,0 +1,17 @@ +Verify the HTTP endpoint builds the message and invokes `EmailService` without sending live email. Register a Spock mock as the primary `EmailService` bean: + +[source,groovy] +.src/integration-test/groovy/example/MailControllerSpec.groovy +---- +include::../snippets/src/integration-test/groovy/example/MailControllerSpec.groovy[] +---- + +The spec posts to `/mail/send` and asserts `emailService.send` is called with the expected recipient, subject, and body. + +Run tests: + +[source,bash] +---- +cd ../complete +./gradlew test integrationTest +----
diff --git a/guides/grails-email/v8/snippets/build.gradle b/guides/grails-email/v8/snippets/build.gradle new file mode 100644 index 0000000..fe7513c --- /dev/null +++ b/guides/grails-email/v8/snippets/build.gradle
@@ -0,0 +1,3 @@ + implementation "org.grails.plugins:grails-mail:5.0.3" + implementation "com.sendgrid:sendgrid-java:4.9.3" + implementation "software.amazon.awssdk:ses:2.29.45"
diff --git a/guides/grails-email/v8/snippets/grails-app/conf/application.yml b/guides/grails-email/v8/snippets/grails-app/conf/application.yml new file mode 100644 index 0000000..dfcc859 --- /dev/null +++ b/guides/grails-email/v8/snippets/grails-app/conf/application.yml
@@ -0,0 +1,28 @@ +#tag::mailConfig[] +grails: + mail: + host: ${SMTP_HOST:localhost} + port: ${SMTP_PORT:1025} + username: ${SMTP_USERNAME:} + password: ${SMTP_PASSWORD:} + default: + from: ${SMTP_FROM:no-reply@example.com} + props: + mail.smtp.auth: ${SMTP_AUTH:false} + mail.smtp.starttls.enable: ${SMTP_STARTTLS:false} +#end::mailConfig[] +#tag::emailProvider[] +email: + provider: ${EMAIL_PROVIDER:smtp} +#end::emailProvider[] +#tag::sendgrid[] +sendgrid: + api: ${SENDGRID_APIKEY:} + from: ${SENDGRID_FROM_EMAIL:} +#end::sendgrid[] +#tag::awsses[] +aws: + ses: + source: ${AWS_SOURCE:} + region: ${AWS_REGION:} +#end::awsses[]
diff --git a/guides/grails-email/v8/snippets/grails-app/conf/spring/resources.groovy b/guides/grails-email/v8/snippets/grails-app/conf/spring/resources.groovy new file mode 100644 index 0000000..975fdf8 --- /dev/null +++ b/guides/grails-email/v8/snippets/grails-app/conf/spring/resources.groovy
@@ -0,0 +1,26 @@ +import example.AwsSesEmailService +import example.SendGridEmailService +import example.SmtpEmailService +import grails.util.Environment + +beans = { + if (Environment.current == Environment.TEST) { + return + } + //tag::emailServiceBeans[] + def provider = application.config.getProperty('email.provider', String, 'smtp') + switch (provider) { + case 'sendgrid': + emailService(SendGridEmailService) + break + case 'ses': + emailService(AwsSesEmailService) + break + case 'smtp': + emailService(SmtpEmailService) + break + default: + throw new IllegalArgumentException("Unsupported email.provider: ${provider}") + } + //end::emailServiceBeans[] +}
diff --git a/guides/grails-email/v8/snippets/grails-app/controllers/example/EmailCmd.groovy b/guides/grails-email/v8/snippets/grails-app/controllers/example/EmailCmd.groovy new file mode 100644 index 0000000..0675da4 --- /dev/null +++ b/guides/grails-email/v8/snippets/grails-app/controllers/example/EmailCmd.groovy
@@ -0,0 +1,27 @@ +package example + +import grails.compiler.GrailsCompileStatic +import grails.validation.Validateable +import groovy.transform.ToString + +@ToString +@GrailsCompileStatic +class EmailCmd implements Validateable, Email { + String recipient + List<String> cc = [] + List<String> bcc = [] + String subject + String htmlBody + String textBody + String replyTo + + static constraints = { + recipient nullable: false // <1> + subject nullable: false // <2> + htmlBody nullable: true + textBody nullable: true, validator: { String val, EmailCmd obj -> // <3> + !(!obj.htmlBody && !val) + } + replyTo nullable: true + } +}
diff --git a/guides/grails-email/v8/snippets/grails-app/controllers/example/MailController.groovy b/guides/grails-email/v8/snippets/grails-app/controllers/example/MailController.groovy new file mode 100644 index 0000000..89678f2 --- /dev/null +++ b/guides/grails-email/v8/snippets/grails-app/controllers/example/MailController.groovy
@@ -0,0 +1,25 @@ +package example + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY + +@Slf4j +@CompileStatic +class MailController { + + EmailService emailService + + static allowedMethods = [send: 'POST'] + + def send(EmailCmd cmd) { + if (cmd.hasErrors()) { + respond cmd.errors, status: UNPROCESSABLE_ENTITY + return + } + log.info 'Sending mail' + emailService.send(cmd) + render status: 200 + } +}
diff --git a/guides/grails-email/v8/snippets/grails-app/services/example/SmtpEmailService.groovy b/guides/grails-email/v8/snippets/grails-app/services/example/SmtpEmailService.groovy new file mode 100644 index 0000000..2556127 --- /dev/null +++ b/guides/grails-email/v8/snippets/grails-app/services/example/SmtpEmailService.groovy
@@ -0,0 +1,36 @@ +package example + +import grails.plugins.mail.MailService +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +@Slf4j +@CompileStatic +class SmtpEmailService implements EmailService { // <1> + + MailService mailService + + @Override + void send(Email email) { + mailService.sendMail { + to email.recipient + subject email.subject + if (email.textBody) { + text email.textBody + } + if (email.htmlBody) { + html email.htmlBody + } + if (email.replyTo) { + replyTo email.replyTo + } + if (email.cc) { + cc email.cc as String[] + } + if (email.bcc) { + bcc email.bcc as String[] + } + } + log.debug('SMTP message handed off to MailService for {}', email.recipient) + } +}
diff --git a/guides/grails-email/v8/snippets/grails-app/views/errors/_errors.gson b/guides/grails-email/v8/snippets/grails-app/views/errors/_errors.gson new file mode 100644 index 0000000..88e3ae0 --- /dev/null +++ b/guides/grails-email/v8/snippets/grails-app/views/errors/_errors.gson
@@ -0,0 +1,44 @@ +import org.springframework.validation.* + +/** + * Renders validation errors according to vnd.error: https://github.com/blongden/vnd.error + */ +model { + Errors errors +} + +response.status UNPROCESSABLE_ENTITY + +json { + Errors errorsObject = (Errors)this.errors + def allErrors = errorsObject.allErrors + int errorCount = allErrors.size() + String contextPath = request.contextPath ?: '' + String resourcePath = request.uri + String contextRelativePath = resourcePath.startsWith(contextPath) ? resourcePath.substring(contextPath.length()) : resourcePath + String resourceLink = g.link(uri: contextRelativePath, absolute: true) + if(errorCount == 1) { + def error = allErrors.iterator().next() + message messageSource.getMessage(error, locale) + path resourcePath + _links { + self { + href resourceLink + } + } + } + else { + total errorCount + _embedded { + errors(allErrors) { ObjectError error -> + message messageSource.getMessage(error, locale) + path resourcePath + _links { + self { + href resourceLink + } + } + } + } + } +}
diff --git a/guides/grails-email/v8/snippets/src/integration-test/groovy/example/MailControllerSpec.groovy b/guides/grails-email/v8/snippets/src/integration-test/groovy/example/MailControllerSpec.groovy new file mode 100644 index 0000000..9c4efb6 --- /dev/null +++ b/guides/grails-email/v8/snippets/src/integration-test/groovy/example/MailControllerSpec.groovy
@@ -0,0 +1,108 @@ +package example + +import grails.testing.mixin.integration.Integration +import groovy.json.JsonSlurper +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestTemplate +import spock.lang.Specification +import spock.mock.DetachedMockFactory + +@Integration +class MailControllerSpec extends Specification { + + @Autowired + EmailService emailService + + void '/mail/send invokes email service with expected message'() { + when: + RestTemplate client = new RestTemplate() + ResponseEntity<Map> resp = client.postForEntity( + "http://localhost:$serverPort/mail/send", + [ + subject : 'Test', + recipient: 'delamos@grails.example', + textBody : 'Hola hola' + ], + Map + ) + + then: + resp.statusCode == HttpStatus.OK + 1 * emailService.send({ Email email -> + email.recipient == 'delamos@grails.example' && + email.subject == 'Test' && + email.textBody == 'Hola hola' + }) // <1> + } + + void 'invalid request without recipient returns 422 with validation errors'() { + when: + RestTemplate client = new RestTemplate() + HttpClientErrorException ex = null + try { + client.postForEntity( + "http://localhost:$serverPort/mail/send", + [ + subject : 'Test', + textBody: 'Hola hola' + ], + Map + ) + } catch (HttpClientErrorException e) { + ex = e + } + + then: + ex != null + ex.statusCode == HttpStatus.UNPROCESSABLE_CONTENT + def body = new JsonSlurper().parseText(ex.responseBodyAsString) + body.message + body.path == '/mail/send' + body._links.self.href == "http://localhost:$serverPort/mail/send" + 0 * emailService.send(_) + } + + void 'invalid request without subject returns 422 with validation errors'() { + when: + RestTemplate client = new RestTemplate() + HttpClientErrorException ex = null + try { + client.postForEntity( + "http://localhost:$serverPort/mail/send", + [ + recipient: 'delamos@grails.example', + textBody : 'Hola hola' + ], + Map + ) + } catch (HttpClientErrorException e) { + ex = e + } + + then: + ex != null + ex.statusCode == HttpStatus.UNPROCESSABLE_CONTENT + def body = new JsonSlurper().parseText(ex.responseBodyAsString) + body.message + body.path == '/mail/send' + body._links.self.href == "http://localhost:$serverPort/mail/send" + 0 * emailService.send(_) + } + + @TestConfiguration + static class EmailServiceConfiguration { + private DetachedMockFactory factory = new DetachedMockFactory() + + @Bean + @Primary + EmailService emailService() { + factory.Mock(EmailService) + } + } +}
diff --git a/guides/grails-email/v8/snippets/src/main/groovy/example/AwsSesEmailService.groovy b/guides/grails-email/v8/snippets/src/main/groovy/example/AwsSesEmailService.groovy new file mode 100644 index 0000000..752144d --- /dev/null +++ b/guides/grails-email/v8/snippets/src/main/groovy/example/AwsSesEmailService.groovy
@@ -0,0 +1,83 @@ +package example + +import grails.config.Config +import grails.core.support.GrailsConfigurationAware +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ses.SesClient +import software.amazon.awssdk.services.ses.model.Body +import software.amazon.awssdk.services.ses.model.Content +import software.amazon.awssdk.services.ses.model.Destination +import software.amazon.awssdk.services.ses.model.Message +import software.amazon.awssdk.services.ses.model.SendEmailRequest +import software.amazon.awssdk.services.ses.model.SendEmailResponse + +@Slf4j +@CompileStatic +class AwsSesEmailService implements EmailService, GrailsConfigurationAware { // <1> + + String sourceEmail + SesClient sesClient + + @Override + void setConfiguration(Config co) { + String awsRegion = co.getProperty('aws.ses.region', String) + if (!awsRegion) { + throw new IllegalStateException('aws.ses.region not set') + } + this.sesClient = SesClient.builder().region(Region.of(awsRegion)).build() + + this.sourceEmail = co.getProperty('aws.ses.source', String) + if (!this.sourceEmail) { + throw new IllegalStateException('aws.ses.source not set') + } + } + + private Body bodyOfEmail(Email email) { + Body.Builder bodyBuilder = Body.builder() + if (email.htmlBody) { + bodyBuilder.html(Content.builder().data(email.htmlBody).build()) + } + if (email.textBody) { + bodyBuilder.text(Content.builder().data(email.textBody).build()) + } + bodyBuilder.build() + } + + private Destination destination(Email email) { + Destination.Builder destinationBuilder = Destination.builder().toAddresses(email.recipient) + if (email.cc) { + destinationBuilder = destinationBuilder.ccAddresses(email.cc) + } + if (email.bcc) { + destinationBuilder = destinationBuilder.bccAddresses(email.bcc) + } + destinationBuilder.build() + } + + private Message composeMessage(Email email) { + Content subject = Content.builder().data(email.subject).build() + Body body = bodyOfEmail(email) + Message.builder().subject(subject).body(body).build() + } + + @Override + void send(Email email) { + try { + Destination destination = destination(email) + Message message = composeMessage(email) + SendEmailRequest sendEmailRequest = SendEmailRequest.builder() + .source(sourceEmail) + .destination(destination) + .message(message) + .build() + SendEmailResponse response = sesClient.sendEmail(sendEmailRequest) + log.info('Email sent! {}', response.messageId()) + + } catch (Exception ex) { + log.warn('The email was not sent.', ex) + throw ex + } + } +}
diff --git a/guides/grails-email/v8/snippets/src/main/groovy/example/Email.groovy b/guides/grails-email/v8/snippets/src/main/groovy/example/Email.groovy new file mode 100644 index 0000000..c1e38aa --- /dev/null +++ b/guides/grails-email/v8/snippets/src/main/groovy/example/Email.groovy
@@ -0,0 +1,14 @@ +package example + +import groovy.transform.CompileStatic + +@CompileStatic +interface Email { + String getRecipient() + List<String> getCc() + List<String> getBcc() + String getSubject() + String getHtmlBody() + String getTextBody() + String getReplyTo() +}
diff --git a/guides/grails-email/v8/snippets/src/main/groovy/example/EmailService.groovy b/guides/grails-email/v8/snippets/src/main/groovy/example/EmailService.groovy new file mode 100644 index 0000000..f219336 --- /dev/null +++ b/guides/grails-email/v8/snippets/src/main/groovy/example/EmailService.groovy
@@ -0,0 +1,8 @@ +package example + +import groovy.transform.CompileStatic + +@CompileStatic +interface EmailService { + void send(Email email) +}
diff --git a/guides/grails-email/v8/snippets/src/main/groovy/example/SendGridEmailService.groovy b/guides/grails-email/v8/snippets/src/main/groovy/example/SendGridEmailService.groovy new file mode 100644 index 0000000..58aa80a --- /dev/null +++ b/guides/grails-email/v8/snippets/src/main/groovy/example/SendGridEmailService.groovy
@@ -0,0 +1,93 @@ +package example + +import com.sendgrid.Method +import com.sendgrid.Request +import com.sendgrid.Response +import com.sendgrid.SendGrid +import com.sendgrid.helpers.mail.Mail +import com.sendgrid.helpers.mail.objects.Content +import com.sendgrid.helpers.mail.objects.Email as SendGridEmail +import com.sendgrid.helpers.mail.objects.Personalization +import grails.config.Config +import grails.core.support.GrailsConfigurationAware +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +@Slf4j +@CompileStatic +class SendGridEmailService implements EmailService, GrailsConfigurationAware { // <1> + + String api + String from + + @Override + void setConfiguration(Config co) { + this.api = co.getProperty('sendgrid.api', String) + if (!this.api) { + throw new IllegalStateException('sendgrid.api not set') + } + this.from = co.getProperty('sendgrid.from', String) + if (!this.from) { + throw new IllegalStateException('sendgrid.from not set') + } + } + + @Override + void send(Email email) { + Mail mail = buildEmail(email) + SendGrid sg = new SendGrid(api) + Request request = new Request() + try { + request.with { + method = Method.POST + endpoint = 'mail/send' + body = mail.build() + } + Response response = sg.api(request) + log.info('Status Code: {}', String.valueOf(response.statusCode)) + log.debug('Body: {}', response.body) + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new IllegalStateException( + "SendGrid returned status ${response.statusCode}: ${response.body}") + } + } catch (IOException ex) { + log.error(ex.message, ex) + throw ex + } + } + + private Personalization buildPersonalization(Email email) { + Personalization personalization = new Personalization() + personalization.subject = email.subject + + SendGridEmail to = new SendGridEmail(email.recipient) + personalization.addTo(to) + + if (email.cc) { + for (String cc : email.cc) { + personalization.addCc(new SendGridEmail(cc)) + } + } + if (email.bcc) { + for (String bcc : email.bcc) { + personalization.addBcc(new SendGridEmail(bcc)) + } + } + personalization + } + + private Mail buildEmail(Email email) { + Personalization personalization = buildPersonalization(email) + Mail mail = new Mail() + SendGridEmail fromEmail = new SendGridEmail(this.from) + mail.from = fromEmail + mail.addPersonalization(personalization) + if (email.textBody) { + mail.addContent(new Content('text/plain', email.textBody)) + } + if (email.htmlBody) { + mail.addContent(new Content('text/html', email.htmlBody)) + } + mail + } +}
diff --git a/guides/grails-http-client/v8/guide/domainAndControllers.adoc b/guides/grails-http-client/v8/guide/domainAndControllers.adoc new file mode 100644 index 0000000..3a91108 --- /dev/null +++ b/guides/grails-http-client/v8/guide/domainAndControllers.adoc
@@ -0,0 +1,40 @@ +Local record label data stays in GORM while album search results come from the external API. + +[[recordLabel]] +== RecordLabel domain + +[source,groovy] +.grails-app/domain/example/RecordLabel.groovy +---- +include::../snippets/grails-app/domain/example/RecordLabel.groovy[] +---- + +Seed development data in `BootStrap`: + +[source,groovy] +.grails-app/init/example/BootStrap.groovy +---- +include::../snippets/grails-app/init/example/BootStrap.groovy[] +---- + +[[urlMappings]] +== URL mappings + +[source,groovy] +.grails-app/controllers/example/UrlMappings.groovy +---- +include::../snippets/grails-app/controllers/example/UrlMappings.groovy[] +---- + +[[recordLabelController]] +== RecordLabelController + +The REST controller delegates persistence to GORM and returns JSON views: + +[source,groovy] +.grails-app/controllers/example/RecordLabelController.groovy +---- +include::../snippets/grails-app/controllers/example/RecordLabelController.groovy[] +---- + +Call `validate()` after binding request JSON and before `save`. That returns structured `422` responses for constraint violations.
diff --git a/guides/grails-http-client/v8/guide/gettingStarted.adoc b/guides/grails-http-client/v8/guide/gettingStarted.adoc new file mode 100644 index 0000000..838e7ff --- /dev/null +++ b/guides/grails-http-client/v8/guide/gettingStarted.adoc
@@ -0,0 +1,22 @@ +Clone the repository and run the starting application: + +[source,bash] +---- +git clone -b grails8 https://github.com/grails-guides/grails-http-client.git +cd grails-http-client/initial +./gradlew bootRun +---- + +Open http://localhost:8080/[http://localhost:8080/] for the welcome JSON payload. The `initial/` project is a vanilla Grails 8 REST API starter with no HTTP client yet. + +To skip ahead, `cd ../complete` and run the same commands - that tree contains the finished `@HttpExchange` client, services, and tests. + +[[cloneAndRun]] +== Verify tests + +[source,bash] +---- +./gradlew test +---- + +Both `initial` and `complete` must pass in CI (see `.github/workflows/grails8.yml`).
diff --git a/guides/grails-http-client/v8/guide/helpWithGrails.adoc b/guides/grails-http-client/v8/guide/helpWithGrails.adoc new file mode 100644 index 0000000..e062f61 --- /dev/null +++ b/guides/grails-http-client/v8/guide/helpWithGrails.adoc
@@ -0,0 +1 @@ +include::{commondir}/common-helpWithGrails.adoc[]
diff --git a/guides/grails-http-client/v8/guide/httpClientSetup.adoc b/guides/grails-http-client/v8/guide/httpClientSetup.adoc new file mode 100644 index 0000000..904089d --- /dev/null +++ b/guides/grails-http-client/v8/guide/httpClientSetup.adoc
@@ -0,0 +1,47 @@ +Register HTTP service interfaces with `@ImportHttpServices` and explicitly import the client configuration: + +[source,groovy] +.grails-app/init/example/Application.groovy +---- +include::../snippets/grails-app/init/example/Application.groovy[] +---- + +`@ImportHttpServices` scans `example` for `@HttpExchange` interfaces and registers a `RestClient`-backed proxy bean for each one. `@Import(ItunesClientConfiguration)` ensures Grails loads the `RestClient` group configurer. This requires the Grails 8 Spring 7 / Boot 4 baseline. + +[[itunesClient]] +== ItunesClient + +Declare the iTunes Search API client as a Spring HTTP service interface: + +[source,groovy] +.src/main/groovy/example/ItunesClient.groovy +---- +include::../snippets/src/main/groovy/example/ItunesClient.groovy[] +---- + +Configure the `RestClient` base URL from `itunes.base-url` (defaults to the public iTunes API; integration tests override it with MockWebServer). The iTunes API returns JSON with a `text/javascript` content type, so add that media type to Spring's Jackson converter: + +[source,groovy] +.src/main/groovy/example/ItunesClientConfiguration.groovy +---- +include::../snippets/src/main/groovy/example/ItunesClientConfiguration.groovy[] +---- + +[[dtos]] +== Response DTOs + +Map the JSON response with simple POGOs: + +[source,groovy] +.src/main/groovy/example/Album.groovy +---- +include::../snippets/src/main/groovy/example/Album.groovy[] +---- + +[source,groovy] +.src/main/groovy/example/SearchResult.groovy +---- +include::../snippets/src/main/groovy/example/SearchResult.groovy[] +---- + +With `spring-boot-starter-json` on the classpath and `text/javascript` enabled above, Spring deserializes the iTunes JSON into these types automatically.
diff --git a/guides/grails-http-client/v8/guide/introduction.adoc b/guides/grails-http-client/v8/guide/introduction.adoc new file mode 100644 index 0000000..998dea4 --- /dev/null +++ b/guides/grails-http-client/v8/guide/introduction.adoc
@@ -0,0 +1,5 @@ +Learn how to call external REST APIs from a Grails 8 application using Spring Framework HTTP Services: define a `@HttpExchange` interface, register it with `@ImportHttpServices`, inject the generated client into a Grails service, and expose results through JSON views. Local `RecordLabel` data stays in GORM/PostgreSQL; album metadata comes from the iTunes Search API. + +No Micronaut plugin is required - this uses the same Spring stack Grails 8 already runs on. Add `spring-boot-starter-json` so the HTTP client can deserialize JSON responses. + +This guide follows the standard Grails guides layout: work in the `initial/` project and compare your progress with `complete/`.
diff --git a/guides/grails-http-client/v8/guide/requirements.adoc b/guides/grails-http-client/v8/guide/requirements.adoc new file mode 100644 index 0000000..738e931 --- /dev/null +++ b/guides/grails-http-client/v8/guide/requirements.adoc
@@ -0,0 +1,5 @@ +* Approximately 45 minutes +* JDK 21 (Apache Grails 8 requires Java 21) +* A https://www.grails.org/[Grails] installation or the bundled Gradle wrapper in `initial/` and `complete/` +* **Docker** - required for integration tests (Testcontainers PostgreSQL) +* **PostgreSQL** on `localhost:5432` - required for `./gradlew bootRun` in both `initial/` and `complete/` (default database `devDb`; see `grails-app/conf/application.yml`)
diff --git a/guides/grails-http-client/v8/guide/runningTheApp.adoc b/guides/grails-http-client/v8/guide/runningTheApp.adoc new file mode 100644 index 0000000..2d4dcdc --- /dev/null +++ b/guides/grails-http-client/v8/guide/runningTheApp.adoc
@@ -0,0 +1,17 @@ +From the repository root: + +[source,bash] +---- +cd complete +./gradlew bootRun +---- + +Example endpoints: + +[source,bash] +---- +curl -s http://localhost:8080/api/recordLabels +curl -s 'http://localhost:8080/api/search?q=U2' +---- + +The search endpoint returns album metadata from the iTunes Search API. Record labels are served from your local PostgreSQL database.
diff --git a/guides/grails-http-client/v8/guide/searchService.adoc b/guides/grails-http-client/v8/guide/searchService.adoc new file mode 100644 index 0000000..58e441b --- /dev/null +++ b/guides/grails-http-client/v8/guide/searchService.adoc
@@ -0,0 +1,17 @@ +Inject the `@HttpExchange` client into a Grails service: + +[source,groovy] +.grails-app/services/example/ItunesSearchService.groovy +---- +include::../snippets/grails-app/services/example/ItunesSearchService.groovy[] +---- + +Expose search results through a thin controller: + +[source,groovy] +.grails-app/controllers/example/SearchController.groovy +---- +include::../snippets/grails-app/controllers/example/SearchController.groovy[] +---- + +The service trims blank search terms and returns an empty list rather than calling the remote API with invalid input.
diff --git a/guides/grails-http-client/v8/guide/summary.adoc b/guides/grails-http-client/v8/guide/summary.adoc new file mode 100644 index 0000000..74ff4bf --- /dev/null +++ b/guides/grails-http-client/v8/guide/summary.adoc
@@ -0,0 +1,8 @@ +You added a declarative HTTP client to a Grails 8 REST API: + +* `@ImportHttpServices` and a `@HttpExchange` interface for the iTunes Search API +* A Grails service that injects the generated client +* Local GORM data alongside external API results +* Spock unit and integration tests + +Next steps: add error handling for remote API failures, cache search results, or secure outbound calls with API keys.
diff --git a/guides/grails-http-client/v8/guide/testing.adoc b/guides/grails-http-client/v8/guide/testing.adoc new file mode 100644 index 0000000..66003d6 --- /dev/null +++ b/guides/grails-http-client/v8/guide/testing.adoc
@@ -0,0 +1,52 @@ +[[unitTests]] +== Unit tests + +Domain constraints: + +[source,groovy] +.src/test/groovy/example/RecordLabelSpec.groovy +---- +include::../snippets/src/test/groovy/example/RecordLabelSpec.groovy[] +---- + +Service delegation to the HTTP client with a mock: + +[source,groovy] +.src/test/groovy/example/ItunesSearchServiceSpec.groovy +---- +include::../snippets/src/test/groovy/example/ItunesSearchServiceSpec.groovy[] +---- + +[[integrationTests]] +== Integration tests + +Verify the HTTP client is registered as a Spring bean, encodes query values, accepts the iTunes API's `text/javascript` response, and deserializes albums: + +[source,groovy] +.src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy +---- +include::../snippets/src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy[] +---- + +Assert GORM persistence against real PostgreSQL (Testcontainers): + +[source,groovy] +.src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy +---- +include::../snippets/src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy[] +---- + +Run unit tests (from the repository root): + +[source,bash] +---- +cd complete +./gradlew test +---- + +Run integration tests (requires Docker): + +[source,bash] +---- +./gradlew integrationTest +----
diff --git a/guides/grails-http-client/v8/snippets/grails-app/controllers/example/RecordLabelController.groovy b/guides/grails-http-client/v8/snippets/grails-app/controllers/example/RecordLabelController.groovy new file mode 100644 index 0000000..a065bed --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/controllers/example/RecordLabelController.groovy
@@ -0,0 +1,56 @@ +package example + +import grails.gorm.transactions.Transactional + +class RecordLabelController { + + static responseFormats = ['json'] + static allowedMethods = [index: 'GET', show: 'GET', save: 'POST', update: 'PUT', delete: 'DELETE'] + + def index(Integer max) { + params.max = Math.min(max ?: 10, 100) + respond RecordLabel.list(params), model: [recordLabelCount: RecordLabel.count()] + } + + def show(Long id) { + respond RecordLabel.get(id) + } + + @Transactional + def save() { + def recordLabel = new RecordLabel(request.JSON as Map) + if (!recordLabel.validate()) { + respond recordLabel.errors, status: 422 + return + } + recordLabel.save(failOnError: true, flush: true) + respond recordLabel, status: 201 + } + + @Transactional + def update(Long id) { + def recordLabel = RecordLabel.get(id) + if (!recordLabel) { + render status: 404 + return + } + recordLabel.properties = request.JSON + if (!recordLabel.validate()) { + respond recordLabel.errors, status: 422 + return + } + recordLabel.save(failOnError: true, flush: true) + respond recordLabel + } + + @Transactional + def delete(Long id) { + def recordLabel = RecordLabel.get(id) + if (!recordLabel) { + render status: 404 + return + } + recordLabel.delete(flush: true) + render status: 204 + } +}
diff --git a/guides/grails-http-client/v8/snippets/grails-app/controllers/example/SearchController.groovy b/guides/grails-http-client/v8/snippets/grails-app/controllers/example/SearchController.groovy new file mode 100644 index 0000000..cfc7d72 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/controllers/example/SearchController.groovy
@@ -0,0 +1,21 @@ +package example + +import groovy.transform.CompileStatic + +@CompileStatic +class SearchController { + + static responseFormats = ['json'] + static allowedMethods = [index: 'GET'] + + ItunesSearchService itunesSearchService + + def index(String q) { + if (!q?.trim()) { + respond([searchTerm: q, albums: []]) + return + } + List<Album> albums = itunesSearchService.searchAlbums(q) + respond([searchTerm: q.trim(), albums: albums]) + } +}
diff --git a/guides/grails-http-client/v8/snippets/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-http-client/v8/snippets/grails-app/controllers/example/UrlMappings.groovy new file mode 100644 index 0000000..991686c --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
@@ -0,0 +1,19 @@ +package example + +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?"{ + constraints { + // apply constraints here + } + } + + "/api/search"(controller: 'search', action: 'index') + "/api/recordLabels"(resources: 'recordLabel') + + "/"(controller: 'application', action: 'index') + "500"(view: '/error') + "404"(view: '/notFound') + } +}
diff --git a/guides/grails-http-client/v8/snippets/grails-app/domain/example/RecordLabel.groovy b/guides/grails-http-client/v8/snippets/grails-app/domain/example/RecordLabel.groovy new file mode 100644 index 0000000..ca0e595 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/domain/example/RecordLabel.groovy
@@ -0,0 +1,19 @@ +package example + +import grails.persistence.Entity +import groovy.util.logging.Slf4j + +@Slf4j +@Entity +class RecordLabel { + + String name + + static constraints = { + name blank: false, nullable: false, maxSize: 100, unique: true + } + + String toString() { + name + } +}
diff --git a/guides/grails-http-client/v8/snippets/grails-app/init/example/Application.groovy b/guides/grails-http-client/v8/snippets/grails-app/init/example/Application.groovy new file mode 100644 index 0000000..fd19cda --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/init/example/Application.groovy
@@ -0,0 +1,16 @@ +package example + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import groovy.transform.CompileStatic +import org.springframework.context.annotation.Import +import org.springframework.web.service.registry.ImportHttpServices + +@CompileStatic +@ImportHttpServices(basePackages = 'example') +@Import(ItunesClientConfiguration) +class Application extends GrailsAutoConfiguration { + static void main(String[] args) { + GrailsApp.run(Application, args) + } +}
diff --git a/guides/grails-http-client/v8/snippets/grails-app/init/example/BootStrap.groovy b/guides/grails-http-client/v8/snippets/grails-app/init/example/BootStrap.groovy new file mode 100644 index 0000000..31657b3 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/init/example/BootStrap.groovy
@@ -0,0 +1,21 @@ +package example + +class BootStrap { + + def init = { servletContext -> + environments { + development { + RecordLabel.withTransaction { + if (RecordLabel.count() == 0) { + ['Island Records', 'Motown', 'Blue Note'].each { labelName -> + new RecordLabel(name: labelName).save(failOnError: true) + } + } + } + } + } + } + + def destroy = { + } +}
diff --git a/guides/grails-http-client/v8/snippets/grails-app/services/example/ItunesSearchService.groovy b/guides/grails-http-client/v8/snippets/grails-app/services/example/ItunesSearchService.groovy new file mode 100644 index 0000000..b6073de --- /dev/null +++ b/guides/grails-http-client/v8/snippets/grails-app/services/example/ItunesSearchService.groovy
@@ -0,0 +1,19 @@ +package example + +import groovy.transform.CompileStatic +import org.springframework.beans.factory.annotation.Autowired + +@CompileStatic +class ItunesSearchService { + + @Autowired + ItunesClient itunesClient + + List<Album> searchAlbums(String searchTerm) { + if (!searchTerm?.trim()) { + return [] + } + SearchResult searchResult = itunesClient.search(searchTerm.trim()) + searchResult?.results ?: [] + } +}
diff --git a/guides/grails-http-client/v8/snippets/src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy b/guides/grails-http-client/v8/snippets/src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy new file mode 100644 index 0000000..24a8c38 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy
@@ -0,0 +1,68 @@ +package example + +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import spock.lang.Specification + +@Integration +@Rollback +class ItunesClientIntegrationSpec extends Specification { + + static MockWebServer mockWebServer = new MockWebServer() + + static { + mockWebServer.start() + } + + @Autowired + ItunesClient itunesClient + + @DynamicPropertySource + static void itunesBaseUrl(DynamicPropertyRegistry registry) { + String baseUrl = mockWebServer.url('/').toString() + if (baseUrl.endsWith('/')) { + baseUrl = baseUrl[0..-2] + } + registry.add('itunes.base-url', { baseUrl }) + } + + void cleanupSpec() { + mockWebServer.shutdown() + } + + void 'declarative ItunesClient HTTP service is registered as a Spring bean'() { + expect: + itunesClient != null + itunesClient instanceof ItunesClient + } + + void 'search binds the term query parameter and deserializes albums'() { + given: + String searchTerm = 'U2 & Friends' + mockWebServer.enqueue(new MockResponse() + .setHeader('Content-Type', 'text/javascript; charset=utf-8') + .setBody('''{"resultCount":1,"results":[{"artistName":"U2","collectionName":"The Joshua Tree","collectionViewUrl":"https://example.com/album"}]}''')) + + when: + SearchResult result = itunesClient.search(searchTerm) + + then: + result.resultCount == 1 + result.results.size() == 1 + result.results[0].artistName == 'U2' + result.results[0].collectionName == 'The Joshua Tree' + result.results[0].collectionViewUrl == 'https://example.com/album' + + and: + RecordedRequest request = mockWebServer.takeRequest() + request.method == 'GET' + request.path.contains('/search?') + request.requestUrl.queryParameter('term') == searchTerm + } +}
diff --git a/guides/grails-http-client/v8/snippets/src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy b/guides/grails-http-client/v8/snippets/src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy new file mode 100644 index 0000000..e62adae --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy
@@ -0,0 +1,21 @@ +package example + +import grails.testing.mixin.integration.Integration +import grails.gorm.transactions.Rollback +import spock.lang.Specification + +@Integration +@Rollback +class RecordLabelIntegrationSpec extends Specification { + + void 'record labels persist in PostgreSQL'() { + when: + RecordLabel.withTransaction { + new RecordLabel(name: 'Integration Label').save(flush: true, failOnError: true) + } + + then: + RecordLabel.count() >= 1 + RecordLabel.findByName('Integration Label') != null + } +}
diff --git a/guides/grails-http-client/v8/snippets/src/main/groovy/example/Album.groovy b/guides/grails-http-client/v8/snippets/src/main/groovy/example/Album.groovy new file mode 100644 index 0000000..c9a792f --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/main/groovy/example/Album.groovy
@@ -0,0 +1,10 @@ +package example + +import groovy.transform.CompileStatic + +@CompileStatic +class Album { + String artistName + String collectionName + String collectionViewUrl +}
diff --git a/guides/grails-http-client/v8/snippets/src/main/groovy/example/ItunesClient.groovy b/guides/grails-http-client/v8/snippets/src/main/groovy/example/ItunesClient.groovy new file mode 100644 index 0000000..33a6f5c --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/main/groovy/example/ItunesClient.groovy
@@ -0,0 +1,14 @@ +package example + +import groovy.transform.CompileStatic +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.HttpExchange + +@CompileStatic +@HttpExchange +interface ItunesClient { + + @GetExchange('/search?limit=25&media=music&entity=album&term={term}') + SearchResult search(@PathVariable('term') String term) +}
diff --git a/guides/grails-http-client/v8/snippets/src/main/groovy/example/ItunesClientConfiguration.groovy b/guides/grails-http-client/v8/snippets/src/main/groovy/example/ItunesClientConfiguration.groovy new file mode 100644 index 0000000..cd6afc8 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/main/groovy/example/ItunesClientConfiguration.groovy
@@ -0,0 +1,34 @@ +package example + +import groovy.transform.CompileStatic +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer + +@CompileStatic +@Configuration +class ItunesClientConfiguration { + + private static final MediaType JAVASCRIPT = MediaType.parseMediaType('text/javascript') + + @Bean + RestClientHttpServiceGroupConfigurer itunesBaseUrlConfigurer( + @Value('${itunes.base-url:https://itunes.apple.com}') String itunesBaseUrl) { + return { groups -> + groups.forEachClient { group, builder -> + builder.baseUrl(itunesBaseUrl) + builder.messageConverters { List<HttpMessageConverter<?>> converters -> + JacksonJsonHttpMessageConverter jacksonConverter = + (JacksonJsonHttpMessageConverter) converters.find { HttpMessageConverter<?> converter -> + converter instanceof JacksonJsonHttpMessageConverter + } + jacksonConverter.supportedMediaTypes = jacksonConverter.supportedMediaTypes + JAVASCRIPT + } + } + } + } +}
diff --git a/guides/grails-http-client/v8/snippets/src/main/groovy/example/SearchResult.groovy b/guides/grails-http-client/v8/snippets/src/main/groovy/example/SearchResult.groovy new file mode 100644 index 0000000..65e8cc1 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/main/groovy/example/SearchResult.groovy
@@ -0,0 +1,9 @@ +package example + +import groovy.transform.CompileStatic + +@CompileStatic +class SearchResult { + int resultCount + List<Album> results = [] +}
diff --git a/guides/grails-http-client/v8/snippets/src/test/groovy/example/ItunesSearchServiceSpec.groovy b/guides/grails-http-client/v8/snippets/src/test/groovy/example/ItunesSearchServiceSpec.groovy new file mode 100644 index 0000000..abe9b6d --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/test/groovy/example/ItunesSearchServiceSpec.groovy
@@ -0,0 +1,25 @@ +package example + +import spock.lang.Specification + +class ItunesSearchServiceSpec extends Specification { + + ItunesSearchService service = new ItunesSearchService() + + void 'searchAlbums delegates to the ItunesClient HTTP service'() { + given: + def client = Mock(ItunesClient) + service.itunesClient = client + def albums = [new Album(artistName: 'U2', collectionName: 'The Joshua Tree')] + client.search('U2') >> new SearchResult(resultCount: 1, results: albums) + + expect: + service.searchAlbums('U2') == albums + } + + void 'searchAlbums returns an empty list for blank terms'() { + expect: + service.searchAlbums(null).isEmpty() + service.searchAlbums(' ').isEmpty() + } +}
diff --git a/guides/grails-http-client/v8/snippets/src/test/groovy/example/RecordLabelSpec.groovy b/guides/grails-http-client/v8/snippets/src/test/groovy/example/RecordLabelSpec.groovy new file mode 100644 index 0000000..5cd56a6 --- /dev/null +++ b/guides/grails-http-client/v8/snippets/src/test/groovy/example/RecordLabelSpec.groovy
@@ -0,0 +1,32 @@ +package example + +import grails.testing.gorm.DataTest +import spock.lang.Specification + +class RecordLabelSpec extends Specification implements DataTest { + + Class<?>[] getDomainClassesToMock() { + [RecordLabel] as Class[] + } + + void 'name cannot be blank'() { + when: + RecordLabel recordLabel = new RecordLabel(name: '') + + then: + !recordLabel.validate() + recordLabel.errors['name'].code in ['blank', 'nullable'] + } + + void 'name must be unique'() { + given: + new RecordLabel(name: 'Motown').save(flush: true) + + when: + RecordLabel duplicate = new RecordLabel(name: 'Motown') + + then: + !duplicate.validate() + duplicate.errors['name'].code == 'unique' + } +}
diff --git a/guides/grails-mock-http-server/v3/guide/openweather.adoc b/guides/grails-mock-http-server/v3/guide/openweather.adoc index e56744e..f217422 100644 --- a/guides/grails-mock-http-server/v3/guide/openweather.adoc +++ b/guides/grails-mock-http-server/v3/guide/openweather.adoc
@@ -8,6 +8,4 @@ After you register, you get an API Key. You will need an API key to interact with the Open Weather Map API. -image::apiKey.png[] - TIP: the API key may take several minutes to become active.
diff --git a/guides/grails-mock-http-server/v3/guide/runningTheApp.adoc b/guides/grails-mock-http-server/v3/guide/runningTheApp.adoc index 9673a93..db89caa 100644 --- a/guides/grails-mock-http-server/v3/guide/runningTheApp.adoc +++ b/guides/grails-mock-http-server/v3/guide/runningTheApp.adoc
@@ -1,5 +1,5 @@ include::{commondir}/common-runapp.adoc[] -If you setup a valid API Key in `application.yml`, you will see the London weather prediction. +If you set the `OPENWEATHER_APPID` environment variable to a valid API key, you will see the London weather prediction. -image::homepage.png[] \ No newline at end of file +image::homepage.png[]
diff --git a/guides/grails-mock-http-server/v3/img/apiKey.png b/guides/grails-mock-http-server/v3/img/apiKey.png deleted file mode 100644 index 6d9be3c..0000000 --- a/guides/grails-mock-http-server/v3/img/apiKey.png +++ /dev/null Binary files differ
diff --git a/guides/grails-mock-http-server/v3/snippets/grails-app/conf/application.yml b/guides/grails-mock-http-server/v3/snippets/grails-app/conf/application.yml index 03edb2d..2910726 100644 --- a/guides/grails-mock-http-server/v3/snippets/grails-app/conf/application.yml +++ b/guides/grails-mock-http-server/v3/snippets/grails-app/conf/application.yml
@@ -132,7 +132,7 @@ --- #tag::openweathermap[] openweather: - appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4 + appid: '${OPENWEATHER_APPID:}' cityName: London countryCode: uk #end::openweathermap[]
diff --git a/guides/grails-mock-http-server/v4/guide/openweather.adoc b/guides/grails-mock-http-server/v4/guide/openweather.adoc index e56744e..f217422 100644 --- a/guides/grails-mock-http-server/v4/guide/openweather.adoc +++ b/guides/grails-mock-http-server/v4/guide/openweather.adoc
@@ -8,6 +8,4 @@ After you register, you get an API Key. You will need an API key to interact with the Open Weather Map API. -image::apiKey.png[] - TIP: the API key may take several minutes to become active.
diff --git a/guides/grails-mock-http-server/v4/guide/runningTheApp.adoc b/guides/grails-mock-http-server/v4/guide/runningTheApp.adoc index 9673a93..db89caa 100644 --- a/guides/grails-mock-http-server/v4/guide/runningTheApp.adoc +++ b/guides/grails-mock-http-server/v4/guide/runningTheApp.adoc
@@ -1,5 +1,5 @@ include::{commondir}/common-runapp.adoc[] -If you setup a valid API Key in `application.yml`, you will see the London weather prediction. +If you set the `OPENWEATHER_APPID` environment variable to a valid API key, you will see the London weather prediction. -image::homepage.png[] \ No newline at end of file +image::homepage.png[]
diff --git a/guides/grails-mock-http-server/v4/img/apiKey.png b/guides/grails-mock-http-server/v4/img/apiKey.png deleted file mode 100644 index 6d9be3c..0000000 --- a/guides/grails-mock-http-server/v4/img/apiKey.png +++ /dev/null Binary files differ
diff --git a/guides/grails-mock-http-server/v4/snippets/grails-app/conf/application.yml b/guides/grails-mock-http-server/v4/snippets/grails-app/conf/application.yml index 05c5c56..a81470e 100644 --- a/guides/grails-mock-http-server/v4/snippets/grails-app/conf/application.yml +++ b/guides/grails-mock-http-server/v4/snippets/grails-app/conf/application.yml
@@ -139,7 +139,7 @@ --- #tag::openweathermap[] openweather: - appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4 + appid: '${OPENWEATHER_APPID:}' cityName: London countryCode: uk #end::openweathermap[]
diff --git a/pages/community.html b/pages/community.html index bff6ddf..73c8381 100644 --- a/pages/community.html +++ b/pages/community.html
@@ -111,28 +111,25 @@ <img loading="lazy" height="200" src="[%url]/images/eric_helgeson.png" alt="Eric Helgeson - 2017 Grails Rock Star Award"> </div> + </div> + </article> + <article> + <h3 class="column-header">Grails Lifetime Contributor Awards</h3> + <div class="three-columns"> + <div class="column align-center"> + <img loading="lazy" height="200" src="[%url]/images/soren-glasius-2026-lifetime-contributor.png" + alt="Søren Glasius - 2026 Grails Lifetime Contributor Award"> + </div> <div class="column align-center"> <img loading="lazy" height="200" src="[%url]/images/graeme.png" - alt="Graeme Rocher - Grails framework lifetime contributor Award"> + alt="Graeme Rocher - Grails Lifetime Contributor Award"> </div> <div class="column align-center"> <img loading="lazy" height="200" src="[%url]/images/jeff_scott_brown.png" - alt="Jeff Scott Brown - Grails framework lifetime contributor Award"> + alt="Jeff Scott Brown - Grails Lifetime Contributor Award"> </div> </div> - </article> - <!-- TODO These events didn't end up happening, would have been past, and the copy is off.--> - <!-- <article>--> - <!-- <h3 class="column-header">Conferences</h3>--> - <!-- <p>The Grails framework and its ecosystem are often represented at various Java-oriented conferences, but there are particular events fully dedicated to the Groovy/Grails ecosystem. Here are the upcoming events you might be interested in learning about.</p><div class="two-columns">--> - <!-- <div class="column"><div class="event">--> - <!-- <img loading="lazy" src="[%url]/images/confs/gr8confeu.png" alt="GR8Conf EU 2020">--> - <!-- <h3>--> - <!-- <a href="https://gr8conf.eu/">GR8Conf EU 2020</a>--> - <!-- </h3>--> - <!-- <span class="location">Copenhagen, Denmark</span>--> - <!-- <span class="dates">June 2 - 4, 2020</span><p>Groovy, the Grails framework, and related technologies have seen astounding growth in interest and adoption over the past few years, and with good reason. GR8Conf is a series of conferences founded to spread the word worldwide. The 2018 GR8Conf Europe is celebrating its 10th year, and it's expected to be a blast. As in 2017 the conference had a DevOps day, this year DevOps topics will be mixed with the rest of the topics. GR8Conf is an independent, affordable series of conferences and covers All Things Groovy</p>--> <article> <h3 class="column-header">Contributors</h3> @@ -479,57 +476,6 @@ <div class="column"> <div class="guide-group"> <div class="guide-group-header"> - <img loading="lazy" src="[%url]/images/usergroup.svg" alt="North-America"> - <h2>North-America</h2> - </div> - <ul> - <!-- TODO a lot of these links are broken, I've no doubt many of the communities still exist in some form but how should we proceed? --> - <!-- <li>--> - <!-- <a href="https://www.meetup.com/Austin-Groovy-and-Grails-Users/">United States - Austin Groovy and Grails User Group (TX)</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://www.meetup.com/Grails-Boston/">United States - Boston Groovy, Grails, Spring Meetup (B2GS)</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://coderconsortium.com/">United States - Coder Consortium of Sacramento</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://www.dcgroovy.org/">United States - DC Groovy</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://dfw2gug.org/">United States - DFW Groovy & Grails User Group</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://www.meetup.com/Grails-and-Ales/">United States - Groovy and Grails Users of Columbus OH</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://groovy.mn/">United States - Groovy Users of Minnesota</a>--> - <!-- </li>--> - <!-- <li>--> - <!-- <a href="https://www.meetup.com/Los-Angeles-GUG/">United States - Los Angeles Groovy Users Group</a>--> - <!-- </li>--> - <li> - <a href="https://www.meetup.com/grails/">United States - NYC Groovy / Grails Meetup</a> - </li> - <!-- <li>--> - <!-- <a href="https://www.scottsdale-groovy.com/">United States - Scottsdale Groovy Brigade</a>--> - <!-- </li>--> - <li> - <a href="https://www.meetup.com/java-161/">United States - SF Bay Groovy and Grails Meetup - Group</a> - </li> - <li> - <a href="https://www.meetup.com/st-louis-groovy-and-grails-meetup/">United States - St. Louis - Groovy & Grails Meetup</a> - </li> - </ul> - </div> - </div> - - <div class="column"> - - <div class="guide-group"> - <div class="guide-group-header"> <img loading="lazy" src="[%url]/images/usergroup.svg" alt="Asia"> <h2>Asia</h2> </div> @@ -540,7 +486,9 @@ </li> </ul> </div> + </div> + <div class="column"> <div class="guide-group"> <div class="guide-group-header"> <img loading="lazy" src="[%url]/images/usergroup.svg" alt="Europe"> @@ -565,4 +513,4 @@ </p> </div> -</div> \ No newline at end of file +</div>
diff --git a/pages/index.html b/pages/index.html index 7c43966..475202a 100644 --- a/pages/index.html +++ b/pages/index.html
@@ -123,6 +123,17 @@ <div class="two-columns"> <div class="column"> <div class="event"> + <img src="[%url]/images/confs/stljug.jpg" alt="St. Louis Java User's Group" loading="lazy"> + <h3> + <a href="https://www.meetup.com/gatewayjug/events/314881147/">Full-Stack Simplicity Meets Agentic AI: An Introduction to Apache Grails 8</a> + </h3> + <span class="location">St. Louis, MO, United States</span> + <span class="dates">August 13, 2026</span> + <p>The St. Louis Java User's Group hosts a hybrid talk on Apache Grails 8.</p> + </div> + </div> + <div class="column"> + <div class="event"> <img src="[%url]/images/confs/communityovercode.png" alt="Community over Code" loading="lazy"> <h3> <a href="https://communityovercode.org">Community over Code</a>
diff --git a/pages/support-schedule.html b/pages/support-schedule.html index da5a11e..1209f06 100644 --- a/pages/support-schedule.html +++ b/pages/support-schedule.html
@@ -91,17 +91,17 @@ <tr> <td>7.2</td> <td>Active Development</td> - <td>Coming Soon</td> - <td>Coming Soon</td> - <td>Jun 2026</td> - <td>Coming Soon</td> + <td>Jul 2026</td> + <td>Ongoing</td> + <td>Jul 2026</td> + <td><a href="support.html">Available</a></td> </tr> <tr> <td>7.1</td> <td>Active Maintenance</td> <td>Apr 2026</td> <td>Ongoing</td> - <td>Jun 2026</td> + <td>Jul 2026</td> <td><a href="support.html">Available</a></td> </tr> <tr> @@ -109,7 +109,7 @@ <td>Active Maintenance</td> <td>Oct 2025</td> <td>Ongoing</td> - <td>Jun 2026</td> + <td>Jul 2026</td> <td><a href="support.html">Available</a></td> </tr> <tr>
diff --git a/pages/support.html b/pages/support.html index 9e513be..ed35bb0 100644 --- a/pages/support.html +++ b/pages/support.html
@@ -32,8 +32,9 @@ <ul> <li>Europe <ul> - <li><a href="https://gtit.dk/" rel="nofollow noopener noreferrer" target="_blank">Gennemtænkt IT A/S (Denmark)</a> - <li><a href="https://www.softwareschneiderei.de/" rel="nofollow noopener noreferrer" target="_blank">Softwareschneiderei GmbH (Germany)</a> + <li><a href="https://gtit.dk/" rel="nofollow noopener noreferrer" target="_blank">Gennemtænkt IT A/S (Denmark)</a></li> + <li><a href="https://www.softwareschneiderei.de/" rel="nofollow noopener noreferrer" target="_blank">Softwareschneiderei GmbH (Germany)</a></li> + <li><a href="https://tucanoo.com/" rel="nofollow noopener noreferrer" target="_blank">Tucanoo Solutions Ltd</a> (United Kingdom)</li> </ul> </li> <li>North America
diff --git a/posts/2026-07-28-soren-glasius-lifetime-contributor-award.md b/posts/2026-07-28-soren-glasius-lifetime-contributor-award.md new file mode 100644 index 0000000..11bf30f --- /dev/null +++ b/posts/2026-07-28-soren-glasius-lifetime-contributor-award.md
@@ -0,0 +1,69 @@ +--- +title: Søren Glasius Named 2026 Grails® Lifetime Contributor +date: July 28, 2026 +description: The Apache Grails community honors Søren Glasius with the 2026 Grails Lifetime Contributor Award. +author: James Fredley +image: grails-blog-index-3.png +--- + +# [%title] + +[%author] + +[%date] + +The Apache Grails community is proud to honor [Søren Berg Glasius](https://github.com/sbglasius) with the 2026 Grails Lifetime Contributor Award. The award recognizes long-term, sustained contributions that have strengthened Grails as a framework, an ecosystem, and a community. + +For years, Grails users, plugin authors, conference speakers, and project contributors have seen the same pattern: Søren shows up for Grails, helps others succeed with it, and strengthens its ecosystem. Today he serves on the Project Management Committees for Apache Grails and Apache Groovy. + +## A Builder Across the Grails Framework + +Søren's work shows up throughout the framework itself. In the [Grails core codebase](https://github.com/apache/grails-core), his commits span years of work on data binding, REST and JSON view rendering, URL mappings, Geb and Testcontainers-based functional testing, the release process, and project metadata. When Grails graduated to a top-level Apache project, Søren took on unglamorous housekeeping work that helped clear remaining incubation traces from the codebase. As an Apache Grails PMC member and committer, he is listed among the developers in Grails core's publishing metadata. + +His [external-config](https://github.com/sbglasius/external-config) plugin is a clear example. He wrote it to restore the Grails 2-style external configuration that many teams relied on, and maintained it across Grails 3, 4, 5, and 6 - until that capability was absorbed directly into Grails core. A community plugin he authored and stewarded for years became part of the framework itself. + +## Plugins and the Grails Plugin Collective + +Plugins extend Grails for many production applications, and this is where Søren's sustained maintenance work stands out. He is a member of the [Grails Plugin Collective](https://github.com/gpc) (GPC), the long-running community organization that keeps dozens of essential Grails plugins alive across framework generations. + +On the [Grails Fields Plugin](https://github.com/gpc/fields) - the plugin that lets Grails applications render and customize form fields - he has well over a hundred commits and a long series of releases that carried it forward through recent Grails versions. He has also contributed to other GPC plugins, including the [rendering](https://github.com/gpc/rendering) plugin, GreenMail email testing, taggable, Excel import, and PostgreSQL extensions. + +Beyond GPC, Søren authored and maintains several practical libraries, including an [i18n-enums](https://github.com/sbglasius/I18nEnumsGrailsPlugin) plugin for internationalized enums and [utilities for understanding what changes between Grails versions](https://github.com/sbglasius/grails-versions). Together, these projects demonstrate the kind of patient, cross-version plugin stewardship that keeps an ecosystem usable as the framework underneath it evolves. + +## Teaching, Documentation, and Practical Guidance + +Søren has also created practical learning resources for the community. While working as a Grails advocate and software engineer with [Object Computing, Inc.](https://objectcomputing.com/) (OCI), and now at [Gennemtænkt IT A/S (GTIT)](https://gtit.dk) he is helping customers with Grails upgrades, plugin development, conference speaking, and technical writing. On this blog, he authored [How to Use Travis-CI to Build and Deploy Your Plugin](/blog/2016-10-03.html), guiding plugin authors through build automation and publishing, and co-authored [How to Add Build Info to Your Project](/blog/2017-04-02.html), explaining how Grails applications can surface build metadata from Gradle into the running app. + +He also brought that practical approach to conference stages. At G3 Summit 2016 he presented "Get to Know the Grails framework," "Tour de Plugin," and "Fields Plugin - Deep Dive," and he brought Grails to European audiences with talks such as "The advantages of Grails" at Greach 2016 and a Fields Plugin deep dive at Greach 2018. For years he has also taught hands-on Groovy and Grails courses with material he developed himself. + +## A Community Organizer and Technical Voice + +Beyond code and documentation, Søren has helped build the Grails community. In 2009 he [co-founded GR8Conf](https://glaforge.dev/posts/2009/03/04/announcing-the-gr8-conference-a-conference-dedicated-to-groovy-grails-and-griffon/) with Guillaume Laforge, then ran it for over a decade as the CEO of GR8 ApS - coordinating speakers, sponsors, venues, and the conference's visual identity while welcoming 120 to 200 attendees to Copenhagen each year. The conference ran a full week: two days of workshops followed by three conference days. + +The conference spread beyond Denmark too, with GR8Conf US running five times and GR8Conf IN twice, before Covid brought the whole series to a halt it never recovered from. GR8Conf brought many of the brightest minds in the Groovy ecosystem together, and the official GR8Conf EU 2016 wrap-up singled out Søren and the GR8Crew for the months of work behind it. Most of the talks are still around too, with recordings of the majority of sessions available on YouTube. + +He also sat on the Danish Java user group, Javagruppen, board from 2001 to 2015, one year as the board chair. This work does not appear in a commit logs, but it is a major part of how open source communities grow, renew, and welcome new contributors. + +> "Søren has been deeply committed to the Groovy and Grails communities for so many years. Who hasn't heard of the GR8Conf events in Europe, the US and India? That's his masterpiece! He's been a kind and caring member of the ecosystem, contributing in many ways and places, and I'm happy to see him receiving a Grails Lifetime Contributor Award." + +<p style="text-align:right;">—Guillaume Laforge, Co-founder of Apache Groovy</p> + +That same instinct for stewardship carried into formal project leadership. Søren served on the Grails Foundation Technical Advisory Board from 2022 to 2024, where he represented the perspective of real Grails users and long-term adopters - weighing in on Grails 6, Java baselines, Spring Boot support, and maintenance windows. More recently, he helped with Grails' transition from the Grails Foundation to the Apache Software Foundation, supporting the project's move to long-term Apache governance. Today he continues to contribute through Apache Grails and Apache Groovy project work, public discussion, and hands-on consulting for teams moving complex Grails applications forward. + +Whether booking a Copenhagen venue, mentoring a plugin author, or sitting on a technical board, Søren gave the people around the technology the same care as the technology. That focus on bringing developers together - and keeping them connected long after the talks ended - is what colleagues cite again and again. + +> "I've always admired Søren's passion for bringing people together. Through his tireless work organizing GR8Conf over so many years, he created far more than a conference—he built a community. GR8Conf became the place where developers met, learned from one another, shared ideas, and formed friendships that have lasted for years. That sense of community has been one of the greatest strengths of the Groovy and Grails ecosystem, and Søren has been at the heart of making it happen. His dedication, generosity, and enthusiasm have left a lasting mark on all of us, and I can't think of anyone more deserving of this recognition." + +<p style="text-align:right;">—Graeme Rocher, Creator of the Grails and Micronaut frameworks</p> + +## Thank You, Søren + +A lifetime contribution is not measured by a single feature, release, or event. It is the steady work of improving code, teaching users, maintaining plugins, organizing gatherings, reviewing direction, and helping new contributors get involved. + +On behalf of the Apache Grails community, thank you, Søren, for your years of technical excellence, stewardship, and generosity. Grails is stronger because of your work. + +> "The community is responsible for Grails' success, and throughout the framework's history, no one has done more to bring us together than Søren. He is not only an extraordinary community leader, but a great friend. Thank you, Søren!" + +<p style="text-align:right;">—Jeff Scott Brown, Object Computing Partner, Grails and Micronaut Foundation Co-Founder</p> + +Please join us in congratulating Søren on the 2026 Grails Lifetime Contributor Award.
diff --git a/templates/partials/site-head.html b/templates/partials/site-head.html index 13e85be..a6aaa5b 100644 --- a/templates/partials/site-head.html +++ b/templates/partials/site-head.html
@@ -1,7 +1,7 @@ <meta charset='UTF-8'/> <meta name='viewport' content='width=device-width, initial-scale=1'/> <link rel='icon' href='https://grails.apache.org/images/favicon.ico'/> -<link rel='mask-icon' href='https://grails.apache.org/images/grails-pinned-icon.svg' color='feb672'/> +<link rel='mask-icon' href='https://grails.apache.org/images/grails-pinned-icon.svg' color='#feb672'/> <link rel='alternate' type='application/rss+xml' title='RSS' href='https://grails.apache.org/rss.xml'/> <link rel='stylesheet' href='https://grails.apache.org/stylesheets/screen.css'/> <link rel='stylesheet' href='https://grails.apache.org/stylesheets/plugin.css'/> @@ -9,7 +9,8 @@ <!-- Matomo --> <script> - var _paq = window._paq = window._paq || []; + var _paq = window._paq || []; + window._paq = _paq; _paq.push(['setDoNotTrack', true]); _paq.push(['disableCookies']); _paq.push(['trackPageView']); @@ -38,7 +39,29 @@ data-modal-title="Apache Grails AI Assistant" data-modal-example-questions-title="Try asking me..." data-modal-example-questions="How does database migration work?,How does Spring Security work?" - data-button-text-color="#FBB576" + data-button-text="Ask AI" + data-button-text-color="#feb672" + data-button-text-font-size="0" + data-button-bg-color="#3F4346" + data-button-height="44px" + data-button-width="44px" + data-button-image-height="24" + data-button-image-width="24" + data-button-padding="10px" + data-button-border-radius="50%" + data-button-position-bottom="12px" + data-button-position-right="12px" + data-launcher-button-text="Ask AI" + data-launcher-button-font-size="0" + data-launcher-button-background-color="#3F4346" + data-launcher-button-height="44px" + data-launcher-button-width="44px" + data-launcher-button-image-height="24" + data-launcher-button-image-width="24" + data-launcher-button-padding="10px" + data-launcher-button-border-radius="50%" + data-launcher-button-bottom="12px" + data-launcher-button-right="12px" data-modal-header-bg-color="#FFFFFF" - data-modal-title-color="#FBB576" + data-modal-title-color="#255AA8" data-consent-required="true"></script>
diff --git a/templates/partials/site-header.html b/templates/partials/site-header.html index 500c77c..3ce3ea6 100644 --- a/templates/partials/site-header.html +++ b/templates/partials/site-header.html
@@ -1,8 +1,11 @@ <header class='main-header'> <div class='content'> <a href='https://grails.apache.org/index.html'><img class='grails-logo' src='https://grails.apache.org/images/grails_logo.svg' alt='Grails Logo'/></a> - <a href='javascript:show("top-menus", "show-navigation-link")' id='show-navigation-link' - class='mobile align-center'>Show Navigation</a> + <button type='button' id='show-navigation-link' + class='mobile align-center navigation-toggle' + aria-expanded='false' + aria-controls='top-menus' + onclick='toggleNavigation()'>Show Navigation</button> <div id='top-menus'> <nav class='secondary-menu' id='secondary-menu'> <ul>