EnginesIndexPage digests
This commit is contained in:
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user