diff --git a/_includes/packages/macros.njk b/_includes/packages/macros.njk index 0a1eba647..f3c6b0ba6 100644 --- a/_includes/packages/macros.njk +++ b/_includes/packages/macros.njk @@ -184,8 +184,9 @@ {% set icon_id = (label | label_icon_id) if with_icons else '' %} {% if icon_id %} {% set tint = (label | label_icon_tint) if with_icons else '' %} + {% set icon_sprite = (label | label_icon_sprite) if with_icons else '' %} {% endif %} {{ label }} diff --git a/eleventy.config.mjs b/eleventy.config.mjs index 611a9e5c9..f71f9584d 100644 --- a/eleventy.config.mjs +++ b/eleventy.config.mjs @@ -27,6 +27,8 @@ const FEATURED_LABELS = [ 'theme', ] const LABELS_RANK = new Map(FEATURED_LABELS.map((label, index) => [label, index])) +const LABEL_ICON_MINIMUM_USAGE = 3 +const LABEL_ICON_RECENT_WINDOW_DAYS = 365 const MS_IN_DAY = 24 * 60 * 60 * 1000 const MAGIC_FRESHNESS_WINDOW_DAYS = 365 * 2 // bonus for packages that had updates @@ -374,6 +376,7 @@ export default async function (eleventyConfig) { const siteOrigin = isProd ? prodOrigin : devOrigin const staticOutputDir = isProd ? 'static_' + util.gitHash : 'static' const bundledScriptEntries = new Set() + let labelIcons = null eleventyConfig.addPassthroughCopy( { static: staticOutputDir }, @@ -388,6 +391,11 @@ export default async function (eleventyConfig) { eleventyConfig.on('eleventy.after', async ({ directories } = {}) => { const outputDir = directories?.output ?? '_site' await writeVendorModules(path.join(outputDir, staticOutputDir, 'vendor')) + writeLabelIconSprites( + 'static/label-icons.svg', + path.join(outputDir, staticOutputDir), + labelIcons, + ) if (!isProd) { return @@ -524,6 +532,7 @@ export default async function (eleventyConfig) { const packages = all_packages.map(packageData) const packagesWithMagic = computeMagicMetadata(packages) + const labels = util.collectLabels(all_packages) const livingHomePackages = packages.filter(pkg => !pkg.removed) @@ -535,6 +544,21 @@ export default async function (eleventyConfig) { const newestHomePackages = packagesByDate('first_seen').slice(0, HOME_SECTION_PACKAGE_LIMIT) const updatedHomePackages = packagesByDate('last_modified').slice(0, HOME_SECTION_PACKAGE_LIMIT) + const latestPackageUpdate = livingHomePackages.reduce( + (latest, pkg) => Math.max(latest, new Date(pkg.last_modified ?? 0).getTime() || 0), + 0, + ) + const recentCutoff = latestPackageUpdate - LABEL_ICON_RECENT_WINDOW_DAYS * MS_IN_DAY + const recentlyUpdatedPackages = livingHomePackages.filter( + pkg => new Date(pkg.last_modified ?? 0).getTime() >= recentCutoff, + ) + labelIcons = filters.configureLabelIcons(labels, { + minimumUsage: LABEL_ICON_MINIMUM_USAGE, + preferredPackages: [ + ...newestHomePackages, + ...recentlyUpdatedPackages, + ], + }) const remarkablePackages = () => { const alreadyFeatured = new Set() @@ -613,9 +637,7 @@ export default async function (eleventyConfig) { } } - eleventyConfig.addCollection('labels', () => { - return util.collectLabels(all_packages) - }) + eleventyConfig.addCollection('labels', () => labels) eleventyConfig.addCollection('libraries', () => { return Object.values(workspace.libraries) @@ -678,6 +700,7 @@ export default async function (eleventyConfig) { // Register all named exports from external module as filters for (const [name, fn] of Object.entries(filters)) { + if (name === 'configureLabelIcons') continue eleventyConfig.addFilter(name, fn) } @@ -696,6 +719,32 @@ export default async function (eleventyConfig) { } } +function writeLabelIconSprites(sourcePath, outputDir, labelIcons) { + if (!(labelIcons?.primarySources instanceof Set) || !fs.existsSync(sourcePath)) { + return + } + + const source = fs.readFileSync(sourcePath, 'utf8') + fs.mkdirSync(outputDir, { recursive: true }) + fs.writeFileSync( + path.join(outputDir, 'label-icons.svg'), + labelIconSpriteForSources(source, labelIcons.primarySources), + 'utf8', + ) + fs.writeFileSync( + path.join(outputDir, 'label-icons-extra.svg'), + labelIconSpriteForSources(source, labelIcons.secondarySources), + 'utf8', + ) +} + +function labelIconSpriteForSources(source, sources) { + return source.replace( + /]*\bid="label-icon-([^"]+)"[^>]*>[\s\S]*?<\/symbol>\r?\n?/g, + (symbol, iconSource) => sources.has(iconSource) ? symbol : '', + ) +} + async function bundleJs(staticOutputDir, entries, isProd) { if (!entries.size) { return diff --git a/eleventy.filters.mjs b/eleventy.filters.mjs index ac04f6547..7f9457e08 100644 --- a/eleventy.filters.mjs +++ b/eleventy.filters.mjs @@ -14,6 +14,8 @@ const configPath = path.join(__dirname, 'label-icons-config.json') let labelIconSourceSet = new Set() let labelIconAliases = {} let labelIconTints = {} +let labelIconPrimarySourceSet = null +let labelIconSecondarySourceSet = null const longDateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }) const compactNumberFormatter = new Intl.NumberFormat('en', { notation: 'compact' }) @@ -70,6 +72,18 @@ export function label_icon_tints_json() { return JSON.stringify(labelIconTints) } +export function configureLabelIcons(labels, { minimumUsage = 1, preferredPackages = [] } = {}) { + labelIconPrimarySourceSet = primaryLabelIconSources(labels, minimumUsage, preferredPackages) + labelIconSecondarySourceSet = new Set( + Array.from(labelIconSourceSet).filter(source => !labelIconPrimarySourceSet.has(source)), + ) + + return { + primarySources: labelIconPrimarySourceSet, + secondarySources: labelIconSecondarySourceSet, + } +} + export function label_normalization_note(changes) { const sortedChanges = changes .map(change => ({ from: String(change.from), to: String(change.to) })) @@ -103,6 +117,7 @@ export function search_index_json(packages) { packages: packages.map(compactSearchPackage), label_icon_aliases: labelIconAliases, label_icon_tints: labelIconTints, + label_icon_secondary: Array.from(labelIconSecondarySourceSet ?? []), }) } @@ -186,6 +201,53 @@ function joinAsSentenceList(parts) { return `${parts.slice(0, -1).join(', ')}, and ${parts.at(-1)}` } +function primaryLabelIconSources(labels, minimumUsage, preferredPackages) { + const counts = new Map() + const threshold = Math.max(1, Number(minimumUsage) || 1) + + for (const item of labels ?? []) { + const key = typeof item?.key === 'string' ? item.key : String(item ?? '') + const canonical = sourceLabelFor(key) + if (!canonical) continue + + const count = Number(item?.count ?? 1) + counts.set(canonical, (counts.get(canonical) ?? 0) + (Number.isFinite(count) ? count : 1)) + } + + const sources = new Set() + for (const source of labelIconSourceSet) { + if ((counts.get(source) ?? 0) >= threshold) { + sources.add(source) + } + } + + for (const pkg of preferredPackages) { + for (const label of pkg.labels ?? []) { + const source = sourceLabelFor(label) + if (source) sources.add(source) + } + } + + return sources +} + +function sourceLabelFor(label) { + if (typeof label !== 'string') return '' + const normalized = label.trim().toLowerCase() + if (!normalized) return '' + + const alias = labelIconAliases[normalized] + if (alias && labelIconSourceSet.has(alias)) { + return alias + } + + if (labelIconSourceSet.has(normalized)) { + return normalized + } + + return '' +} + function canonicalLabel(label) { if (typeof label !== 'string') return '' const normalized = label.trim().toLowerCase() @@ -215,6 +277,14 @@ export function label_icon_tint(label) { return labelIconTints[canonical] ?? '' } +export function label_icon_sprite(label) { + const canonical = canonicalLabel(label) + if (!canonical) return '' + return labelIconSecondarySourceSet?.has(canonical) + ? 'static/label-icons-extra.svg' + : 'static/label-icons.svg' +} + // number formatting with grouping (e.g. 10,000) export function grouping(count) { return groupedNumberFormatter.format(count) @@ -265,6 +335,24 @@ export function bust(p) { if (import.meta.vitest) { const { describe, it, expect } = import.meta.vitest + describe('label icon sprites', () => { + it('keeps common and preferred icons in the primary sprite', () => { + const sprites = configureLabelIcons([ + { key: 'python', count: 3 }, + { key: 'typst', count: 1 }, + ], { + minimumUsage: 3, + preferredPackages: [{ labels: ['typst'] }], + }) + + expect(sprites.primarySources).toContain('python') + expect(sprites.primarySources).toContain('typst') + expect(sprites.secondarySources).toContain('audio') + expect(label_icon_sprite('typst')).toBe('static/label-icons.svg') + expect(label_icon_sprite('audio')).toBe('static/label-icons-extra.svg') + }) + }) + describe('date_time_format', () => { it.each([ [[new Date('2025-09-08T11:12:59Z')], '2025-09-08 11:12'], diff --git a/label-icons-config.json b/label-icons-config.json index 8714418ae..8952e5758 100644 --- a/label-icons-config.json +++ b/label-icons-config.json @@ -1,9 +1,10 @@ { "_": "Manually maintained set of icons to exclude and an alias map from labels to icon/language ids", - "exclude": ["ai"], + "exclude": ["ai", "c", "c#", "c++"], "aliases": { "javascript": "js", "ecmascript": "js", - "ecmascript6": "js" + "ecmascript6": "js", + "cpp": "c++" } } diff --git a/label-icons.json b/label-icons.json index 1a2a7730c..784ca9a6f 100644 --- a/label-icons.json +++ b/label-icons.json @@ -10,15 +10,11 @@ "binary": "graphite", "blade": "orange", "bower": "yellow", - "c": "purple", - "c#": "blue", - "c++": "sky", "cairo": "graphite", "clojure": "purple", "cmake": "blue", "coffeescript": "orange", "composer": "orange", - "cpp": "sky", "crystal": "graphite", "css": "blue", "csv": "green", diff --git a/labels.njk b/labels.njk index 8659dba8f..319cd4000 100644 --- a/labels.njk +++ b/labels.njk @@ -55,8 +55,9 @@ page_type: labels {% set icon_id = item.key | label_icon_id %} {% if icon_id %} {% set tint = item.key | label_icon_tint %} + {% set icon_sprite = item.key | label_icon_sprite %} {% endif %} {{ item.key }} diff --git a/static/label-icons.svg b/static/label-icons.svg index 17527e642..52e0e3305 100644 --- a/static/label-icons.svg +++ b/static/label-icons.svg @@ -38,15 +38,6 @@ - - - - - - - - - @@ -62,9 +53,6 @@ - - - diff --git a/static/module/card.js b/static/module/card.js index f23873578..38ef2ed29 100644 --- a/static/module/card.js +++ b/static/module/card.js @@ -260,7 +260,9 @@ export class Card { svg.setAttribute('class', `label-icon${tint ? ' label-icon--' + tint : ''}`) svg.setAttribute('aria-hidden', 'true') const use = document.createElementNS(svgNS, 'use') - use.setAttribute('href', `${this.staticBase}label-icons.svg#${iconId}`) + const secondary = window.__LABEL_ICON_SECONDARY__?.has(canonical) + const sprite = secondary ? 'label-icons-extra.svg' : 'label-icons.svg' + use.setAttribute('href', `${this.staticBase}${sprite}#${iconId}`) svg.appendChild(use) a.appendChild(svg) } diff --git a/static/package-search.js b/static/package-search.js index f1472efda..8bfccca40 100644 --- a/static/package-search.js +++ b/static/package-search.js @@ -37,6 +37,7 @@ const knownLabels = new Set( ) window.__LABEL_ICON_ALIASES__ = rawIndex.label_icon_aliases ?? {} window.__LABEL_ICON_TINTS__ = rawIndex.label_icon_tints ?? {} +window.__LABEL_ICON_SECONDARY__ = new Set(rawIndex.label_icon_secondary ?? []) const minisrch = createMinisearch(MiniSearch, packages)