@dirsigler/astro-techradar 0.3.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 +75 -8
- package/config.ts +59 -20
- package/integration.ts +6 -0
- package/package.json +1 -1
- package/schemas.ts +17 -0
- package/src/components/Radar.astro +77 -15
- package/src/components/RadarLegend.astro +11 -1
- package/src/layouts/Base.astro +35 -5
- package/src/pages/feed.xml.ts +64 -0
- package/src/pages/index.astro +99 -17
- package/src/pages/technology/[...slug].astro +144 -3
- package/src/themes/default.css +16 -16
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
189
|
-
|
|
|
190
|
-
| `title` | `string`
|
|
191
|
-
| `ring`
|
|
192
|
-
| `moved` | `-1 \| 0 \| 1`
|
|
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;
|
|
@@ -18,11 +42,14 @@ export interface ResolvedColorMode {
|
|
|
18
42
|
}
|
|
19
43
|
|
|
20
44
|
export interface TechRadarUserConfig {
|
|
21
|
-
/**
|
|
22
|
-
|
|
45
|
+
/** Brand name shown in the navbar. */
|
|
46
|
+
name: string;
|
|
47
|
+
|
|
48
|
+
/** Main page title shown as the h1 heading. Falls back to name if not set. */
|
|
49
|
+
title?: string;
|
|
23
50
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
51
|
+
/** Subtitle shown below the title on the main page. */
|
|
52
|
+
subtitle?: string;
|
|
26
53
|
|
|
27
54
|
/**
|
|
28
55
|
* URL path prefix where the tech radar is mounted (e.g. "/techradar").
|
|
@@ -35,13 +62,10 @@ export interface TechRadarUserConfig {
|
|
|
35
62
|
logo?: string;
|
|
36
63
|
|
|
37
64
|
/** Footer text. Supports simple HTML. */
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/** Show "Edit this page" links on technology pages. Default: true */
|
|
41
|
-
allowEditing?: boolean;
|
|
65
|
+
footer?: string;
|
|
42
66
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
67
|
+
/** Edit page configuration. */
|
|
68
|
+
editing?: EditConfig;
|
|
45
69
|
|
|
46
70
|
/** Social links shown in the footer. */
|
|
47
71
|
socialLinks?: SocialLink[];
|
|
@@ -51,19 +75,28 @@ export interface TechRadarUserConfig {
|
|
|
51
75
|
|
|
52
76
|
/** Color mode configuration. */
|
|
53
77
|
color?: ColorModeConfig;
|
|
78
|
+
|
|
79
|
+
/** Enable RSS feed at {basePath}/feed.xml. Default: true */
|
|
80
|
+
feed?: boolean;
|
|
81
|
+
|
|
82
|
+
/** Search configuration. */
|
|
83
|
+
search?: SearchConfig;
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
export interface ResolvedConfig {
|
|
87
|
+
name: string;
|
|
57
88
|
title: string;
|
|
58
|
-
|
|
89
|
+
subtitle?: string;
|
|
59
90
|
/** Normalized base path with leading slash, no trailing slash. Empty string when mounted at root. */
|
|
60
91
|
basePath: string;
|
|
61
92
|
logo?: string;
|
|
62
|
-
|
|
63
|
-
|
|
93
|
+
footer: string;
|
|
94
|
+
editing: ResolvedEdit;
|
|
64
95
|
socialLinks: SocialLink[];
|
|
65
96
|
theme: string;
|
|
66
97
|
color: ResolvedColorMode;
|
|
98
|
+
feed: boolean;
|
|
99
|
+
search: ResolvedSearch;
|
|
67
100
|
}
|
|
68
101
|
|
|
69
102
|
/** Normalize a user-supplied path: ensure leading slash, strip trailing slash. Returns "" for root. */
|
|
@@ -76,20 +109,26 @@ function normalizeBasePath(raw?: string): string {
|
|
|
76
109
|
|
|
77
110
|
export function resolveConfig(user: TechRadarUserConfig): ResolvedConfig {
|
|
78
111
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
name: user.name,
|
|
113
|
+
title: user.title ?? user.name,
|
|
114
|
+
subtitle: user.subtitle,
|
|
81
115
|
basePath: normalizeBasePath(user.basePath),
|
|
82
116
|
logo: user.logo,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
footer: user.footer ?? '',
|
|
118
|
+
editing: {
|
|
119
|
+
enabled: user.editing?.enabled ?? true,
|
|
120
|
+
baseUrl: (user.editing?.enabled ?? true) ? user.editing?.baseUrl : undefined,
|
|
121
|
+
},
|
|
88
122
|
socialLinks: user.socialLinks ?? [],
|
|
89
123
|
theme: user.theme ?? 'default',
|
|
90
124
|
color: {
|
|
91
125
|
toggle: user.color?.toggle ?? true,
|
|
92
126
|
mode: user.color?.mode ?? 'system',
|
|
93
127
|
},
|
|
128
|
+
feed: user.feed ?? true,
|
|
129
|
+
search: {
|
|
130
|
+
enabled: user.search?.enabled ?? true,
|
|
131
|
+
placeholder: user.search?.placeholder ?? 'Search technologies...',
|
|
132
|
+
},
|
|
94
133
|
};
|
|
95
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
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.
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
214
|
-
tooltip
|
|
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
|
-
|
|
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;
|
package/src/layouts/Base.astro
CHANGED
|
@@ -10,7 +10,7 @@ interface Props {
|
|
|
10
10
|
description?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const { title = config.
|
|
13
|
+
const { title = config.name, description = 'Technology Radar — tracking technology adoption across our organization' } = Astro.props;
|
|
14
14
|
const siteBase = import.meta.env.BASE_URL.replace(/\/+$/, '');
|
|
15
15
|
const base = `${siteBase}${config.basePath}/`;
|
|
16
16
|
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
|
@@ -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 }}>
|
|
@@ -61,13 +69,16 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
|
|
61
69
|
{config.logo && (
|
|
62
70
|
<img
|
|
63
71
|
src={config.logo.startsWith('https://') ? config.logo : `${base}${config.logo.replace(/^\//, '')}`}
|
|
64
|
-
alt={config.
|
|
72
|
+
alt={config.name}
|
|
65
73
|
class="h-8 w-auto"
|
|
66
74
|
/>
|
|
67
75
|
)}
|
|
68
|
-
{config.
|
|
76
|
+
{config.name}
|
|
69
77
|
</a>
|
|
70
|
-
|
|
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
|
© <a href="https://github.com/dirsigler/techradar" target="_blank" rel="noopener noreferrer" class="footer-link">Dennis Irsigler</a>
|
|
83
94
|
·
|
|
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.
|
|
96
|
+
{config.footer && <Fragment> · <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, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
package/src/pages/index.astro
CHANGED
|
@@ -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,16 +52,30 @@ 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
|
-
<Base title={config.
|
|
58
|
+
<Base title={config.name}>
|
|
57
59
|
<div class="text-center mb-10">
|
|
58
|
-
<h1 class="text-4xl font-bold" style="color: var(--radar-text);">{config.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
<h1 class="text-4xl font-bold" style="color: var(--radar-text);">{config.title}</h1>
|
|
61
|
+
{config.subtitle && (
|
|
62
|
+
<p class="mt-3 text-lg" style="color: var(--radar-text-muted);">
|
|
63
|
+
{config.subtitle}
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
62
66
|
</div>
|
|
63
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
|
+
|
|
64
79
|
<!-- Filter bar -->
|
|
65
80
|
<div id="filter-bar" class="mb-8 flex flex-col items-center gap-3">
|
|
66
81
|
<div class="flex flex-wrap items-center justify-center gap-2">
|
|
@@ -114,7 +129,7 @@ const ringEntries = Object.entries(RING_LABELS) as [string, string][];
|
|
|
114
129
|
border-color: var(--radar-border-subtle);
|
|
115
130
|
color: var(--radar-text);
|
|
116
131
|
}
|
|
117
|
-
|
|
132
|
+
.filter-btn.active {
|
|
118
133
|
background-color: var(--radar-active-bg);
|
|
119
134
|
border-color: var(--radar-active-bg);
|
|
120
135
|
color: var(--radar-active-text);
|
|
@@ -162,44 +177,111 @@ const ringEntries = Object.entries(RING_LABELS) as [string, string][];
|
|
|
162
177
|
const dots = document.querySelectorAll<SVGCircleElement>('.radar-dot');
|
|
163
178
|
const legendRows = document.querySelectorAll<HTMLElement>('.legend-row');
|
|
164
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
|
+
|
|
165
230
|
buttons.forEach((btn) => {
|
|
166
231
|
btn.addEventListener('click', () => {
|
|
167
|
-
|
|
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
|
+
|
|
168
240
|
buttons.forEach((b) => b.classList.remove('active'));
|
|
169
241
|
btn.classList.add('active');
|
|
170
242
|
|
|
171
243
|
const filter = btn.dataset.filter!;
|
|
172
244
|
const type = btn.dataset.type!;
|
|
173
245
|
|
|
174
|
-
// Filter radar dots
|
|
175
246
|
dots.forEach((dot) => {
|
|
176
|
-
const link = dot.closest('a') as SVGAElement | null;
|
|
177
|
-
if (!link) return;
|
|
178
|
-
|
|
179
247
|
const segment = dot.getAttribute('data-segment') ?? '';
|
|
180
248
|
const ring = dot.getAttribute('data-ring') ?? '';
|
|
249
|
+
const tags = (dot.getAttribute('data-tags') ?? '').split(',').filter(Boolean);
|
|
181
250
|
|
|
182
251
|
let visible = true;
|
|
183
252
|
if (type === 'segment') visible = segment === filter;
|
|
184
253
|
else if (type === 'ring') visible = ring === filter;
|
|
254
|
+
else if (type === 'tag') visible = tags.includes(filter);
|
|
185
255
|
|
|
186
|
-
|
|
187
|
-
link.style.opacity = visible ? '1' : '0.1';
|
|
188
|
-
link.style.pointerEvents = visible ? 'auto' : 'none';
|
|
256
|
+
filterDot(dot, visible);
|
|
189
257
|
});
|
|
190
258
|
|
|
191
|
-
// Filter legend rows
|
|
192
259
|
legendRows.forEach((row) => {
|
|
193
260
|
const segment = row.dataset.segment ?? '';
|
|
194
261
|
const ring = row.dataset.ring ?? '';
|
|
262
|
+
const tags = (row.dataset.tags ?? '').split(',').filter(Boolean);
|
|
195
263
|
|
|
196
264
|
let visible = true;
|
|
197
265
|
if (type === 'segment') visible = segment === filter;
|
|
198
266
|
else if (type === 'ring') visible = ring === filter;
|
|
267
|
+
else if (type === 'tag') visible = tags.includes(filter);
|
|
199
268
|
|
|
200
|
-
row
|
|
201
|
-
row.style.pointerEvents = visible ? 'auto' : 'none';
|
|
269
|
+
filterRow(row, visible);
|
|
202
270
|
});
|
|
203
271
|
});
|
|
204
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
|
+
}
|
|
205
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.
|
|
39
|
-
? `${config.
|
|
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;
|
package/src/themes/default.css
CHANGED
|
@@ -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: #
|
|
11
|
-
--radar-text-faint: #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
76
|
-
--radar-active-text: #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
124
|
+
--radar-text-muted: #a3b1c1;
|
|
125
125
|
--radar-text-faint: #94a3b8;
|
|
126
126
|
--radar-border: #334155;
|
|
127
|
-
--radar-border-subtle: #
|
|
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: #
|
|
133
|
-
--radar-active-text: #
|
|
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: #
|
|
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: #
|
|
169
|
+
--radar-prose-quotes: #a3b1c1;
|
|
170
170
|
--radar-prose-hr: #475569;
|
|
171
171
|
}
|