@@ -634,6 +634,222 @@ module.exports = function(eleventyConfig) {
634634
635635 eleventyConfig . addShortcode ( "year" , ( ) => `${ new Date ( ) . getFullYear ( ) } ` ) ;
636636
637+ // Feature catalog helpers for tier badges
638+ const featureCatalog = yaml . load ( fs . readFileSync ( "./src/_data/featureCatalog.yaml" , "utf8" ) ) ;
639+
640+ function changelogTitle ( url ) {
641+ const slug = url . replace ( / \/ $ / , '' ) . split ( '/' ) . pop ( ) ;
642+ const parts = url . replace ( / \/ $ / , '' ) . split ( '/' ) . filter ( Boolean ) ;
643+ // url: /changelog/2026/02/slug/ -> src/changelog/2026/02/slug.md
644+ const filePath = path . join ( "./src" , parts . join ( '/' ) + '.md' ) ;
645+ try {
646+ const content = fs . readFileSync ( filePath , 'utf8' ) ;
647+ const match = content . match ( / ^ - - - [ \s \S ] * ?t i t l e : \s * [ " ' ] ? ( .+ ?) [ " ' ] ? \s * $ / m) ;
648+ if ( match ) return match [ 1 ] ;
649+ } catch ( e ) { /* file not found, fall back */ }
650+ return slug . replace ( / - / g, ' ' ) . replace ( / \b \w / g, c => c . toUpperCase ( ) ) ;
651+ }
652+
653+ function findFeatureById ( id ) {
654+ for ( const section of featureCatalog . sections ) {
655+ for ( const feature of section . features ) {
656+ if ( feature . id === id ) return feature ;
657+ }
658+ }
659+ return null ;
660+ }
661+
662+ function getChangelogUrls ( feature ) {
663+ if ( ! feature . changelog ) return [ ] ;
664+ const entries = Array . isArray ( feature . changelog ) ? feature . changelog : [ feature . changelog ] ;
665+ return entries . map ( entry => typeof entry === 'string' ? entry : entry . url ) ;
666+ }
667+
668+ function getChangelogUrlsForRelease ( feature , release ) {
669+ if ( ! feature . changelog ) return [ ] ;
670+ const entries = Array . isArray ( feature . changelog ) ? feature . changelog : [ feature . changelog ] ;
671+ return entries
672+ . filter ( entry => typeof entry === 'object' && entry . release === release )
673+ . map ( entry => entry . url ) ;
674+ }
675+
676+ function findFeatureByChangelog ( changelogUrl ) {
677+ const normalized = changelogUrl . replace ( / \/ $ / , '' ) + '/' ;
678+ for ( const section of featureCatalog . sections ) {
679+ for ( const feature of section . features ) {
680+ const urls = getChangelogUrls ( feature ) ;
681+ for ( const url of urls ) {
682+ if ( ( url . replace ( / \/ $ / , '' ) + '/' ) === normalized ) return feature ;
683+ }
684+ }
685+ }
686+ return null ;
687+ }
688+
689+ function deriveTierLabel ( tierData ) {
690+ if ( ! tierData ) return null ;
691+ const starter = tierData . starter && tierData . starter . value ;
692+ const pro = tierData . pro && tierData . pro . value ;
693+ const enterprise = tierData . enterprise && tierData . enterprise . value ;
694+ if ( starter && pro && enterprise ) return "All tiers" ;
695+ if ( pro && enterprise ) return "Pro+" ;
696+ if ( enterprise === 'contact' ) return "Enterprise (on request)" ;
697+ if ( enterprise ) return "Enterprise" ;
698+ return "Not available" ;
699+ }
700+
701+ function renderTierBadges ( feature ) {
702+ if ( ! feature ) return '' ;
703+ const cloudLabel = deriveTierLabel ( feature . cloud ) ;
704+ const selfHostedLabel = deriveTierLabel ( feature . selfHosted ) ;
705+ if ( ! cloudLabel && ! selfHostedLabel ) return '' ;
706+ let html = `<div class="ff-tier-badges">` ;
707+ if ( cloudLabel ) {
708+ const unavailable = cloudLabel === 'Not available' ;
709+ html += `<span class="ff-tier-badge ${ unavailable ? 'ff-tier--unavailable' : 'ff-tier--available' } ">` ;
710+ html += `<span class="ff-tier-badge__label">Cloud</span>` ;
711+ html += `<span class="ff-tier-badge__value">${ cloudLabel } </span>` ;
712+ html += `</span>` ;
713+ }
714+ if ( selfHostedLabel ) {
715+ const unavailable = selfHostedLabel === 'Not available' ;
716+ html += `<span class="ff-tier-badge ${ unavailable ? 'ff-tier--unavailable' : 'ff-tier--available' } ">` ;
717+ html += `<span class="ff-tier-badge__label">Self-Hosted</span>` ;
718+ html += `<span class="ff-tier-badge__value">${ selfHostedLabel } </span>` ;
719+ html += `</span>` ;
720+ }
721+ html += '</div>' ;
722+ return html ;
723+ }
724+
725+ function renderChangelogLinks ( urls ) {
726+ if ( ! urls || urls . length === 0 ) return '' ;
727+ let html = '<div class="ff-related-changelogs">Changelog: ' ;
728+ const links = urls . map ( url => {
729+ const label = changelogTitle ( url ) ;
730+ return `<a href="${ url } ">${ label } </a>` ;
731+ } ) ;
732+ html += links . join ( ' | ' ) ;
733+ html += '</div>' ;
734+ return html ;
735+ }
736+
737+ // Inject tier badges and changelog links into release blog posts based on frontmatter
738+ eleventyConfig . addTransform ( "releaseFeatures" , function ( content ) {
739+ if ( ! this . page . outputPath || ! this . page . outputPath . endsWith ( ".html" ) ) return content ;
740+
741+ // Transforms don't have access to template data, so parse frontmatter from source
742+ const inputPath = this . page . inputPath ;
743+ if ( ! inputPath || ! inputPath . endsWith ( '.md' ) ) return content ;
744+
745+ let frontmatter ;
746+ try {
747+ const source = fs . readFileSync ( inputPath , 'utf8' ) ;
748+ const fmMatch = source . match ( / ^ - - - \n ( [ \s \S ] * ?) \n - - - / ) ;
749+ if ( ! fmMatch ) return content ;
750+ frontmatter = yaml . load ( fmMatch [ 1 ] ) ;
751+ } catch ( e ) { return content ; }
752+
753+ const features = frontmatter . features ;
754+ const release = frontmatter . release ;
755+ if ( ! features || ! Array . isArray ( features ) || features . length === 0 ) return content ;
756+
757+ // Build injection map: heading text -> { badges HTML, changelogs HTML }
758+ const injections = [ ] ;
759+ for ( const entry of features ) {
760+ let badges = '' ;
761+ let changelogs = '' ;
762+
763+ if ( entry . id ) {
764+ // Feature from featureCatalog
765+ const feature = findFeatureById ( entry . id ) ;
766+ if ( ! feature ) continue ;
767+ badges = renderTierBadges ( feature ) ;
768+ const changelogUrls = release ? getChangelogUrlsForRelease ( feature , release ) : getChangelogUrls ( feature ) ;
769+ changelogs = renderChangelogLinks ( changelogUrls ) ;
770+ } else if ( entry . tiers ) {
771+ // Inline tier specification (no feature ID)
772+ const inlineFeature = { } ;
773+ if ( entry . tiers . cloud ) {
774+ // Convert shorthand ("all", "pro+", "enterprise") to tier structure
775+ const t = entry . tiers . cloud ;
776+ inlineFeature . cloud = {
777+ starter : { value : t === 'all' ? true : null } ,
778+ pro : { value : ( t === 'all' || t === 'pro+' ) ? true : null } ,
779+ enterprise : { value : true }
780+ } ;
781+ }
782+ if ( entry . tiers . selfHosted ) {
783+ const t = entry . tiers . selfHosted ;
784+ inlineFeature . selfHosted = {
785+ starter : { value : t === 'all' ? true : null } ,
786+ pro : { value : ( t === 'all' || t === 'pro+' ) ? true : null } ,
787+ enterprise : { value : true }
788+ } ;
789+ }
790+ badges = renderTierBadges ( inlineFeature ) ;
791+ }
792+
793+ if ( badges || changelogs ) {
794+ injections . push ( { heading : entry . heading , badges, changelogs } ) ;
795+ }
796+ }
797+
798+ if ( injections . length === 0 ) return content ;
799+
800+ // Find all headings (h2-h6) in the HTML with their positions
801+ const headingRegex = / < h ( [ 2 - 6 ] ) \s [ ^ > ] * > .* ?< \/ h \1> / gs;
802+ const headingMatches = [ ] ;
803+ let match ;
804+ while ( ( match = headingRegex . exec ( content ) ) !== null ) {
805+ // Extract text content from heading (strip HTML tags)
806+ const textContent = match [ 0 ] . replace ( / < [ ^ > ] + > / g, '' ) . trim ( ) ;
807+ headingMatches . push ( { index : match . index , length : match [ 0 ] . length , text : textContent , level : parseInt ( match [ 1 ] ) } ) ;
808+ }
809+
810+ // Process injections in reverse order so indices stay valid
811+ const ops = [ ] ; // { index, html } — insert html at index
812+
813+ for ( const injection of injections ) {
814+ // Find matching heading
815+ const headingIdx = headingMatches . findIndex ( h => h . text === injection . heading ) ;
816+ if ( headingIdx === - 1 ) continue ;
817+
818+ const heading = headingMatches [ headingIdx ] ;
819+
820+ // Insert badges right after the heading tag, adding heading-level class for spacing
821+ if ( injection . badges ) {
822+ const badgesWithLevel = injection . badges . replace ( 'class="ff-tier-badges"' , `class="ff-tier-badges ff-tier-badges--h${ heading . level } "` ) ;
823+ ops . push ( { index : heading . index + heading . length , html : badgesWithLevel } ) ;
824+ }
825+
826+ // Insert changelogs before the next heading at the same or higher level
827+ // H2 changelogs go before the next H2; H3 changelogs go before the next H2 or H3
828+ if ( injection . changelogs ) {
829+ const nextPeer = headingMatches . find ( ( h , i ) => i > headingIdx && h . level <= heading . level ) ;
830+ const insertBefore = nextPeer ? nextPeer . index : content . length ;
831+ ops . push ( { index : insertBefore , html : injection . changelogs } ) ;
832+ }
833+ }
834+
835+ // Sort by index descending so we can splice without shifting
836+ ops . sort ( ( a , b ) => b . index - a . index ) ;
837+ for ( const op of ops ) {
838+ content = content . slice ( 0 , op . index ) + op . html + content . slice ( op . index ) ;
839+ }
840+
841+ return content ;
842+ } ) ;
843+
844+ // Make helpers available to changelog layout via filters
845+ eleventyConfig . addFilter ( "featureForChangelog" , function ( url ) {
846+ return findFeatureByChangelog ( url ) ;
847+ } ) ;
848+
849+ eleventyConfig . addFilter ( "tierLabel" , function ( tierData ) {
850+ return deriveTierLabel ( tierData ) ;
851+ } ) ;
852+
637853 function loadSVG ( file ) {
638854 let relativeFilePath = `./src/_includes/components/icons/${ file } .svg` ;
639855 let data = fs . readFileSync ( relativeFilePath , function ( err , contents ) {
@@ -697,7 +913,7 @@ module.exports = function(eleventyConfig) {
697913 return await imageHandler ( src , alt , title , widths , sizes , currentWorkingFilePath , eleventyConfig , async = true , DEV_MODE )
698914 } ) ;
699915
700- eleventyConfig . addAsyncShortcode ( "tileImage" , async function ( item , image , defaultImage , defaultDescription , imageSize , title = null ) {
916+ eleventyConfig . addAsyncShortcode ( "tileImage" , async function ( item , image , defaultImage , defaultDescription , imageSize , title = null , priority = false ) {
701917 let imageSrc , imageDescription ;
702918
703919 if ( item && item . data && item . data . image ) {
@@ -716,7 +932,7 @@ module.exports = function(eleventyConfig) {
716932
717933 const currentWorkingFilePath = this . page . inputPath ;
718934
719- return await imageHandler ( imageSrc , imageDescription , title , [ imageSize ] , null , currentWorkingFilePath , eleventyConfig , async = true , DEV_MODE ) ;
935+ return await imageHandler ( imageSrc , imageDescription , title , [ imageSize ] , null , currentWorkingFilePath , eleventyConfig , async = true , DEV_MODE , priority ) ;
720936 } ) ;
721937
722938 // Create a collection for sidebar navigation
0 commit comments