From 80cc70cd27f2ebe08e44445a2d75045036d33b21 Mon Sep 17 00:00:00 2001 From: Kristofer Koishigawa <2051070+scissorsneedfoodtoo@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:15:29 +1300 Subject: [PATCH 1/4] feat: add helper to split class central courses into sidebar rails --- .eleventy.js | 4 +++ utils/class-central/rails.js | 23 +++++++++++++ utils/class-central/rails.test.js | 56 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 utils/class-central/rails.js create mode 100644 utils/class-central/rails.test.js diff --git a/.eleventy.js b/.eleventy.js index 6109cfb3e..c662c6bff 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -23,6 +23,7 @@ import { toISOStringShortcode } from './utils/shortcodes/dates.js'; import { sitePath } from './utils/site-path.js'; +import { getRailCourses } from './utils/class-central/rails.js'; import { config } from './config/index.js'; const { readFileSync, readdirSync, writeFileSync } = gracefulFS; @@ -116,6 +117,9 @@ export default function (config) { return true; }); + // Split Class Central courses into sidebar rails + config.addFilter('classCentralRails', getRailCourses); + // Don't ignore the same files ignored in the git repo config.setUseGitIgnore(false); diff --git a/utils/class-central/rails.js b/utils/class-central/rails.js new file mode 100644 index 000000000..b4b66fd51 --- /dev/null +++ b/utils/class-central/rails.js @@ -0,0 +1,23 @@ +// Split a post's sponsored courses into sidebar rails + +// One rail per 5 minutes of reading time, leaving room for ads between rails +export const READING_MINUTES_PER_RAIL = 5; +export const COURSES_PER_RAIL = 4; + +export const getRailCount = readingTime => + Math.max(1, Math.floor((readingTime || 0) / READING_MINUTES_PER_RAIL)); + +// Cycle through the courses four per rail, e.g. with a-g: abcd, efga, bcde +export const getRailCourses = (ccData, readingTime) => { + const courses = ccData?.courses; + if (!courses?.length) return []; + + const perRail = Math.min(COURSES_PER_RAIL, courses.length); + + return Array.from({ length: getRailCount(readingTime) }, (_, rail) => + Array.from( + { length: perRail }, + (_, i) => courses[(rail * perRail + i) % courses.length] + ) + ); +}; diff --git a/utils/class-central/rails.test.js b/utils/class-central/rails.test.js new file mode 100644 index 000000000..7b7ee1d61 --- /dev/null +++ b/utils/class-central/rails.test.js @@ -0,0 +1,56 @@ +import { + getRailCount, + getRailCourses, + READING_MINUTES_PER_RAIL +} from './rails.js'; + +describe('Class Central sidebar rails:', () => { + describe('getRailCount', () => { + test('Always returns at least one rail', () => { + expect(getRailCount(0)).toBe(1); + expect(getRailCount(1)).toBe(1); + expect(getRailCount(undefined)).toBe(1); + }); + + test('Adds a rail for every block of reading minutes', () => { + expect(getRailCount(READING_MINUTES_PER_RAIL - 1)).toBe(1); + expect(getRailCount(READING_MINUTES_PER_RAIL * 2)).toBe(2); + expect(getRailCount(READING_MINUTES_PER_RAIL * 3 + 1)).toBe(3); + }); + }); + + describe('getRailCourses', () => { + const courses = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + + test('Cycles through the courses four at a time', () => { + expect(getRailCourses({ courses }, READING_MINUTES_PER_RAIL * 3)).toEqual( + [ + ['a', 'b', 'c', 'd'], + ['e', 'f', 'g', 'a'], + ['b', 'c', 'd', 'e'] + ] + ); + }); + + test('Shows every course in each rail when there are four or fewer', () => { + expect( + getRailCourses( + { courses: ['a', 'b', 'c'] }, + READING_MINUTES_PER_RAIL * 2 + ) + ).toEqual([ + ['a', 'b', 'c'], + ['a', 'b', 'c'] + ]); + }); + + test('Returns no rails without courses', () => { + expect(getRailCourses({ courses: [] }, 30)).toEqual([]); + expect(getRailCourses({}, 30)).toEqual([]); + }); + + test('Returns no rails for a post without Class Central data', () => { + expect(getRailCourses(undefined, 30)).toEqual([]); + }); + }); +}); From 26f43fe1b03544422e78f06e8b728e0557090c5c Mon Sep 17 00:00:00 2001 From: Kristofer Koishigawa <2051070+scissorsneedfoodtoo@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:16:19 +1300 Subject: [PATCH 2/4] feat: use 10 mock class central courses per post --- .../fixtures/mock-class-central-courses.json | 527 +++++++++++++++++- 1 file changed, 523 insertions(+), 4 deletions(-) diff --git a/cypress/fixtures/mock-class-central-courses.json b/cypress/fixtures/mock-class-central-courses.json index 45f444814..780486765 100644 --- a/cypress/fixtures/mock-class-central-courses.json +++ b/cypress/fixtures/mock-class-central-courses.json @@ -5,29 +5,51 @@ "fetchedAt": "2026-01-01T00:00:00.000Z", "slug": "learn-music-production-for-beginners", "title": "Learn Music Production for Beginners", - "courseIds": [100001, 100002, 100003, 100004], + "courseIds": [ + 100001, 100002, 100003, 100004, 100014, 100015, 100016, 100017, 100018, + 100019 + ], "subjectSlugs": ["music-production", "audio-mixing"] }, "66e093d482502b33f0f6b7ce": { "fetchedAt": "2026-01-01T00:00:00.000Z", "slug": "pass-the-azure-ai-engineer-associate-certification-ai-102", "title": "Pass the Azure AI Engineer Associate Certification (AI-102)", - "courseIds": [100005, 100006, 100007, 100008], + "courseIds": [ + 100005, 100006, 100007, 100008, 100020, 100021, 100022, 100023, 100024, + 100025 + ], "subjectSlugs": ["ai", "microsoft-azure"] }, "66db21dc4fe490d5a33e959d": { "fetchedAt": "2026-01-01T00:00:00.000Z", "slug": "surviving-40-years-in-tech-jack-herrington-podcast-140", "title": "Surviving 40 years in the software industry [Interview with Jack Herrington Podcast #140]", - "courseIds": [100009, 100010, 100011], + "courseIds": [ + 100009, 100010, 100011, 100026, 100027, 100028, 100029, 100030, 100031, + 100032 + ], "subjectSlugs": ["software-engineering"] }, "66d4608b230dff016690584b": { "fetchedAt": "2026-01-01T00:00:00.000Z", "slug": "what-is-recursion", "title": "How Does Recursion Work? Explained with Code Examples", - "courseIds": [100012, 100013], + "courseIds": [ + 100012, 100013, 100033, 100034, 100035, 100036, 100037, 100038, 100039, + 100040 + ], "subjectSlugs": [] + }, + "66b1e4bf0938e6258a76bbdd": { + "fetchedAt": "2026-01-01T00:00:00.000Z", + "slug": "the-c-programming-handbook-for-beginners", + "title": "The C Programming Handbook for Beginners", + "courseIds": [ + 100041, 100042, 100043, 100044, 100045, 100046, 100047, 100048, 100049, + 100050 + ], + "subjectSlugs": ["c-programming"] } }, "courses": { @@ -202,6 +224,498 @@ "effort": "3 hours 15 minutes", "rating": { "provider": { "average": 4.41, "count": 98 } }, "provider": { "name": "BrightPath" } + }, + "100014": { + "id": 100014, + "name": "Mock Synthesis and Sound Design Basics", + "url": { + "go": "https://example.com/go/mock-synthesis-and-sound-design-basics" + }, + "imageUrl": "https://example.com/img/courses/mock-synthesis-and-sound-design-basics.jpg", + "instructors": "Instructor D", + "level": "beginner", + "certificate": true, + "effort": "4 weeks, 3 hours a week", + "rating": { "provider": { "average": 4.5625, "count": 16 } }, + "provider": { "name": "EduStream" } + }, + "100015": { + "id": 100015, + "name": "Mock Make Your First Electronic Track", + "url": { + "go": "https://example.com/go/mock-make-your-first-electronic-track" + }, + "imageUrl": "https://example.com/img/courses/mock-make-your-first-electronic-track.jpg", + "instructors": "Instructor A", + "level": "beginner", + "certificate": false, + "effort": "3 hours 6 minutes", + "rating": { "provider": { "average": 4.49, "count": 1749 } }, + "provider": { "name": "CourseHub" } + }, + "100016": { + "id": 100016, + "name": "Mock Complete Electronic Music Production", + "url": { + "go": "https://example.com/go/mock-complete-electronic-music-production" + }, + "imageUrl": "https://example.com/img/courses/mock-complete-electronic-music-production.jpg", + "instructors": "Instructor E", + "level": "beginner", + "certificate": true, + "effort": "11 hours 55 minutes", + "rating": { "provider": { "average": 4.47, "count": 3034 } }, + "provider": { "name": "CourseHub" } + }, + "100017": { + "id": 100017, + "name": "Mock Digital Audio Workstation Fundamentals", + "url": { + "go": "https://example.com/go/mock-digital-audio-workstation-fundamentals" + }, + "imageUrl": "https://example.com/img/courses/mock-digital-audio-workstation-fundamentals.jpg", + "instructors": "Instructor B, Instructor C and Mock Music Academy", + "level": "beginner", + "certificate": true, + "effort": "1 day 1 hour 15 minutes", + "rating": { "provider": { "average": 4.61275, "count": 5250 } }, + "provider": { "name": "SkillForge" } + }, + "100018": { + "id": 100018, + "name": "Mock Melodies and Chords for Intermediate Producers", + "url": { + "go": "https://example.com/go/mock-melodies-and-chords-for-intermediate-producers" + }, + "imageUrl": "https://example.com/img/courses/mock-melodies-and-chords-for-intermediate-producers.jpg", + "instructors": "Instructor C", + "level": "intermediate", + "certificate": false, + "effort": "48 minutes", + "rating": { "provider": { "average": 3.90625, "count": 251 } }, + "provider": { "name": "OpenLearn" } + }, + "100019": { + "id": 100019, + "name": "Mock Write Music in Any Genre", + "url": { "go": "https://example.com/go/mock-write-music-in-any-genre" }, + "imageUrl": "https://example.com/img/courses/mock-write-music-in-any-genre.jpg", + "instructors": "OpenLearn", + "level": "beginner", + "certificate": true, + "effort": "7 weeks, 1 hour a week", + "rating": { "provider": null }, + "provider": { "name": "BrightPath" } + }, + "100020": { + "id": 100020, + "name": "Mock Cognitive and Knowledge Services", + "url": { + "go": "https://example.com/go/mock-cognitive-and-knowledge-services" + }, + "imageUrl": "https://example.com/img/courses/mock-cognitive-and-knowledge-services.jpg", + "instructors": "Mock Cloud Co.", + "level": "intermediate", + "certificate": true, + "effort": "9 weeks, 1 hour a week", + "rating": { "provider": null }, + "provider": { "name": "EduStream" } + }, + "100021": { + "id": 100021, + "name": "Mock Cognitive and Knowledge Services", + "url": { + "go": "https://example.com/go/mock-cognitive-and-knowledge-services-100021" + }, + "imageUrl": "https://example.com/img/courses/mock-cognitive-and-knowledge-services-100021.jpg", + "instructors": "Mock Cloud Co.", + "level": "intermediate", + "certificate": true, + "effort": "9 weeks, 1 hour a week", + "rating": { "provider": null }, + "provider": { "name": "BrightPath" } + }, + "100022": { + "id": 100022, + "name": "Mock AI Fundamentals Exam Guide", + "url": { "go": "https://example.com/go/mock-ai-fundamentals-exam-guide" }, + "imageUrl": "https://example.com/img/courses/mock-ai-fundamentals-exam-guide.jpg", + "instructors": "Mock Publisher - Course Instructors", + "level": "beginner", + "certificate": true, + "effort": "11 weeks, 1 hour a week", + "rating": { "provider": null }, + "provider": { "name": "EduStream" } + }, + "100023": { + "id": 100023, + "name": "Mock AI Apps and Agents Developer Associate", + "url": { + "go": "https://example.com/go/mock-ai-apps-and-agents-developer-associate" + }, + "imageUrl": "https://example.com/img/courses/mock-ai-apps-and-agents-developer-associate.jpg", + "instructors": "Instructor D", + "level": "beginner", + "certificate": true, + "effort": "17 hours 17 minutes", + "rating": { "provider": { "average": 4.55657, "count": 2744 } }, + "provider": { "name": "CourseHub" } + }, + "100024": { + "id": 100024, + "name": "Mock AI Fundamentals", + "url": { "go": "https://example.com/go/mock-ai-fundamentals" }, + "imageUrl": "https://example.com/img/courses/mock-ai-fundamentals.jpg", + "instructors": "Instructor A", + "level": "beginner", + "certificate": true, + "effort": "4 weeks, 3-6 hours a week", + "rating": { "provider": null }, + "provider": { "name": "OpenLearn" } + }, + "100025": { + "id": 100025, + "name": "Mock AI Fundamentals: Build and Deploy AI", + "url": { + "go": "https://example.com/go/mock-ai-fundamentals-build-and-deploy-ai" + }, + "imageUrl": "https://example.com/img/courses/mock-ai-fundamentals-build-and-deploy-ai.jpg", + "instructors": "", + "level": "beginner", + "certificate": true, + "effort": "1 week, 3-4 hours a week", + "rating": { "provider": { "average": 4.4, "count": 5 } }, + "provider": { "name": "OpenLearn" } + }, + "100026": { + "id": 100026, + "name": "Mock Mentoring Junior Developers", + "url": { + "go": "https://example.com/go/mock-mentoring-junior-developers" + }, + "imageUrl": "https://example.com/img/courses/mock-mentoring-junior-developers.jpg", + "instructors": "Instructor E", + "level": "beginner", + "certificate": false, + "effort": "1 hour 27 minutes", + "rating": { "provider": { "average": 4.72222, "count": 9 } }, + "provider": { "name": "SkillForge" } + }, + "100027": { + "id": 100027, + "name": "Mock Building Side Projects That Last", + "url": { + "go": "https://example.com/go/mock-building-side-projects-that-last" + }, + "imageUrl": "https://example.com/img/courses/mock-building-side-projects-that-last.jpg", + "instructors": "Instructor B", + "level": "intermediate", + "certificate": false, + "effort": "2 hours 29 minutes", + "rating": { "provider": { "average": 3.75, "count": 3 } }, + "provider": { "name": "SkillForge" } + }, + "100028": { + "id": 100028, + "name": "Mock Public Speaking for Engineers", + "url": { + "go": "https://example.com/go/mock-public-speaking-for-engineers" + }, + "imageUrl": "https://example.com/img/courses/mock-public-speaking-for-engineers.jpg", + "instructors": "Instructor D", + "level": "intermediate", + "certificate": false, + "effort": "32 minutes", + "rating": { "provider": { "average": 3.82353, "count": 17 } }, + "provider": { "name": "SkillForge" } + }, + "100029": { + "id": 100029, + "name": "Mock Start a Developer Podcast", + "url": { "go": "https://example.com/go/mock-start-a-developer-podcast" }, + "imageUrl": "https://example.com/img/courses/mock-start-a-developer-podcast.jpg", + "instructors": "Instructor A", + "level": "beginner", + "certificate": false, + "effort": "1 hour 26 minutes", + "rating": { "provider": { "average": 4.60938, "count": 32 } }, + "provider": { "name": "SkillForge" } + }, + "100030": { + "id": 100030, + "name": "Mock Healthy Habits for Desk Workers", + "url": { + "go": "https://example.com/go/mock-healthy-habits-for-desk-workers" + }, + "imageUrl": "https://example.com/img/courses/mock-healthy-habits-for-desk-workers.jpg", + "instructors": "Instructor B, Instructor C, and Instructor D", + "level": "beginner", + "certificate": true, + "effort": "5 weeks, 2 hours a week", + "rating": { "provider": { "average": 4.59426, "count": 1846 } }, + "provider": { "name": "EduStream" } + }, + "100031": { + "id": 100031, + "name": "Mock Technical Writing 2026: Docs That Get Read", + "url": { + "go": "https://example.com/go/mock-technical-writing-2026-docs-that-get-read" + }, + "imageUrl": "https://example.com/img/courses/mock-technical-writing-2026-docs-that-get-read.jpg", + "instructors": "Instructor E", + "level": "beginner", + "certificate": true, + "effort": "13 hours 34 minutes", + "rating": { "provider": { "average": 4.56, "count": 4130 } }, + "provider": { "name": "CourseHub" } + }, + "100032": { + "id": 100032, + "name": "Mock Technical Writing 2026: Docs That Get Read", + "url": { + "go": "https://example.com/go/mock-technical-writing-2026-docs-that-get-read-100032" + }, + "imageUrl": "https://example.com/img/courses/mock-technical-writing-2026-docs-that-get-read-100032.jpg", + "instructors": "Instructor E", + "level": "beginner", + "certificate": true, + "effort": "10 hours 48 minutes", + "rating": { "provider": null }, + "provider": { "name": "EduStream" } + }, + "100033": { + "id": 100033, + "name": "Mock Build a Guessing Game from Scratch", + "url": { + "go": "https://example.com/go/mock-build-a-guessing-game-from-scratch" + }, + "imageUrl": "https://example.com/img/courses/mock-build-a-guessing-game-from-scratch.jpg", + "instructors": "Instructor C", + "level": "beginner", + "certificate": true, + "effort": "1 hour 55 minutes", + "rating": { "provider": { "average": 4.64, "count": 125 } }, + "provider": { "name": "EduStream" } + }, + "100034": { + "id": 100034, + "name": "Mock Programming for Beginners", + "url": { "go": "https://example.com/go/mock-programming-for-beginners" }, + "imageUrl": "https://example.com/img/courses/mock-programming-for-beginners.jpg", + "instructors": "Instructor D | 900,000+ Students and Mock Coding Faculty", + "level": "beginner", + "certificate": true, + "effort": "11 hours 45 minutes", + "rating": { "provider": { "average": 4.37, "count": 2927 } }, + "provider": { "name": "CourseHub" } + }, + "100035": { + "id": 100035, + "name": "Mock Complete Coding for Beginners (2026)", + "url": { + "go": "https://example.com/go/mock-complete-coding-for-beginners-2026" + }, + "imageUrl": "https://example.com/img/courses/mock-complete-coding-for-beginners-2026.jpg", + "instructors": "Instructor A and Mock Academy • over 2.7 million students worldwide!", + "level": "beginner", + "certificate": true, + "effort": "16 hours 1 minute", + "rating": { "provider": { "average": 4.48462, "count": 2514 } }, + "provider": { "name": "CourseHub" } + }, + "100036": { + "id": 100036, + "name": "Mock Learn to Program", + "url": { "go": "https://example.com/go/mock-learn-to-program" }, + "imageUrl": "https://example.com/img/courses/mock-learn-to-program.jpg", + "instructors": "Instructor B", + "level": "beginner", + "certificate": true, + "effort": "6 weeks, 1 hour a week", + "rating": { "provider": null }, + "provider": { "name": "EduStream" } + }, + "100037": { + "id": 100037, + "name": "Mock Complete Programming for Beginners 2026", + "url": { + "go": "https://example.com/go/mock-complete-programming-for-beginners-2026" + }, + "imageUrl": "https://example.com/img/courses/mock-complete-programming-for-beginners-2026.jpg", + "instructors": "Instructor E", + "level": "beginner", + "certificate": false, + "effort": "1 hour 57 minutes", + "rating": { "provider": { "average": 4.47917, "count": 12 } }, + "provider": { "name": "SkillForge" } + }, + "100038": { + "id": 100038, + "name": "Mock Programming in Three Weeks", + "url": { "go": "https://example.com/go/mock-programming-in-three-weeks" }, + "imageUrl": "https://example.com/img/courses/mock-programming-in-three-weeks.jpg", + "instructors": "Instructor C", + "level": "beginner", + "certificate": false, + "effort": "19 hours 30 minutes", + "rating": { "provider": { "average": 4.46, "count": 127 } }, + "provider": { "name": "SkillForge" } + }, + "100039": { + "id": 100039, + "name": "Mock Introduction to Functional Languages", + "url": { + "go": "https://example.com/go/mock-introduction-to-functional-languages" + }, + "imageUrl": "https://example.com/img/courses/mock-introduction-to-functional-languages.jpg", + "instructors": "", + "level": "beginner", + "certificate": true, + "effort": "2 weeks, 2-4 hours a week", + "rating": { "provider": null }, + "provider": { "name": "OpenLearn" } + }, + "100040": { + "id": 100040, + "name": "Mock Functional Programming Patterns", + "url": { + "go": "https://example.com/go/mock-functional-programming-patterns" + }, + "imageUrl": "https://example.com/img/courses/mock-functional-programming-patterns.jpg", + "instructors": "", + "level": "intermediate", + "certificate": true, + "effort": "5 weeks, 2-4 hours a week", + "rating": { "provider": null }, + "provider": { "name": "OpenLearn" } + }, + "100041": { + "id": 100041, + "name": "Mock C Programming from Zero", + "url": { "go": "https://example.com/go/mock-c-programming-from-zero" }, + "imageUrl": "https://example.com/img/courses/mock-c-programming-from-zero.jpg", + "instructors": "Instructor A • 120,000+ Students", + "level": "beginner", + "certificate": true, + "effort": "2 days 32 minutes", + "rating": { "provider": { "average": 4.63492, "count": 4191 } }, + "provider": { "name": "CourseHub" } + }, + "100042": { + "id": 100042, + "name": "Mock Pointers and Memory in C", + "url": { "go": "https://example.com/go/mock-pointers-and-memory-in-c" }, + "imageUrl": "https://example.com/img/courses/mock-pointers-and-memory-in-c.jpg", + "instructors": "Instructor B", + "level": "intermediate", + "certificate": true, + "effort": "9 hours 35 minutes", + "rating": { "provider": { "average": 3.94737, "count": 19 } }, + "provider": { "name": "SkillForge" } + }, + "100043": { + "id": 100043, + "name": "Mock Systems Programming Fundamentals", + "url": { + "go": "https://example.com/go/mock-systems-programming-fundamentals" + }, + "imageUrl": "https://example.com/img/courses/mock-systems-programming-fundamentals.jpg", + "instructors": "", + "level": "intermediate", + "certificate": true, + "effort": "9 weeks, 1-4 hours a week", + "rating": { "provider": null }, + "provider": { "name": "OpenLearn" } + }, + "100044": { + "id": 100044, + "name": "Mock Data Structures in C", + "url": { "go": "https://example.com/go/mock-data-structures-in-c" }, + "imageUrl": "https://example.com/img/courses/mock-data-structures-in-c.jpg", + "instructors": "Instructor C : 45,000+ Students and Instructor D", + "level": "beginner", + "certificate": true, + "effort": "21 hours 9 minutes", + "rating": { "provider": { "average": 4.49533, "count": 1446 } }, + "provider": { "name": "CourseHub" } + }, + "100045": { + "id": 100045, + "name": "Mock Embedded C for Microcontrollers", + "url": { + "go": "https://example.com/go/mock-embedded-c-for-microcontrollers" + }, + "imageUrl": "https://example.com/img/courses/mock-embedded-c-for-microcontrollers.jpg", + "instructors": "Instructor E", + "level": "intermediate", + "certificate": false, + "effort": "4 hours 58 minutes", + "rating": { "provider": { "average": 4.79167, "count": 6 } }, + "provider": { "name": "SkillForge" } + }, + "100046": { + "id": 100046, + "name": "Mock Debugging C Programs", + "url": { "go": "https://example.com/go/mock-debugging-c-programs" }, + "imageUrl": "https://example.com/img/courses/mock-debugging-c-programs.jpg", + "instructors": "Instructor A", + "level": "beginner", + "certificate": false, + "effort": "53 minutes", + "rating": { "provider": { "average": 4.0625, "count": 4 } }, + "provider": { "name": "SkillForge" } + }, + "100047": { + "id": 100047, + "name": "Mock Introduction to Operating Systems", + "url": { + "go": "https://example.com/go/mock-introduction-to-operating-systems" + }, + "imageUrl": "https://example.com/img/courses/mock-introduction-to-operating-systems.jpg", + "instructors": " Mock University", + "level": "beginner", + "certificate": true, + "effort": "6 weeks, 2-3 hours a week", + "rating": { "provider": null }, + "provider": { "name": "EduStream" } + }, + "100048": { + "id": 100048, + "name": "Mock Compilers and Build Tools", + "url": { "go": "https://example.com/go/mock-compilers-and-build-tools" }, + "imageUrl": "https://example.com/img/courses/mock-compilers-and-build-tools.jpg", + "instructors": "Instructor D", + "level": "intermediate", + "certificate": true, + "effort": "1 hour 53 minutes", + "rating": { "provider": { "average": 4.45704, "count": 2223 } }, + "provider": { "name": "EduStream" } + }, + "100049": { + "id": 100049, + "name": "Mock Writing a Shell in C", + "url": { "go": "https://example.com/go/mock-writing-a-shell-in-c" }, + "imageUrl": "https://example.com/img/courses/mock-writing-a-shell-in-c.jpg", + "instructors": "Instructor B", + "level": "intermediate", + "certificate": true, + "effort": "15 hours 20 minutes", + "rating": { "provider": { "average": 4.45952, "count": 5880 } }, + "provider": { "name": "CourseHub" } + }, + "100050": { + "id": 100050, + "name": "Mock Concurrency with Threads in C", + "url": { + "go": "https://example.com/go/mock-concurrency-with-threads-in-c" + }, + "imageUrl": "https://example.com/img/courses/mock-concurrency-with-threads-in-c.jpg", + "instructors": "Instructor C", + "level": "intermediate", + "certificate": true, + "effort": "3 weeks, 2 hours a week", + "rating": { "provider": { "average": 4.5, "count": 812 } }, + "provider": { "name": "BrightPath" } } }, "subjects": { @@ -229,6 +743,11 @@ "slug": "software-engineering", "name": "Software Engineering", "url": "https://example.com/subject/software-engineering" + }, + "c-programming": { + "slug": "c-programming", + "name": "C Programming", + "url": "https://example.com/subject/c-programming" } } } From 1f8291e66f41d5b7ad34d50c8ebef00cad321561 Mon Sep 17 00:00:00 2001 From: Kristofer Koishigawa <2051070+scissorsneedfoodtoo@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:05 +1300 Subject: [PATCH 3/4] feat: show class central courses throughout the post sidebar, cycle through available courses --- .../e2e/english/post/sponsored-courses.cy.ts | 87 ++++++++++++++++++- src/_includes/assets/css/screen.css | 10 +++ src/_includes/layouts/post.njk | 30 ++++--- src/_includes/partials/class-central-ad.njk | 22 ++--- 4 files changed, 125 insertions(+), 24 deletions(-) diff --git a/cypress/e2e/english/post/sponsored-courses.cy.ts b/cypress/e2e/english/post/sponsored-courses.cy.ts index c4e2d1497..b1c414f9d 100644 --- a/cypress/e2e/english/post/sponsored-courses.cy.ts +++ b/cypress/e2e/english/post/sponsored-courses.cy.ts @@ -2,10 +2,18 @@ const selectors = { rail: "[data-test-label='class-central-course-rail']", banner: "[data-test-label='class-central-course-banner']", courseCard: '.partner-courses-course', + courseTitle: '.partner-courses-title', + segment: "[data-test-label='sidebar-segment']", adWrapper: "[data-test-label='ad-wrapper']", bottomAd: '.banner-ad-bottom' }; +// Text screen readers announce for a section's landmark +const getLandmarkName = (section: HTMLElement) => + section.ownerDocument + .getElementById(section.getAttribute('aria-labelledby') ?? '') + ?.textContent?.trim(); + describe('Sponsored courses (Class Central)', () => { context('English post with related courses', () => { beforeEach(() => { @@ -21,7 +29,7 @@ describe('Sponsored courses (Class Central)', () => { cy.get(selectors.bottomAd).should('not.exist'); }); - it('renders one course card per course in both the rail and the banner', () => { + it('renders four course cards in both the rail and the banner', () => { cy.get(selectors.rail) .find(selectors.courseCard) .should('have.length', 4); @@ -41,11 +49,88 @@ describe('Sponsored courses (Class Central)', () => { }); }); + it('gives the single rail a different landmark name from the banner', () => { + cy.get(selectors.rail).then($rail => + expect(getLandmarkName($rail[0])).to.equal( + 'Recommended courses from our partners (sidebar)' + ) + ); + cy.get(selectors.banner).then($banner => + expect(getLandmarkName($banner[0])).to.equal( + 'Recommended courses from our partners' + ) + ); + }); + it('still shows Google ads in the sidebar alongside the rail', () => { cy.get('.sidebar').find(selectors.adWrapper).should('have.length.gte', 1); }); }); + context('Long English post with related courses', () => { + // 63 minute read with ten related courses + const courseTitles = [ + 'Mock C Programming from Zero', + 'Mock Pointers and Memory in C', + 'Mock Systems Programming Fundamentals', + 'Mock Data Structures in C', + 'Mock Embedded C for Microcontrollers', + 'Mock Debugging C Programs', + 'Mock Introduction to Operating Systems', + 'Mock Compilers and Build Tools', + 'Mock Writing a Shell in C', + 'Mock Concurrency with Threads in C' + ]; + const getRailTitles = ($rail: JQuery) => + $rail + .find(selectors.courseTitle) + .toArray() + .map(el => el.textContent?.trim()); + + beforeEach(() => { + cy.visit('/the-c-programming-handbook-for-beginners/'); + }); + + it('shows one rail at the top of each sidebar segment', () => { + cy.get(selectors.segment) + .should('have.length.gt', 1) + .each($segment => { + expect($segment.children().first().is(selectors.rail)).to.equal(true); + }); + cy.get(selectors.rail).its('length').should('be.gt', 1); + }); + + it('spreads the rails down the length of the sidebar', () => { + cy.get(selectors.rail).then($rails => { + const tops = $rails + .toArray() + .map(rail => rail.getBoundingClientRect().top); + tops.slice(1).forEach((top, i) => expect(top).to.be.gt(tops[i])); + }); + }); + + it('cycles through the courses four at a time across every rail', () => { + cy.get(selectors.rail) + .should('have.length.gte', 3) + .each(($rail, railIndex) => { + const expected = [0, 1, 2, 3].map( + i => courseTitles[(railIndex * 4 + i) % courseTitles.length] + ); + expect(getRailTitles($rail)).to.deep.equal(expected); + }); + }); + + it('gives each rail and the banner a unique landmark name', () => { + cy.get(`${selectors.rail}, ${selectors.banner}`).then($sections => { + const names = $sections.toArray().map(getLandmarkName); + expect(names[0]).to.equal( + `Recommended courses from our partners (sidebar, 1 of ${names.length - 1})` + ); + expect(new Set(names).size).to.equal(names.length); + }); + }); + }); + context('English post without related courses', () => { beforeEach(() => { cy.visit('/how-do-numerical-conversions-work/'); diff --git a/src/_includes/assets/css/screen.css b/src/_includes/assets/css/screen.css index 495b4aa4c..c4cadb80d 100644 --- a/src/_includes/assets/css/screen.css +++ b/src/_includes/assets/css/screen.css @@ -3097,6 +3097,16 @@ html.dark-mode .partner-courses-logo { align-items: center; padding: 0 10px 10px; } + + /* Equal-height sidebar sections, each with an optional Class Central rail and ads */ + .ad-layout .sidebar-segment { + display: flex; + flex: 1 1 0; + width: 100%; + flex-direction: column; + justify-content: space-between; + align-items: center; + } } /* 16. RTL layout diff --git a/src/_includes/layouts/post.njk b/src/_includes/layouts/post.njk index 2cca0ea30..169e9b662 100644 --- a/src/_includes/layouts/post.njk +++ b/src/_includes/layouts/post.njk @@ -13,13 +13,13 @@ the rail and bottom banner only render when site.lang is "en". #} {% set relatedCourses = classCentralCourses[post.id] %} {% set showClassCentralCourses = site.lang === "en" and relatedCourses.courses | length %} +{# Split the sidebar into one equal-height segment per rail, with ads filling the rest of each segment #} +{% set classCentralRails = (relatedCourses | classCentralRails(post.reading_time)) if showClassCentralCourses else [] %} +{% set numOfSidebarSegments = (classCentralRails | length) or 1 %} {# Include 2 ads by default for all articles. Then add 1 ad for every 2 mins of reading time if reading time <= 4 mins. Else, add 1 ad for every 1 min of reading -time. Subtract 1 when the Class Central rail is showing, since it already fills -the sidebar's first slot with sponsored course cards - without this, short posts -end up with the full rail *and* the full ad count stacked on top of each other. -(The unconditional +2 baseline means this can never drop below 1.) #} -{% set numOfSidebarAds = ((((post.reading_time / 2) if post.reading_time <= 4 else (post.reading_time)) + 2) | round(0, 'floor')) - (1 if showClassCentralCourses else 0) %} +time. Subtract 1 ad per Class Central rail, and split the rest between segments. #} +{% set numOfSidebarAds = ((((post.reading_time / 2) if post.reading_time <= 4 else (post.reading_time)) + 2) | round(0, 'floor')) - (classCentralRails | length) %} {% block content %}
@@ -68,17 +68,23 @@ end up with the full rail *and* the full ad count stacked on top of each other. {% if adsEnabled %} {% endif %} diff --git a/src/_includes/partials/class-central-ad.njk b/src/_includes/partials/class-central-ad.njk index 9fc7f3c34..c777c2e0f 100644 --- a/src/_includes/partials/class-central-ad.njk +++ b/src/_includes/partials/class-central-ad.njk @@ -3,9 +3,8 @@ Two macros for vertical ads in the sidebar and bottom banner ads: - classCentralCourseAd(ccData) - narrow vertical rail, top of the sidebar - classCentralCourseBanner(ccData) - wide horizontal band, above the bottom - ad slot + classCentralCourseAd(ccData, courses, index, total) - sidebar rail, one per segment + classCentralCourseBanner(ccData) - wide band above the bottom ad slot #} {# Logo / advertisement disclosure row #} @@ -77,10 +76,10 @@ {% endmacro %} {# Up to four course cards in a list and footer link #} -{% macro courseList(ccData) %} +{% macro courseList(ccData, courses) %} {% set topSubject = ccData.subjects[0] %} @@ -95,19 +94,20 @@ {% endmacro %} {# Outer container for both vertical and horizontal banner ad types #} -{% macro shell(ccData, variant) %} +{# labelSuffix gives each section a unique landmark name for screen readers #} +{% macro shell(ccData, variant, courses, labelId, labelSuffix) %}
-

