Skip to content

Commit 0eab402

Browse files
authored
add licensing & tiers data source + render on licensing page/service page frontmatter (#534)
1 parent 5a36183 commit 0eab402

File tree

11 files changed

+2247
-379
lines changed

11 files changed

+2247
-379
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"build": "astro build",
99
"preview": "astro preview",
1010
"astro": "astro",
11-
"lint:links": "astro build"
11+
"lint:links": "astro build",
12+
"sync:licensing-tags": "node scripts/sync-licensing-tags.mjs"
1213
},
1314
"dependencies": {
1415
"@astrojs/markdoc": "^0.15.10",

scripts/sync-licensing-tags.mjs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Syncs the `tags` frontmatter in service docs with the canonical
5+
* licensing data in src/data/licensing/current-plans.json.
6+
*
7+
* Usage: node scripts/sync-licensing-tags.mjs [--dry-run]
8+
*
9+
* --dry-run Print what would change without writing files.
10+
*/
11+
12+
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
13+
import { join, resolve } from 'node:path';
14+
import { fileURLToPath } from 'node:url';
15+
16+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
17+
const ROOT = resolve(__dirname, '..');
18+
19+
const JSON_PATH = join(ROOT, 'src/data/licensing/current-plans.json');
20+
const SERVICES_DIR = join(ROOT, 'src/content/docs/aws/services');
21+
22+
const PLAN_HIERARCHY = ['Hobby', 'Base', 'Ultimate', 'Enterprise'];
23+
24+
const dryRun = process.argv.includes('--dry-run');
25+
26+
const licensingData = JSON.parse(readFileSync(JSON_PATH, 'utf-8'));
27+
28+
const serviceTagsMap = new Map();
29+
30+
for (const category of licensingData.categories) {
31+
for (const svc of category.services) {
32+
if (!svc.serviceId) continue;
33+
if (!serviceTagsMap.has(svc.serviceId)) {
34+
serviceTagsMap.set(svc.serviceId, new Set());
35+
}
36+
serviceTagsMap.get(svc.serviceId).add(svc.minimumPlan);
37+
}
38+
}
39+
40+
function deriveTags(serviceId) {
41+
const plans = serviceTagsMap.get(serviceId);
42+
if (!plans) return null;
43+
return [...plans].sort(
44+
(a, b) => PLAN_HIERARCHY.indexOf(a) - PLAN_HIERARCHY.indexOf(b)
45+
);
46+
}
47+
48+
const files = readdirSync(SERVICES_DIR).filter((f) => f.endsWith('.mdx'));
49+
50+
let updated = 0;
51+
let skipped = 0;
52+
let unchanged = 0;
53+
54+
for (const file of files) {
55+
const serviceId = file.replace('.mdx', '');
56+
57+
if (serviceId === 'index') continue;
58+
59+
const expectedTags = deriveTags(serviceId);
60+
61+
if (!expectedTags) {
62+
skipped++;
63+
continue;
64+
}
65+
66+
const filePath = join(SERVICES_DIR, file);
67+
const content = readFileSync(filePath, 'utf-8');
68+
69+
const fmMatch = content.match(/^(---\n)([\s\S]*?\n)(---)/);
70+
if (!fmMatch) {
71+
console.warn(` WARN: no frontmatter in ${file}, skipping`);
72+
skipped++;
73+
continue;
74+
}
75+
76+
const fmOpen = fmMatch[1];
77+
const fmBody = fmMatch[2];
78+
const fmClose = fmMatch[3];
79+
const afterFm = content.slice(fmMatch[0].length);
80+
81+
const tagsLine = `tags: [${expectedTags.map((t) => `"${t}"`).join(', ')}]`;
82+
83+
const existingTagsMatch = fmBody.match(
84+
/^tags:\s*\[([^\]]*)\][^\S\n]*$/m
85+
);
86+
87+
let newFmBody;
88+
89+
if (existingTagsMatch) {
90+
const currentTags = existingTagsMatch[1]
91+
.split(',')
92+
.map((t) => t.trim().replace(/["']/g, ''))
93+
.filter(Boolean)
94+
.sort((a, b) => PLAN_HIERARCHY.indexOf(a) - PLAN_HIERARCHY.indexOf(b));
95+
96+
if (
97+
currentTags.length === expectedTags.length &&
98+
currentTags.every((t, i) => t === expectedTags[i])
99+
) {
100+
unchanged++;
101+
continue;
102+
}
103+
104+
newFmBody = fmBody.replace(/^tags:\s*\[.*\][^\S\n]*$/m, tagsLine);
105+
} else {
106+
newFmBody = fmBody + tagsLine + '\n';
107+
}
108+
109+
const newContent = fmOpen + newFmBody + fmClose + afterFm;
110+
111+
if (dryRun) {
112+
const oldStr = existingTagsMatch
113+
? existingTagsMatch[0].trim()
114+
: '(none)';
115+
console.log(` WOULD UPDATE ${file}: ${oldStr}${tagsLine}`);
116+
} else {
117+
writeFileSync(filePath, newContent, 'utf-8');
118+
const oldStr = existingTagsMatch
119+
? existingTagsMatch[0].trim()
120+
: '(none)';
121+
console.log(` UPDATED ${file}: ${oldStr}${tagsLine}`);
122+
}
123+
updated++;
124+
}
125+
126+
console.log(
127+
`\nDone${dryRun ? ' (dry run)' : ''}. Updated: ${updated}, Unchanged: ${unchanged}, Skipped (not in JSON): ${skipped}`
128+
);
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import React, { useState, useMemo } from 'react';
2+
import data from '@/data/licensing/legacy-plans.json';
3+
4+
type ServiceEntry = {
5+
name: string;
6+
serviceId: string;
7+
plans: Record<string, boolean>;
8+
};
9+
10+
type Category = {
11+
name: string;
12+
services: ServiceEntry[];
13+
};
14+
15+
type EnhancementEntry = {
16+
name: string;
17+
docsUrl?: string;
18+
plans: Record<string, boolean | string>;
19+
};
20+
21+
type LegacyData = {
22+
metadata: Record<string, any>;
23+
usageAllocations: Record<string, any>;
24+
categories: Category[];
25+
enhancements: EnhancementEntry[];
26+
};
27+
28+
const legacyData = data as LegacyData;
29+
30+
const PLANS = ['Starter', 'Teams'];
31+
32+
function renderCellValue(value: boolean | string): React.ReactNode {
33+
if (value === true) return '✅';
34+
if (value === false) return '❌';
35+
return value;
36+
}
37+
38+
const headerStyle: React.CSSProperties = {
39+
textAlign: 'center',
40+
border: '1px solid #999CAD',
41+
background: '#AFB2C2',
42+
color: 'var(--sl-color-gray-1)',
43+
fontFamily: 'AeonikFono',
44+
fontSize: '14px',
45+
fontWeight: '500',
46+
lineHeight: '16px',
47+
letterSpacing: '-0.15px',
48+
padding: '12px 8px',
49+
};
50+
51+
const bodyFont: React.CSSProperties = {
52+
color: 'var(--sl-color-gray-1)',
53+
fontFamily: 'AeonikFono',
54+
fontSize: '14px',
55+
fontWeight: '400',
56+
lineHeight: '16px',
57+
letterSpacing: '-0.15px',
58+
};
59+
60+
const cellStyle: React.CSSProperties = {
61+
border: '1px solid #999CAD',
62+
padding: '12px 8px',
63+
textAlign: 'center',
64+
whiteSpace: 'normal',
65+
};
66+
67+
const categoryRowStyle: React.CSSProperties = {
68+
border: '1px solid #999CAD',
69+
padding: '10px 8px',
70+
fontFamily: 'AeonikFono',
71+
fontSize: '14px',
72+
fontWeight: '600',
73+
color: 'var(--sl-color-gray-1)',
74+
background: 'color-mix(in srgb, var(--sl-color-gray-6) 50%, transparent)',
75+
};
76+
77+
const inputStyle: React.CSSProperties = {
78+
color: '#707385',
79+
fontFamily: 'AeonikFono',
80+
fontSize: '14px',
81+
fontWeight: '500',
82+
lineHeight: '24px',
83+
letterSpacing: '-0.2px',
84+
};
85+
86+
export default function LegacyLicensingCoverage() {
87+
const [filter, setFilter] = useState('');
88+
const lowerFilter = filter.toLowerCase();
89+
90+
const filteredCategories = useMemo(() => {
91+
if (!lowerFilter) return legacyData.categories;
92+
return legacyData.categories
93+
.map((cat) => ({
94+
...cat,
95+
services: cat.services.filter((svc) =>
96+
svc.name.toLowerCase().includes(lowerFilter)
97+
),
98+
}))
99+
.filter((cat) => cat.services.length > 0);
100+
}, [lowerFilter]);
101+
102+
const filteredEnhancements = useMemo(() => {
103+
if (!lowerFilter) return legacyData.enhancements;
104+
return legacyData.enhancements.filter((e) =>
105+
e.name.toLowerCase().includes(lowerFilter)
106+
);
107+
}, [lowerFilter]);
108+
109+
const hasResults =
110+
filteredCategories.length > 0 || filteredEnhancements.length > 0;
111+
112+
return (
113+
<div className="w-full">
114+
<div className="flex flex-wrap gap-3 mb-4 mt-3">
115+
<input
116+
type="text"
117+
placeholder="Filter by service or feature name..."
118+
value={filter}
119+
onChange={(e) => setFilter(e.target.value)}
120+
className="border rounded px-3 py-2 w-full max-w-sm"
121+
style={inputStyle}
122+
/>
123+
</div>
124+
125+
<div className="block max-w-full overflow-x-auto overflow-y-hidden">
126+
<table
127+
style={{
128+
display: 'table',
129+
borderCollapse: 'collapse',
130+
tableLayout: 'fixed',
131+
width: '100%',
132+
minWidth: '100%',
133+
}}
134+
>
135+
<colgroup>
136+
<col style={{ width: '52%' }} />
137+
<col style={{ width: '24%' }} />
138+
<col style={{ width: '24%' }} />
139+
</colgroup>
140+
<thead>
141+
<tr>
142+
<th style={{ ...headerStyle, textAlign: 'left' }}>
143+
AWS Services
144+
</th>
145+
{PLANS.map((plan) => (
146+
<th key={plan} style={headerStyle}>
147+
Legacy Plan: {plan}
148+
</th>
149+
))}
150+
</tr>
151+
</thead>
152+
<tbody style={bodyFont}>
153+
{!hasResults && (
154+
<tr>
155+
<td
156+
colSpan={PLANS.length + 1}
157+
style={{ ...cellStyle, textAlign: 'center', padding: '24px' }}
158+
>
159+
No matching services or features found.
160+
</td>
161+
</tr>
162+
)}
163+
{filteredCategories.map((cat) => (
164+
<React.Fragment key={cat.name}>
165+
<tr>
166+
<td
167+
colSpan={PLANS.length + 1}
168+
style={categoryRowStyle}
169+
>
170+
{cat.name}
171+
</td>
172+
</tr>
173+
{cat.services.map((svc, idx) => (
174+
<tr key={`${cat.name}-${idx}`}>
175+
<td style={{ ...cellStyle, textAlign: 'left' }}>
176+
{svc.serviceId ? (
177+
<a href={`/aws/services/${svc.serviceId}/`}>
178+
{svc.name}
179+
</a>
180+
) : (
181+
svc.name
182+
)}
183+
</td>
184+
{PLANS.map((plan) => (
185+
<td key={plan} style={cellStyle}>
186+
{renderCellValue(svc.plans[plan])}
187+
</td>
188+
))}
189+
</tr>
190+
))}
191+
</React.Fragment>
192+
))}
193+
194+
{filteredEnhancements.length > 0 && (
195+
<>
196+
<tr>
197+
<td
198+
colSpan={PLANS.length + 1}
199+
style={categoryRowStyle}
200+
>
201+
Emulator Enhancements
202+
</td>
203+
</tr>
204+
{filteredEnhancements.map((enh, idx) => (
205+
<tr key={`enh-${idx}`}>
206+
<td style={{ ...cellStyle, textAlign: 'left' }}>
207+
{enh.docsUrl ? (
208+
<a href={enh.docsUrl}>{enh.name}</a>
209+
) : (
210+
enh.name
211+
)}
212+
</td>
213+
{PLANS.map((plan) => (
214+
<td
215+
key={plan}
216+
style={{
217+
...cellStyle,
218+
fontSize:
219+
typeof enh.plans[plan] === 'string'
220+
? '12px'
221+
: '14px',
222+
}}
223+
>
224+
{renderCellValue(enh.plans[plan])}
225+
</td>
226+
))}
227+
</tr>
228+
))}
229+
</>
230+
)}
231+
</tbody>
232+
</table>
233+
</div>
234+
</div>
235+
);
236+
}

0 commit comments

Comments
 (0)