@dirsigler/astro-techradar 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,12 @@ An [Astro](https://astro.build) integration that adds a complete, interactive te
27
27
  - **Fully Static & Fast** — Builds to plain HTML/CSS/JS. Deploy anywhere
28
28
  - **Movement Indicators** — Mark technologies as moved in/out to highlight recent changes
29
29
  - **SEO Ready** — Open Graph, Twitter Cards, canonical URLs, and custom 404 page
30
+ - **RSS Feed** — Auto-generated feed at `{basePath}/feed.xml` for subscribers
31
+ - **Owner Attribution** — Assign teams or individuals to technologies
32
+ - **External Links** — Link technologies to docs, repos, communities with typed icons
33
+ - **Tags & Filtering** — Cross-cutting tags for discovery, filterable on the main radar page
34
+ - **Related Technologies** — Manual and auto-suggested related technologies on detail pages
35
+ - **Keyboard Navigation** — Arrow keys to cycle radar dots, focus indicators, screen reader friendly
30
36
 
31
37
  ---
32
38
 
@@ -109,6 +115,25 @@ Each technology file:
109
115
  title: Kubernetes
110
116
  ring: adopt
111
117
  moved: 0
118
+ owner:
119
+ name: Platform Engineering
120
+ url: https://github.com/orgs/example/teams/platform
121
+ tags:
122
+ - cloud-native
123
+ - containers
124
+ related:
125
+ - cloud/helm
126
+ - cloud/argocd
127
+ links:
128
+ - label: Documentation
129
+ url: https://kubernetes.io/docs/
130
+ type: docs
131
+ - label: GitHub
132
+ url: https://github.com/kubernetes/kubernetes
133
+ type: repo
134
+ - label: Kubernetes Slack
135
+ url: https://kubernetes.slack.com
136
+ type: community
112
137
  history:
113
138
  - date: "2025-03"
114
139
  ring: adopt
@@ -124,7 +149,36 @@ Your description in Markdown. Explain why this technology is in this ring
124
149
  and what your experience has been.
125
150
  ```
126
151
 
127
- The `history` field is optional. When present, a timeline is rendered on the technology detail page showing how its ring classification changed over time. Each entry has:
152
+ #### `owner` (optional)
153
+
154
+ Displays a team or person responsible for this technology on the detail page.
155
+
156
+ | Field | Required | Description |
157
+ | ----- | -------- | ----------- |
158
+ | `name` | yes | Owner name (e.g. team, guild, or person) |
159
+ | `url` | no | Link to an external resource (GitHub team, Slack channel, wiki page) |
160
+
161
+ #### `links` (optional)
162
+
163
+ External resource links displayed inline on the detail page. Links are automatically sorted by type in a consistent order: docs, repo, website, community.
164
+
165
+ | Field | Required | Description |
166
+ | ----- | -------- | ----------- |
167
+ | `label` | yes | Display text for the link |
168
+ | `url` | yes | Full URL |
169
+ | `type` | no | Link type — determines the icon. One of `docs`, `repo`, `website`, `community`. Defaults to `website` |
170
+
171
+ #### `tags` (optional)
172
+
173
+ Free-text tags for cross-cutting concerns (e.g. `cloud-native`, `security`, `iac`). Tags appear as `#tag` pills on the detail page and enable tag-based filtering on the main radar page. Clicking a tag pill navigates to the radar filtered by that tag.
174
+
175
+ #### `related` (optional)
176
+
177
+ A list of technology slugs (matching entry IDs like `cloud/helm`) to display as related technologies on the detail page. In addition to these manual entries, the radar automatically suggests technologies that share 2 or more tags with the current entry.
178
+
179
+ #### `history` (optional)
180
+
181
+ When present, a timeline is rendered on the technology detail page showing how its ring classification changed over time.
128
182
 
129
183
  | Field | Required | Description |
130
184
  | ----- | -------- | ----------- |
@@ -152,13 +206,21 @@ techradar({
152
206
  // Optional
153
207
  basePath: "/techradar", // Mount under a sub-path (e.g. acme.com/techradar/)
154
208
  logo: "/logo.svg", // Path to logo in public/
155
- footerText: "Built by the Platform Team", // Supports HTML
156
- editBaseUrl: "https://github.com/your-org/your-radar/edit/main/segments",
209
+ footer: "Built by the Platform Team", // Supports HTML
210
+ editing: {
211
+ enabled: true, // Show "Edit this page" links (default: true)
212
+ baseUrl: "https://github.com/your-org/your-radar/edit/main/segments",
213
+ },
157
214
  theme: "default", // 'default' | 'catppuccin-mocha' | path to custom CSS
158
215
  color: {
159
216
  toggle: true, // Show the light/dark mode toggle (default: true)
160
217
  mode: "system", // 'light' | 'dark' | 'system' (default: 'system')
161
218
  },
219
+ search: {
220
+ enabled: true, // Show the search input in the navbar (default: true)
221
+ placeholder: "Search technologies...", // Placeholder text
222
+ },
223
+ feed: true, // Enable RSS feed at {basePath}/feed.xml (default: true)
162
224
  socialLinks: [
163
225
  {
164
226
  label: "GitHub",
@@ -185,11 +247,16 @@ socialLinks: [
185
247
 
186
248
  ### Technology Frontmatter
187
249
 
188
- | Field | Type | Description |
189
- | :------ | :----------------------------------------- | :---------------------------------------------- |
190
- | `title` | `string` | Display name |
191
- | `ring` | `'adopt' \| 'trial' \| 'assess' \| 'hold'` | Which ring the technology belongs to |
192
- | `moved` | `-1 \| 0 \| 1` | Movement indicator (-1 = out, 0 = none, 1 = in) |
250
+ | Field | Type | Description |
251
+ | :-- | :-- | :-- |
252
+ | `title` | `string` | Display name |
253
+ | `ring` | `'adopt' \| 'trial' \| 'assess' \| 'hold'` | Which ring the technology belongs to |
254
+ | `moved` | `-1 \| 0 \| 1` | Movement indicator (-1 = out, 0 = none, 1 = in) |
255
+ | `owner` | `{ name, url? }` | Optional team/person responsible (see [owner](#owner-optional)) |
256
+ | `links` | `Array<{ label, url, type? }>` | Optional external links (see [links](#links-optional)) |
257
+ | `tags` | `string[]` | Optional free-text tags for filtering (see [tags](#tags-optional)) |
258
+ | `related` | `string[]` | Optional technology slugs for related (see [related](#related-optional)) |
259
+ | `history` | `Array<{ date, ring, description? }>` | Optional ring change timeline (see [history](#history-optional)) |
193
260
 
194
261
  ### Segment Frontmatter
195
262
 
package/config.ts CHANGED
@@ -5,6 +5,30 @@ export interface SocialLink {
5
5
  icon?: string;
6
6
  }
7
7
 
8
+ export interface EditConfig {
9
+ /** Show "Edit this page" links on technology pages. Default: true */
10
+ enabled?: boolean;
11
+ /** Base URL for edit links (e.g. "https://github.com/org/repo/edit/main/segments"). */
12
+ baseUrl?: string;
13
+ }
14
+
15
+ export interface ResolvedEdit {
16
+ enabled: boolean;
17
+ baseUrl?: string;
18
+ }
19
+
20
+ export interface SearchConfig {
21
+ /** Enable the search input in the navbar. Default: true */
22
+ enabled?: boolean;
23
+ /** Placeholder text for the search input. Default: 'Search technologies...' */
24
+ placeholder?: string;
25
+ }
26
+
27
+ export interface ResolvedSearch {
28
+ enabled: boolean;
29
+ placeholder: string;
30
+ }
31
+
8
32
  export interface ColorModeConfig {
9
33
  /** Show the light/dark mode toggle in the header. Default: true */
10
34
  toggle?: boolean;
@@ -38,13 +62,10 @@ export interface TechRadarUserConfig {
38
62
  logo?: string;
39
63
 
40
64
  /** Footer text. Supports simple HTML. */
41
- footerText?: string;
42
-
43
- /** Show "Edit this page" links on technology pages. Default: true */
44
- allowEditing?: boolean;
65
+ footer?: string;
45
66
 
46
- /** Base URL for "Edit" links on technology pages (e.g. "https://github.com/org/repo/edit/main/segments"). */
47
- editBaseUrl?: string;
67
+ /** Edit page configuration. */
68
+ editing?: EditConfig;
48
69
 
49
70
  /** Social links shown in the footer. */
50
71
  socialLinks?: SocialLink[];
@@ -54,6 +75,12 @@ export interface TechRadarUserConfig {
54
75
 
55
76
  /** Color mode configuration. */
56
77
  color?: ColorModeConfig;
78
+
79
+ /** Enable RSS feed at {basePath}/feed.xml. Default: true */
80
+ feed?: boolean;
81
+
82
+ /** Search configuration. */
83
+ search?: SearchConfig;
57
84
  }
58
85
 
59
86
  export interface ResolvedConfig {
@@ -63,11 +90,13 @@ export interface ResolvedConfig {
63
90
  /** Normalized base path with leading slash, no trailing slash. Empty string when mounted at root. */
64
91
  basePath: string;
65
92
  logo?: string;
66
- footerText: string;
67
- editBaseUrl?: string;
93
+ footer: string;
94
+ editing: ResolvedEdit;
68
95
  socialLinks: SocialLink[];
69
96
  theme: string;
70
97
  color: ResolvedColorMode;
98
+ feed: boolean;
99
+ search: ResolvedSearch;
71
100
  }
72
101
 
73
102
  /** Normalize a user-supplied path: ensure leading slash, strip trailing slash. Returns "" for root. */
@@ -85,16 +114,21 @@ export function resolveConfig(user: TechRadarUserConfig): ResolvedConfig {
85
114
  subtitle: user.subtitle,
86
115
  basePath: normalizeBasePath(user.basePath),
87
116
  logo: user.logo,
88
- footerText: user.footerText ?? '',
89
- editBaseUrl:
90
- (user.allowEditing ?? true) === false
91
- ? undefined
92
- : user.editBaseUrl,
117
+ footer: user.footer ?? '',
118
+ editing: {
119
+ enabled: user.editing?.enabled ?? true,
120
+ baseUrl: (user.editing?.enabled ?? true) ? user.editing?.baseUrl : undefined,
121
+ },
93
122
  socialLinks: user.socialLinks ?? [],
94
123
  theme: user.theme ?? 'default',
95
124
  color: {
96
125
  toggle: user.color?.toggle ?? true,
97
126
  mode: user.color?.mode ?? 'system',
98
127
  },
128
+ feed: user.feed ?? true,
129
+ search: {
130
+ enabled: user.search?.enabled ?? true,
131
+ placeholder: user.search?.placeholder ?? 'Search technologies...',
132
+ },
99
133
  };
100
134
  }
package/integration.ts CHANGED
@@ -40,6 +40,12 @@ export function createIntegration(
40
40
  'src/pages/technology/[...slug].astro',
41
41
  ),
42
42
  });
43
+ if (config.feed) {
44
+ injectRoute({
45
+ pattern: `${bp}/feed.xml`,
46
+ entrypoint: path.join(PKG_DIR, 'src/pages/feed.xml.ts'),
47
+ });
48
+ }
43
49
  // Resolve theme CSS
44
50
  let themeCSS = '';
45
51
  const builtinPath = path.join(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dirsigler/astro-techradar",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "An interactive technology radar Astro integration — track technology adoption across your organization",
6
6
  "license": "MIT",
package/schemas.ts CHANGED
@@ -22,4 +22,21 @@ export const technologySchema = z.object({
22
22
  }),
23
23
  )
24
24
  .optional(),
25
+ owner: z
26
+ .object({
27
+ name: z.string(),
28
+ url: z.string().url().optional(),
29
+ })
30
+ .optional(),
31
+ links: z
32
+ .array(
33
+ z.object({
34
+ label: z.string(),
35
+ url: z.string().url(),
36
+ type: z.enum(['docs', 'repo', 'website', 'community']).default('website'),
37
+ }),
38
+ )
39
+ .optional(),
40
+ tags: z.array(z.string()).optional(),
41
+ related: z.array(z.string()).optional(),
25
42
  });
@@ -9,6 +9,7 @@ interface Technology {
9
9
  segment: string;
10
10
  color: string;
11
11
  order: number;
12
+ tags: string[];
12
13
  }
13
14
 
14
15
  interface Segment {
@@ -172,6 +173,7 @@ const segmentLabels = segments.map((seg) => {
172
173
  data-title={dot.title}
173
174
  data-ring={dot.ring}
174
175
  data-segment={dot.segment}
176
+ data-tags={technologies.find((t) => t.slug === dot.slug)?.tags.join(',') ?? ''}
175
177
  />
176
178
  {dot.moved === 1 && (
177
179
  <polygon
@@ -197,26 +199,83 @@ const segmentLabels = segments.map((seg) => {
197
199
  const svg = document.querySelector('.radar-container svg');
198
200
  const tooltip = document.getElementById('radar-tooltip');
199
201
  if (svg && tooltip) {
202
+ const container = svg.closest('.radar-container') as HTMLElement;
203
+
204
+ function showTooltip(dot: SVGCircleElement) {
205
+ const title = dot.getAttribute('data-title') ?? '';
206
+ const ring = dot.getAttribute('data-ring') ?? '';
207
+ tooltip!.textContent = `${title} (${ring})`;
208
+ tooltip!.classList.remove('hidden');
209
+ }
210
+
211
+ function positionTooltipAtDot(dot: SVGCircleElement) {
212
+ const containerRect = container.getBoundingClientRect();
213
+ const dotRect = dot.getBoundingClientRect();
214
+ tooltip!.style.left = `${dotRect.left - containerRect.left + dotRect.width / 2 + 12}px`;
215
+ tooltip!.style.top = `${dotRect.top - containerRect.top - 10}px`;
216
+ }
217
+
218
+ function hideTooltip() {
219
+ tooltip!.classList.add('hidden');
220
+ }
221
+
222
+ // Collect all radar links for arrow key navigation
223
+ const allLinks = Array.from(svg.querySelectorAll<SVGAElement>('.radar-link'));
224
+
225
+ function getVisibleLinks(): SVGAElement[] {
226
+ return allLinks.filter((link) => link.style.opacity !== '0.1');
227
+ }
228
+
200
229
  svg.querySelectorAll('.radar-dot').forEach((dot) => {
201
- dot.addEventListener('mouseenter', (e) => {
202
- const target = e.target as SVGCircleElement;
203
- const title = target.getAttribute('data-title') ?? '';
204
- const ring = target.getAttribute('data-ring') ?? '';
205
- tooltip.textContent = `${title} (${ring})`;
206
- tooltip.classList.remove('hidden');
207
- });
230
+ const link = dot.closest('.radar-link') as SVGAElement | null;
231
+
232
+ dot.addEventListener('mouseenter', () => showTooltip(dot as SVGCircleElement));
208
233
 
209
234
  dot.addEventListener('mousemove', (e) => {
210
- const container = svg.closest('.radar-container') as HTMLElement;
211
235
  const rect = container.getBoundingClientRect();
212
236
  const me = e as MouseEvent;
213
- tooltip.style.left = `${me.clientX - rect.left + 12}px`;
214
- tooltip.style.top = `${me.clientY - rect.top - 10}px`;
237
+ tooltip!.style.left = `${me.clientX - rect.left + 12}px`;
238
+ tooltip!.style.top = `${me.clientY - rect.top - 10}px`;
215
239
  });
216
240
 
217
- dot.addEventListener('mouseleave', () => {
218
- tooltip.classList.add('hidden');
219
- });
241
+ dot.addEventListener('mouseleave', hideTooltip);
242
+
243
+ // Show tooltip on keyboard focus
244
+ if (link) {
245
+ link.addEventListener('focus', () => {
246
+ showTooltip(dot as SVGCircleElement);
247
+ positionTooltipAtDot(dot as SVGCircleElement);
248
+ });
249
+ link.addEventListener('blur', hideTooltip);
250
+ }
251
+ });
252
+
253
+ // Arrow key navigation within the radar
254
+ svg.addEventListener('keydown', (e) => {
255
+ const ke = e as KeyboardEvent;
256
+ if (ke.key === 'Escape') {
257
+ (document.activeElement as HTMLElement)?.blur();
258
+ hideTooltip();
259
+ return;
260
+ }
261
+
262
+ if (ke.key !== 'ArrowLeft' && ke.key !== 'ArrowRight') return;
263
+
264
+ const visible = getVisibleLinks();
265
+ if (visible.length === 0) return;
266
+
267
+ const focused = document.activeElement?.closest('.radar-link') as SVGAElement | null;
268
+ const currentIndex = focused ? visible.indexOf(focused) : -1;
269
+
270
+ let nextIndex: number;
271
+ if (ke.key === 'ArrowRight') {
272
+ nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % visible.length;
273
+ } else {
274
+ nextIndex = currentIndex < 0 ? visible.length - 1 : (currentIndex - 1 + visible.length) % visible.length;
275
+ }
276
+
277
+ ke.preventDefault();
278
+ visible[nextIndex].focus();
220
279
  });
221
280
  }
222
281
  </script>
@@ -250,14 +309,17 @@ const segmentLabels = segments.map((seg) => {
250
309
  .radar-link:active {
251
310
  color: inherit;
252
311
  }
253
- .radar-link:focus,
254
- .radar-link:focus-visible {
312
+ .radar-link:focus {
255
313
  outline: none;
256
314
  }
257
315
  .radar-link:hover .radar-dot,
258
316
  .radar-link:focus .radar-dot {
259
317
  r: 13;
260
318
  }
319
+ .radar-link:focus-visible .radar-dot {
320
+ r: 13;
321
+ filter: brightness(1.3) drop-shadow(0 0 10px rgba(205, 214, 244, 0.6));
322
+ }
261
323
 
262
324
  .radar-dot {
263
325
  cursor: pointer;
@@ -10,6 +10,7 @@ interface Technology {
10
10
  href: string;
11
11
  segment: string;
12
12
  color: string;
13
+ tags: string[];
13
14
  }
14
15
 
15
16
  interface Segment {
@@ -45,6 +46,7 @@ const { technologies, segments } = Astro.props;
45
46
  class="legend-row"
46
47
  data-segment={tech.segment}
47
48
  data-ring={tech.ring}
49
+ data-tags={tech.tags.join(',')}
48
50
  >
49
51
  <span class="legend-indicator"><MovedIndicator moved={tech.moved} /></span>
50
52
  <span class="legend-title">{tech.title}</span>
@@ -97,10 +99,18 @@ const { technologies, segments } = Astro.props;
97
99
  transition: background-color 0.15s, color 0.15s, opacity 0.3s ease;
98
100
  }
99
101
 
100
- .legend-row:hover {
102
+ .legend-row:hover,
103
+ .legend-row:focus-visible {
101
104
  background-color: var(--radar-hover-bg);
102
105
  color: var(--radar-text);
103
106
  }
107
+ .legend-row:focus-visible {
108
+ outline: 2px solid var(--radar-link);
109
+ outline-offset: -2px;
110
+ }
111
+ .legend-row:focus:not(:focus-visible) {
112
+ outline: none;
113
+ }
104
114
 
105
115
  .legend-indicator {
106
116
  display: flex;
@@ -34,6 +34,14 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
34
34
  <meta name="twitter:card" content="summary_large_image" />
35
35
  <meta name="twitter:title" content={title} />
36
36
  <meta name="twitter:description" content={description} />
37
+ {config.feed && (
38
+ <link
39
+ rel="alternate"
40
+ type="application/rss+xml"
41
+ title={config.title}
42
+ href={`${base}feed.xml`}
43
+ />
44
+ )}
37
45
  <title>{title}</title>
38
46
  <style set:html={themeCSS}></style>
39
47
  <script is:inline define:vars={{ toggle: config.color.toggle, colorMode: config.color.mode }}>
@@ -67,7 +75,10 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
67
75
  )}
68
76
  {config.name}
69
77
  </a>
70
- {config.color.toggle && <ThemeToggle />}
78
+ <div class="flex items-center gap-3">
79
+ <slot name="nav-end" />
80
+ {config.color.toggle && <ThemeToggle />}
81
+ </div>
71
82
  </nav>
72
83
  </header>
73
84
 
@@ -82,7 +93,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
82
93
  &copy; <a href="https://github.com/dirsigler/techradar" target="_blank" rel="noopener noreferrer" class="footer-link">Dennis Irsigler</a>
83
94
  &nbsp;&middot;&nbsp;
84
95
  <a href="https://github.com/dirsigler/techradar/blob/main/LICENSE" target="_blank" rel="noopener noreferrer" class="footer-link">MIT License</a>
85
- {config.footerText && <Fragment>&nbsp;&middot;&nbsp;<span set:html={config.footerText} /></Fragment>}
96
+ {config.footer && <Fragment>&nbsp;&middot;&nbsp;<span set:html={config.footer} /></Fragment>}
86
97
  </span>
87
98
 
88
99
  {config.socialLinks && config.socialLinks.length > 0 && (
@@ -146,4 +157,23 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
146
157
  .social-link:hover {
147
158
  color: var(--radar-text);
148
159
  }
160
+
161
+ :global(.search-input) {
162
+ width: 12rem;
163
+ height: calc(1.25rem + 1rem + 2px); /* match toggle: icon 1.25rem + padding 0.5rem*2 + border 1px*2 */
164
+ padding: 0 0.625rem;
165
+ border-radius: 0.5rem;
166
+ border: 1px solid var(--radar-border);
167
+ background-color: transparent;
168
+ color: var(--radar-text);
169
+ font-size: 0.8125rem;
170
+ outline: none;
171
+ transition: color 0.15s, border-color 0.15s;
172
+ }
173
+ :global(.search-input)::placeholder {
174
+ color: var(--radar-text-faint);
175
+ }
176
+ :global(.search-input):focus {
177
+ border-color: var(--radar-active-bg);
178
+ }
149
179
  </style>
@@ -0,0 +1,64 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getCollection } from 'astro:content';
3
+ import config from 'virtual:techradar/config';
4
+
5
+ function escapeXml(str: string): string {
6
+ return str
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+ .replace(/'/g, '&apos;');
12
+ }
13
+
14
+ export async function GET(context: APIContext) {
15
+ const site = context.site?.toString().replace(/\/+$/, '') ?? 'https://example.com';
16
+ const technologies = await getCollection('technologies');
17
+
18
+ const items = technologies.map((entry) => {
19
+ const ring = entry.data.ring.charAt(0).toUpperCase() + entry.data.ring.slice(1);
20
+
21
+ let pubDate = '';
22
+ if (entry.data.history && entry.data.history.length > 0) {
23
+ const sorted = [...entry.data.history].sort((a, b) =>
24
+ b.date.localeCompare(a.date),
25
+ );
26
+ const parsed = new Date(sorted[0].date);
27
+ if (!isNaN(parsed.getTime())) {
28
+ pubDate = `<pubDate>${parsed.toUTCString()}</pubDate>`;
29
+ }
30
+ }
31
+
32
+ const link = `${site}${config.basePath}/technology/${entry.id}/`;
33
+
34
+ return ` <item>
35
+ <title>${escapeXml(`${entry.data.title} — ${ring}`)}</title>
36
+ <link>${escapeXml(link)}</link>
37
+ <guid>${escapeXml(link)}</guid>
38
+ <description>${escapeXml(`${entry.data.title} is in the ${ring} ring of the technology radar.`)}</description>
39
+ ${pubDate}
40
+ </item>`;
41
+ });
42
+
43
+ const channelTitle = escapeXml(config.title);
44
+ const channelDescription = escapeXml(
45
+ config.subtitle ?? `${config.name} — tracking technology adoption`,
46
+ );
47
+ const channelLink = `${site}${config.basePath}/`;
48
+
49
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
50
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
51
+ <channel>
52
+ <title>${channelTitle}</title>
53
+ <description>${channelDescription}</description>
54
+ <link>${escapeXml(channelLink)}</link>
55
+ <atom:link href="${escapeXml(`${channelLink}feed.xml`)}" rel="self" type="application/rss+xml" />
56
+ <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
57
+ ${items.join('\n')}
58
+ </channel>
59
+ </rss>`;
60
+
61
+ return new Response(xml, {
62
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
63
+ });
64
+ }
@@ -40,6 +40,7 @@ const technologies = technologyEntries
40
40
  color: seg?.color ?? '#6b7280',
41
41
  order: seg?.order ?? 1,
42
42
  href: `${base}technology/${entry.id}/`,
43
+ tags: (entry.data.tags ?? []) as string[],
43
44
  };
44
45
  })
45
46
  .sort((a, b) => a.title.localeCompare(b.title));
@@ -51,6 +52,7 @@ const legendSegments = segments.map((s) => ({
51
52
  }));
52
53
 
53
54
  const ringEntries = Object.entries(RING_LABELS) as [string, string][];
55
+
54
56
  ---
55
57
 
56
58
  <Base title={config.name}>
@@ -63,6 +65,17 @@ const ringEntries = Object.entries(RING_LABELS) as [string, string][];
63
65
  )}
64
66
  </div>
65
67
 
68
+ {config.search.enabled && (
69
+ <input
70
+ type="search"
71
+ id="radar-search"
72
+ placeholder={config.search.placeholder}
73
+ autocomplete="off"
74
+ class="search-input"
75
+ slot="nav-end"
76
+ />
77
+ )}
78
+
66
79
  <!-- Filter bar -->
67
80
  <div id="filter-bar" class="mb-8 flex flex-col items-center gap-3">
68
81
  <div class="flex flex-wrap items-center justify-center gap-2">
@@ -116,7 +129,7 @@ const ringEntries = Object.entries(RING_LABELS) as [string, string][];
116
129
  border-color: var(--radar-border-subtle);
117
130
  color: var(--radar-text);
118
131
  }
119
- .filter-btn.active {
132
+ .filter-btn.active {
120
133
  background-color: var(--radar-active-bg);
121
134
  border-color: var(--radar-active-bg);
122
135
  color: var(--radar-active-text);
@@ -164,44 +177,111 @@ const ringEntries = Object.entries(RING_LABELS) as [string, string][];
164
177
  const dots = document.querySelectorAll<SVGCircleElement>('.radar-dot');
165
178
  const legendRows = document.querySelectorAll<HTMLElement>('.legend-row');
166
179
 
180
+ const searchInput = document.getElementById('radar-search') as HTMLInputElement | null;
181
+
182
+ function filterDot(dot: SVGCircleElement, visible: boolean) {
183
+ const link = dot.closest('a') as SVGAElement | null;
184
+ if (!link) return;
185
+ link.style.opacity = visible ? '1' : '0.1';
186
+ link.style.pointerEvents = visible ? 'auto' : 'none';
187
+ link.setAttribute('tabindex', visible ? '0' : '-1');
188
+ }
189
+
190
+ function filterRow(row: HTMLElement, visible: boolean) {
191
+ row.style.opacity = visible ? '1' : '0.15';
192
+ row.style.pointerEvents = visible ? 'auto' : 'none';
193
+ row.setAttribute('tabindex', visible ? '0' : '-1');
194
+ }
195
+
196
+ function applySearch(query: string) {
197
+ const q = query.toLowerCase().trim();
198
+
199
+ buttons.forEach((b) => b.classList.remove('active'));
200
+ document.querySelector('[data-filter="all"]')?.classList.add('active');
201
+
202
+ dots.forEach((dot) => {
203
+ const title = (dot.getAttribute('data-title') ?? '').toLowerCase();
204
+ const segment = (dot.getAttribute('data-segment') ?? '').toLowerCase();
205
+ const ring = (dot.getAttribute('data-ring') ?? '').toLowerCase();
206
+ const tags = (dot.getAttribute('data-tags') ?? '').toLowerCase();
207
+ filterDot(dot, !q || title.includes(q) || segment.includes(q) || ring.includes(q) || tags.includes(q));
208
+ });
209
+
210
+ legendRows.forEach((row) => {
211
+ const title = (row.querySelector('.legend-title')?.textContent ?? '').toLowerCase();
212
+ const segment = (row.dataset.segment ?? '').toLowerCase();
213
+ const ring = (row.dataset.ring ?? '').toLowerCase();
214
+ const tags = (row.dataset.tags ?? '').toLowerCase();
215
+ filterRow(row, !q || title.includes(q) || segment.includes(q) || ring.includes(q) || tags.includes(q));
216
+ });
217
+ }
218
+
219
+ searchInput?.addEventListener('input', () => applySearch(searchInput!.value));
220
+
221
+ const allBtn = document.querySelector<HTMLButtonElement>('[data-filter="all"]')!;
222
+
223
+ function resetAll() {
224
+ buttons.forEach((b) => b.classList.remove('active'));
225
+ allBtn.classList.add('active');
226
+ dots.forEach((dot) => filterDot(dot, true));
227
+ legendRows.forEach((row) => filterRow(row, true));
228
+ }
229
+
167
230
  buttons.forEach((btn) => {
168
231
  btn.addEventListener('click', () => {
169
- // Update active state
232
+ if (searchInput) searchInput.value = '';
233
+
234
+ // Toggle off if already active (unless it's the "All" button)
235
+ if (btn.classList.contains('active') && btn.dataset.filter !== 'all') {
236
+ resetAll();
237
+ return;
238
+ }
239
+
170
240
  buttons.forEach((b) => b.classList.remove('active'));
171
241
  btn.classList.add('active');
172
242
 
173
243
  const filter = btn.dataset.filter!;
174
244
  const type = btn.dataset.type!;
175
245
 
176
- // Filter radar dots
177
246
  dots.forEach((dot) => {
178
- const link = dot.closest('a') as SVGAElement | null;
179
- if (!link) return;
180
-
181
247
  const segment = dot.getAttribute('data-segment') ?? '';
182
248
  const ring = dot.getAttribute('data-ring') ?? '';
249
+ const tags = (dot.getAttribute('data-tags') ?? '').split(',').filter(Boolean);
183
250
 
184
251
  let visible = true;
185
252
  if (type === 'segment') visible = segment === filter;
186
253
  else if (type === 'ring') visible = ring === filter;
254
+ else if (type === 'tag') visible = tags.includes(filter);
187
255
 
188
- // Fade non-matching dots instead of hiding
189
- link.style.opacity = visible ? '1' : '0.1';
190
- link.style.pointerEvents = visible ? 'auto' : 'none';
256
+ filterDot(dot, visible);
191
257
  });
192
258
 
193
- // Filter legend rows
194
259
  legendRows.forEach((row) => {
195
260
  const segment = row.dataset.segment ?? '';
196
261
  const ring = row.dataset.ring ?? '';
262
+ const tags = (row.dataset.tags ?? '').split(',').filter(Boolean);
197
263
 
198
264
  let visible = true;
199
265
  if (type === 'segment') visible = segment === filter;
200
266
  else if (type === 'ring') visible = ring === filter;
267
+ else if (type === 'tag') visible = tags.includes(filter);
201
268
 
202
- row.style.opacity = visible ? '1' : '0.15';
203
- row.style.pointerEvents = visible ? 'auto' : 'none';
269
+ filterRow(row, visible);
204
270
  });
205
271
  });
206
272
  });
273
+
274
+ // Activate filter from URL query parameters
275
+ const params = new URLSearchParams(window.location.search);
276
+ const urlQuery = params.get('q');
277
+ const urlTag = params.get('tag');
278
+ if (urlQuery && searchInput) {
279
+ searchInput.value = urlQuery;
280
+ applySearch(urlQuery);
281
+ } else if (urlTag) {
282
+ const tagBtn = Array.from(buttons).find(
283
+ (b) => b.dataset.type === 'tag' && b.dataset.filter === urlTag,
284
+ );
285
+ if (tagBtn) tagBtn.click();
286
+ }
207
287
  </script>
@@ -3,6 +3,7 @@ import { getCollection, render, type CollectionEntry } from 'astro:content';
3
3
  import Base from '../../layouts/Base.astro';
4
4
  import RingBadge from '../../components/RingBadge.astro';
5
5
  import MovedIndicator from '../../components/MovedIndicator.astro';
6
+ import { Icon } from 'astro-icon/components';
6
7
  import config from 'virtual:techradar/config';
7
8
  import type { GetStaticPaths } from 'astro';
8
9
 
@@ -35,9 +36,48 @@ const segmentColor = segmentEntry?.data.color ?? '#6c7086';
35
36
 
36
37
  // Build "Edit on GitHub" URL from the entry's file path
37
38
  const techFileName = entry.id.split('/').pop();
38
- const editUrl = config.editBaseUrl
39
- ? `${config.editBaseUrl}/${segmentSlug}/${techFileName}.md`
39
+ const editUrl = config.editing.enabled && config.editing.baseUrl
40
+ ? `${config.editing.baseUrl}/${segmentSlug}/${techFileName}.md`
40
41
  : null;
42
+
43
+ const linkIconMap: Record<string, string> = {
44
+ docs: 'lucide:book-open',
45
+ repo: 'lucide:git-fork',
46
+ website: 'lucide:globe',
47
+ community: 'lucide:messages-square',
48
+ };
49
+ const linkOrder: Record<string, number> = { docs: 0, repo: 1, website: 2, community: 3 };
50
+ const links = (entry.data.links ?? [])
51
+ .map((link: { label: string; url: string; type: string }) => ({
52
+ ...link,
53
+ icon: linkIconMap[link.type] ?? 'lucide:globe',
54
+ order: linkOrder[link.type] ?? 99,
55
+ }))
56
+ .sort((a, b) => a.order - b.order);
57
+
58
+ const tags = (entry.data.tags ?? []) as string[];
59
+ const manualRelatedSlugs = (entry.data.related ?? []) as string[];
60
+
61
+ // Build related technologies: manual + auto (2+ shared tags)
62
+ const allTechnologies = await getCollection('technologies');
63
+ const manualRelated = manualRelatedSlugs
64
+ .map((slug) => allTechnologies.find((t) => t.id === slug))
65
+ .filter(Boolean);
66
+
67
+ const manualIds = new Set(manualRelatedSlugs);
68
+ manualIds.add(entry.id); // exclude self
69
+
70
+ let autoRelated: typeof manualRelated = [];
71
+ if (tags.length > 0) {
72
+ autoRelated = allTechnologies.filter((t) => {
73
+ if (manualIds.has(t.id)) return false;
74
+ const otherTags = (t.data.tags ?? []) as string[];
75
+ const shared = tags.filter((tag) => otherTags.includes(tag));
76
+ return shared.length >= 2;
77
+ });
78
+ }
79
+
80
+ const relatedTechnologies = [...manualRelated, ...autoRelated];
41
81
  ---
42
82
 
43
83
  <Base title={`${entry.data.title} — Tech Radar`}>
@@ -57,13 +97,50 @@ const editUrl = config.editBaseUrl
57
97
  {entry.data.title}
58
98
  <MovedIndicator moved={entry.data.moved} />
59
99
  </h1>
60
- <div class="mt-3 flex items-center gap-3">
100
+ <div class="mt-3 flex items-center gap-3 flex-wrap">
61
101
  <RingBadge ring={entry.data.ring} />
62
102
  <span class="flex items-center gap-1.5 text-sm" style="color: var(--radar-text-muted);">
63
103
  <span class="inline-block h-2.5 w-2.5 rounded-full" style={`background-color: ${segmentColor}`}></span>
64
104
  {segmentTitle}
65
105
  </span>
106
+ {entry.data.owner && (
107
+ <span class="flex items-center gap-1.5 text-sm" style="color: var(--radar-text-muted);">
108
+ <Icon name="lucide:users-round" class="h-3.5 w-3.5" />
109
+ {entry.data.owner.url ? (
110
+ <a href={entry.data.owner.url} target="_blank" rel="noopener noreferrer" class="owner-link">
111
+ {entry.data.owner.name}
112
+ </a>
113
+ ) : (
114
+ entry.data.owner.name
115
+ )}
116
+ </span>
117
+ )}
118
+ {links.length > 0 && (
119
+ <>
120
+ <span class="meta-divider" />
121
+ {links.map((link) => (
122
+ <a
123
+ href={link.url}
124
+ target="_blank"
125
+ rel="noopener noreferrer"
126
+ class="tech-link"
127
+ >
128
+ <Icon name={link.icon} class="h-3.5 w-3.5" />
129
+ {link.label}
130
+ </a>
131
+ ))}
132
+ </>
133
+ )}
66
134
  </div>
135
+ {tags.length > 0 && (
136
+ <div class="mt-3 flex items-center gap-2 flex-wrap">
137
+ {tags.map((tag) => (
138
+ <a href={`${base}?tag=${encodeURIComponent(tag)}`} class="tag-pill">
139
+ #{tag}
140
+ </a>
141
+ ))}
142
+ </div>
143
+ )}
67
144
  </header>
68
145
 
69
146
  <div class="prose max-w-none">
@@ -95,6 +172,18 @@ const editUrl = config.editBaseUrl
95
172
  </section>
96
173
  )}
97
174
 
175
+ {relatedTechnologies.length > 0 && (
176
+ <div class="mt-8 text-sm" style="color: var(--radar-text-muted);">
177
+ <span class="font-medium" style="color: var(--radar-text-secondary);">Related</span>
178
+ {relatedTechnologies.map((tech, i) => (
179
+ <>
180
+ <span class="mx-1.5">{i > 0 ? '·' : ''}</span>
181
+ <a href={`${base}technology/${tech.id}/`} class="related-link">{tech.data.title}</a>
182
+ </>
183
+ ))}
184
+ </div>
185
+ )}
186
+
98
187
  {editUrl && (
99
188
  <div class="mt-8 pt-6" style="border-top: 1px solid var(--radar-border);">
100
189
  <a
@@ -136,6 +225,58 @@ const editUrl = config.editBaseUrl
136
225
  color: var(--radar-link);
137
226
  }
138
227
 
228
+ .owner-link {
229
+ color: var(--radar-text-muted);
230
+ text-decoration: none;
231
+ transition: color 0.15s;
232
+ }
233
+ .owner-link:hover {
234
+ color: var(--radar-link);
235
+ }
236
+
237
+ .meta-divider {
238
+ width: 1px;
239
+ height: 1rem;
240
+ background-color: var(--radar-border);
241
+ }
242
+
243
+ .tech-link {
244
+ display: inline-flex;
245
+ align-items: center;
246
+ gap: 0.375rem;
247
+ font-size: 0.875rem;
248
+ color: var(--radar-text-muted);
249
+ text-decoration: none;
250
+ transition: color 0.15s;
251
+ }
252
+ .tech-link:hover {
253
+ color: var(--radar-link);
254
+ }
255
+
256
+ .tag-pill {
257
+ display: inline-block;
258
+ padding: 0.125rem 0.5rem;
259
+ border-radius: 0.25rem;
260
+ border: 1px solid var(--radar-border-subtle);
261
+ font-size: 0.75rem;
262
+ color: var(--radar-text-muted);
263
+ text-decoration: none;
264
+ transition: color 0.15s, border-color 0.15s;
265
+ }
266
+ .tag-pill:hover {
267
+ color: var(--radar-link);
268
+ border-color: var(--radar-link);
269
+ }
270
+
271
+ .related-link {
272
+ color: var(--radar-text-muted);
273
+ text-decoration: none;
274
+ transition: color 0.15s;
275
+ }
276
+ .related-link:hover {
277
+ color: var(--radar-link);
278
+ }
279
+
139
280
  .history-timeline {
140
281
  list-style: none;
141
282
  padding: 0;
@@ -7,8 +7,8 @@
7
7
  --radar-bg-secondary: #f8fafc;
8
8
  --radar-text: #0f172a;
9
9
  --radar-text-secondary: #334155;
10
- --radar-text-muted: #64748b;
11
- --radar-text-faint: #94a3b8;
10
+ --radar-text-muted: #475569; /* slate-600, ~7.4:1 on white */
11
+ --radar-text-faint: #64748b; /* slate-500, ~4.8:1 on white */
12
12
  --radar-border: #e2e8f0;
13
13
  --radar-border-subtle: #cbd5e1;
14
14
 
@@ -24,7 +24,7 @@
24
24
  --radar-chart-edge: #f8fafc;
25
25
  --radar-chart-ring: #cbd5e1;
26
26
  --radar-chart-divider: #cbd5e1;
27
- --radar-chart-label: #64748b;
27
+ --radar-chart-label: #475569;
28
28
  --radar-chart-band: 15, 23, 42;
29
29
  --radar-dot-stroke: #ffffff;
30
30
 
@@ -64,16 +64,16 @@
64
64
  --radar-bg-secondary: #1e293b;
65
65
  --radar-text: #e2e8f0;
66
66
  --radar-text-secondary: #cbd5e1;
67
- --radar-text-muted: #94a3b8;
68
- --radar-text-faint: #94a3b8;
67
+ --radar-text-muted: #a3b1c1; /* ~8.3:1 on #0f172a */
68
+ --radar-text-faint: #94a3b8; /* ~5.6:1 on #0f172a */
69
69
  --radar-border: #334155;
70
- --radar-border-subtle: #475569;
70
+ --radar-border-subtle: #566375; /* brighter for visibility, ~3.5:1 */
71
71
 
72
72
  /* Interactive */
73
73
  --radar-hover-bg: #334155;
74
74
  --radar-link: #60a5fa;
75
- --radar-active-bg: #3b82f6;
76
- --radar-active-text: #0f172a;
75
+ --radar-active-bg: #2563eb; /* deeper blue, 5.6:1 with white text */
76
+ --radar-active-text: #ffffff;
77
77
 
78
78
  /* Radar SVG */
79
79
  --radar-chart-center: #334155;
@@ -81,7 +81,7 @@
81
81
  --radar-chart-edge: #0f172a;
82
82
  --radar-chart-ring: #475569;
83
83
  --radar-chart-divider: #475569;
84
- --radar-chart-label: #94a3b8;
84
+ --radar-chart-label: #a3b1c1;
85
85
  --radar-chart-band: 226, 232, 240;
86
86
  --radar-dot-stroke: #0f172a;
87
87
 
@@ -109,7 +109,7 @@
109
109
  --radar-prose-links: #60a5fa;
110
110
  --radar-prose-bold: #e2e8f0;
111
111
  --radar-prose-code: #a78bfa;
112
- --radar-prose-quotes: #94a3b8;
112
+ --radar-prose-quotes: #a3b1c1;
113
113
  --radar-prose-hr: #475569;
114
114
  }
115
115
  }
@@ -121,16 +121,16 @@
121
121
  --radar-bg-secondary: #1e293b;
122
122
  --radar-text: #e2e8f0;
123
123
  --radar-text-secondary: #cbd5e1;
124
- --radar-text-muted: #94a3b8;
124
+ --radar-text-muted: #a3b1c1;
125
125
  --radar-text-faint: #94a3b8;
126
126
  --radar-border: #334155;
127
- --radar-border-subtle: #475569;
127
+ --radar-border-subtle: #566375;
128
128
 
129
129
  /* Interactive */
130
130
  --radar-hover-bg: #334155;
131
131
  --radar-link: #60a5fa;
132
- --radar-active-bg: #3b82f6;
133
- --radar-active-text: #0f172a;
132
+ --radar-active-bg: #2563eb;
133
+ --radar-active-text: #ffffff;
134
134
 
135
135
  /* Radar SVG */
136
136
  --radar-chart-center: #334155;
@@ -138,7 +138,7 @@
138
138
  --radar-chart-edge: #0f172a;
139
139
  --radar-chart-ring: #475569;
140
140
  --radar-chart-divider: #475569;
141
- --radar-chart-label: #94a3b8;
141
+ --radar-chart-label: #a3b1c1;
142
142
  --radar-chart-band: 226, 232, 240;
143
143
  --radar-dot-stroke: #0f172a;
144
144
 
@@ -166,6 +166,6 @@
166
166
  --radar-prose-links: #60a5fa;
167
167
  --radar-prose-bold: #e2e8f0;
168
168
  --radar-prose-code: #a78bfa;
169
- --radar-prose-quotes: #94a3b8;
169
+ --radar-prose-quotes: #a3b1c1;
170
170
  --radar-prose-hr: #475569;
171
171
  }