diff --git a/apps/frontend/src/stores/__tests__/engines.store.test.ts b/apps/frontend/src/stores/__tests__/engines.store.test.ts index ba9835a..d7d8756 100644 --- a/apps/frontend/src/stores/__tests__/engines.store.test.ts +++ b/apps/frontend/src/stores/__tests__/engines.store.test.ts @@ -1,59 +1,74 @@ import { describe, it, expect } from 'vitest' import { getEngineDigest } from '../engines.store' -const WIKI_CONTENT = ` -> WarpEngine is a **standalone**, mountable Rails engine that turns any Rails application into a retro software catalog. +// Generic markdown in the shape every engine-tagged wiki page must follow: +// intro paragraph, then "What You Get" as the first heading with bullets. +// Dummy text only — real content always comes from the wiki at runtime. +const SAMPLE_MARKDOWN = ` +> Example Engine is a **sample** framework that turns markdown into landing cards. # What You Get -- **Catalog domain**: \`Software\`, \`Release\` — all soft-deleted, with download tracking. -- **CI pipeline server**: Woodpecker [configuration extension](https://woodpecker-ci.org/docs) — the engine serves the full pipeline. +- **First feature**: some \`inline code\` — with a longer explanation. +- **Second feature**: a [link](https://example.org) inside the text. - Plain bullet without a bold lead. -# Endpoints +# Later Section - **Not picked up**: a list under a later heading. ` describe('getEngineDigest', () => { it('extracts the intro from the leading blockquote with inline markdown stripped', () => { - const digest = getEngineDigest(WIKI_CONTENT) + const digest = getEngineDigest(SAMPLE_MARKDOWN) expect(digest.intro).toBe( - 'WarpEngine is a standalone, mountable Rails engine that turns any Rails application into a retro software catalog.', + 'Example Engine is a sample framework that turns markdown into landing cards.', ) }) - it('takes the heading above the first bullet list as the highlights title', () => { - expect(getEngineDigest(WIKI_CONTENT).highlightsTitle).toBe('What You Get') + it('takes the What You Get heading as the highlights title', () => { + expect(getEngineDigest(SAMPLE_MARKDOWN).highlightsTitle).toBe('What You Get') }) it('parses bold-lead bullets into title and text', () => { - const [first, second] = getEngineDigest(WIKI_CONTENT).highlights + const [first, second] = getEngineDigest(SAMPLE_MARKDOWN).highlights expect(first).toEqual({ - title: 'Catalog domain', - text: 'Software, Release — all soft-deleted, with download tracking.', + title: 'First feature', + text: 'some inline code — with a longer explanation.', + }) + expect(second).toEqual({ + title: 'Second feature', + text: 'a link inside the text.', }) - expect(second.title).toBe('CI pipeline server') - expect(second.text).toBe('Woodpecker configuration extension — the engine serves the full pipeline.') }) it('keeps bullets without a bold lead as plain text', () => { - const third = getEngineDigest(WIKI_CONTENT).highlights[2] + const third = getEngineDigest(SAMPLE_MARKDOWN).highlights[2] expect(third).toEqual({ title: '', text: 'Plain bullet without a bold lead.' }) }) - it('stops at the heading after the first list', () => { - const digest = getEngineDigest(WIKI_CONTENT) - expect(digest.highlights).toHaveLength(3) + it('stops at the heading after the What You Get section', () => { + expect(getEngineDigest(SAMPLE_MARKDOWN).highlights).toHaveLength(3) + }) + + it('matches the heading case-insensitively', () => { + const digest = getEngineDigest('# WHAT you get\n- **A**: b.') + expect(digest.highlights).toEqual([{ title: 'A', text: 'b.' }]) + }) + + it('yields no highlights when the first heading is not What You Get', () => { + const digest = getEngineDigest('Intro line.\n\n# Features\n- **A**: b.\n\n# What You Get\n- **C**: d.') + expect(digest.intro).toBe('Intro line.') + expect(digest.highlights).toEqual([]) }) it('caps the highlights at six items', () => { - const many = '# Features\n' + Array.from({ length: 9 }, (_, i) => `- item ${i}`).join('\n') + const many = '# What You Get\n' + Array.from({ length: 9 }, (_, i) => `- item ${i}`).join('\n') expect(getEngineDigest(many).highlights).toHaveLength(6) }) it('uses the first paragraph as intro when there is no blockquote', () => { - const digest = getEngineDigest('Just a plain paragraph.\n\n---\n\n# Stuff\n- one') + const digest = getEngineDigest('Just a plain paragraph.\n\n---\n\n# What You Get\n- one') expect(digest.intro).toBe('Just a plain paragraph.') expect(digest.highlights).toEqual([{ title: '', text: 'one' }]) }) diff --git a/apps/frontend/src/stores/engines.store.ts b/apps/frontend/src/stores/engines.store.ts index 0515523..d6906f6 100644 --- a/apps/frontend/src/stores/engines.store.ts +++ b/apps/frontend/src/stores/engines.store.ts @@ -9,14 +9,16 @@ export interface EngineHighlight { text: string } -// Landing-page digest of a wiki engine page: the intro paragraph plus the -// first bullet list (e.g. "What You Get") parsed into feature highlights. +// Landing-page digest of a wiki engine page. Convention: every engine-tagged +// page opens with an intro paragraph, then a "What You Get" heading as its +// first heading, with the feature bullets underneath. export interface EngineDigest { intro: string highlightsTitle: string highlights: EngineHighlight[] } +const HIGHLIGHTS_HEADING = 'what you get' const MAX_HIGHLIGHTS = 6 // Inline markdown (links, bold, code) stripped so the text reads as plain prose. @@ -31,33 +33,38 @@ function stripInline(md: string): string { export function getEngineDigest(content: string): EngineDigest { const digest: EngineDigest = { intro: '', highlightsTitle: '', highlights: [] } - let heading = '' + let inHighlights = false for (const raw of (content || '').split('\n')) { const line = raw.trim() if (!line || /^([-*_])\1{2,}$/.test(line)) continue - if (/^#{1,6}\s/.test(line)) { - // A heading after the list means the highlight section is over. - if (digest.highlights.length) break - heading = stripInline(line.replace(/^#{1,6}\s*/, '')) + const heading = line.match(/^#{1,6}\s+(.*)$/) + if (heading) { + // The section ends at the next heading; and if the page's first heading + // is not "What You Get", it doesn't follow the convention — no highlights. + if (inHighlights) break + const title = stripInline(heading[1]) + if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break + inHighlights = true + digest.highlightsTitle = title continue } - if (/^[-*]\s/.test(line)) { - const item = line.replace(/^[-*]\s+/, '') - const lead = item.match(/^\*\*([^*]+)\*\*\s*[:—–-]?\s*(.*)$/) + if (inHighlights) { + const bullet = line.match(/^[-*]\s+(.*)$/) + if (!bullet) continue + const lead = bullet[1].match(/^\*\*([^*]+)\*\*\s*[:—–-]?\s*(.*)$/) digest.highlights.push( lead ? { title: stripInline(lead[1]), text: stripInline(lead[2]) } - : { title: '', text: stripInline(item) }, + : { title: '', text: stripInline(bullet[1]) }, ) - if (!digest.highlightsTitle) digest.highlightsTitle = heading continue } - // First prose line (paragraph or blockquote) before any list is the intro. - if (!digest.intro && !digest.highlights.length) { + // First prose line (paragraph or blockquote) before the heading is the intro. + if (!digest.intro) { digest.intro = stripInline(line.replace(/^>\s*/, '')) } }