EnginesIndexPage digests

This commit is contained in:
2026-08-06 18:19:40 +02:00
parent a0ced02345
commit d82bb6f468
3 changed files with 170 additions and 5 deletions
@@ -33,7 +33,7 @@
</div>
<div v-else class="engine-list">
<article v-for="(page, index) in enginePages" :key="page.id" class="engine-card group">
<article v-for="({ page, digest }, index) in cards" :key="page.id" class="engine-card group">
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
<div class="engine-card-body">
@@ -42,7 +42,21 @@
</h2>
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
<p v-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<p v-if="digest.intro" class="engine-card-preview">{{ digest.intro }}</p>
<p v-else-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<section v-if="digest.highlights.length" class="engine-highlights">
<h3 v-if="digest.highlightsTitle" class="engine-highlights-title">{{ digest.highlightsTitle }}</h3>
<ul class="engine-highlights-grid">
<li v-for="(item, i) in digest.highlights" :key="i" class="engine-highlight">
<span class="engine-highlight-icon"><i class="fa-solid fa-check"></i></span>
<p class="engine-highlight-body">
<strong v-if="item.title" class="engine-highlight-lead">{{ item.title }}</strong>
<template v-if="item.title && item.text"> </template>{{ item.text }}
</p>
</li>
</ul>
</section>
<div class="engine-card-actions">
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
@@ -62,11 +76,11 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { useEnginesStore } from '../../stores/engines.store'
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -76,6 +90,10 @@ const store = useEnginesStore()
const { pages: enginePages, loading, error } = storeToRefs(store)
const { getCleanPreview } = store
const cards = computed(() =>
enginePages.value.map((page) => ({ page, digest: getEngineDigest(page.content) })),
)
// Explore points at the engine's git repository (from wiki metadata);
// pages without one fall back to their wiki page.
const exploreUrl = (page: WikiPageWithContent): string =>
@@ -135,6 +153,27 @@ onMounted(() => store.fetch())
.engine-card-preview {
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
}
.engine-highlights {
@apply mb-10;
}
.engine-highlights-title {
@apply text-xs font-bold uppercase tracking-[0.2em] text-emerald-400 mb-5;
}
.engine-highlights-grid {
@apply grid md:grid-cols-2 gap-x-10 gap-y-4 max-w-4xl;
}
.engine-highlight {
@apply flex items-start gap-3;
}
.engine-highlight-icon {
@apply mt-1 flex h-5 w-5 flex-none items-center justify-center rounded-md bg-emerald-500/15 text-emerald-400 text-[10px];
}
.engine-highlight-body {
@apply text-sm text-slate-400 leading-relaxed;
}
.engine-highlight-lead {
@apply text-white font-semibold;
}
.engine-card-actions {
@apply flex flex-wrap items-center gap-6;
}
@@ -0,0 +1,64 @@
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.
# 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.
- Plain bullet without a bold lead.
# Endpoints
- **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)
expect(digest.intro).toBe(
'WarpEngine is a standalone, mountable Rails engine that turns any Rails application into a retro software catalog.',
)
})
it('takes the heading above the first bullet list as the highlights title', () => {
expect(getEngineDigest(WIKI_CONTENT).highlightsTitle).toBe('What You Get')
})
it('parses bold-lead bullets into title and text', () => {
const [first, second] = getEngineDigest(WIKI_CONTENT).highlights
expect(first).toEqual({
title: 'Catalog domain',
text: 'Software, Release — all soft-deleted, with download tracking.',
})
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]
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('caps the highlights at six items', () => {
const many = '# Features\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')
expect(digest.intro).toBe('Just a plain paragraph.')
expect(digest.highlights).toEqual([{ title: '', text: 'one' }])
})
it('returns an empty digest for empty content', () => {
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
})
})
+63 -1
View File
@@ -4,6 +4,68 @@ import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export interface EngineHighlight {
title: string
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.
export interface EngineDigest {
intro: string
highlightsTitle: string
highlights: EngineHighlight[]
}
const MAX_HIGHLIGHTS = 6
// Inline markdown (links, bold, code) stripped so the text reads as plain prose.
function stripInline(md: string): string {
return md
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function getEngineDigest(content: string): EngineDigest {
const digest: EngineDigest = { intro: '', highlightsTitle: '', highlights: [] }
let heading = ''
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*/, ''))
continue
}
if (/^[-*]\s/.test(line)) {
const item = line.replace(/^[-*]\s+/, '')
const lead = item.match(/^\*\*([^*]+)\*\*\s*[:—–-]?\s*(.*)$/)
digest.highlights.push(
lead
? { title: stripInline(lead[1]), text: stripInline(lead[2]) }
: { title: '', text: stripInline(item) },
)
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) {
digest.intro = stripInline(line.replace(/^>\s*/, ''))
}
}
digest.highlights = digest.highlights.slice(0, MAX_HIGHLIGHTS)
return digest
}
export const useEnginesStore = defineStore('engines', () => {
const pages = ref<WikiPageWithContent[]>([])
const { loading, error, withCache, invalidate } = useLoadable()
@@ -19,5 +81,5 @@ export const useEnginesStore = defineStore('engines', () => {
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
return { pages, loading, error, fetch, getCleanPreview, invalidate }
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
})