Skip to content

Commit d34d32b

Browse files
committed
feat: read catalog component tags from README front-matter
1 parent 5484cdd commit d34d32b

22 files changed

Lines changed: 1469 additions & 81 deletions

File tree

docs/src/content/docs/03-features/06-catalog/02-tui.mdx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ sidebar:
77
---
88

99
import { Aside } from '@astrojs/starlight/components';
10+
import Since from '@components/Since.astro';
11+
import Before from '@components/Before.astro';
1012

1113
Launch the user interface for searching and managing your module catalog.
1214

@@ -102,3 +104,86 @@ This section describes behavior that is only available when the [`catalog-redesi
102104
</Aside>
103105

104106
The list view is split into three tabs: `All`, `Modules`, and `Templates`. `All` is selected when the TUI launches. Press `tab` to move to the next tab and `shift+tab` to move to the previous; cycling wraps at either end. The active tab filters the list down to components of that kind, and each tab keeps its own cursor position and search filter, so switching back preserves where you were.
107+
108+
## README front-matter
109+
110+
<Aside type="tip" title="Catalog Redesign Experiment">
111+
This section describes behavior that is only available when the [`catalog-redesign`](/reference/experiments/active#catalog-redesign) experiment is enabled.
112+
</Aside>
113+
114+
Component authors can override the catalog UI's default title and description by adding a YAML block at the top of the component's `README.md`. Two forms are accepted.
115+
116+
The conventional dash-separated form:
117+
118+
```markdown
119+
---
120+
name: VPC App
121+
description: A VPC for application workloads.
122+
---
123+
124+
# VPC App
125+
126+
...
127+
```
128+
129+
An HTML-comment-wrapped form, which stays invisible on Markdown viewers that render dash-separated front-matter as a horizontal rule. The opening tag must be `<!-- Frontmatter` (case-insensitive):
130+
131+
```markdown
132+
<!-- Frontmatter
133+
name: VPC App
134+
description: A VPC for application workloads.
135+
-->
136+
137+
# VPC App
138+
139+
...
140+
```
141+
142+
Both forms are parsed the same way.
143+
144+
Recognized keys:
145+
146+
- `name`: the component title shown in the list view. When unset, the title falls back to the first `# H1` heading in the README, then to the directory name.
147+
- `description`: the short description shown beneath the title in the list view. When unset, it is derived from the README body.
148+
149+
A third key, `tags`, controls the colored pills shown next to each component; see the next section. Unknown keys are ignored.
150+
151+
## Component tags
152+
153+
<Aside type="tip" title="Catalog Redesign Experiment">
154+
This section describes behavior that is only available when the [`catalog-redesign`](/reference/experiments/active#catalog-redesign) experiment is enabled.
155+
</Aside>
156+
157+
<Before version="1.0.4">
158+
159+
In a future release, the catalog UI will read tag information from the front-matter of component `README.md` files.
160+
161+
</Before>
162+
163+
<Since version="1.0.4">
164+
165+
Catalog authors can attach tags to a component by adding a `tags` key to the component's `README.md` front-matter. Tags appear as colored pills next to the component in the list view, and as a row above the rendered README in the detail view.
166+
167+
Either inline-array or dash-list YAML form is accepted:
168+
169+
```markdown
170+
<!-- Frontmatter
171+
name: VPC App
172+
description: A VPC for application workloads.
173+
tags: [networking, aws, module]
174+
-->
175+
```
176+
177+
```markdown
178+
<!-- Frontmatter
179+
name: VPC App
180+
tags:
181+
- networking
182+
- aws
183+
- module
184+
-->
185+
```
186+
187+
Tag values that name a component type also promote the component into that type's tab. For example, a component classified as a `template` whose tags include `module` appears under both the `Templates` tab (by its native kind) and the `Modules` tab (by tag), without changing how it's scaffolded.
188+
189+
</Since>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
version: "v1.0.4"
3+
category: "experiments-updated"
4+
---
5+
6+
#### `catalog-redesign`: component tags
7+
8+
The `catalog-redesign` experiment now reads a `tags` field from the component's `README.md` front-matter. Tags appear as colored pills next to the component in the list view and in the detail view above the rendered README.
9+
10+
```markdown
11+
<!-- Frontmatter
12+
name: VPC App
13+
description: A VPC for application workloads.
14+
tags: [networking, aws, module]
15+
-->
16+
```
17+
18+
Either inline-array or dash-list YAML form is accepted. Tags render in gray by default. When a tag matches a known component-type name (`module`, `template`, `unit`, or `stack`, case-insensitive), the pill takes on that type's color.
19+
20+
A tag matching a component-type name also promotes the component into that type's tab. A `template` whose tags include `module` appears under both `Templates` (by its native kind) and `Modules` (by tag), without changing how it scaffolds.
21+
22+
To learn more, see [Component tags](/features/catalog/tui#component-tags).

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ require (
102102
github.com/xeipuuv/gojsonschema v1.2.0
103103
go.uber.org/mock v0.6.0
104104
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
105+
gopkg.in/yaml.v3 v3.0.1
105106
)
106107

107108
require (
@@ -305,7 +306,6 @@ require (
305306
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect
306307
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
307308
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
308-
gopkg.in/yaml.v3 v3.0.1 // indirect
309309
sigs.k8s.io/yaml v1.6.0 // indirect
310310
)
311311

internal/cli/commands/catalog/tui/redesign/component.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ func (c *Component) Description() string {
110110
// FilterValue is what the list fuzzy-matches against when the user filters.
111111
func (c *Component) FilterValue() string { return c.Title() }
112112

113+
// Tags returns the component's README front-matter tags, in authoring order.
114+
// Returns nil when the component has no Doc or declares no tags.
115+
func (c *Component) Tags() []string {
116+
if c.Doc == nil {
117+
return nil
118+
}
119+
120+
return c.Doc.Tags()
121+
}
122+
113123
// URL returns the browser-friendly source URL for the component, or an empty
114124
// string if one could not be derived.
115125
func (c *Component) URL() string { return c.url }

internal/cli/commands/catalog/tui/redesign/component_doc.go

Lines changed: 118 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"regexp"
66
"strings"
77

8+
"gopkg.in/yaml.v3"
9+
810
"github.com/gruntwork-io/terragrunt/internal/errors"
911
"github.com/gruntwork-io/terragrunt/internal/vfs"
1012
)
@@ -27,11 +29,6 @@ const (
2729

2830
var (
2931
docFiles = []string{"README.md", "README.adoc"}
30-
31-
frontmatterKeys = map[string]docDataKey{
32-
"name": docTitle,
33-
"description": docDescription,
34-
}
3532
)
3633

3734
type docDataKey byte
@@ -49,13 +46,17 @@ func (regs docRegs) Replace(str string) string {
4946

5047
// ComponentDoc is the parsed README (Markdown or AsciiDoc) for a Component.
5148
type ComponentDoc struct {
52-
tagCache map[docDataKey]string
53-
tagRegs map[docTagName]*regexp.Regexp
54-
frontmatterCache map[docDataKey]string
55-
frontmatterReg *regexp.Regexp
56-
rawContent string
57-
fileExt string
58-
tagStripRegs docRegs
49+
tagCache map[docDataKey]string
50+
tagRegs map[docTagName]*regexp.Regexp
51+
frontmatterCache map[docDataKey]string
52+
frontmatterTags []string
53+
frontmatterReg *regexp.Regexp
54+
frontmatterDashReg *regexp.Regexp
55+
frontmatterBody string
56+
rawContent string
57+
fileExt string
58+
tagStripRegs docRegs
59+
frontmatterDone bool
5960
}
6061

6162
// NewComponentDoc builds a ComponentDoc from raw README content.
@@ -64,10 +65,13 @@ func NewComponentDoc(rawContent, fileExt string) *ComponentDoc {
6465
rawContent: rawContent,
6566
fileExt: fileExt,
6667

67-
tagRegs: make(map[docTagName]*regexp.Regexp),
68-
frontmatterReg: regexp.MustCompile(`(?i)^[\s\n]*<!-- frontmatter[\s\n]*([\S\s]*?)[\s\n]*-->`),
68+
tagRegs: make(map[docTagName]*regexp.Regexp),
69+
frontmatterReg: regexp.MustCompile(`(?i)^[\s\n]*<!-- frontmatter[\s\n]*([\S\s]*?)[\s\n]*-->`),
70+
frontmatterDashReg: regexp.MustCompile(`(?m)\A[\s\n]*---[\s\n]+([\S\s]*?)[\s\n]+---(?:[\s\n]|$)`),
6971
}
7072

73+
doc.extractFrontmatter()
74+
7175
switch fileExt {
7276
case mdExt:
7377
doc.tagRegs[tagH1Block] = regexp.MustCompile(`(?:^|\n)\#{1}\s([\S\s]+?)(?:[\r\n]+\#|[\r\n]*$)`)
@@ -164,6 +168,22 @@ func (doc *ComponentDoc) Title() string {
164168
return doc.parseTag(docTitle)
165169
}
166170

171+
// Tags returns the list of tags declared in the README front-matter, in
172+
// authoring order. Returns nil when no tags are defined or no front-matter
173+
// is present.
174+
func (doc *ComponentDoc) Tags() []string {
175+
doc.ensureFrontmatter()
176+
177+
if len(doc.frontmatterTags) == 0 {
178+
return nil
179+
}
180+
181+
out := make([]string, len(doc.frontmatterTags))
182+
copy(out, doc.frontmatterTags)
183+
184+
return out
185+
}
186+
167187
// Description returns a short description, optionally capped at maxLength.
168188
func (doc *ComponentDoc) Description(maxLength int) string {
169189
desc := doc.parseFrontmatter(docDescription)
@@ -215,36 +235,104 @@ func (doc *ComponentDoc) IsMarkDown() bool {
215235
}
216236

217237
func (doc *ComponentDoc) parseFrontmatter(key docDataKey) string {
218-
if doc.frontmatterReg == nil {
219-
return ""
238+
doc.ensureFrontmatter()
239+
240+
return doc.frontmatterCache[key]
241+
}
242+
243+
// ensureFrontmatter parses the README front-matter block as YAML on first
244+
// use, populating frontmatterCache (name/description) and frontmatterTags.
245+
//
246+
// Unknown keys and parse errors are silently ignored.
247+
func (doc *ComponentDoc) ensureFrontmatter() {
248+
if doc.frontmatterDone {
249+
return
250+
}
251+
252+
doc.frontmatterDone = true
253+
doc.frontmatterCache = make(map[docDataKey]string)
254+
255+
if doc.frontmatterBody == "" {
256+
return
257+
}
258+
259+
var raw map[string]any
260+
if err := yaml.Unmarshal([]byte(doc.frontmatterBody), &raw); err != nil || raw == nil {
261+
return
220262
}
221263

222-
if doc.frontmatterCache == nil {
223-
doc.frontmatterCache = make(map[docDataKey]string)
264+
for k, v := range raw {
265+
switch strings.ToLower(strings.TrimSpace(k)) {
266+
case "name":
267+
if s, ok := v.(string); ok {
268+
doc.frontmatterCache[docTitle] = strings.TrimSpace(s)
269+
}
270+
case "description":
271+
if s, ok := v.(string); ok {
272+
doc.frontmatterCache[docDescription] = strings.TrimSpace(s)
273+
}
274+
case "tags":
275+
doc.frontmatterTags = coerceTags(v)
276+
}
277+
}
278+
}
224279

225-
match := doc.frontmatterReg.FindStringSubmatch(doc.rawContent)
226-
if len(match) == 0 {
227-
return ""
280+
// extractFrontmatter captures the YAML body of the README's front-matter
281+
// block (if any) and removes the matched block from rawContent so downstream
282+
// rendering (glamour, tag-stripping) does not treat the front-matter as part
283+
// of the README body. Either the dash-separated form (`---\n...\n---`) or
284+
// the HTML-comment-wrapped form (`<!-- Frontmatter ... -->`) is accepted.
285+
func (doc *ComponentDoc) extractFrontmatter() {
286+
for _, reg := range []*regexp.Regexp{doc.frontmatterDashReg, doc.frontmatterReg} {
287+
if reg == nil {
288+
continue
228289
}
229290

230-
lines := strings.SplitSeq(match[1], "\n")
291+
loc := reg.FindStringSubmatchIndex(doc.rawContent)
292+
if len(loc) == 0 {
293+
continue
294+
}
295+
296+
doc.frontmatterBody = doc.rawContent[loc[2]:loc[3]]
297+
doc.rawContent = strings.TrimLeft(doc.rawContent[loc[1]:], "\r\n")
298+
299+
return
300+
}
301+
}
302+
303+
// coerceTags accepts the YAML-decoded value of the `tags` key and returns a
304+
// trimmed, non-empty slice of strings. It accepts either a sequence
305+
// (`["a","b"]` or a `- a` block) or a single string.
306+
func coerceTags(v any) []string {
307+
switch val := v.(type) {
308+
case []any:
309+
out := make([]string, 0, len(val))
231310

232-
for line := range lines {
233-
rawKey, rawVal, ok := strings.Cut(line, ":")
311+
for _, item := range val {
312+
s, ok := item.(string)
234313
if !ok {
235314
continue
236315
}
237316

238-
key := strings.ToLower(strings.TrimSpace(rawKey))
239-
val := strings.TrimSpace(rawVal)
240-
241-
if key, ok := frontmatterKeys[key]; ok {
242-
doc.frontmatterCache[key] = val
317+
s = strings.TrimSpace(s)
318+
if s == "" {
319+
continue
243320
}
321+
322+
out = append(out, s)
323+
}
324+
325+
return out
326+
case string:
327+
s := strings.TrimSpace(val)
328+
if s == "" {
329+
return nil
244330
}
331+
332+
return []string{s}
245333
}
246334

247-
return doc.frontmatterCache[key]
335+
return nil
248336
}
249337

250338
func (doc *ComponentDoc) parseTag(key docDataKey) string {

0 commit comments

Comments
 (0)