Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"lint:links": "astro build"
"lint:links": "astro build",
"sync:licensing-tags": "node scripts/sync-licensing-tags.mjs"
},
"dependencies": {
"@astrojs/markdoc": "^0.15.10",
Expand Down
128 changes: 128 additions & 0 deletions scripts/sync-licensing-tags.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env node

/**
* Syncs the `tags` frontmatter in service docs with the canonical
* licensing data in src/data/licensing/current-plans.json.
*
* Usage: node scripts/sync-licensing-tags.mjs [--dry-run]
*
* --dry-run Print what would change without writing files.
*/

import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = fileURLToPath(new URL('.', import.meta.url));
const ROOT = resolve(__dirname, '..');

const JSON_PATH = join(ROOT, 'src/data/licensing/current-plans.json');
const SERVICES_DIR = join(ROOT, 'src/content/docs/aws/services');

const PLAN_HIERARCHY = ['Hobby', 'Base', 'Ultimate', 'Enterprise'];

const dryRun = process.argv.includes('--dry-run');

const licensingData = JSON.parse(readFileSync(JSON_PATH, 'utf-8'));

const serviceTagsMap = new Map();

for (const category of licensingData.categories) {
for (const svc of category.services) {
if (!svc.serviceId) continue;
if (!serviceTagsMap.has(svc.serviceId)) {
serviceTagsMap.set(svc.serviceId, new Set());
}
serviceTagsMap.get(svc.serviceId).add(svc.minimumPlan);
}
}

function deriveTags(serviceId) {
const plans = serviceTagsMap.get(serviceId);
if (!plans) return null;
return [...plans].sort(
(a, b) => PLAN_HIERARCHY.indexOf(a) - PLAN_HIERARCHY.indexOf(b)
);
}

const files = readdirSync(SERVICES_DIR).filter((f) => f.endsWith('.mdx'));

let updated = 0;
let skipped = 0;
let unchanged = 0;

for (const file of files) {
const serviceId = file.replace('.mdx', '');

if (serviceId === 'index') continue;

const expectedTags = deriveTags(serviceId);

if (!expectedTags) {
skipped++;
continue;
}

const filePath = join(SERVICES_DIR, file);
const content = readFileSync(filePath, 'utf-8');

const fmMatch = content.match(/^(---\n)([\s\S]*?\n)(---)/);
if (!fmMatch) {
console.warn(` WARN: no frontmatter in ${file}, skipping`);
skipped++;
continue;
}

const fmOpen = fmMatch[1];
const fmBody = fmMatch[2];
const fmClose = fmMatch[3];
const afterFm = content.slice(fmMatch[0].length);

const tagsLine = `tags: [${expectedTags.map((t) => `"${t}"`).join(', ')}]`;

const existingTagsMatch = fmBody.match(
/^tags:\s*\[([^\]]*)\][^\S\n]*$/m
);

let newFmBody;

if (existingTagsMatch) {
const currentTags = existingTagsMatch[1]
.split(',')
.map((t) => t.trim().replace(/["']/g, ''))
.filter(Boolean)
.sort((a, b) => PLAN_HIERARCHY.indexOf(a) - PLAN_HIERARCHY.indexOf(b));

if (
currentTags.length === expectedTags.length &&
currentTags.every((t, i) => t === expectedTags[i])
) {
unchanged++;
continue;
}

newFmBody = fmBody.replace(/^tags:\s*\[.*\][^\S\n]*$/m, tagsLine);
} else {
newFmBody = fmBody + tagsLine + '\n';
}

const newContent = fmOpen + newFmBody + fmClose + afterFm;

if (dryRun) {
const oldStr = existingTagsMatch
? existingTagsMatch[0].trim()
: '(none)';
console.log(` WOULD UPDATE ${file}: ${oldStr} → ${tagsLine}`);
} else {
writeFileSync(filePath, newContent, 'utf-8');
const oldStr = existingTagsMatch
? existingTagsMatch[0].trim()
: '(none)';
console.log(` UPDATED ${file}: ${oldStr} → ${tagsLine}`);
}
updated++;
}

console.log(
`\nDone${dryRun ? ' (dry run)' : ''}. Updated: ${updated}, Unchanged: ${unchanged}, Skipped (not in JSON): ${skipped}`
);
236 changes: 236 additions & 0 deletions src/components/licensing-coverage/LegacyLicensingCoverage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import React, { useState, useMemo } from 'react';
import data from '@/data/licensing/legacy-plans.json';

type ServiceEntry = {
name: string;
serviceId: string;
plans: Record<string, boolean>;
};

type Category = {
name: string;
services: ServiceEntry[];
};

type EnhancementEntry = {
name: string;
docsUrl?: string;
plans: Record<string, boolean | string>;
};

type LegacyData = {
metadata: Record<string, any>;
usageAllocations: Record<string, any>;
categories: Category[];
enhancements: EnhancementEntry[];
};

const legacyData = data as LegacyData;

const PLANS = ['Starter', 'Teams'];

function renderCellValue(value: boolean | string): React.ReactNode {
if (value === true) return '✅';
if (value === false) return '❌';
return value;
}

const headerStyle: React.CSSProperties = {
textAlign: 'center',
border: '1px solid #999CAD',
background: '#AFB2C2',
color: 'var(--sl-color-gray-1)',
fontFamily: 'AeonikFono',
fontSize: '14px',
fontWeight: '500',
lineHeight: '16px',
letterSpacing: '-0.15px',
padding: '12px 8px',
};

const bodyFont: React.CSSProperties = {
color: 'var(--sl-color-gray-1)',
fontFamily: 'AeonikFono',
fontSize: '14px',
fontWeight: '400',
lineHeight: '16px',
letterSpacing: '-0.15px',
};

const cellStyle: React.CSSProperties = {
border: '1px solid #999CAD',
padding: '12px 8px',
textAlign: 'center',
whiteSpace: 'normal',
};

const categoryRowStyle: React.CSSProperties = {
border: '1px solid #999CAD',
padding: '10px 8px',
fontFamily: 'AeonikFono',
fontSize: '14px',
fontWeight: '600',
color: 'var(--sl-color-gray-1)',
background: 'color-mix(in srgb, var(--sl-color-gray-6) 50%, transparent)',
};

const inputStyle: React.CSSProperties = {
color: '#707385',
fontFamily: 'AeonikFono',
fontSize: '14px',
fontWeight: '500',
lineHeight: '24px',
letterSpacing: '-0.2px',
};

export default function LegacyLicensingCoverage() {
const [filter, setFilter] = useState('');
const lowerFilter = filter.toLowerCase();

const filteredCategories = useMemo(() => {
if (!lowerFilter) return legacyData.categories;
return legacyData.categories
.map((cat) => ({
...cat,
services: cat.services.filter((svc) =>
svc.name.toLowerCase().includes(lowerFilter)
),
}))
.filter((cat) => cat.services.length > 0);
}, [lowerFilter]);

const filteredEnhancements = useMemo(() => {
if (!lowerFilter) return legacyData.enhancements;
return legacyData.enhancements.filter((e) =>
e.name.toLowerCase().includes(lowerFilter)
);
}, [lowerFilter]);

const hasResults =
filteredCategories.length > 0 || filteredEnhancements.length > 0;

return (
<div className="w-full">
<div className="flex flex-wrap gap-3 mb-4 mt-3">
<input
type="text"
placeholder="Filter by service or feature name..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="border rounded px-3 py-2 w-full max-w-sm"
style={inputStyle}
/>
</div>

<div className="block max-w-full overflow-x-auto overflow-y-hidden">
<table
style={{
display: 'table',
borderCollapse: 'collapse',
tableLayout: 'fixed',
width: '100%',
minWidth: '100%',
}}
>
<colgroup>
<col style={{ width: '52%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '24%' }} />
</colgroup>
<thead>
<tr>
<th style={{ ...headerStyle, textAlign: 'left' }}>
AWS Services
</th>
{PLANS.map((plan) => (
<th key={plan} style={headerStyle}>
Legacy Plan: {plan}
</th>
))}
</tr>
</thead>
<tbody style={bodyFont}>
{!hasResults && (
<tr>
<td
colSpan={PLANS.length + 1}
style={{ ...cellStyle, textAlign: 'center', padding: '24px' }}
>
No matching services or features found.
</td>
</tr>
)}
{filteredCategories.map((cat) => (
<React.Fragment key={cat.name}>
<tr>
<td
colSpan={PLANS.length + 1}
style={categoryRowStyle}
>
{cat.name}
</td>
</tr>
{cat.services.map((svc, idx) => (
<tr key={`${cat.name}-${idx}`}>
<td style={{ ...cellStyle, textAlign: 'left' }}>
{svc.serviceId ? (
<a href={`/aws/services/${svc.serviceId}/`}>
{svc.name}
</a>
) : (
svc.name
)}
</td>
{PLANS.map((plan) => (
<td key={plan} style={cellStyle}>
{renderCellValue(svc.plans[plan])}
</td>
))}
</tr>
))}
</React.Fragment>
))}

{filteredEnhancements.length > 0 && (
<>
<tr>
<td
colSpan={PLANS.length + 1}
style={categoryRowStyle}
>
Emulator Enhancements
</td>
</tr>
{filteredEnhancements.map((enh, idx) => (
<tr key={`enh-${idx}`}>
<td style={{ ...cellStyle, textAlign: 'left' }}>
{enh.docsUrl ? (
<a href={enh.docsUrl}>{enh.name}</a>
) : (
enh.name
)}
</td>
{PLANS.map((plan) => (
<td
key={plan}
style={{
...cellStyle,
fontSize:
typeof enh.plans[plan] === 'string'
? '12px'
: '14px',
}}
>
{renderCellValue(enh.plans[plan])}
</td>
))}
</tr>
))}
</>
)}
</tbody>
</table>
</div>
</div>
);
}
Loading