Recommended courses from our partners

+

Recommended courses from our partners{% if labelSuffix %} ({{ labelSuffix }}){% endif %}

{{ disclosure() }} - {{ courseList(ccData) }} + {{ courseList(ccData, courses) }}
{% endmacro %} -{% macro classCentralCourseAd(ccData) %}{{ shell(ccData, "rail") }}{% endmacro %} -{% macro classCentralCourseBanner(ccData) %}{{ shell(ccData, "banner") }}{% endmacro %} +{% macro classCentralCourseAd(ccData, courses, index, total) %}{{ shell(ccData, "rail", courses, "rail-" ~ index, "sidebar, " ~ index ~ " of " ~ total if total > 1 else "sidebar") }}{% endmacro %} +{% macro classCentralCourseBanner(ccData) %}{{ shell(ccData, "banner", ccData.courses.slice(0, 4), "banner") }}{% endmacro %} From a3258e59bdfaa1c0cdfda0c9b9c25711e5a7c9df Mon Sep 17 00:00:00 2001 From: Kristofer Koishigawa <2051070+scissorsneedfoodtoo@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:18:34 +1300 Subject: [PATCH 4/4] feat: fill each sidebar segment with google ads --- src/_includes/partials/gam-lazy-loader.njk | 73 +++++++++++----------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/src/_includes/partials/gam-lazy-loader.njk b/src/_includes/partials/gam-lazy-loader.njk index 4de03bb1e..0a19eb09a 100644 --- a/src/_includes/partials/gam-lazy-loader.njk +++ b/src/_includes/partials/gam-lazy-loader.njk @@ -68,42 +68,45 @@ document.addEventListener('DOMContentLoaded', function() { googletag.cmd.push(function() { if(isSideBarDisplayed){ - var sidebarHeight = sidebar.offsetHeight; - var adTextSideBarHeight = 0; var sideBarAdContainer = 600 + 17; - - // Since the sidebar contains content from multiple sources, - // account for its height so ads can fill leftover space - var existingContentHeight = 0; - for (var j = 0; j < sidebar.children.length; j++) { - existingContentHeight += sidebar.children[j].offsetHeight; - } - - var styles = window.getComputedStyle(sidebar); - var avaiableSideAdSpace = sidebarHeight - adTextSideBarHeight - existingContentHeight - parseFloat(styles.paddingTop) - parseFloat(styles.paddingBottom); - var numSideElements = Math.floor(avaiableSideAdSpace / sideBarAdContainer); - for (var i = 0; i < numSideElements; i++) { - // container element - var containerElement = document.createElement('div'); - containerElement.classList.add('ad-wrapper'); - - //text element - var textElement = document.createElement('div'); - textElement.classList.add('ad-text'); - textElement.innerText = localizedAdText; - - // ad element - var adElement = document.createElement('div'); - var sideAdElementId = 'side-gam-ad-' + i; - adElement.id = sideAdElementId; - adElement.classList.add('side-bar-ad-slot'); - - // finalize setup - containerElement.appendChild(textElement); - containerElement.appendChild(adElement); - sidebar.appendChild(containerElement); - googletag.defineSlot('/23075930536/post-side', [[292, 30], [240, 400], [300, 75], [216, 54], [250, 360], [300, 50], 'fluid', [300, 31], [120, 20], [300, 250], [120, 30], [180, 150], [200, 446], [168, 42], [200, 200], [160, 600], [120, 90], [125, 125], [240, 133], [120, 60], [1, 1], [120, 240], [220, 90], [216, 36], [250, 250], [168, 28], [234, 60], [120, 600], [300, 600], [88, 31], [300, 100]], sideAdElementId).addService(googletag.pubads()); - } + var sideAdIndex = 0; + + // Measure every segment which start with CC courses before adding Google ads + // so they don't affect each other + var segments = Array.prototype.slice.call(sidebar.querySelectorAll('.sidebar-segment')); + var segmentAdCounts = segments.map(function(segment) { + var existingContentHeight = 0; + for (var j = 0; j < segment.children.length; j++) { + existingContentHeight += segment.children[j].offsetHeight; + } + var avaiableSideAdSpace = segment.offsetHeight - existingContentHeight; + return Math.max(0, Math.floor(avaiableSideAdSpace / sideBarAdContainer)); + }); + + segments.forEach(function(segment, segmentIndex) { + for (var i = 0; i < segmentAdCounts[segmentIndex]; i++) { + // container element + var containerElement = document.createElement('div'); + containerElement.classList.add('ad-wrapper'); + + // text element + var textElement = document.createElement('div'); + textElement.classList.add('ad-text'); + textElement.innerText = localizedAdText; + + // ad element + var adElement = document.createElement('div'); + var sideAdElementId = 'side-gam-ad-' + sideAdIndex++; + adElement.id = sideAdElementId; + adElement.classList.add('side-bar-ad-slot'); + + // finalize setup + containerElement.appendChild(textElement); + containerElement.appendChild(adElement); + segment.appendChild(containerElement); + googletag.defineSlot('/23075930536/post-side', [[292, 30], [240, 400], [300, 75], [216, 54], [250, 360], [300, 50], 'fluid', [300, 31], [120, 20], [300, 250], [120, 30], [180, 150], [200, 446], [168, 42], [200, 200], [160, 600], [120, 90], [125, 125], [240, 133], [120, 60], [1, 1], [120, 240], [220, 90], [216, 36], [250, 250], [168, 28], [234, 60], [120, 600], [300, 600], [88, 31], [300, 100]], sideAdElementId).addService(googletag.pubads()); + } + }); } // Define the bottom ad when it should exist (no Class Central banner)