diff --git a/index.html b/index.html
index 759976e..7c25f84 100644
--- a/index.html
+++ b/index.html
@@ -235,6 +235,95 @@
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
+
+ /* -------------------------------------------------------
+ * Suggested (external) tool cards
+ * ------------------------------------------------------- */
+
+ .suggested-tools {
+ margin-top: var(--space-2xl);
+ }
+
+ .section-heading {
+ font-size: var(--text-xl);
+ font-weight: 600;
+ color: var(--color-text-heading);
+ margin-bottom: var(--space-sm);
+ letter-spacing: -0.01em;
+ }
+
+ .suggested-grid {
+ margin-top: var(--space-lg);
+ }
+
+ /* External card modifier — dashed border + relative for badge */
+ .tool-card--external {
+ position: relative;
+ border-style: dashed;
+ cursor: default;
+ }
+
+ .tool-card--external:hover {
+ border-color: var(--color-border-hover);
+ transform: translateY(-2px);
+ }
+
+ /* "External ↗" badge */
+ .external-badge {
+ position: absolute;
+ top: var(--space-md);
+ right: var(--space-md);
+ font-size: 0.6875rem;
+ font-weight: 500;
+ line-height: 1;
+ padding: 3px 8px;
+ border-radius: 999px;
+ color: var(--color-primary);
+ background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-raised));
+ border: 1px solid color-mix(in srgb, var(--color-primary) 25%, var(--color-border));
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ pointer-events: none;
+ }
+
+ /* Links list inside an external card */
+ .tool-card-links {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: var(--space-md);
+ }
+
+ .tool-card-links a {
+ font-size: var(--text-sm);
+ color: var(--color-primary);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ width: fit-content;
+ }
+
+ .tool-card-links a:hover {
+ color: var(--color-primary-hover);
+ text-decoration: underline;
+ }
+
+ .external-arrow {
+ font-size: 0.75em;
+ opacity: 0.7;
+ }
+
+ /* Staggered entrance for suggested cards */
+ @media (prefers-reduced-motion: no-preference) {
+ .suggested-grid .tool-card {
+ animation: fadeIn 0.3s ease both;
+ }
+ .suggested-grid .tool-card:nth-child(1) { animation-delay: 100ms; }
+ .suggested-grid .tool-card:nth-child(2) { animation-delay: 200ms; }
+ .suggested-grid .tool-card:nth-child(3) { animation-delay: 300ms; }
+ .suggested-grid .tool-card:nth-child(4) { animation-delay: 400ms; }
+ }
@@ -284,12 +373,53 @@
diff --git a/scripts/build-suggested-tools.js b/scripts/build-suggested-tools.js
index 80d733a..1d0eabf 100644
--- a/scripts/build-suggested-tools.js
+++ b/scripts/build-suggested-tools.js
@@ -5,8 +5,8 @@
* Each page must contain marker comments:
* ...
*
- * The home page gets a standalone , while tool sub-pages get
- * inline links appended to their existing "Related tools:" footer line.
+ * The home page gets card-based layout (using ### groups with metadata).
+ * Tool sub-pages get inline links appended to their existing footer line.
*/
const fs = require('fs');
@@ -27,24 +27,64 @@ const PAGE_MAP = {
// ---------------------------------------------------------------------------
function parseMarkdown(src) {
const sections = {};
- let current = null;
+ let currentSection = null;
+ let currentGroup = null;
for (const raw of src.split('\n')) {
const line = raw.trim();
// H2 heading = page key
- const h2 = line.match(/^##\s+(.+)$/);
+ const h2 = line.match(/^##\s+([^#].*)$/);
if (h2) {
- current = h2[1].trim().toLowerCase();
- sections[current] = [];
+ currentSection = h2[1].trim().toLowerCase();
+ sections[currentSection] = { groups: [], links: [] };
+ currentGroup = null;
continue;
}
+ if (!currentSection) continue;
+
+ // H3 heading = card group (e.g., "### 📡 Feed Validators")
+ const h3 = line.match(/^###\s+(.+)$/);
+ if (h3) {
+ const titleText = h3[1].trim();
+ // Extract leading emoji icon if present
+ const iconMatch = titleText.match(/^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F?)\s*/u);
+ const icon = iconMatch ? iconMatch[1] : null;
+ const name = iconMatch ? titleText.slice(iconMatch[0].length).trim() : titleText;
+
+ currentGroup = { icon, name, description: '', tags: [], links: [] };
+ sections[currentSection].groups.push(currentGroup);
+ continue;
+ }
+
+ // Tags line (e.g., "Tags: RSS, Podcasts, Validation")
+ if (currentGroup) {
+ const tagsMatch = line.match(/^Tags:\s*(.+)$/i);
+ if (tagsMatch) {
+ currentGroup.tags = tagsMatch[1].split(',').map(t => t.trim()).filter(Boolean);
+ continue;
+ }
+ }
+
// Markdown link inside a list item
- if (current && /^-\s+\[/.test(line)) {
+ if (/^-\s+\[/.test(line)) {
const match = line.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (match) {
- sections[current].push({ name: match[1], url: match[2] });
+ const link = { name: match[1], url: match[2] };
+ if (currentGroup) {
+ currentGroup.links.push(link);
+ } else {
+ sections[currentSection].links.push(link);
+ }
+ }
+ continue;
+ }
+
+ // Plain text line = description for current group
+ if (currentGroup && line && !line.startsWith('#') && !line.startsWith('-')) {
+ if (!currentGroup.description) {
+ currentGroup.description = line;
}
}
}
@@ -53,10 +93,58 @@ function parseMarkdown(src) {
}
// ---------------------------------------------------------------------------
-// Render HTML for each mode
+// Render HTML — card layout for home page
+// ---------------------------------------------------------------------------
+function renderCardSection(groups) {
+ const cards = groups.map(group => {
+ const parts = [];
+ parts.push(' ');
+ return parts.join('\n');
+ }).join('\n\n');
+
+ return [
+ ' ',
+ ].join('\n');
+}
+
+// ---------------------------------------------------------------------------
+// Render HTML — simple list fallback (home page without groups)
// ---------------------------------------------------------------------------
-function renderSection(tools) {
- if (tools.length === 0) return '';
+function renderListSection(tools) {
const items = tools
.map(t =>
` ${t.name} ↗`
@@ -72,9 +160,30 @@ function renderSection(tools) {
].join('\n');
}
-function renderInline(tools) {
- if (tools.length === 0) return '';
- return tools
+// ---------------------------------------------------------------------------
+// Render HTML — home page section (cards or list)
+// ---------------------------------------------------------------------------
+function renderSection(section) {
+ if (section.groups && section.groups.length > 0) {
+ return renderCardSection(section.groups);
+ }
+ if (section.links && section.links.length > 0) {
+ return renderListSection(section.links);
+ }
+ return '';
+}
+
+// ---------------------------------------------------------------------------
+// Render HTML — inline links for tool sub-pages
+// ---------------------------------------------------------------------------
+function renderInline(section) {
+ // Collect all links from both groups and flat links
+ const allLinks = [...section.links];
+ for (const group of section.groups) {
+ allLinks.push(...group.links);
+ }
+ if (allLinks.length === 0) return '';
+ return allLinks
.map(t =>
` ${t.name}`
)
@@ -109,8 +218,8 @@ const sections = parseMarkdown(md);
let changed = 0;
for (const [key, config] of Object.entries(PAGE_MAP)) {
- const tools = sections[key];
- if (!tools || tools.length === 0) {
+ const section = sections[key];
+ if (!section || (section.groups.length === 0 && section.links.length === 0)) {
console.log(` skip ${config.file} (no tools listed under "${key}")`);
continue;
}
@@ -119,8 +228,8 @@ for (const [key, config] of Object.entries(PAGE_MAP)) {
const html = fs.readFileSync(filePath, 'utf-8');
const rendered = config.mode === 'section'
- ? renderSection(tools)
- : renderInline(tools);
+ ? renderSection(section)
+ : renderInline(section);
const result = inject(html, rendered);
if (result === null) {
@@ -128,8 +237,9 @@ for (const [key, config] of Object.entries(PAGE_MAP)) {
process.exit(1);
}
+ const toolCount = section.groups.length + section.links.length;
fs.writeFileSync(filePath, result, 'utf-8');
- console.log(` wrote ${config.file} (${tools.length} tools)`);
+ console.log(` wrote ${config.file} (${toolCount} entries)`);
changed++;
}
diff --git a/suggested-tools.md b/suggested-tools.md
index ee8d707..fe4c413 100644
--- a/suggested-tools.md
+++ b/suggested-tools.md
@@ -5,13 +5,30 @@ sections across the site. Run `node scripts/build-suggested-tools.js` or
push to trigger the GitHub Action to rebuild.
Each `##` section maps to a page (`home`, `formatter`, `og-image`).
-Add a standard markdown link to include a tool on that page.
+
+For the **home** page, use `###` sub-headings to define card groups.
+Each group gets an icon (emoji in the heading), a plain-text description
+line, a `Tags:` line, and one or more markdown links. Similar tools
+share a single card.
+
+Sub-pages (`formatter`, `og-image`) keep the flat link list format.
## home
+### 📡 Feed Validators
+Validate podcast and web feeds for errors and compatibility before publishing.
+Tags: RSS, Podcasts, Validation
- [Cast Feed Validator](https://www.castfeedvalidator.com/)
- [W3C Feed Validator](https://validator.w3.org/feed/)
+
+### 📐 Responsive Embeds
+Generate responsive embed codes for videos, maps, and other media.
+Tags: Responsive, Embeds, Video
- [Embed Responsively](https://embedresponsively.com/)
+
+### 🖼️ Image Alt Text
+AI-powered tool to generate descriptive, accessible alt text for images.
+Tags: Accessibility, AI, Images
- [Image Alt Writer 2](https://chatgpt.com/g/g-6821f526808c81918e50e06207c3f359-image-alt-writer-2)
## formatter